
隨著RxJava、Reactor等異步框架的流行,異步編程受到了越來越多的關注,尤其是在 IO 密集型的業務場景中,相比傳統的同步開發模式,異步編程的優勢越來越明顯,
那到底什么是異步編程?異步化真正的好處又是什么?如何選擇適合自己團隊的異步技術?在實施異步框架落地的程序中有哪些需要注意的地方?
本文從以下幾個方面結合真實專案異步改造經驗對異步編程進行分析,希望能給大家一些客觀認識:
-
使用 RxJava 異步改造后的效果
-
什么是異步編程?異步實作原理
-
異步技術選型參考
-
異步化真正的好處是什么?
-
異步化落地的難點及解決方案
-
擴展:異步其他解決方案-協程
使用 RxJava 異步改造后的效果
下圖是我們后端 java 專案使用 RxJava 改造成異步前后的 RT(回應時長)效果對比:


統計資料基于 App 端的 gateway,以 75 線為準,還有 80、85、90、99 線,從圖中可以看出改成異步后介面整體的平均回應時長降低了 **40%**左右,
(回應時間是以發送請求到收到后端介面回應資料的時長,上圖改造的這個后端 java 介面內部流程比較復雜,因為公司都是微服務架構,該介面內部又呼叫了 6 個其他服務的介面,最后把這些介面的資料匯總在一起回傳給前端)
這張圖是同步介面和改造成異步介面前后的 CPU 負載情況對比
改造前 cpu load : 35.46

改造后 cpu load : 14.25

改成異步后 CPU 的負載情況也有明顯下降,但 CPU 使用率并無影響(一般情況下異步化后 cpu 的利用率會有所提高,但要看具體的業務場景)
CPU LoadAverage 是指:一段時間內處于可運行狀態和不可中斷狀態的行程平均數量,(可運行分為正在運行行程和正在等待 CPU 的行程;不可中斷則是它正在做某些作業不能被中斷比如等待磁盤 IO、網路 IO 等)
而我們的服務業務場景大部分都是 IO 密集型業務,功能實作很多需要依賴底層介面,會進行頻繁的 IO 操作,
下圖是 2019 年在全球架構師峰會上阿里分享的異步化改造后的 RT 和 QPS 效果:

(圖片來源:淘寶應用架構升級——反應式架構的探索與實踐)
什么是異步編程?
回應式編程 + NIO
1. 異步和同步的區別:
我們先從 I/O 的角度看下同步模式下介面 A 呼叫介面 B 的互動流程:
下圖是傳統的同步模式下 io 執行緒的互動流程,可以看出 io 是阻塞的,即 bio 的運行模式

介面 A 發起呼叫介面 B 后,這段時間什么事情也不能做,主執行緒阻塞一直等到介面 B 資料回傳,然后才能進行其他操作,可想而知如果介面 A 呼叫的介面不止 B 的話(A->B->C->D->E,,,),那么等待的時間也是遞增的,而且這期間 CPU 也要一直占用著,白白浪費資源,也就是上圖看到的 cpu load 高的原因,
而且還有一個隱患就是如果呼叫的其他服務中的介面比如 C 超時,或介面 C 掛掉了,那么對呼叫方服務 A 來說,剩余的介面比如 D、E 都會無限等待下去,,,
其實大部分情況下我們收到資料后內部的處理邏輯耗時都很短,這個可以通過埋點執行時間統計,大部分時間都浪費在了 IO 等待上,
下面這個視頻演示了同步模式下我們線上環境真實的介面呼叫情況,即介面呼叫的執行緒執行和變化情況,(使用的工具是 JDK 自帶的 jvisual 來監控執行緒變化情況)
這里先交代下大致背景:服務端 api 介面 A 內部一共呼叫了 6 個其他服務的介面,大致互動是這樣的:
A 介面(B -> C -> D -> E -> F -> G)回傳聚合資料
背景:使用 Jemter 測驗工具壓測 100 個執行緒并發請求介面,以觀察執行緒的運行情況(可以全屏觀看):
https://v.qq.com/x/page/v315879ickz.html
http-nio-8080-exec*開頭的是 tomcat 執行緒池中的執行緒,即前端請求我們后端介面時要通過 tomcat 服務器接收和轉發的執行緒,因為我們后端 api 介面內部又呼叫了其他服務的 6 個介面(B、C、D、E、F、G),同步模式下需要等待上一個介面回傳資料才能繼續呼叫下一個介面,所以可以從視頻中看出,大部分的 http 執行緒耗時都在 8 秒以上(綠色線條代表執行緒是"運行中"狀態,8 秒包括等待介面回傳的時間和我們內部邏輯處理的總時間,因為是本地環境測驗,受機器和網路影響較大)
然后我們再看下異步模式的互動流程,即 nio 方式:

大致流程就是介面 A 發起呼叫介面 B 的請求后就立即回傳,而不用阻塞等待介面 B 回應,這樣的好處是http-nio-8080-exec*執行緒可以馬上得到復用,接著處理下一個前端請求的任務,如果介面 B 處理完回傳資料后,會有一個回呼執行緒池處理真正的回應,即這種模式下我們的業務流程是 http 執行緒只處理請求,回呼執行緒處理介面回應,
這個視頻演示了異步模式下介面 A 的執行緒執行情況,同樣也是使用 Jemter 測驗工具壓測 100 個執行緒并發請求介面,以觀察執行緒的運行情況(可以全屏觀看):
https://v.qq.com/x/page/f3158ebjre7.html
模擬的條件和同步模式一樣,同樣是 100 個執行緒并發請求介面,但這次http-nio-8080-exec*開頭的執行緒只處理請求任務,而不再等待全部的介面回傳,所以 http 的執行緒運行時間普遍都很短(大部分在 1.8 秒左右完成),AsfThread-executor-*是我們系統封裝的回呼執行緒池,處理底層介面的真正回應資料,
演示視頻中的AsfThread-executor-*的回呼執行緒只創建了 30 多個,而請求的 http 執行緒有 100 個,也就是說這 30 多個回呼執行緒處理了介面 B 的 100 次回應(其實應該是 600 次,因為介面 B 內部又呼叫了 6 個其他介面,這 6 次也都是在異步執行緒里處理回應的),因為每個介面回傳的時間不一樣,加上網路傳輸的時間,所以可以利用這個時間差充分復用執行緒即 cpu 資源,視頻中回呼執行緒AsfThread-executor-*的綠色運行狀態是多段的,表示復用了多次,也就是少量回呼執行緒處理了全部(600 次)的回應,這正是 IO 多路復用的機制,
nio 模式下雖然http-nio-8080-exec*執行緒和回呼執行緒AsfThread-executor-*的運行時間都很短,但是從 http 執行緒開始到 asf 回呼處理完回傳給前端結果的時間和 bio 即同步模式下的時間差異不大(在相同的邏輯流程下),并不是 nio 模式下服務回應的整體時間就會縮短,而是會提升 CPU 的利用率,因為 CPU 不再會阻塞等待(不可中斷狀態減少),這樣 CPU 就能有更多的資源來處理其他的請求任務,相同單位時間內能處理更多的任務,所以 nio 模式帶來的好處是:
-
提升 QPS(用更少的執行緒資源實作更高的并發能力)
-
降低 CPU 負荷,提高利用率
2. Nio 原理

結合上面的介面互動圖可知,介面 B 通過網路回傳資料給呼叫方(介面 A)這一程序,對應底層實作就是網卡接收到回傳資料后,通過自身的 DMA(直接記憶體訪問)將資料拷貝到內核緩沖區,這一步不需要 CPU 參與操作,也就是把原先 CPU 等待的事情交給了底層網卡去處理,這樣 CPU 就可以專注于我們的應用程式即介面內部的邏輯運算,
3. Nio In Java

nio 在 java 里的實作主要是上圖中的幾個核心組件:channel、buffer、selector,這些組件組合起來即實作了上面所講的多路復用機制,如下圖所示:

回應式編程
1. 什么是回應式編程?它和傳統的編程方式有什么區別?
回應式可以簡單的理解為收到某個事件或通知后采取的一系列動作,如上文中所說的回應作業系統的網路資料通知,然后以回呼的方式處理資料,
傳統的命令式編程主要由:順序、分支、回圈 等控制流來完成不同的行為
回應式編程的特點是:
-
以邏輯為中心轉換為以資料為中心
-
從命令式到宣告式的轉換
2. Java.Util.Concurrent.Future
在 Java 使用 nio 后無法立即拿到真實的資料,而且先得到一個"future",可以理解為郵戳或快遞單,為了獲悉真正的資料我們需要不停的通過快遞單號查詢快遞進度,所以 J.U.C 中的 Future 是 Java 對異步編程的第一個解決方案,通常和執行緒池結合使用,偽代碼形式如下:
ExecutorService executor = Executors.newCachedThreadPool(); // 執行緒池
Future的缺點很明顯:
-
無法方便得知任務何時完成
-
無法方便獲得任務結果
-
在主執行緒獲得任務結果會導致主執行緒阻塞
3. ListenableFuture
Google 并發包下的listenableFuture對 Java 原生的 future 做了擴展,顧名思義就是使用監聽器模式實作的回呼機制,所以叫可監聽的 future,
Futures.addCallback(listenableFuture, new FutureCallback<String>() {
回呼機制的最大問題是:Callback Hell(回呼地獄)
試想如果呼叫的介面多了,而且介面之間有依賴的話,最終寫出來的代碼可能就是下面這個樣子:

-
代碼的字面形式和其所表達的業務含義不匹配
-
業務的先后關系在代碼層面變成了包含和被包含的關系
-
大量使用 Callback 機制,使應該是先后的業務邏輯在代碼形式上表現為層層嵌套,這會導致代碼難以理解和維護,
那么如何解決 Callback Hell 問題呢?
回應式編程
其實主要是以下兩種解決方式:
-
事件驅動機制
-
鏈式呼叫(Lambda)
4. CompletableFuture
Java8 里的CompletableFuture和 Java9 的Flow Api勉強算是上面問題的解決方案:
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() ->
但CompletableFuture處理簡單的任務可以使用,但并不是一個完整的反應式編程解決方案,在服務呼叫復雜的情況下,存在服務編排、背景關系傳遞、柔性限流(背壓)方面的不足
如果使用CompletableFuture面對這些問題可能需要自己額外造一些輪子,Java9 的Flow雖然是基于 Reactive Streams 規范實作的,但沒有 RxJava、Project Reactor 這些異步框架豐富和強大和完整的解決方案,
當然如果介面邏輯比較簡單,完全可以使用listenableFuture或CompletableFuture
5. Reactive Streams
在網飛推出 RxJava1.0 并在 Android 端普及流行開后,回應式編程的規范也呼之欲出:
https://www.reactive-streams.org/
包括后來的 RxJava2.0、Project Reactor 都是基于 Reactive Streams 規范實作的,
關于他們和listenableFuture、 CompletableFuture的區別通過下面的例子大家應該就會清楚,
比如下面的基于回呼的代碼示例:獲取用戶的 5 個收藏串列功能

圖中標注序號的步驟對應如下:
-
根據 uid 呼叫用戶收藏串列介面
userService.getFavorites -
成功的回呼邏輯
-
如果用戶收藏串列為空
-
呼叫推薦服務
suggestionService.getSuggestions -
推薦服務成功后的回呼邏輯
-
取前 5 條推薦并展示(
Java8 Stream api) -
推薦服務失敗的回呼,展示錯誤資訊
-
如果用戶收藏串列有資料回傳
-
取前 5 潭訓圈呼叫詳情介面
favoriteService.getDetails成功回呼則展示詳情,失敗回呼則展示錯誤資訊
可以看出主要邏輯都是在回呼函式(onSuccess()、onError())中處理的,在可讀性和后期維護成本上比較大,
基于 Reactive Streams 規范實作的回應式編程解決方案如下:

-
呼叫用戶收藏串列介面
-
壓平資料流呼叫詳情介面
-
如果收藏串列為空呼叫推薦介面
-
取前 5 條
-
切換成異步執行緒處理上述宣告介面回傳結果)
-
成功則展示正常資料,錯誤展示錯誤資訊
可以看出因為這些異步框架提供了豐富的 api,所以我們可以把主要精力放在資料的流轉上,而不是原來的邏輯控制上,這也是異步編程帶來的思想上的轉變,
下圖是 RxJava 的operator api:

(如果這些運算子滿足不了你的需求,你也可以自定義運算子)
所以說異步最吸引人的地方在于資源的充分利用,不把資源浪費在等待的時間上(nio),代價是增加了程式的復雜度,而 Reactive Program 封裝了這些復雜性,使其變得簡單,
所以我們無論使用哪種異步框架,盡量使用框架提供的 api,而不是像上圖那種基于回呼業務的代碼,把業務邏輯都寫在 onSuccess、onError 等回呼方法里,這樣無法發揮異步框架的真正作用:
Codes Like Sync,Works Like Async
即以同步的方式編碼,達到異步的效果與性能,兼顧可維護性與可伸縮性,
異步框架技術選型

(圖片來源:淘寶應用架構升級——反應式架構的探索與實踐)
上面這張圖也是阿里在 2019 年的深圳全球架構師峰會上分享的 PPT 截圖(文章末尾有鏈接),供大家參考,選型標準主要是基于穩定性、普及性、成本這 3 點考慮
如果是我個人更愿意選擇 Project Reactor 作為首選異步框架,(具體差異網上很多分析,大家可以自行百度谷歌),還有一點是因為 Netflix 的尿性,推出的開源產品漸漸都不維護了,而且 Project Reactor 提供了reactor-adapter組件,可以方便的和 RxJava 的 api 轉換,
其實還有 Vert.x 也算異步框架 (底層使用 netty 實作 nio, 最新版已支持 reactive stream 規范)
異步化真正的好處
Scalability
伸縮性主要體現在以下兩個方面:
-
elastic 彈性
-
resilient 容錯性
(異步化在平時不會明顯降低 RT、提高 QPS,文章開頭的資料也是在大促這種流量高峰下的體現出的異步效果)
從架構和應用等更高緯度看待異步帶來的好處則會提升系統的兩大能力:彈性 和 容錯性
前者反映了系統應對壓力的表現,后者反映了系統應對故障的表現
1. 容錯性
像 RxJava,Reactor 這些異步框架處理回呼資料時一般會切換執行緒背景關系,其實就是使用不同的執行緒池來隔離不同的資料流處理邏輯,下圖說明了這一特性的好處:

即利用異步框架支持執行緒池切換的特性實作服務/介面隔離,進而提高系統的高可用,
2. 彈性

back-pressure 是一種重要的反饋機制,相比于傳統的熔斷限流等方式,是一種更加柔性的自適應限流,使得系統得以優雅地回應負載,而不是在負載下崩潰,
異步化落地的難點及解決方案
還是先看下淘寶總結的異步改造中難點問題:

(圖片來源:淘寶應用架構升級——反應式架構的探索與實踐)
中間件全異步牽涉到到公司中臺化戰略或框架部門的支持,包括公司內部常用的中間件比如 MQ、redis、dal 等,超出了本文討論的范圍,感興趣的可以看下文章末尾的參考資料,
執行緒模型統一的背景在上一節異步化好處時有提到過,其實主要還是對執行緒池的管理,做好服務隔離,執行緒池設定和注意事項可以參考之前的兩篇文章:Java踩坑記系列之執行緒池 、執行緒池ForkJoinPool簡介
這里主要說下背景關系傳遞和阻塞檢測的問題:
1. 背景關系傳遞
改造成異步服務后,不能再使用ThreadLocal傳遞背景關系 context,因為異步框架比如 RxJava 一般在收到通知后會先呼叫observeOn()方法切換成另外一個執行緒處理回呼,比如我們在請求介面時在ThreadLocal的 context 里設定了一個值,在回呼執行緒里從 context 里取不到這個值的,因為此時已經不是同一個ThreadLocal了,所以需要我們手動在切換背景關系的時候傳遞 context 從一個執行緒到另一個執行緒環境,偽代碼如下:
Context context = ThreadLocalUtils.get(); // 獲取當前執行緒的背景關系
在observeOn()方法切換成另外一個執行緒后呼叫doOnEvent方法將原來的 context 賦給新的執行緒ThreadLocal
注意:這里的代碼只是提供一種解決思路,實際在使用前和使用后還要考慮清空ThreadLocal,因為執行緒有可能會回收到執行緒池下次復用,而不是立即清理,這樣就會污染背景關系環境,
可以將傳遞背景關系的方法封裝成公共方法,不需要每次都手動切換,
2. 阻塞檢測
阻塞檢測主要是要能及時發現我們某個異步任務長時間阻塞的發生,比如異步執行緒執行時間過長進而影響整個介面的回應,原來同步場景下我們的日志都是串行記錄到 ES 或 Cat 上的,現在改成異步后,每次處理介面資料的邏輯可能在不同的執行緒中完成,這樣記錄的日志就需要我們主動去合并(依據具體的業務場景而定),如果日志無法關聯起來,對我們排查問題會增加很多難度,所幸的是隨著異步的流行,現在很多日志和監控系統都已支持異步了,
Project Reactor 自己也有阻塞檢測功能
3. 其他問題
除了上面提到的兩個問題外,還有一些比如 RxJava2.0 之后不支持回傳 null,如果我們原來的代碼或編程習慣所致回傳結果有 null 的情況,可以考慮使用 java8 的Optional.ofNullable()包裝一下,然后回傳的 RxJava 型別是這樣的:Single<Optional>,其他異步框架如果有類似的問題同理,
異步其他解決方案:纖程/協程
-
Quasar
-
Kilim
-
Kotlin
-
Open JDK Loom
-
AJDK wisp2
協程并不是什么新技術,它在很多語言中都有實作,比如 Python、Lua、Go 都支持協程,
協程與執行緒不同之處在于,執行緒由內核調度,而協程的調度是行程自身完成的,這樣就可以不受作業系統對執行緒數量的限制,一個執行緒內部可以創建成千上萬個協程,因為上文講到的異步技術都是基于執行緒的操作和封裝,Java 中的執行緒概念對應的就是作業系統的執行緒,
1. Quasar、Kilim
開源的 Java 輕量級執行緒(協程)框架,通過利用Java instrument技術對位元組碼進行修改,使方法掛起前后可以保存和恢復 JVM 堆疊幀,方法內部已執行到的位元組碼位置也通過增加狀態機的方式記錄,在下次恢復執行可直接跳轉至最新位置,
2. Kotlin
Kotlin Coroutine 協程庫,因為 Kotlin 的運行依賴于 JVM,不能對 JVM 進行修改,因此 Kotlin 不能在底層支持協程,同時 Kotlin 是一門編程語言,需要在語言層面支持協程,所以 Kotlin 對協程支持最核心的部分是在編譯器中完成,這一點其實和 Quasar、Kilim 實作原理類似,都是在編譯期通過修改位元組碼的方式實作協程
3. Project Loom
Project Loom 發起的原因是因為長期以來 Java 的執行緒是與作業系統的執行緒一一對應的,這限制了 Java 平臺并發能力提升,Project Loom 是從 JVM 層面對多執行緒技術進行徹底的改變,
OpenJDK 在 2018 年創建了 Loom 專案,目標是在 JVM 上實作輕量級的執行緒,并解除 JVM 執行緒與內核執行緒的映射,其實 Loom 專案的核心開發人員正是從 Quasar 專案過來的,目的也很明確,就是要將這項技術集成到底層 JVM 里,所以 Quasar 專案目前已經不維護了,,,
4. AJDK Wisp2
Alibaba Dragonwell 是阿里巴巴的 Open JDK 發行版,提供長期支持,dragonwell8 已開源協程功能(之前的版本是不支持的),開啟 jvm 命令:-XX:+UseWisp2 即支持協程,
總結
-
Future 在異步方面支持有限
-
Callback 在編排能力方面有 Callback Hell 的短板
-
Project Loom 最新支持的 Open JDK 版本是 16,目前還在測驗中
-
AJDK wisp2 需要換掉整個 JVM,需要考慮改動成本和收益比
所以目前實作異步化比較成熟的方案是 Reactive Streams
以上是老對異步編程的理解,如有問題歡迎指正,
另外開發人員也不要將自己局限在某種特定技術上,對各種技術都保持開放的態度是開發人員技能不斷提高的前提,只會簡單說某某語言、某某技術比其它技術更好的人永遠不會成為出色的產品[狗頭],
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/290590.html
標籤:其他
上一篇:強烈推薦程式員學習的網站
