同步、異步,并發、并行、串行,這些名詞在我們的開發中會經常遇到,這里對異步編程做一個詳細的歸納總結,希望可以對這方面的開發有一些幫助,

1 幾個名詞的概念
多任務的時候,才會遇到的情況,如:同步、異步,并發、并行,
1.1 理清它們的基本概念
并發:多個任務在同一個時間段內同時執行,如果是單核心計算機,CPU 會不斷地切換任務來完成并發操作,
并行:多任務在同一個時刻同時執行,計算機需要有多核心,每個核心獨立執行一個任務,多個任務同時執行,不需要切換,
同步:多任務開始執行,任務 A、B、C 全部執行完成后才算是結束,
異步:多任務開始執行,只需要主任務 A 執行完成就算結束,主任務執行的時候,可以同時執行異步任務 B、C,主任務 A 可以不需要等待異步任務 B、C 的結果,
并發、并行,是邏輯結構的設計模式,
同步、異步,是邏輯呼叫方式,
串行是同步的一種實作,就是沒有并發,所有任務一個一個執行完成,
并發、并行是異步的 2 種實作方式,
1.2 舉一個例子

你的朋友在廣州,但是有 2 輛小汽車在深圳,需要你幫忙把這 2 輛小汽車送到廣州去,
同步的方式,你先開一輛小汽車到廣州,然后再坐火車回深圳,再開另外一輛小汽車去廣州,這是串行的方法,2 輛車需要的時間也就更長了,
異步的方式,你開一輛小汽車從深圳去廣州,同時請一個代駕把另外一輛小汽車從深圳開去廣州,這也就是并行方法,兩個人兩輛車,可以同時行駛,速度很快,
并發的方式,你一個人,先開一輛車走 500 米,停車跑回來,再開另外一輛車前行 1000 米,停車再跑回來,回圈從深圳往廣州開,并發的方式,你可以把 2 輛車一塊送到朋友手里,但是程序還是很辛苦的,
1.3 思考問題
你找一家汽車托運公司,把 2 輛車一起托運到廣州,這種方式是同步、異步,并發、并行的哪種情況呢?
2 并發/并行執行會遇到的問題
2.1 問題 1:并發的任務數量控制

假設:某個介面的并發請求會達到 1 萬的 qps,所以對介面的性能、回應時長都要求很高,
介面內部又有大量 redis、mysql 資料讀寫,程式中還有很多處理邏輯,如果介面內的所有邏輯處理、資料呼叫都是串行化,那么單個請求耗時可能會超過 100ms,為了性能優化,就會把資料讀取的部分與邏輯計算的部分分開來考慮和實作,能夠獨立的部分單獨剝離出來作為異步任務來執行,這樣就把串行化的耗時優化為并發執行,充分利用多核計算機的性能,減少單個介面請求的耗時,
假設的資料具體化,如:這個介面的資料全部是可以獨立獲取(支持并發),需要讀取來自不同資料結構的 redis 共 10 個,讀取不同資料表的資料共 10 個,那么一次請求,資料獲取就會啟動 10 個 redis 讀取任務,10 個 mysql 讀取任務,每秒鐘 1 萬介面請求,會有 10 萬個 redis 讀取任務和 10 萬個 mysql 讀取任務,這 21 萬的并發任務,在一秒鐘內由 16/32 核的后端部署單機來完成,雖然在同一時刻的任務數量不一定會是 21 萬(速度快的話會少于 21 萬,如果處理速度慢,出現請求積壓擁堵,會超過 21 萬),
這時候,會遇到的瓶頸,
記憶體,如果每個任務需要 500k 記憶體,那么 210k*0.5M=210*0.5G=105G.
CPU,任務調度,像 golang 的協程可能開銷還小一些,如果是 java 的執行緒調度,作業系統會因為調度而空轉,
網路,每次資料讀取 5k,那么 200k5k=2005M=1G.
埠,埠號最多能分配出來 65536 個,明顯不夠用了,
資料源,redis 可以支持 10 萬 qps 的請求,但是 mysql 就難以支持 10 萬 qps 了,
上面可能出現的瓶頸中,通過計算機資源擴容可以解決大部分問題,比如:部署 50 個后端實體,每個實體只需要應對 200 的 qps,壓力就小了很多,對于資料源,mysql 可以有多個 slave 來支持只讀的請求,
但是,如果介面的并發量更大呢?或者某個/某些資料源讀取出現例外,需要重試,或者出現擁堵,介面回應變慢,任務數量也就會出現暴增,后端服務的各方面瓶頸又會隨之出現,
所以,我們需要特別注意和關心后端開啟的異步任務數量,要做好例外情況的防范,及時中斷掉擁堵/超時的任務,避免任務暴增導致整個服務不可用,
2.2 思考問題
你要如何應對這類并發任務暴增的情況呢?如何提前預防?如何及時干預呢?
2.3 問題 2:共享資料的讀寫順序和依賴關系
共享資料的并發讀寫,是并發編程中的老大難問題,如:讀寫臟資料,舊資料覆寫新資料等等,
而資料的依賴關系,也就決定了任務的執行先后順序,
為了避免共享資料的競爭讀寫,為了保證任務的先后關系,就需要用到鎖、佇列等手段,這時候,并發的程序又被部分的拉平為串行化執行,
2.4 舉個例子

https://www.ticketmaster.com/eastern-conf-semis-tbd-at-boston-boston-massachusetts/event/01005C6AA5531A90
NBA 季后賽,去現場看球,要搶購球票,體育館最多容納 1 萬人(1 萬張球票),
體育館不同距離、不同位置的票,價格和優惠都不相同,有單人位、有雙人位,也有 3、4 人位,你約著朋友共 10 個人去看球,要買票,要選位置,這時候搶票就會很尷尬,因為位置連著的可能會被別人搶走,同時買的票越多,與人沖突的概率就越大,會導致搶票特別困難,
同時,這個系統的開發也很頭大,搶購(秒殺)的并發非常大,預計在開始的一秒鐘會超過 10 萬人同時進來,再加上刷票的機器人,介面請求量可能瞬間達到 100 萬的 QPS,
較簡單的實作方式,所有的請求都異步執行,訂單全部進入訊息佇列,下單馬上回應處理中,請等待,然后,后端程式再從訊息佇列中串行化處理每一個訂單,把出現沖突的訂單直接報錯,這樣,估計 1 秒鐘可以處理 1000 個訂單,10 秒鐘可以處理 1 萬個訂單,考慮訂單的沖突問題,1 萬張球票的 9000 張可能在 30 秒內賣出去,此時只處理了 3 萬個訂單,第一秒鐘進來的 100 萬訂單已經在訊息佇列中堆積,又有 30 秒鐘的新訂單進來,需要很久才可以把剩下的 1000 張球票賣出去啊,同理,下單的用戶需要等待太久才知道自己的訂單結果,這個程序輪詢的請求也會很多很多,
換一種方案,不使用佇列串行化處理訂單,直接并發的處理每一個訂單,那么處理流程中的資料都需要梳理清楚,
1 針對每一個用戶的請求加鎖,避免同一個用戶的重入;
2 每一個/組座位預生成一個 key:0,默認 0 說明沒有下單;
3 預估平均每一個訂單包含 2 個/組座位,需要更新 2 個座位 key;
4 下單的時候給座位 key 執行 INCR key 數字遞增操作,只有回傳 1 的訂單才是成功,其他都是失敗;
5 如果同一個訂單中的座位 key 有沖突的情況下,需要回滾成功 key(INCR key = 1)重置(SET key 0);
6 訂單成功/失敗,處理完成后,去掉用戶的請求鎖;
7 訂單資料入庫到 mysql(訊息佇列,避免 mysql 成為瓶頸);
綜上,需要用到 1 個鎖(2 次操作),平均 2 個座位 key(每個座位號 1-2 次操作),這里只有 2 個座位 key 可以并發更新,為了讓 redis 不成為資料讀寫的瓶頸(超過 100w 的 QPS 寫操作),不能使用單實體模式,而要使用 redis 集群,使用由 10-20 個 redis 實體組成的集群,來支持這么高的 redis 資料讀寫,
算上 redis 資料讀寫、引數、例外、邏輯處理,一個請求大概耗時 10ms 左右,單核至少可以支持 100 并發,由于這里有大量 IO 處理,后端服務可以支持的并發可以更高些,預計單核 200 并發,16 核就可以支持 3200 并發,總共需要支持 100 萬并發,預計需要 312 臺后端服務器,
這種方案比佇列的方案需要的服務器資源更多,但是用戶的等待時間很短,體驗就好很多,
2.5 思考問題
實際情況會是怎樣呢?會有 10 萬人同時搶票嗎?會有 100 萬的超高并發嗎?訂票系統真的會準備 300 多臺服務器來應對搶票嗎?
3 狀態處理:忽略結果

3.1 使用場景和案例
使用場景,主流程之外的異步任務,可能重要程度不高,或者處理的復雜度太高,有時候會忽略異步任務的處理結果,
案例 1:異步的資料上報、資料存盤/計算/統計/分析,
案例 2:模板化創建服務,有很多個任務,有前后關聯任務,也有相互獨立任務,有些執行速度很慢,有些任務失敗后也可以手動重試來修復,
忽略結果的情況,就會遇到下面的問題,
3.2 問題 1:資料一致性
看下案例 1 的情況,
異步的日志上報,是否成功發送到服務端呢?
異步的指標資料上報,是否正確匯總統計和發送到服務端呢?
異步的任務,資料發送到訊息佇列,是否被后端應用程式消費呢?
服務端是否正常存盤和處理完成呢?
如果因為網路原因,因為并發量太大導致服務負載問題,因為程式 bug 的原因,導致資料沒能正確上報和處理,這時候的資料不一致、丟失的問題,就會難以及時排查和事后補發,
如果在本地完整記錄一份資料,以備資料審查,又要考慮高并發高性能的瓶頸,畢竟本地日志讀寫性能受到磁盤速度的影響,性能會很差,
3.3 問題 2:功能可靠性
看下案例 2 的情況,
創建服務的程序中,有創建代碼倉庫、開啟日志采集和自定義鏡像中心,CI/CD 等耗時很長的任務,這里開啟日志采集和自定義鏡像中心如果出現例外,對整個服務的運行沒有影響,而且開發者發現問題后也可以自己手動操作下,再次開啟日志采集和自定義鏡像功能,所以在模板化處理中,這些異步處理任務就沒有關注任務的狀態,
那么問題就很明顯,模板化創建服務的程序中,是不能保證全部功能都正常執行完成的,會有部分功能可能有例外,而且也沒有提示和后續指引,
當然模板化創建服務的程式,也可以把全部任務的狀態都檢查結果,只是會增加一些處理的復雜度和難度,
3.4 思考問題
實際開發中,有遇到類似上面的兩個案例嗎?你會如何處理呢?所有的異步任務,都會檢查狀態結果嗎?為什么呢?
4 狀態處理:結果回傳
4.1 使用場景和案例
大部分的異步任務對于狀態結果還是很關注的,比如:后續的處理邏輯或者任務依賴某個異步任務,或者異步任務非常重要,需要把結果回傳給請求方,
案例 1:模板化創建服務的程序中,需要異步創建服務的 git 代碼倉庫,還要給倉庫添加成員、webhook、初始化代碼等,整個程序全部串行化作為一個任務的話,耗時會比較長,可以把創建服務的 git 代碼倉庫作為一個異步任務,然后得到成功的結果后再異步的發起添加成員、加 webhook、初始化代碼等任務,同時,這里的 CI/CD 有配置相關,有執行相關,整個程序也很長,CD 部署成功之后才可以開啟日志采集等配置,所以也需要關注 CD 部署的結果,
案例 2:各種 webhook、callback 介面和方法,就是基于回呼的方式,如:golang 中的 channel 通知,工蜂中的代碼 push 等 webhook,監控告警中的 callback 等,
案例 3:發布訂閱模式,如引入訊息佇列服務,主程式把資料發送給訊息佇列,異步任務訂閱相應的主題然后處理,處理完成后也可以把結果再發送給訊息佇列,或者把結果發送給主調程式的介面,或者等待主調程式來查詢結果,當然也可能是上面的忽略結果的情況,
從上可以總結出來,對于異步任務的狀態處理,需要關注結果的話,有兩種主要的方法,分別是:輪詢查詢和等待回呼,
4.2 方法 1:輪詢查詢

上面的案例 1 中,模板化創建服務的程序很慢,所以整個功能都是異步的,用戶大概要等待 10s 左右才知道最后的結果,所以,用戶在創建服務之后,瀏覽器會不斷輪詢服務端介面,看看創建服務的結果,各個步驟的處理結果,服務配置是否都成功完成了,
類似的功能實作應該有很多,比如:服務構建、部署、創建鏡像倉庫、搶購買票等,把任務執行和任務結果通過異步的方式強制分離開,用戶可以等待,但是不用停留在當前任務中持續等待,而是可以去做別的事情,隨時回來關注下這個任務的處理結果就好了,大部分執行時間很長的任務都會放到異步執行緒中執行,用戶關注結果的話,就可以通過查詢的方式來獲取結果,程式自動來回傳結果的話,就可以用到輪詢查詢了,
局限性 1:頻率和實時性
輪詢的方式延時可能會比較高,因為跟定時器的間隔時間有關系,
局限性 2:增加請求壓力
因為輪詢,要不斷地請求服務端,所以對后端的請求壓力也會比較大,
4.3 方法 2:通知回呼

等待回呼幾乎是實時的,處理有結果回傳就馬上通過回呼通知到主程式/用戶,那么效率和體驗上就會好很多,
但是這里也有一個前提要求,回呼的時候,主程式必須還在運行,否則回呼也就沒有了主體,也就無效了,所以要求主程式需要持續等待異步任務的回呼,不能過早的退出,
一般程式中使用異步任務,需要得到任務狀態的結果,使用等待回呼的情況更多一些,
特別注意 1:等待超時
等待的時間,一般不能是無限長,這樣容易造成某些例外情況下的任務爆炸,記憶體泄露,所以需要對異步任務設定一個等待超時,過期后就要中斷任務了,也就不能通過回呼來得到結果了,直接認為是任務例外了,
特別注意 2:例外情況
當主程式在等待異步任務的回呼時,如果異步任務自身有例外,無法成功執行,也無法完成回呼的操作,那么主程式也就無法得到想要的結果,也不知道任務狀態的結果是成功還是失敗,這時候也就會遇到上面等待超時的情況了,
特別注意 3:回呼地獄
使用 nodejs 異步編程的時候,所有的 io 操作都是異步回呼,于是就很容易陷入 N 層的回呼,代碼就會變得例外丑陋和難以維護,于是就出現了很多的異步編程框架/模式,像:Promise,Generator,async/await 等,這里不做過多講解,
4.4 思考問題
實際作業中,還有哪些地方需要處理異步任務的狀態結果回傳呢?除了輪詢和回呼,還有其他的方法嗎?
5 例外處理
同步的程式,處理例外情況,在 java 中只需要一個 try catch 就可以捕獲到全部的例外,
5.1 重點 1:分別做例外處理
異步的程式,try catch 只能捕獲到當前主程式的例外,主程式中的異步執行緒是無法被捕獲的,這時候,就需要針對異步執行緒中的異步任務也要單獨進行 try catch 捕獲例外,
在 golang 中,開啟協程,還是需要在異步任務的 defer 方法中,加入一個 recover() ,以避免沒有處理的例外導致整個行程的 panic,
5.2 重點 2:例外結果的記錄,查詢或者回呼
當我們把異步任務中的例外情況都處理好了,不會導致異步執行緒把整個行程整奔潰了,那么還有問題,怎么把例外的結果回傳給主行程,這就涉及到上面的狀態處理了,
如果可以忽略結果,那么只需要寫一下錯誤日志就好了,
如果需要處理狀態,那就要記錄下例外資訊或者通知回呼給到主行程,
5.3 思考問題
實際作業中,你會對所有的可能例外情況都做相應的處理嗎?例外結果,都是怎么處理的呢?
6 典型場景和思考
前面已經講到一些案例,總結下來的典型場景有如下幾種
6.1 訂閱發布模式,訊息佇列
6.2 慢請求,耗時長的任務
6.3 高并發、高性能要求時的多任務處理
6.4 不確定執行的時間點,觸發器
人腦(單核)不擅長異步思考,電腦(多核)卻更適合,
編程的時候,是人腦適配電腦,還是電腦服務人腦?
在大部分的編程中,大家都只需要考慮同步的方式來寫代碼邏輯,少部分時候,就要考慮使用異步的方式,而且,有很多的開發框架、類別庫已經把異步處理封裝,可以簡化異步任務的開發和除錯作業,
所以,對于開發者來說,默認還是同步方式思考和開發,當不得不使用異步的時候,才會考慮異步的方式,畢竟讓人腦適配電腦,這個程序還是有些困難的,
作者:michaeywang,騰訊 IEG 運營開發工程師
本文來自博客園,作者:古道輕風,轉載請注明原文鏈接:https://www.cnblogs.com/88223100/p/Asynchronous-Programming-Guide-North.html
轉載請註明出處,本文鏈接:https://www.uj5u.com/ruanti/502498.html
標籤:其他
下一篇:前途無量的MEMS傳感器技術
