圖示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é)果。這很容易理解,異步的任務遲早會完成,調(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)用的高性能,很快也在服務器后端領(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è)務開發(fā)。下面是一個 RxJava 的例子:


CPS 變換:Coroutine 與 async/await

無論是反應式還是 Promise,說到底仍然沒有擺脫手工構(gòu)造 Continuation:開發(fā)者要把業(yè)務邏輯寫成回調(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異步編程,清晰易懂,收藏了
當前路徑:http://www.muchs.cn/news47/101597.html

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

廣告

聲明:本網(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)

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