同步、非同步,併發、並行、串列,這些名詞在我們的開發中會經常遇到,這裡對非同步編程做一個詳細的歸納總結,希望可以對這方面的開發有一些幫助。 ...
同步、非同步,併發、並行、串列,這些名詞在我們的開發中會經常遇到,這裡對非同步編程做一個詳細的歸納總結,希望可以對這方面的開發有一些幫助。
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