做者: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
然而做爲一位合格的程序員,你必定也據說過,線程是昂貴的:編程
這兩個緣由驅使咱們儘量避免建立太多的線程,而異步編程的目的就是消除 IO wait 阻塞——絕大多數時候,這是咱們建立一堆線程、甚至引入線程池的罪魁禍首。後端
回調函數知道的人不少,但瞭解 Continuation 的人很少。Continuation 有時被晦澀地翻譯成「計算續體」,我們仍是直接用單詞好了。promise
把一個計算過程在中間打斷,剩下的部分用一個對象表示,這就是 Continuation。操做系統暫停一個線程時保存的那些現場數據,也能夠看做一個 Continuation。有了它,咱們就能在這個點接着剛剛的斷點繼續執行。服務器
打斷一個計算過程聽起來很厲害吧!實際上它每時每刻都在發生——假設函數 f()
中間調用了 g()
,那 g()
運行完成時,要返回到 f()
剛剛調用 g()
的地方接着執行。這個過程再天然不過了,以致於全部編程語言(彙編除外)都把它掩藏起來,讓你在編程中感受不到調用棧的存在。
操做系統用昂貴的軟中斷機制實現了棧的保存和恢復。那有沒有別的方式實現 Continuation 呢?最樸素的想法就是,把全部用獲得的信息包成一個函數對象,在調用 g()
的時候一塊兒傳進去,並約定:一旦 g()
完成,就拿着結果去調用這個 Continuation。
這種編程模式被稱爲 Continuation-passing style(CPS):
f()
還未執行的部分包成一個函數對象 cont
,一同傳給被調用者 g()
;g()
函數體;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。
你應該已經發現了,這也就是回調函數,我只是換了個名字而已。
光有回調函數其實並無卵用。對於純粹的計算工做,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。
回調函數哪裏都好,就是不大好用,以及太醜了。
第一個問題是可讀性大大降低,因爲咱們繞開操做系統自制 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 的例子:
不管是反應式仍是 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 多線程系列面試題和答案,很是齊全。
近期熱文推薦:
1.600+ 道 Java面試題及答案整理(2021最新版)
2.終於靠開源項目弄到 IntelliJ IDEA 激活碼了,真香!
3.阿里 Mock 工具正式開源,幹掉市面上全部 Mock 工具!
4.Spring Cloud 2020.0.0 正式發佈,全新顛覆性版本!
以爲不錯,別忘了隨手點贊+轉發哦!