異步編程的幾種方式,你知道幾種?

做者:Eric Fu\
連接:https://ericfu.me/several-way...html

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

這篇文章會涉及到回調、Promise、反應式、async/await、用戶態線程等異步編程的實現方案。若是你熟悉它們中的一兩種,那應該也能很快理解其餘幾個。react

爲何須要異步?

操做系統能夠看做是個虛擬機(VM),進程生活在操做系統創造的虛擬世界裏。進程不用知道到底有多少 core 多少內存,只要進程不要索取的太過度,操做系統就僞裝有無限多的資源可用。程序員

基於這個思想,線程(Thread)的個數並不受硬件限制:你的程序能夠只有一個線程、也能夠有成百上千個。操做系統會默默作好調度,讓諸多線程共享有限的 CPU 時間片。這個調度的過程對線程是徹底透明的。面試

那麼,操做系統是怎樣作到在線程無感知的狀況下調度呢?答案是上下文切換(Context Switch),簡單來講,操做系統利用軟中斷機制,把程序從任意位置打斷,而後保存當前全部寄存器——包括最重要的指令寄存器 PC 和棧頂指針 SP,還有一些線程控制信息(TCB),整個過程會產生數個微秒的 overhead。spring

然而做爲一位合格的程序員,你必定也據說過,線程是昂貴的:編程

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

這兩個緣由驅使咱們儘量避免建立太多的線程,而異步編程的目的就是消除 IO wait 阻塞——絕大多數時候,這是咱們建立一堆線程、甚至引入線程池的罪魁禍首。後端

Continuation

回調函數知道的人不少,但瞭解 Continuation 的人很少。Continuation 有時被晦澀地翻譯成「計算續體」,我們仍是直接用單詞好了。promise

把一個計算過程在中間打斷,剩下的部分用一個對象表示,這就是 Continuation。操做系統暫停一個線程時保存的那些現場數據,也能夠看做一個 Continuation。有了它,咱們就能在這個點接着剛剛的斷點繼續執行。服務器

打斷一個計算過程聽起來很厲害吧!實際上它每時每刻都在發生——假設函數 f() 中間調用了 g(),那 g() 運行完成時,要返回到 f() 剛剛調用 g() 的地方接着執行。這個過程再天然不過了,以致於全部編程語言(彙編除外)都把它掩藏起來,讓你在編程中感受不到調用棧的存在。

操做系統用昂貴的軟中斷機制實現了棧的保存和恢復。那有沒有別的方式實現 Continuation 呢?最樸素的想法就是,把全部用獲得的信息包成一個函數對象,在調用 g() 的時候一塊兒傳進去,並約定:一旦 g() 完成,就拿着結果去調用這個 Continuation。

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

  1. 把調用者 f() 還未執行的部分包成一個函數對象 cont,一同傳給被調用者 g()
  2. 正常運行 g() 函數體;
  3. g() 完成後,連同它的結果一塊兒回調 cont,從而繼續執行 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 風格的函數帶一個額外的參數:一個顯式的 Continuation,具體來講就是個僅有一個參數的函數。當 CPS 函數計算完返回值時,它「返回」的方式就是拿着返回值調用那個 Continuation。

你應該已經發現了,這也就是回調函數,我只是換了個名字而已。

異步的樸素實現:Callback

光有回調函數其實並無卵用。對於純粹的計算工做,Call Stack 就很好,爲什麼要費時費力用回調來作 Continuation 呢?你說的對,但僅限於沒有 IO 的狀況。咱們知道 IO 一般要比 CPU 慢上好幾個數量級,在 BIO 中,線程發起 IO 以後只能暫停,而後等待 IO 完成再由操做系統喚醒。

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

而異步 IO 中,進程發起 IO 操做時也會一併輸入回調(也就是 Continuation),這大大解放了生產力——現場無需等待,能夠當即返回去作其餘事情。一旦 IO 成功後,AIO 的 Event Loop 會調用剛剛設置的回調函數,把剩下的工做完成。這種模式有時也被稱爲 Fire and Forget。

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

就這麼簡單,經過咱們本身實現的 Continuation,線程再也不受 IO 阻塞,能夠自由自在地跑滿 CPU。

一顆語法糖:Promise

回調函數哪裏都好,就是不大好用,以及太醜了。

第一個問題是可讀性大大降低,因爲咱們繞開操做系統自制 Continuation,全部函數調用都要傳入一個 lambda 表達式,你的代碼看起來就像要起飛同樣,縮進止不住地往右挪(the "Callback Hell")。

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

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

第一層含義是:我如今還不是真正的結果,可是承諾之後會拿到這個結果。這很容易理解,異步的任務早晚會完成,調用者若是比較蠢萌,他也能夠用 Promise.get() 強行要拿到結果,順便阻塞了當前線程,異步變成了同步。

第二層含義是:若是你(調用者)有什麼吩咐,就告訴我好了。這就有趣了,換句話說,回調函數再也不是傳給 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 的可讀性,也讓異常處理稍稍優雅了些,但終究是顆語法糖。

反應式編程

反應式(Reactive)最先源於函數式編程中的一種模式,隨着微軟發起 ReactiveX 項目並一步步壯大,被移植到各類語言和平臺上。Reactive 最初在 GUI 編程中有普遍的應用,因爲異步調用的高性能,很快也在服務器後端領域遍地開花。

Reactive 能夠看做是對 Promise 的極大加強,相比 Promise,反應式引入了流(Flow)的概念。ReactiveX 中的事件流從一個 Observable 對象流出,這個對象能夠是一個按鈕,也能夠是 Restful API,總之,它能被外界觸發。與 Promise 不一樣的是,事件可能被觸發屢次,因此處理代碼也會被屢次調用。

一旦容許調用屢次,從數據流動的角度看,事實上模型已是 Push 而非 Pull。那麼問題來了,若是調用頻率很是高,以致於咱們處理速度跟不上了怎麼辦?因此 RX 框架又引入了 Backpressure 機制來進行流控,最簡單的流控方式就是:一旦 buffer 滿,就丟棄掉以後的事件。

ReactiveX 框架的另外一個優勢是內置了不少好用的算子,好比:merge(Flow 合併),debounce(開關除顫)等等,方便了業務開發。下面是一個 RxJava 的例子:

CPS 變換:Coroutine 與 async/await

不管是反應式仍是 Promise,說到底仍然沒有擺脫手工構造 Continuation:開發者要把業務邏輯寫成回調函數。對於線性的邏輯基本能夠應付自如,可是若是邏輯複雜一點呢?(好比,考慮下包含循環的狀況)

有些語言例如 C#,JavaScript 和 Python 提供了 async/await 關鍵字。與 Reactive 同樣,這一樣出自微軟 C# 語言。在這些語言中,你會感到史無前例的爽感:異步編程終於擺脫了回調函數!惟一要作的只是在異步函數調用時加上 await,編譯器就會自動把它轉化爲協程(Coroutine),而非昂貴的線程。

魔法的背後是 CPS 變換,CPS 變換把普通函數轉換成一個 CPS 的函數,即 Continuation 也能做爲一個調用參數。函數不只能從頭運行,還能根據 Continuation 的指示繼續某個點(好比調用 IO 的地方)運行。

能夠看到,函數已經再也不是一個函數了,而是變成一個狀態機。每次 call 它、或者它 call 其餘異步函數時,狀態機都會作一些計算和狀態輪轉。說好的 Continuation 在哪呢?就是對象本身(this)啊。

CPS 變換實現很是複雜,尤爲是考慮到 try-catch 以後。可是不要緊,複雜性都在編譯器裏,用戶只要學兩個關鍵詞便可。這個特性很是優雅,比 Java 那個廢柴的 CompletableFuture 不知道高到哪去了

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

終極方案:用戶態線程

有了 async/await,代碼已經簡潔不少了,基本上和同步代碼無異。是否有可能讓異步代碼和同步代碼徹底同樣呢?聽起來就像免費午飯,可是的確能夠作到!

用戶態線程的表明是 Golang。JVM 上也有些實現,好比 Quasar,不過由於 JDBC、Spring 這些周邊生態(它們佔據了大部分 IO 操做)的缺失基本沒有什麼用。

用戶態線程是把操做系統提供的線程機制徹底拋棄,換句話說,不去用這個 VM 的虛擬化機制。好比硬件有 8 個核心,那就建立 8 個系統線程,而後把 N 個用戶線程調度到這 8 個系統線程上跑。N 個用戶線程的調度在用戶進程裏實現,因爲一切都在進程內部,切換代價要遠遠小於操做系統 Context Switch。

另外一方面,全部可能阻塞系統級線程的事情,例如 sleep()recv() 等,用戶態線程必定不能碰,不然它一旦阻塞住也就帶着那 8 個系統線程中的一個阻塞了。Go Runtime 接管了全部這樣的系統調用,並用一個統一的 Event loop 來輪詢和分發。

另外,因爲用戶態線程很輕量,咱們徹底不必再用線程池,若是須要開線程就直接建立。好比 Java 中的 WebServer 幾乎必定有個線程池,而 Go 能夠給每一個請求開闢一個 goroutine 去處理。併發編程從未如此美好!

總結

以上方案中,Promise、Reactive 本質上仍是回調函數,只是框架的存在必定程度上下降了開發者的心智負擔。而 async/await 和用戶態線程的解決方案要優雅和完全的多,前者經過編譯期的 CPS 變換幫用戶創造出 CPS 式的函數調用;後者則繞開操做系統、從新實現一套線程機制,一切調度工做由 Runtime 接管。

不知道是否是由於歷史包袱過重,Java 語言自己提供的異步編程支持弱得可憐,即使是 CompletableFuture 仍是在 Java 8 才引入,其後果就是不少庫都沒有異步的支持。雖然 Quasar 在沒有語言級支持的狀況下引入了 CPS 變換,可是因爲缺乏周邊生態的支持,實際很難用在項目中。

最後,關注公衆號Java技術棧,在後臺回覆:面試,能夠獲取我整理的 Java 多線程系列面試題和答案,很是齊全。

References

  1. https://blog.tsunanet.net/201...
  2. http://reactivex.io/
  3. https://zhuanlan.zhihu.com/p/...
  4. http://docs.paralleluniverse....
  5. http://morsmachine.dk/go-sche...
  6. https://medium.com/@ThatGuyTi...

近期熱文推薦:

1.600+ 道 Java面試題及答案整理(2021最新版)

2.終於靠開源項目弄到 IntelliJ IDEA 激活碼了,真香!

3.阿里 Mock 工具正式開源,幹掉市面上全部 Mock 工具!

4.Spring Cloud 2020.0.0 正式發佈,全新顛覆性版本!

5.《Java開發手冊(嵩山版)》最新發布,速速下載!

以爲不錯,別忘了隨手點贊+轉發哦!

相關文章
相關標籤/搜索