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

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

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

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

為什么需要異步?

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

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

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


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

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

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

Continuation

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

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

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


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

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

  1. 把調(diào)用者 f() 還未執(zhí)行的部分包成一個(gè)函數(shù)對(duì)象 cont,一同傳給被調(diào)用者 g();
  2. 正常運(yùn)行 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 風(fēng)格的函數(shù)帶一個(gè)額外的參數(shù):一個(gè)顯式的 Continuation,具體來(lái)說就是個(gè)僅有一個(gè)參數(shù)的函數(shù)。當(dāng) CPS 函數(shù)計(jì)算完返回值時(shí),它 “返回” 的方式就是拿著返回值調(diào)用那個(gè) Continuation。

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

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

光有回調(diào)函數(shù)其實(shí)并沒有卵用。對(duì)于純粹的計(jì)算工作,Call Stack 就很好,為何要費(fèi)時(shí)費(fèi)力用回調(diào)來(lái)做 Continuation 呢?你說的對(duì),但僅限于沒有 IO 的情況。我們知道 IO 通常要比 CPU 慢上好幾個(gè)數(shù)量級(jí),在 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 中,進(jìn)程發(fā)起 IO 操作時(shí)也會(huì)一并輸入回調(diào)(也就是 Continuation),這大大解放了生產(chǎn)力——現(xiàn)場(chǎng)無(wú)需等待,可以立即返回去做其他事情。一旦 IO 成功后,AIO 的 Event Loop 會(huì)調(diào)用剛剛設(shè)置的回調(diào)函數(shù),把剩下的工作完成。這種模式有時(shí)也被稱為 Fire and Forget。

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

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

一顆語(yǔ)法糖:Promise

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

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

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

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

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

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

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)雅了些,但終究是顆語(yǔ)法糖。

反應(yīng)式編程

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

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

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

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


CPS 變換:Coroutine 與 async/await

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


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

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

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

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

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

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

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

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

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

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


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

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

總結(jié)

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

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

網(wǎng)站名稱:圖示Java異步編程,清晰易懂,收藏了
當(dāng)前路徑:http://www.bm7419.com/news47/101597.html

成都網(wǎng)站建設(shè)公司_創(chuàng)新互聯(lián),為您提供品牌網(wǎng)站建設(shè)網(wǎng)站策劃、域名注冊(cè)、動(dòng)態(tài)網(wǎng)站網(wǎng)站制作、移動(dòng)網(wǎng)站建設(shè)

廣告

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

成都seo排名網(wǎng)站優(yōu)化