從根本上了解異步編程體系

作者:ronaldoliu,騰訊 IEG 后臺(tái)開發(fā)工程師
或許你也聽說了,摩爾定律失效了。技術(shù)的發(fā)展不會(huì)永遠(yuǎn)是指數(shù)上升,當(dāng)芯片的集成度越來越高,高到 1 平方毫米能集成幾億個(gè)晶體管時(shí),也就是人們常說的幾納米工藝,我們的半導(dǎo)體行業(yè)就踩到天花板了。因?yàn)樵傩∠氯ィw管內(nèi)甚至都快無法通過一個(gè)原子了,然后就是不得不面臨量子效應(yīng),也就是人們常開玩笑說的——玄學(xué),所謂遇事不決,量子力學(xué)。
總而言之,我們的計(jì)算機(jī)硬件技術(shù)發(fā)展到了瓶頸期了,CPU 的運(yùn)行速度幾乎不會(huì)再有太多提升了。并且隨著移動(dòng)互聯(lián)網(wǎng)的普及和萬物互聯(lián),我們的應(yīng)用面臨的請(qǐng)求壓力也越來越大。當(dāng)硬件能力難以提升時(shí),我們就不得不比以往任何時(shí)候都更加需要在軟件和系統(tǒng)層面進(jìn)行優(yōu)化。
計(jì)算機(jī)中有一個(gè)非常顯著的特點(diǎn),就是不同硬件的訪問速度有著天壤之別,這讓幾乎所有的優(yōu)化都是圍繞這個(gè)點(diǎn)來進(jìn)行。Jeff Dean 曾提出了一組著名的數(shù)字來描述這些硬件訪問速度的差別,我們可以通過這個(gè)頁面有個(gè)直觀的感受。

其中我們需要關(guān)注的就是內(nèi)存訪問和網(wǎng)絡(luò)請(qǐng)求以及磁盤訪問的耗時(shí)數(shù)量級(jí)。
由于 CPU 內(nèi)部做了很多優(yōu)化(比如流水線),我們可以粗略地認(rèn)為執(zhí)行一條指令時(shí)間約是 1ns(幾個(gè)時(shí)鐘周期)。而內(nèi)存訪問大概是 100ns 的數(shù)量級(jí),也就是比執(zhí)行一條指令慢 100 倍;從 ssd 上隨機(jī)讀取一次的時(shí)間是 16000ns 級(jí)別,而從機(jī)械磁盤讀取一次,則需要 2000000ns=2ms。我們無時(shí)無刻都在使用的網(wǎng)絡(luò),橫跨大西洋跑一個(gè)來回需要的時(shí)間約 150000000ns=150ms。你可以看到,相比這些硬件,CPU 的運(yùn)行速度真的是太快太快了?;蛟S ns 級(jí)單位不直觀,那可以把它換成我們更熟悉的秒來感受下。假如 CPU 執(zhí)行一條指令是 1 秒,那么訪問內(nèi)存需要 1 分 40 秒,從 SSD 上隨機(jī)讀取一次數(shù)據(jù)需要 4 小時(shí) 24 分鐘,從磁盤讀取一次數(shù)據(jù)需要 23 天多,一次橫跨大西洋的網(wǎng)絡(luò)請(qǐng)求則需要 4.8 年…你現(xiàn)在可以直觀地感受到 CPU 有多么快了吧。
但是快也有快的煩惱,正所謂無敵是多么寂寞。正因?yàn)?CPU 速度太快,從 CPU 的角度來說,那其它硬件的速度太慢了(相對(duì)論?)。然而關(guān)鍵問題是,我們程序的運(yùn)算幾乎都會(huì)依賴這些“慢”硬件,比如在硬盤上讀取某些文件的數(shù)據(jù)到內(nèi)存中,再對(duì)這些數(shù)據(jù)進(jìn)行運(yùn)算。這就不得不面臨一個(gè)問題,由于某條指令依賴于從硬盤加載的數(shù)據(jù),CPU 為了執(zhí)行這條指令就不得不等到硬盤數(shù)據(jù)加載完。比如要執(zhí)行answer = a+1,但是 a 存在磁盤上。為了執(zhí)行一條 1ns 的加法運(yùn)算,CPU 卻等了 20000000ns,有種等到宇宙盡頭的感覺。
為了應(yīng)對(duì)包括以上這個(gè)問題等其它一系列問題,計(jì)算機(jī)先驅(qū)們就設(shè)計(jì)了支持分時(shí)任務(wù)的操作系統(tǒng)。我們不說那些老生常談的操作系統(tǒng)歷史了,直接快進(jìn)到現(xiàn)在,這個(gè)分時(shí)任務(wù)可以粗略地對(duì)應(yīng)成我們常說的線程。操作系統(tǒng)以線程作為最小調(diào)度執(zhí)行單位,線程代表一個(gè)獨(dú)立的計(jì)算任務(wù),當(dāng)遇到需要費(fèi)時(shí)的操作時(shí),操作系統(tǒng)就把當(dāng)前線程停下,讓 CPU 去執(zhí)行別的線程任務(wù),這樣 CPU 就不需要為了要執(zhí)行一個(gè)幾納秒的指令而等上兩百萬納秒。如果操作系統(tǒng)調(diào)度得當(dāng),可以大大提高 CPU 的利用率,在相同的時(shí)間內(nèi)完成多得多的任務(wù)。
在 20 年前,利用多線程就是解決并發(fā)的最主流方案。當(dāng)時(shí)最流行的 apache 服務(wù)器就是多線程模型,來一個(gè)請(qǐng)求就新建一個(gè)線程去處理,操作系統(tǒng)負(fù)責(zé)回收和調(diào)度這些線程。這在當(dāng)年是完全沒有問題的。想想 20 年前,那時(shí)候網(wǎng)絡(luò)還不發(fā)達(dá),用電腦上網(wǎng)的人非常少,網(wǎng)站的功能也非常簡單,因此服務(wù)器不會(huì)面臨太大的并發(fā)訪問。但是隨著時(shí)間的推移,尤其是移動(dòng)互聯(lián)網(wǎng)的發(fā)展,萬物互聯(lián),大家基本上也都是手機(jī)不離手。因而大型網(wǎng)站面臨的用戶訪問量指數(shù)級(jí)地增大,多線程的不足很快就顯露出來,尤其是在 web 領(lǐng)域。
為什么 web 領(lǐng)域是一個(gè)典型呢?因?yàn)榇蠖鄶?shù) web 服務(wù)都是 IO 密集型,通常都是:
收到請(qǐng)求?->?查數(shù)據(jù)庫?->?RPC別的幾個(gè)服務(wù)?->?組合一下數(shù)據(jù)?->?返回
在這個(gè)過程中,CPU 的參與其實(shí)很少,絕大部分時(shí)間都是在等待 DB 響應(yīng)以及等待下游服務(wù)的響應(yīng)。如果使用多線程模型來做 web server,你就會(huì)發(fā)現(xiàn),雖然操作系統(tǒng)上有很多線程,但絕大部分線程都處在等待網(wǎng)絡(luò)響應(yīng)或者等待磁盤讀取中,CPU 的利用率依然很低,而且大部分 CPU 都耗在操作系統(tǒng)的線程調(diào)度上了。并且,隨著并發(fā)請(qǐng)求量的增加,線程的開銷也越來越不容忽視。由于每個(gè)線程都有自己獨(dú)有的堆??臻g,一般默認(rèn)是 8M。我們簡單計(jì)算一下,光 1000 個(gè)線程就會(huì)占用 8G 的內(nèi)存。加上線程切換時(shí)的開銷,每次切換,操作系統(tǒng)需要保存當(dāng)前線程的各種寄存器,后續(xù)才能夠恢復(fù)線程繼續(xù)執(zhí)行。當(dāng)線程數(shù)量多時(shí),這個(gè)開銷也是比較可觀的。
因此,即使多線程是最直觀最容易理解且操作系統(tǒng)天然支持的解決并發(fā)的方案,但是由于系統(tǒng)面臨的并發(fā)數(shù)越來越大,在有限的資源下,我們也不得不尋找更好的解決方法。
終于進(jìn)入正題了,異步。有一點(diǎn)需要提前說明:
異步的目的不是讓單個(gè)任務(wù)執(zhí)行得更快,而是為了讓計(jì)算機(jī)在相同時(shí)間內(nèi)可以完成更多任務(wù)。
其實(shí)異步是一個(gè)非常復(fù)雜而龐大的體系,主要可以分為三個(gè)方面:
硬件 操作系統(tǒng) 異步編程范式
毫無疑問硬件是支持異步的根本,但是我們既然標(biāo)題是“異步編程體系”,拿重心還是在“程序”上,著重聊聊和異步相關(guān)的操作系統(tǒng)和編程范式。我在平時(shí)和大家聊天以及看網(wǎng)上 blog 的時(shí)候會(huì)發(fā)現(xiàn),很多人一聊到異步總是很容易把操作系統(tǒng)對(duì)異步的支持和異步編程范式混雜在一起。聊到異步最常見的關(guān)鍵詞就是:IO 多路復(fù)用、epoll、libev、回調(diào)地獄、async/await 等等。接下來的文章,我將比較成體系地梳理一下這些概念,讓你真正地從根本上了解異步相關(guān)的東西。
初步理解異步的收益
我們從一段最簡單的代碼開始。
let?number?=?read("number.txt");
print(number?+?1);
以上偽代碼從 number.txt 這個(gè)文件中讀取一個(gè)數(shù)字,把它加一然后輸出到屏幕,非常簡單。由于需要一個(gè)確定的 number 的值才能進(jìn)行 number+1 計(jì)算,因此要執(zhí)行 print 前必然要等 read 執(zhí)行完成(number 才有值)。而 read 是去讀取磁盤上的文件,物理上就需要很長的時(shí)間,所以要完成 read+print,總的耗時(shí)是不會(huì)因?yàn)椴捎猛竭€是異步就會(huì)發(fā)生變化。因此:
單個(gè)異步任務(wù)絕不會(huì)比同步任務(wù)執(zhí)行得更快
我們用異步最大的目的是充分利用 CPU 資源。接著上面的例子,假如操作系統(tǒng)提供了一個(gè) read_async 函數(shù),調(diào)用它之后能夠立刻返回一個(gè)對(duì)象,我們后續(xù)可以通過這個(gè)對(duì)象來判斷讀取操作是否完成了。來看看我們的代碼可能會(huì)有什么變化:
let?operation?=?read_async("number.txt");
let?number?=?0;
while?true?{
??if?operation.is_finish()?{
????number?=?operation.get_content();
????break;
??}
}
print(number+1);
似乎變得更糟了??!由于必須要確定 number 的值才能執(zhí)行 print,因此即使我們立刻拿到一個(gè) operation 對(duì)象,我們除了不停地詢問它是否就緒以外,也沒有別的辦法了。CPU 使用率倒是實(shí)打?qū)嵦岣吡?,但是全部都用在了不斷詢問操作是否就緒這個(gè)循環(huán)上了。和之前的同步代碼相比,做同樣的任務(wù),這樣的異步代碼耗費(fèi)了相同的時(shí)間,卻花費(fèi)了多得多的 CPU,但是卻并沒有完成更多的任務(wù)。這樣寫異步代碼,除了更費(fèi)電別無它用,還讓代碼變得更復(fù)雜?。∧钱惒降膬r(jià)值到底在哪里?
接著上面的例子,我稍微變化一下:
let?a?=?read("qq.com");
let?b?=?read("jd.com");
print(a+b);
假如單個(gè)網(wǎng)絡(luò)請(qǐng)求耗時(shí) 50ms,忽略 print 的耗時(shí),那么上面這段代碼總耗時(shí)就是 100ms。我們?cè)儆卯惒娇纯矗?/p>
let?op_a?=?read_async("qq.com");
let?op_b?=?read_async("jd.com");
let?a?=?“”;
let?b?=?“”;
while?true?{
??if?op_a.is_finish()?{
????a?=?op_a.get_content();
????break;
??}
}
while?true?{
??if?op_b.is_finish()?{
????b?=?op_b.get_content();
????break;
??}
}
print(a+b);
同樣,即使是異步讀取,程序中立刻返回了,但是也是要等到至少 50ms 以后才有結(jié)果返回。但是這里差別就出來了。當(dāng) CPU 一直循環(huán)執(zhí)行op_a.is_finish()50ms 以后,它終于完成了,此時(shí) a 有了確定的值。然后程序繼續(xù)詢問 op_b。這里要注意了,一開始程序連續(xù)執(zhí)行兩個(gè)異步請(qǐng)求,這兩個(gè)請(qǐng)求同時(shí)發(fā)送出去了,理想情況下它們可以同時(shí)完成。也就是說很可能在 50.001ms 時(shí),op_b 也就緒了。那么這段異步代碼最終執(zhí)行耗時(shí)就是 50ms 左右,相比同步代碼節(jié)約了整整一半的時(shí)間。
異步并不會(huì)讓邏輯上串行的任務(wù)變快,只能讓邏輯上可以并行的任務(wù)執(zhí)行更快
雖然以上異步代碼執(zhí)行速度更快了,但是它也付出了額外的代價(jià)。同步代碼雖然執(zhí)行耗時(shí) 100ms,但是 CPU 可以認(rèn)為一直處于“休眠狀態(tài)”;而以上異步代碼中,CPU 卻一直在不斷地詢問操作是否完成。速度更快,但更費(fèi)電了??!
結(jié)合同步的優(yōu)勢(shì)
在上面的同步代碼中,執(zhí)行一個(gè)同步調(diào)用,操作系統(tǒng)會(huì)把當(dāng)前線程掛起,等調(diào)用成功后再喚醒線程繼續(xù)執(zhí)行,這個(gè)過程中 CPU 可以去執(zhí)行別的線程的任務(wù),而不會(huì)空轉(zhuǎn)。如果沒有別的任務(wù),甚至可以處于半休眠狀態(tài)。這說明了一個(gè)關(guān)鍵問題,即操作系統(tǒng)是完全知道一個(gè)磁盤讀取或者網(wǎng)絡(luò)調(diào)用什么時(shí)候完成的,只有這樣它才能在正確的時(shí)間喚醒對(duì)應(yīng)線程(操作系統(tǒng)在這里用到的技術(shù)就是中斷,這不在本文的范圍內(nèi)就不多討論了)。既然操作系統(tǒng)有這個(gè)能力,那么假如操作系統(tǒng)提供這樣一個(gè)函數(shù):
fn?wait_until_get_ready(Operation)?->?Response?{
??//?阻塞任務(wù),掛起線程,直到operation就緒再喚起線程
}
有了這個(gè)函數(shù),那我們的異步代碼就可以這么寫:
let?op_a?=?read_async("qq.com");
let?op_b?=?read_async("jd.com");
let?a?=?wait_until_get_ready(op_a);
let?b?=?wait_until_get_ready(op_b);
print(a+b);
當(dāng)調(diào)用wait_until_get_ready(op_a)時(shí),op_a 還沒有就緒,操作系統(tǒng)就掛起當(dāng)前線程,直到 50ms 以后 op_a 就緒。這個(gè)過程就像執(zhí)行同步阻塞代碼一樣不耗費(fèi) CPU 資源。然后繼續(xù)執(zhí)行wait_until_get_ready(op_b),發(fā)現(xiàn) op_b 也就緒了。這樣,我們就可以利用異步代碼,只花費(fèi) 50ms,并且不花費(fèi)額外的 CPU 資源,就能完成這個(gè)任務(wù),完美!
要讓我們的異步代碼能夠做到這點(diǎn),其實(shí)依賴兩個(gè)關(guān)鍵因素:
read_async 把任務(wù)交給操作系統(tǒng)后能夠立刻返回,而不會(huì)一直阻塞到它執(zhí)行完畢。通過這個(gè)能力,我們可以讓邏輯上沒有依賴的任務(wù)并發(fā)執(zhí)行 wait_until_get_ready 依賴于操作系統(tǒng)的通知能力,不用自己去輪詢,大大節(jié)約了 CPU 資源
read_async 和 wait_until_get_ready 是我寫的偽代碼函數(shù),而要實(shí)現(xiàn)它們離不開操作系統(tǒng)的底層支撐。這里涉及到非常多的技術(shù),比如常聽說的,select、poll、epoll 這些 Linux 系統(tǒng)支持的方法。kqueue 和 iocp 則分別是 mac 和 windows 版本的"epoll"。不同操作系統(tǒng)實(shí)現(xiàn)這些機(jī)制的原理也不盡相同的,而且相當(dāng)?shù)膹?fù)雜。順便一提,Linux 的 epoll 的設(shè)計(jì)和性能都不如 windows 的 iocp,最新的 5.1 內(nèi)核加入了 io_uring,算是向 windows 的 iocp 致敬了,這可能也會(huì)給 Linux 異步編程帶來更多新的變化。操作系統(tǒng)和硬件對(duì)于異步編程非常重要,不過我決定后面再展開講。
從 0 開始進(jìn)化成 Javascript
接下來我還是基于上面兩個(gè)“異步原語”,來講講是我個(gè)人認(rèn)為在異步編程體系中更為重要而且我們開發(fā)者日常接觸最多的部分——異步編程范式。
上面的例子都是非常簡單的場(chǎng)景,而在實(shí)際場(chǎng)景中,真正并發(fā)幾個(gè)沒有關(guān)聯(lián)的任務(wù)然后等待它們執(zhí)行結(jié)束其實(shí)是不多見的,大多數(shù)是有邏輯依賴關(guān)系的。在有邏輯關(guān)聯(lián)關(guān)系的情況下,我們的代碼將會(huì)變得非常難以實(shí)現(xiàn),難以閱讀。
我們繼續(xù)前面的例子(稍作修改):
let?op_a?=?read_async("qq.com");
let?op_b?=?read_async("jd.com");
let?a?=?wait_until_get_ready(op_a);
write_to("qq.html",?a);
let?b?=?wait_until_get_ready(op_b);
write_to("jd.html",?b);
之前我們假設(shè)每個(gè)異步請(qǐng)求耗時(shí)都是 50ms,但其實(shí)絕大多數(shù)時(shí)候是無法做出這種假設(shè)的,尤其是在網(wǎng)絡(luò)環(huán)境下,兩個(gè)網(wǎng)絡(luò)請(qǐng)求很大概率響應(yīng)時(shí)長不一樣,這個(gè)很容易理解。當(dāng)我們發(fā)出兩個(gè)并發(fā)請(qǐng)求后,其實(shí)并不知道哪個(gè)請(qǐng)求會(huì)先響應(yīng)。我假設(shè) qq.com 的響應(yīng)時(shí)長是 50ms,而 jd.com 的響應(yīng)時(shí)長是 10ms,那么上面的程序會(huì)有什么問題呢?
如果我們先let a = wait_until_get_ready(op_a);,此時(shí)線程會(huì)阻塞直到 op_a 就緒,也就是 50ms 以后才能繼續(xù)執(zhí)行后面的語句。但其實(shí) op_b 早在第 10ms 就已經(jīng)有響應(yīng)了,但我們的程序并沒有及時(shí)去處理。這里的根本原因就是,我們寫代碼時(shí)并不知道每個(gè)異步請(qǐng)求會(huì)在什么時(shí)刻完成,只能按照某種特定順序來執(zhí)行 wait_until_get_ready 操作,這樣勢(shì)必會(huì)造成效率低下。怎么辦呢?
這里的問題就出在 wait_until_get_ready 只支持 wait 一個(gè)異步操作,不好用。那我們可以考慮給開發(fā)操作系統(tǒng)的 Linux Torvads 大爺提需求,系統(tǒng)需要支持這樣的兩個(gè)函數(shù):
fn?add_to_wait_list(operations:?Vec)
fn?wait_until_some_of_them_get_ready()?->Vec
通過add_to_wait_list向全局的監(jiān)聽器注冊(cè)需要監(jiān)聽的異步操作,然后利用wait_until_some_of_them_get_ready,如果沒有事件就緒就阻塞等待,當(dāng)注冊(cè)的異步操作有就緒的了(可能有多個(gè)),就喚醒線程并返回一個(gè)數(shù)組告訴調(diào)用方哪些操作就緒了。如果監(jiān)聽隊(duì)列為空時(shí),wait_until_some_of_them_get_ready 不會(huì)阻塞而直接返回一個(gè)空數(shù)組??梢韵胂螅?dāng) Linux Torvads 排了幾個(gè) User Story 讓操作系統(tǒng)支持了這兩個(gè)功能并給我提供了庫函數(shù)之后,我們的異步代碼就可以更進(jìn)一步:
let?op_a?=?read_async("qq.com");
let?op_b?=?read_async("jd.com");
add_to_wait_list([op_a,?op_b]);
while?true?{
??let?list?=?wait_until_some_of_them_get_ready();
??if?list.is_empty()?{
????break;
??}
??for?op?in?list?{
????if?op.equal(op_a)?{
??????write_to("qq.html",?op.get_content());
????}?else?if?op.equal(op_b)?{
??????write_to("jd.html",?op.get_content());
????}
??}
}
通過這種方式,我們的程序能夠及時(shí)地響應(yīng)異步操作,避免盲目地等待,收到一個(gè)響應(yīng)就能立刻輸出一個(gè)文件。
不過你如果仔細(xì)思考,可以發(fā)現(xiàn)這里還有兩個(gè)問題。第一是由于異步操作的耗時(shí)不同,每次 wait_until_some_of_them_get_ready 返回的可能是一個(gè)就緒的異步操作,也可能是多個(gè),因此我們必須要通過一個(gè) while 循環(huán)不斷去 wait,直到隊(duì)列所有異步操作都就緒為止。第二個(gè)問題是,由于返回了一個(gè)就緒的異步操作的列表,每個(gè)異步操作后續(xù)的邏輯可能都不一樣,我們必須要先判斷是什么事件就緒才能執(zhí)行對(duì)應(yīng)的邏輯,因此不得不做一個(gè)很復(fù)雜的循環(huán)比較。想象一下,如果等待列表里有 1 萬個(gè)異步操作,且每個(gè)異步操作對(duì)應(yīng)的處理邏輯都不一樣,那我們這個(gè)循環(huán)判斷的代碼得多么復(fù)雜——一萬個(gè) switch case 分支!!所以應(yīng)該怎么辦呢?
其實(shí)有個(gè)很簡單的解決辦法:由于 operation 和其就緒后要執(zhí)行的邏輯是一一對(duì)應(yīng)的,因此我們可以直接把對(duì)應(yīng)的后續(xù)執(zhí)行函數(shù)綁定到 operation 上比如:
function?read_async_v1(targetURL:?String,?callback:?Function)?{
??let?operation?=?read_async("qq.com");
??operation.callback?=?callback;
??return?operation;
}
這樣我們可以在創(chuàng)建異步任務(wù)時(shí)就綁定上它后續(xù)的邏輯,也就是所謂的回調(diào)函數(shù)。然后我們 while 循環(huán)內(nèi)部就徹底清爽了,而且避免了一次 O(n)的循環(huán)匹配。這是不是就是 C++所謂的動(dòng)態(tài)派發(fā):
let?op_a?=?read_async_v1("qq.com",?function(data)?{
??send_to("[email protected]",?data);
});
let?op_b?=?read_async_v1("jd.com",?function(data)?{
??write_to("jd.html",?data);
});
add_to_wait_list([op_a,?op_b]);
while?true?{
??let?list?=?wait_until_some_of_them_get_ready();
??if?list.is_empty()?{
????break;
??}
??for?op?in?list?{
????op.callback(op.get_content());
??}
}
這里的關(guān)鍵一步是,read_async 返回的 operation 對(duì)象需要能夠支持綁定回調(diào)函數(shù)。
當(dāng)做到這一步時(shí),其實(shí)我們還可以再更進(jìn)一步,讓 read_async_v1 自己去注冊(cè)監(jiān)聽器:
function?read_async_v2(target,?callback)?{
??let?operation?=?read_async(target);
??operation.callback?=?callback;
??add_to_wait_list([operation]);
}
這樣我們的代碼可以更簡化:
read_async_v2("qq.com",?function(data)?{
??send_to("[email protected]",?data);
});
read_async_v2("jd.com",?function(data)?{
??write_to("jd.html",?data);
});
while?true?{
??let?list?=?wait_until_some_of_them_get_ready();
??if?list.is_empty()?{
????break;
??}
??for?op?in?list?{
????op.callback(op.get_content());
??}
}
由于我們把后續(xù)邏輯都綁定到 operation 上了,每個(gè)異步程序都需要在最后執(zhí)行上述的 while 循環(huán)來等待異步事件就緒,然后執(zhí)行其回調(diào)。因此如果我們有機(jī)會(huì)設(shè)計(jì)一門編程語言,那就可以把這段代碼放到語言的運(yùn)行時(shí)里,讓用戶不需要每次都在最后加這么一段。經(jīng)過這個(gè)改造后,我們的代碼就變成了:
read_async_v2("qq.com",?function(data)?{
??send_to("[email protected]",?data);
});
read_async_v2("jd.com",?function(data)?{
??write_to("jd.html",?data);
});
//?編譯器幫我們把while循環(huán)自動(dòng)插到這里
//?或者什么異步框架幫我們做while循環(huán)
你看,這是不是就是 javascript 了??!js 的 v8 引擎幫我們執(zhí)行了 while 循環(huán),也就是 JS 里大家常說的EventLoop。
可以看到,其實(shí)我們沒有運(yùn)用任何黑魔法,只依賴幾個(gè)操作系統(tǒng)提供的基本元語就可以很自然地過渡到 Javascript 的異步編程模型。
再簡單回顧一下:
為了讓程序在同一時(shí)間內(nèi)處理更多的請(qǐng)求,我們采用多線程。多線程雖然編寫簡單,但是對(duì)內(nèi)存和 CPU 資源消耗大,因此我們考慮利用系統(tǒng)的異步接口進(jìn)行開發(fā); 我不知道異步操作什么時(shí)候結(jié)束,只能不停的輪詢它的狀態(tài)。當(dāng)有多個(gè)異步操作,每個(gè)的響應(yīng)時(shí)間都未知,不知道該去先輪詢哪個(gè)。我們利用操作系統(tǒng)提供的能力,把異步事件加入全局監(jiān)聽隊(duì)列,然后通過 wait_until_some_of_them_get_ready 來等待任意事件就緒,所謂的 EventLoop; 當(dāng)事件就緒后 EventLoop 不知道該執(zhí)行什么邏輯,只能進(jìn)行一個(gè)非常復(fù)雜的判斷才能確認(rèn)后續(xù)邏輯該執(zhí)行哪個(gè)函數(shù)。因此我們給每個(gè)異步事件注冊(cè)回調(diào)函數(shù),這樣 EventLoop 的實(shí)現(xiàn)就高效而清爽了; 所有異步程序都需要在最后執(zhí)行 EventLoop 等待事件就緒然后執(zhí)行回調(diào),因此可以把 EventLoop 這塊邏輯放到語言自身的 runtime 中,不需要每次自己寫。
我們上述利用到的wait_until_some_of_them_get_ready對(duì)應(yīng)到真實(shí)的操作系統(tǒng)上,其實(shí)就是 Linux 的 epoll,Mac 的 kqueue 以及 windows 的 iocp。其實(shí)不止 javascript,C 和 C++很多異步框架也是類似的思路,比如著名的 Redis 使用的 libevent,以及 nodejs 使用的 libuv。這些框架的共同特點(diǎn)就是,它們提供了多種異步的 IO 接口,支持事件注冊(cè)以及通過回調(diào)來進(jìn)行異步編程。只是像 C 代碼,由于不支持閉包,基于它們實(shí)現(xiàn)的異步程序,實(shí)際上比 js 開發(fā)的更難以閱讀和調(diào)試。
所以根據(jù)上述的演進(jìn)過程,你是不是覺得 js 的異步回調(diào)對(duì)于編寫異步代碼已經(jīng)是一個(gè)相當(dāng)高級(jí)的編程方式了。不過接下來才是真正的魔鬼!
回調(diào)地獄
基于回調(diào)開發(fā)異步代碼,很快就會(huì)遇到臭名昭著的回調(diào)地獄。比如:
login(user?=>?{
????getStatus(status?=>?{
????????getOrder(order?=>?{
????????????getPayment(payment?=>?{
????????????????getRecommendAdvertisements(ads?=>?{
????????????????????setTimeout(()?=>?{
????????????????????????alert(ads)
????????????????????},?1000)
????????????????})
????????????})
????????})
????})
})
以上代碼就是所謂的回調(diào)地獄,由于每次異步操作都需要有一個(gè)回調(diào)函數(shù)來執(zhí)行就緒后的后續(xù)邏輯,因此當(dāng)遇上各個(gè)異步操作之前有先后關(guān)系時(shí),勢(shì)必就要回調(diào)套回調(diào)。當(dāng)業(yè)務(wù)代碼一復(fù)雜,回調(diào)套回調(diào)寫多了,造成代碼難以閱讀和調(diào)試,就成了所謂的回調(diào)地獄。
JS 圈的大佬們花了很多精力來思考如何解決回調(diào)地獄的問題,其中最著名的就是 promise。promise并不是什么可以把同步代碼變異步代碼的黑魔法。它只是一種編程手法,或者你可以理解為一種封裝,并沒有借助操作系統(tǒng)額外的能力。這也是為什么我的標(biāo)題是“編程范式”,promise 就是一種范式。
再仔細(xì)看看我們上面的回調(diào)地獄的示例代碼,想想出現(xiàn)這種層層嵌套的本質(zhì)是什么。說到底,其實(shí)就是我們通過回調(diào)這種方式來描述“當(dāng)一個(gè)異步操作完成之后接下來該干什么”。多個(gè)異步操作有先后關(guān)系,因此自然而然形成了回調(diào)地獄。既然多個(gè)異步操作組成了“串行”邏輯,那么我們能用更“串行”的方式來描述這個(gè)邏輯嗎?比如:
login(username,?password)
????.then(user?=>?getStatus(user.id))
????.then(status?=>?getOrder(status.id))
????.then(order?=>?getPayment(order.id))
????.then(payment?=>?getRecommendAdvertisements(payment.total_amount))
????.then(ads?=>?{/*...*/});
這樣看起來就比層層嵌套的回調(diào)直觀一些了,先執(zhí)行 A 操作,then 執(zhí)行 B,then 執(zhí)行 C……邏輯的先后關(guān)系一目了然,書寫方式也符合我們?nèi)祟惔械乃季S方式。但是這種編程方式怎么實(shí)現(xiàn)呢?
回想之前我們實(shí)現(xiàn)異步回調(diào)時(shí),異步函數(shù)會(huì)返回一個(gè) operation 對(duì)象,這個(gè)對(duì)象保存了回調(diào)函數(shù)的函數(shù)指針,因此當(dāng) EventLoop 發(fā)現(xiàn)該 operation 就緒后就可以直接跳轉(zhuǎn)到對(duì)應(yīng)的回調(diào)函數(shù)去執(zhí)行。但是在上述鏈?zhǔn)秸{(diào)用.then 的代碼中,我們調(diào)用login(username, pwd).then(...)時(shí),注意是當(dāng) login 這個(gè)函數(shù)已經(jīng)執(zhí)行完畢了,才調(diào)用的 then。相當(dāng)于我已經(jīng)把異步函數(shù)提交執(zhí)行了之后,才來綁定的回調(diào)函數(shù)。這個(gè)能實(shí)現(xiàn)嗎?
再回顧一下我們之前的read_async_v2:
function?read_async_v2(target,?callback)?{
??let?operation?=?read_async(target);
??operation.callback?=?callback;
??add_to_wait_list([operation]);
}
我們直接在函數(shù)內(nèi)部把 operation 的回調(diào)給設(shè)置好了,并把 operation 加入監(jiān)聽隊(duì)列。但其實(shí)不一定要在這個(gè)時(shí)候去設(shè)置回調(diào)函數(shù),只要在 EventLoop 執(zhí)行之前設(shè)置好就行了?;谶@個(gè)思路我們可以把 operation 保存在一個(gè)對(duì)象中,后續(xù)通過這個(gè)對(duì)象給 operation 添加回調(diào)方法,比如:
function?read_async_v3(target)?{
??let?operation?=?read_async(target);
??add_to_wait_list([operation]);
??return?{
????then:?function(callback)?{
????????operation.callback?=?callback;
????},
??}
}
//?我們可以這樣
read_async_v3("qq.com").then(logic)
但是這種實(shí)現(xiàn)方式只能設(shè)置一個(gè)回調(diào),不能像之前說的完成鏈?zhǔn)秸{(diào)用。為了支持鏈?zhǔn)秸{(diào)用我們可以這樣:
function?read_async_v4(target)?{
??let?operation?=?read_async(target);
??add_to_wait_list([operation]);
??let?chainableObject?=?{
????callbacks:?[],
????then:?function(callback)?{
??????this.callbacks.push(callback);
??????return?this;
????},
????run:?function(data)?{
??????let?nextData?=?data;
??????for?cb?in?this.callbacks?{
????????nextData?=?cb(nextData);
??????}
????}
??};
??operation.callback?=?chainableObject.run;
??return?chainableObject;
}
//?于是我們可以這樣
read_async_v4("qq.com").then(logic1).then(logic2).then(/*...*/)
如上述代碼,主要的實(shí)現(xiàn)思路就是,我們返回一個(gè)對(duì)象,這個(gè)對(duì)象保存一個(gè) callback 列表,每次可以通過調(diào)用對(duì)象的 then 方法新增一個(gè)回調(diào)。由于 then 方法返回了對(duì)象本身,因此可以進(jìn)行鏈?zhǔn)秸{(diào)用。然后我們把 operation 就緒后的 callback 設(shè)置成這個(gè)對(duì)象的 run 方法,也就是說 EventLoop 調(diào)用的其實(shí)是這個(gè)包裝過的對(duì)象的 run 方法,它再來依次執(zhí)行我們之前通過 then 設(shè)置好的回調(diào)函數(shù)。
看起來大功告成了嗎?不,這里有個(gè)嚴(yán)重的問題,就是我們的鏈?zhǔn)秸{(diào)用其實(shí)是綁定到一個(gè)異步調(diào)用上的,當(dāng)這個(gè)異步操作就緒后 run 方法會(huì)把 then 綁定的所有回調(diào)都執(zhí)行完。如果這些回調(diào)里又包含了異步調(diào)用,比如我們先請(qǐng)求 qq.com 然后輸出 qq.com 的內(nèi)容,接著請(qǐng)求 jd.com,然后輸出 jd.com 的內(nèi)容:
read_async_v4("qq.com")
????.then(data?=>?console.log("qq.com:?${data}"))
????.then((_)?=>?read_async_v4("jd.com"))
????.then(data?=>?console.log("jd.com:?{$data}"))
但是上面這段代碼是有問題的,這三個(gè) then 其實(shí)都是 qq.com 的回調(diào),當(dāng)請(qǐng)求 qq.com 完成時(shí),EventLoop 執(zhí)行 operation 的 run 方法,然后 run 方法會(huì)依次調(diào)用這三個(gè)回調(diào)。當(dāng)調(diào)用到第二個(gè)回調(diào)時(shí),此時(shí)它只是發(fā)出了一個(gè)對(duì) jd.com 的異步請(qǐng)求然后返回了一個(gè)針對(duì) jd.com 的 chainable 對(duì)象。因此第三個(gè) then 的入?yún)?data 并不是我們期望的 jd.com 返回的內(nèi)容,而是一個(gè) chainable 對(duì)象。因此最終的輸出可能是:
"qq.com:?...."
"jd.com:?[Object]"
過了一會(huì)兒 jd.com 請(qǐng)求也完成了,但是發(fā)現(xiàn)沒給它設(shè)置回調(diào),所以就直接把結(jié)果丟棄了。這當(dāng)然不是我們想要的樣子!正確的寫法應(yīng)該是:
read_async_v4("qq.com")
????.then(data?=>?console.log("qq.com:?${data}"))
????.then((_)?=>?{
????????read_async_v4("jd.com")
????????????.then(data?=>?console.log("jd.com:?{$data}"))
????????????.then((_)?=>?{
????????????????read_async_v4("baidu.com")
????????????????????.then(data?=>?console.log("baidu.com:?${data}"));
????????????});
????});
只有這樣才是真正我們想要的結(jié)果,先輸出 qq.com 的內(nèi)容,在輸出 jd.com 的內(nèi)容。then 里面如果是異步請(qǐng)求,那么就必須在內(nèi)部完成 then 的綁定……但是這樣不就又回到回調(diào)地獄了嗎???一波操作猛如虎,回頭一看原地杵???、
其實(shí)要解決這個(gè)問題很簡單,只需要修改一下 run 方法,當(dāng)某次回調(diào)返回一個(gè) chainableObject,那就把剩下的回調(diào)綁定到那個(gè)對(duì)象上,然后就可以退出了。比如:
function?read_async_v5(target)?{
??let?operation?=?read_async(target);
??add_to_wait_list([operation]);
??let?chainableObject?=?{
????callbacks:?[],
????then:?function(callback)?{
??????this.callbacks.push(callback);
??????return?this;
????},
????run:?function(data)?{
??????let?nextData?=?data;
???????let?self?=?this;
??????while?self.callbacks.length?>?0?{
??????????//?每次從隊(duì)首彈出一個(gè)回調(diào)函數(shù)
??????????let?cb?=?self.callbacks.pop_front();
??????????nextData?=?cb(nextData);
??????????//?如果回調(diào)返回了一個(gè)ChainableObject,那么就把剩下的callback綁定到它上面
??????????//?然后就可以終止執(zhí)行了
??????????if?isChainableType(nextData)?{
??????????????nextData.callbacks?=?self.callbacks;
??????????????return;
??????????}
??????}
????}
??};
??operation.callback?=?chainableObject.run;
??return?chainableObject;
}
這樣之后,我們就可以真正地實(shí)現(xiàn)異步的鏈?zhǔn)秸{(diào)用,比如:
read_async_v5("qq.com")
????.then(data?=>?console.log("qq.com:?${data}"))
????.then((_)?=>?read_async_v5("jd.com"))
????.then(data?=>?console.log("jd.com:?{$data}"))
雖然一開始所有的 then 都把回調(diào)綁定到對(duì) qq.com 進(jìn)行異步請(qǐng)求的 operation 上,但這只是暫時(shí)的。當(dāng)執(zhí)行完第二個(gè)回調(diào)時(shí),發(fā)現(xiàn)它返回了一個(gè) chainableObject,于是就把剩余的 callback 綁定到這個(gè)新的對(duì)象上,不再繼續(xù)執(zhí)行了。當(dāng) jd.com 的請(qǐng)求就緒后,EventLoop 執(zhí)行它的 operation 的 run 方法,再接著執(zhí)行后面回調(diào)。
如果我們提供一個(gè)庫,里面包含了各種異步方法,它們的共同特點(diǎn)是都會(huì)返回一個(gè) ChainableObject,這樣以來,我們就能夠利用 then 來組合它們完成我們的業(yè)務(wù)開發(fā)。這就是所謂的異步生態(tài)!
比如我們提供一個(gè)異步庫,它包含:http_get, http_post, read_fs, write_fs等返回 chainableObject 的方法,那么通過組合它們,我們能夠非常容易地實(shí)現(xiàn)復(fù)雜的業(yè)務(wù)邏輯,比如前述的回調(diào)地獄可以改寫為:
http_post("/login",?body)
??.then(user?=>?http_get("/order?user_id=${user.id}"))
??.then(order?=>?http_post("/payment",?{oid:?order.id}))
??.then(/*...*/)
看起來是不是和 js 基于 promise 的鏈?zhǔn)秸{(diào)用一模一樣了?
其實(shí)我們上述的 chainableObject 就可以看做是 js 中的 promise。區(qū)別是,由于 js 一開始全是基于回調(diào)的編程模型,各種標(biāo)準(zhǔn)庫內(nèi)置的異步方法都只能接收回調(diào),為了向后兼容,沒法把那些內(nèi)置函數(shù)改成返回 promise。因此 js 的辦法是提供 promise 的構(gòu)造方法,把異步函數(shù)“包裝”成 promise。如果基于我們 chainableObject 實(shí)現(xiàn),就是這樣的:
function?ChainableObject()?{
??return?chainableObject?=?{
????callbacks:?[],
????then:?function(callback)?{/*同之前,略*/},
????run:?function(data)?{
??????let?nextData?=?data;
??????if?self.resolveData?!=?null?{
????????nextData?=?self.resolveData;
??????}
??????while?self.callbacks.length?>?0?{
????????//同上,省略
??????}
????},
????resolveData:?null,
??};
}
function?Convert2Chainable(targetFunction)?{
??let?obj?=?new?ChainableObject();
??function?resolve(data)?{
????obj.resolveData?=?data;
??}
??targetFunction(resolve);
??return?obj;
}
這樣以后,我們可以利用標(biāo)準(zhǔn)庫的老回調(diào)式方法寫出這樣的代碼:
Convert2Chainable(resolve?=>?{
??let?sleep?=?100;
??setTimeout(()?=>?{
????resolve(sleep);
??},?sleep);
}).then(data?=>?console.log("sleep:?${data}ms"))
??.then((_)?=>?Convert2Chainable(resolve?=>?{
????let?sleep?=?200;
????setTimeout(()?=>?{
??????resolve(sleep);
????},?sleep);
})
.then(data?=>?console.log("sleep:?${data}ms"))
.then((_)?=>?Convert2Chainable(resolve?=>?{
??$.ajax("qq.com",?function(res)?{
????resolve(res);
??});
})
.then(data?=>?console.log("qq.com:?${data}"));
這里的關(guān)鍵就是,我們通過Convert2Chainable 函數(shù),可以把任意的標(biāo)準(zhǔn)庫內(nèi)置的異步方法包裝成一個(gè) ChainableObject。ChainableObject 只是我為了解釋清楚異步鏈?zhǔn)秸{(diào)用的具體實(shí)現(xiàn)原理而隨意實(shí)現(xiàn)的一個(gè)對(duì)象,它其實(shí)就是 js 中的 Promise。具體的實(shí)現(xiàn)肯定沒這么簡單,因?yàn)?Promise 還有很多處理錯(cuò)誤的邏輯,但是原理都是一樣的。后續(xù)我將不再使用 ChainableObject 這個(gè)名詞,轉(zhuǎn)而使用 Promise,你知道它們是一回事就行了。
小總結(jié)
本文中我花了一些篇幅,通過例子帶領(lǐng)大家一步一步看懂異步編程的一些本質(zhì)原理。你可以清楚地看到,所有的異步任務(wù)都是由一個(gè)線程發(fā)起,所有后續(xù)的邏輯都由這個(gè)線程來完成。當(dāng)大量并發(fā)請(qǐng)求到來時(shí),我們只用一個(gè)線程就能處理這些所有的請(qǐng)求,這就是異步編程的真正價(jià)值所在。這也是 nodejs 可以用來做一些后端開發(fā)并且能夠達(dá)到相當(dāng)好的性能的重要原因!!我們熟悉的 Redis Nginx 都是基于這樣一套異步編程范式,享受異步帶來的巨大優(yōu)勢(shì)。
And More
但有了 Promise 這種方式,異步編程就大功告成了嗎?其實(shí)并沒有……基于 Promise 這樣的鏈?zhǔn)秸{(diào)用只是比回調(diào)好懂一點(diǎn),但和多線程的同步編寫方式相比,也并沒有那么容易讀。并且錯(cuò)誤的捕獲和傳播也是個(gè)問題。下一篇文章我將講一講 Promise 的問題,然后看看我們能夠提出什么樣更加優(yōu)雅的解決方案——其實(shí)就是 async/await。你要做好心理準(zhǔn)備,async/await 雖然優(yōu)雅,它不像 promise 這樣只是一層庫的封裝,async/await 需要和語言的編譯器結(jié)合起來,對(duì)代碼做一些轉(zhuǎn)換,要實(shí)現(xiàn)它不是一件那么容易的事情。當(dāng)然也不必害怕,因?yàn)樗簧婕熬唧w的編譯原理相關(guān)內(nèi)容,只是做了一些代碼生成而已。
另一方面,我們上述的 wait_until_some_of_them_get_ready 函數(shù)幫我們屏蔽了太多底層的細(xì)節(jié),它其實(shí)就是一個(gè)異步任務(wù)調(diào)度器。它是怎么做到的呢?依賴 OS 的能力是必然的,但是調(diào)度器本身也需要很大的開發(fā)量。調(diào)度器本身的實(shí)現(xiàn)決定著某種編程語言并發(fā)方面的性能優(yōu)劣。Rust 是性能堪比 C/C++的編程語言,同時(shí)提供了 async await 的支持。
最近好文:
認(rèn)識(shí) MySQL 和 Redis 的數(shù)據(jù)一致性問題
TencentOCR 斬獲 ICDAR 2021 三項(xiàng)冠軍
騰訊程序員視頻號(hào)最新視頻
