圖示Java異步編程,清晰易懂,收藏了

2021-02-18    分類: 網(wǎng)站建設(shè)

近期嘗試在搬磚專用語言 Java 上實現(xiàn)異步,起因和過程就不再詳述了,總而言之,心中一萬頭草泥馬奔過。但這個過程也沒有白白浪費,趁機回顧了一下各種異步編程的實現(xiàn)。

這篇文章會涉及到回調(diào)、Promise、反應式、async/await、用戶態(tài)線程等異步編程的實現(xiàn)方案。如果你熟悉它們中的一兩種,那應該也能很快理解其他幾個。

為什么需要異步?

操作系統(tǒng)可以看作是個虛擬機(VM),進程生活在操作系統(tǒng)創(chuàng)造的虛擬世界里。進程不用知道到底有多少 core 多少內(nèi)存,只要進程不要索取的太過分,操作系統(tǒng)就假裝有無限多的資源可用。

基于這個思想,線程(Thread)的個數(shù)并不受硬件限制:你的程序可以只有一個線程、也可以有成百上千個。操作系統(tǒng)會默默做好調(diào)度,讓諸多線程共享有限的 CPU 時間片。這個調(diào)度的過程對線程是完全透明的。

那么,操作系統(tǒng)是怎樣做到在線程無感知的情況下調(diào)度呢?答案是上下文切換(Context Switch),簡單來說,操作系統(tǒng)利用軟中斷機制,把程序從任意位置打斷,然后保存當前所有寄存器——包括最重要的指令寄存器 PC 和棧頂指針 SP,還有一些線程控制信息(TCB),整個過程會產(chǎn)生數(shù)個微秒的 overhead。


然而作為一位合格的程序員,你一定也聽說過,線程是昂貴的:

  • 線程的上下文切換有不少的代價,占用寶貴的 CPU 時間;
  • 每個線程都會占用一些(至少 1 頁)內(nèi)存。

這兩個原因驅(qū)使我們盡可能避免創(chuàng)建太多的線程,而異步編程的目的就是消除 IO wait 阻塞——絕大多數(shù)時候,這是我們創(chuàng)建一堆線程、甚至引入線程池的罪魁禍首。

Continuation

回調(diào)函數(shù)知道的人很多,但了解 Continuation 的人不多。Continuation 有時被晦澀地翻譯成 “計算續(xù)體”,咱們還是直接用單詞好了。

把一個計算過程在中間打斷,剩下的部分用一個對象表示,這就是 Continuation。操作系統(tǒng)暫停一個線程時保存的那些現(xiàn)場數(shù)據(jù),也可以看作一個 Continuation。有了它,我們就能在這個點接著剛剛的斷點繼續(xù)執(zhí)行。

打斷一個計算過程聽起來很厲害吧!實際上它每時每刻都在發(fā)生——假設(shè)函數(shù) f() 中間調(diào)用了 g(),那 g() 運行完成時,要返回到 f() 剛剛調(diào)用 g() 的地方接著執(zhí)行。這個過程再自然不過了,以至于所有編程語言(匯編除外)都把它掩藏起來,讓你在編程中感覺不到調(diào)用棧的存在。


操作系統(tǒng)用昂貴的軟中斷機制實現(xiàn)了棧的保存和恢復。那有沒有別的方式實現(xiàn) Continuation 呢?最樸素的想法就是,把所有用得到的信息包成一個函數(shù)對象,在調(diào)用 g() 的時候一起傳進去,并約定:一旦 g() 完成,就拿著結(jié)果去調(diào)用這個 Continuation。

這種編程模式被稱為 Continuation-passing style(CPS):

  1. 把調(diào)用者 f() 還未執(zhí)行的部分包成一個函數(shù)對象 cont,一同傳給被調(diào)用者 g();
  2. 正常運行 g() 函數(shù)體;
  3. g() 完成后,連同它的結(jié)果一起回調(diào) cont,從而繼續(xù)執(zhí)行 f() 里剩余的代碼。

再拿 Wikipedia 上的定義鞏固一下:

A function written in continuation-passing style takes an extra argument: an explicit “continuation”, i.e. a function of one argument. When the CPS function has computed its result value, it “returns” it by calling the continuation function with this value as the argument.CPS 風格的函數(shù)帶一個額外的參數(shù):一個顯式的 Continuation,具體來說就是個僅有一個參數(shù)的函數(shù)。當 CPS 函數(shù)計算完返回值時,它 “返回” 的方式就是拿著返回值調(diào)用那個 Continuation。

你應該已經(jīng)發(fā)現(xiàn)了,這也就是回調(diào)函數(shù),我只是換了個名字而已。

異步的樸素實現(xiàn):Callback

光有回調(diào)函數(shù)其實并沒有卵用。對于純粹的計算工作,Call Stack 就很好,為何要費時費力用回調(diào)來做 Continuation 呢?你說的對,但僅限于沒有 IO 的情況。我們知道 IO 通常要比 CPU 慢上好幾個數(shù)量級,在 BIO 中,線程發(fā)起 IO 之后只能暫停,然后等待 IO 完成再由操作系統(tǒng)喚醒。

var input = recv_from_socket() // Block at syscall recv()var result = calculator.calculate(input)send_to_socket(result) // Block at syscall send()

而異步 IO 中,進程發(fā)起 IO 操作時也會一并輸入回調(diào)(也就是 Continuation),這大大解放了生產(chǎn)力——現(xiàn)場無需等待,可以立即返回去做其他事情。一旦 IO 成功后,AIO 的 Event Loop 會調(diào)用剛剛設(shè)置的回調(diào)函數(shù),把剩下的工作完成。這種模式有時也被稱為 Fire and Forget。

recv_from_socket((input) -> { var result = calculator.calculate(input) send_to_socket(result) // ignore result})

就這么簡單,通過我們自己實現(xiàn)的 Continuation,線程不再受 IO 阻塞,可以自由自在地跑滿 CPU。

一顆語法糖:Promise

回調(diào)函數(shù)哪里都好,就是不大好用,以及太丑了。

第一個問題是可讀性大大下降,由于我們繞開操作系統(tǒng)自制 Continuation,所有函數(shù)調(diào)用都要傳入一個 lambda 表達式,你的代碼看起來就像要起飛一樣,縮進止不住地往右挪(the “Callback Hell”)。

第二個問題是各種細節(jié)處理起來很麻煩,比如,考慮下異常處理,看來傳一個 Continuation 還不夠,最好再傳個異常處理的 callback。

Promise 是對異步調(diào)用結(jié)果的一個封裝,在 Java 中它叫作 CompletableFuture (JDK8) 或者 ListenableFuture (Guava)。Promise 有兩層含義:

第一層含義是:我現(xiàn)在還不是真正的結(jié)果,但是承諾以后會拿到這個結(jié)果。這很容易理解,異步的任務(wù)遲早會完成,調(diào)用者如果比較蠢萌,他也可以用 Promise.get() 強行要拿到結(jié)果,順便阻塞了當前線程,異步變成了同步。

第二層含義是:如果你(調(diào)用者)有什么吩咐,就告訴我好了。這就有趣了,換句話說,回調(diào)函數(shù)不再是傳給 g(),而是 g() 返回的 Promise,比如之前那段代碼,我們用 Promise 來書寫,看起來順眼了不少。

var promise_input = recv_from_socket()promise_input.then((input) -> { var result = calculator.calculate(input) send_to_socket(result) // ignore result})

Promise 改善了 Callback 的可讀性,也讓異常處理稍稍優(yōu)雅了些,但終究是顆語法糖。

反應式編程

反應式(Reactive)最早源于函數(shù)式編程中的一種模式,隨著微軟發(fā)起 ReactiveX 項目并一步步壯大,被移植到各種語言和平臺上。Reactive 最初在 GUI 編程中有廣泛的應用,由于異步調(diào)用的高性能,很快也在服務(wù)器后端領(lǐng)域遍地開花。

Reactive 可以看作是對 Promise 的極大增強,相比 Promise,反應式引入了流(Flow)的概念。ReactiveX 中的事件流從一個 Observable 對象流出,這個對象可以是一個按鈕,也可以是 Restful API,總之,它能被外界觸發(fā)。與 Promise 不同的是,事件可能被觸發(fā)多次,所以處理代碼也會被多次調(diào)用。

一旦允許調(diào)用多次,從數(shù)據(jù)流動的角度看,事實上模型已經(jīng)是 Push 而非 Pull。那么問題來了,如果調(diào)用頻率非常高,以至于我們處理速度跟不上了怎么辦?所以 RX 框架又引入了 Backpressure 機制來進行流控,最簡單的流控方式就是:一旦 buffer 滿,就丟棄掉之后的事件。

ReactiveX 框架的另一個優(yōu)點是內(nèi)置了很多好用的算子,比如:merge(Flow 合并),debounce(開關(guān)除顫)等等,方便了業(yè)務(wù)開發(fā)。下面是一個 RxJava 的例子:


CPS 變換:Coroutine 與 async/await

無論是反應式還是 Promise,說到底仍然沒有擺脫手工構(gòu)造 Continuation:開發(fā)者要把業(yè)務(wù)邏輯寫成回調(diào)函數(shù)。對于線性的邏輯基本可以應付自如,但是如果邏輯復雜一點呢?(比如,考慮下包含循環(huán)的情況)


有些語言例如 C#,JavaScript 和 Python 提供了 async/await 關(guān)鍵字。與 Reactive 一樣,這同樣出自微軟 C# 語言。在這些語言中,你會感到前所未有的爽感:異步編程終于擺脫了回調(diào)函數(shù)!唯一要做的只是在異步函數(shù)調(diào)用時加上 await,編譯器就會自動把它轉(zhuǎn)化為協(xié)程(Coroutine),而非昂貴的線程。

魔法的背后是 CPS 變換,CPS 變換把普通函數(shù)轉(zhuǎn)換成一個 CPS 的函數(shù),即 Continuation 也能作為一個調(diào)用參數(shù)。函數(shù)不僅能從頭運行,還能根據(jù) Continuation 的指示繼續(xù)某個點(比如調(diào)用 IO 的地方)運行。

例子可以參見我的下一篇文章。由于代碼太長,就不貼在這兒了。

可以看到,函數(shù)已經(jīng)不再是一個函數(shù)了,而是變成一個狀態(tài)機。每次 call 它、或者它 call 其他異步函數(shù)時,狀態(tài)機都會做一些計算和狀態(tài)輪轉(zhuǎn)。說好的 Continuation 在哪呢?就是對象自己(this)啊。

CPS 變換實現(xiàn)非常復雜,尤其是考慮到 try-catch 之后。但是沒關(guān)系,復雜性都在編譯器里,用戶只要學兩個關(guān)鍵詞即可。這個特性非常優(yōu)雅,比 Java 那個廢柴的 CompletableFuture 不知道高到哪去了。(更新:也沒有那么廢柴啦)

JVM 上也有一個實現(xiàn):electronicarts/ea-async,原理和 C# 的 async/await 類似,在編譯期修改 Bytecode 實現(xiàn) CPS 變換。

終極方案:用戶態(tài)線程

有了 async/await,代碼已經(jīng)簡潔很多了,基本上和同步代碼無異。是否有可能讓異步代碼和同步代碼完全一樣呢?聽起來就像免費午餐,但是的確可以做到!

用戶態(tài)線程的代表是 Golang。JVM 上也有些實現(xiàn),比如 Quasar,不過因為 JDBC、Spring 這些周邊生態(tài)(它們占據(jù)了大部分 IO 操作)的缺失基本沒有什么用。

用戶態(tài)線程是把操作系統(tǒng)提供的線程機制完全拋棄,換句話說,不去用這個 VM 的虛擬化機制。比如硬件有 8 個核心,那就創(chuàng)建 8 個系統(tǒng)線程,然后把 N 個用戶線程調(diào)度到這 8 個系統(tǒng)線程上跑。N 個用戶線程的調(diào)度在用戶進程里實現(xiàn),由于一切都在進程內(nèi)部,切換代價要遠遠小于操作系統(tǒng) Context Switch。


另一方面,所有可能阻塞系統(tǒng)級線程的事情,例如 sleep()、recv() 等,用戶態(tài)線程一定不能碰,否則它一旦阻塞住也就帶著那 8 個系統(tǒng)線程中的一個阻塞了。Go Runtime 接管了所有這樣的系統(tǒng)調(diào)用,并用一個統(tǒng)一的 Event loop 來輪詢和分發(fā)。

另外,由于用戶態(tài)線程很輕量,我們完全沒必要再用線程池,如果需要開線程就直接創(chuàng)建。比如 Java 中的 WebServer 幾乎一定有個線程池,而 Go 可以給每個請求開辟一個 goroutine 去處理。并發(fā)編程從未如此美好!

總結(jié)

以上方案中,Promise、Reactive 本質(zhì)上還是回調(diào)函數(shù),只是框架的存在一定程度上降低了開發(fā)者的心智負擔。而 async/await 和用戶態(tài)線程的解決方案要優(yōu)雅和徹底的多,前者通過編譯期的 CPS 變換幫用戶創(chuàng)造出 CPS 式的函數(shù)調(diào)用;后者則繞開操作系統(tǒng)、重新實現(xiàn)一套線程機制,一切調(diào)度工作由 Runtime 接管。

不知道是不是因為歷史包袱太重,Java 語言本身提供的異步編程支持弱得可憐,即便是 CompletableFuture 還是在 Java 8 才引入,其后果就是很多庫都沒有異步的支持。雖然 Quasar 在沒有語言級支持的情況下引入了 CPS 變換,但是由于缺少周邊生態(tài)的支持,實際很難用在項目中。

網(wǎng)站標題:圖示Java異步編程,清晰易懂,收藏了
標題網(wǎng)址:http://muchs.cn/news/101597.html

成都網(wǎng)站建設(shè)公司_創(chuàng)新互聯(lián),為您提供網(wǎng)站導航、建站公司、網(wǎng)站營銷搜索引擎優(yōu)化、Google、網(wǎng)站改版

廣告

聲明:本網(wǎng)站發(fā)布的內(nèi)容(圖片、視頻和文字)以用戶投稿、用戶轉(zhuǎn)載內(nèi)容為主,如果涉及侵權(quán)請盡快告知,我們將會在第一時間刪除。文章觀點不代表本網(wǎng)站立場,如需處理請聯(lián)系客服。電話:028-86922220;郵箱:631063699@qq.com。內(nèi)容未經(jīng)允許不得轉(zhuǎn)載,或轉(zhuǎn)載時需注明來源: 創(chuàng)新互聯(lián)

搜索引擎優(yōu)化