「譯」更快的 async 函數(shù)和 promises

來源:https://www.yuque.com/es2049/blog
譯自:Faster async functions and promises
JavaScript 的異步過程一直被認(rèn)為是不夠快的,更糟糕的是,在 NodeJS 等實(shí)時(shí)性要求高的場(chǎng)景下調(diào)試堪比噩夢(mèng)。不過,這一切正在改變,這篇文章會(huì)詳細(xì)解釋我們是如何優(yōu)化 V8 引擎(也會(huì)涉及一些其它引擎)里的 async 函數(shù)和 promises 的,以及伴隨著的開發(fā)體驗(yàn)的優(yōu)化。
溫馨提示: 這里有個(gè) 視頻:https://www.youtube.com/watch?v=DFP5DKDQfOc,你可以結(jié)合著文章看。
異步編程的新方案
從 callbacks 到 promises,再到 async 函數(shù)
在 promises 正式成為 JavaScript 標(biāo)準(zhǔn)的一部分之前,回調(diào)被大量用在異步編程中,下面是個(gè)例子:
function?handler(done)?{
??validateParams((error)?=>?{
????if?(error)?return?done(error);
????dbQuery((error,?dbResults)?=>?{
??????if?(error)?return?done(error);
??????serviceCall(dbResults,?(error,?serviceResults)?=>?{
????????console.log(result);
????????done(error,?serviceResults);
??????});
????});
??});
}
類似以上深度嵌套的回調(diào)通常被稱為「回調(diào)黑洞」,因?yàn)樗尨a可讀性變差且不易維護(hù)。
幸運(yùn)地是,現(xiàn)在 promises 成為了 JavaScript 語言的一部分,以下實(shí)現(xiàn)了跟上面同樣的功能:
function?handler()?{
??return?validateParams()
????.then(dbQuery)
????.then(serviceCall)
????.then(result?=>?{
??????console.log(result);
??????return?result;
????});
}
最近,JavaScript 支持了 async 函數(shù),上面的異步代碼可以寫成像下面這樣的同步的代碼:
async?function?handler()?{
??await?validateParams();
??const?dbResults?=?await?dbQuery();
??const?results?=?await?serviceCall(dbResults);
??console.log(results);
??return?results;
}
借助 async 函數(shù),代碼變得更簡(jiǎn)潔,代碼的邏輯和數(shù)據(jù)流都變得更可控,當(dāng)然其實(shí)底層實(shí)現(xiàn)還是異步。(注意,JavaScript 還是單線程執(zhí)行,async 函數(shù)并不會(huì)開新的線程。)
從事件監(jiān)聽回調(diào)到 async 迭代器
NodeJS 里 ReadableStreams 作為另一種形式的異步也特別常見,下面是個(gè)例子:
const?http?=?require('http');
http.createServer((req,?res)?=>?{
??let?body?=?'';
??req.setEncoding('utf8');
??req.on('data',?(chunk)?=>?{
????body?+=?chunk;
??});
??req.on('end',?()?=>?{
????res.write(body);
????res.end();
??});
}).listen(1337);
這段代碼有一點(diǎn)難理解:只能通過回調(diào)去拿 chunks 里的數(shù)據(jù)流,而且數(shù)據(jù)流的結(jié)束也必須在回調(diào)里處理。如果你沒能理解到函數(shù)是立即結(jié)束但實(shí)際處理必須在回調(diào)里進(jìn)行,可能就會(huì)引入 bug。
同樣很幸運(yùn),ES2018 特性里引入的一個(gè)很酷的 async 迭代器 可以簡(jiǎn)化上面的代碼:
const?http?=?require('http');
http.createServer(async?(req,?res)?=>?{
??try?{
????let?body?=?'';
????req.setEncoding('utf8');
????for?await?(const?chunk?of?req)?{
??????body?+=?chunk;
????}
????res.write(body);
????res.end();
??}?catch?{
????res.statusCode?=?500;
????res.end();
??}
}).listen(1337);
你可以把所有數(shù)據(jù)處理邏輯都放到一個(gè) async 函數(shù)里使用 for await…of 去迭代 chunks,而不是分別在 'data' 和 'end' 回調(diào)里處理,而且我們還加了 try-catch 塊來避免 unhandledRejection 問題。
以上這些特性你今天就可以在生成環(huán)境使用!async 函數(shù)從 Node.js 8 (V8 v6.2 / Chrome 62) 開始就已全面支持,async 迭代器從 Node.js 10 (V8 v6.8 / Chrome 68) 開始支持。
async 性能優(yōu)化
從 V8 v5.5 (Chrome 55 & Node.js 7) 到 V8 v6.8 (Chrome 68 & Node.js 10),我們致力于異步代碼的性能優(yōu)化,目前的效果還不錯(cuò),你可以放心地使用這些新特性。

上面的是 doxbee 基準(zhǔn)測(cè)試,用于反應(yīng)重度使用 promise 的性能,圖中縱坐標(biāo)表示執(zhí)行時(shí)間,所以越小越好。
另一方面,parallel 基準(zhǔn)測(cè)試 反應(yīng)的是重度使用 Promise.all() 的性能情況,結(jié)果如下:

Promise.all 的性能提高了八倍!
然后,上面的測(cè)試僅僅是小的 DEMO 級(jí)別的測(cè)試,V8 團(tuán)隊(duì)更關(guān)心的是 實(shí)際用戶代碼的優(yōu)化效果。

上面是基于市場(chǎng)上流行的 HTTP 框架做的測(cè)試,這些框架大量使用了 promises 和 async 函數(shù),這個(gè)表展示的是每秒請(qǐng)求數(shù),所以跟之前的表不一樣,這個(gè)是數(shù)值越大越好。從表可以看出,從 Node.js 7 (V8 v5.5) 到 Node.js 10 (V8 v6.8) 性能提升了不少。
性能提升取決于以下三個(gè)因素:
TurboFan,新的優(yōu)化編譯器 ? Orinoco,新的垃圾回收器 ? 一個(gè) Node.js 8 的 bug 導(dǎo)致 await 跳過了一些微 tick(microticks) ?
當(dāng)我們?cè)?Node.js 8 里 啟用 TurboFan 的后,性能得到了巨大的提升。
同時(shí)我們引入了一個(gè)新的垃圾回收器,叫作 Orinoco,它把垃圾回收從主線程中移走,因此對(duì)請(qǐng)求響應(yīng)速度提升有很大幫助。
最后,Node.js 8 中引入了一個(gè) bug 在某些時(shí)候會(huì)讓 await 跳過一些微 tick,這反而讓性能變好了。這個(gè) bug 是因?yàn)闊o意中違反了規(guī)范導(dǎo)致的,但是卻給了我們優(yōu)化的一些思路。這里我們稍微解釋下:
const?p?=?Promise.resolve();
(async?()?=>?{
??await?p;?console.log('after:await');
})();
p.then(()?=>?console.log('tick:a'))
?.then(()?=>?console.log('tick:b'));
上面代碼一開始創(chuàng)建了一個(gè)已經(jīng)完成狀態(tài)的 promise p,然后 await 出其結(jié)果,又同時(shí)鏈了兩個(gè) then,那最終的 console.log 打印的結(jié)果會(huì)是什么呢?
因?yàn)?p 是已完成的,你可能認(rèn)為其會(huì)先打印 'after:await',然后是剩下兩個(gè) tick, 事實(shí)上 Node.js 8 里的結(jié)果是:

雖然以上結(jié)果符合預(yù)期,但是卻不符合規(guī)范。Node.js 10 糾正了這個(gè)行為,會(huì)先執(zhí)行 then 鏈里的,然后才是 async 函數(shù)。

這個(gè)「正確的行為」看起來并不正常,甚至?xí)尯芏?JavaScript 開發(fā)者感到吃驚,還是有必要再詳細(xì)解釋下。在解釋之前,我們先從一些基礎(chǔ)開始。
任務(wù)(tasks)vs. 微任務(wù)(microtasks)
從某層面上來說,JavaScript 里存在任務(wù)和微任務(wù)。任務(wù)處理 I/O 和計(jì)時(shí)器等事件,一次只處理一個(gè)。微任務(wù)是為了 async/await 和 promise 的延遲執(zhí)行設(shè)計(jì)的,每次任務(wù)最后執(zhí)行。在返回事件循環(huán)(event loop)前,微任務(wù)的隊(duì)列會(huì)被清空。

可以通過 Jake Archibald 的 tasks, microtasks, queues, and schedules in the browser 了解更多。Node.js 里任務(wù)模型與此非常類似。
async 函數(shù)
根據(jù) MDN,async 函數(shù)是一個(gè)通過異步執(zhí)行并隱式返回 promise 作為結(jié)果的函數(shù)。從開發(fā)者角度看,async 函數(shù)讓異步代碼看起來像同步代碼。
一個(gè)最簡(jiǎn)單的 async 函數(shù):
async?function?computeAnswer()?{
??return?42;
}
函數(shù)執(zhí)行后會(huì)返回一個(gè) promise,你可以像使用其它 promise 一樣用其返回的值。
const?p?=?computeAnswer();
//?→?Promise
p.then(console.log);
//?prints?42?on?the?next?turn
你只能在下一個(gè)微任務(wù)執(zhí)行后才能得到 promise p 返回的值,換句話說,上面的代碼語義上等價(jià)于使用 Promise.resolve 得到的結(jié)果:
function?computeAnswer()?{
??return?Promise.resolve(42);
}
async 函數(shù)真正強(qiáng)大的地方來源于 await 表達(dá)式,它可以讓一個(gè)函數(shù)執(zhí)行暫停直到一個(gè) promise 已接受(resolved),然后等到已完成(fulfilled)后恢復(fù)執(zhí)行。已完成的 promise 會(huì)作為 await 的值。這里的例子會(huì)解釋這個(gè)行為:
async?function?fetchStatus(url)?{
??const?response?=?await?fetch(url);
??return?response.status;
}
fetchStatus 在遇到 await 時(shí)會(huì)暫停,當(dāng) fetch 這個(gè) promise 已完成后會(huì)恢復(fù)執(zhí)行,這跟直接鏈?zhǔn)教幚?fetch 返回的 promise 某種程度上等價(jià)。
function?fetchStatus(url)?{
??return?fetch(url).then(response?=>?response.status);
}
鏈?zhǔn)教幚砗瘮?shù)里包含了之前跟在 await 后面的代碼。
正常來說你應(yīng)該在 await 后面放一個(gè) Promise,不過其實(shí)后面可以跟任意 JavaScript 的值,如果跟的不是 promise,會(huì)被制轉(zhuǎn)為 promise,所以 await 42 效果如下:
async?function?foo()?{
??const?v?=?await?42;
??return?v;
}
const?p?=?foo();
//?→?Promise
p.then(console.log);
//?prints?`42`?eventually
更有趣的是,await 后可以跟任何 “thenable”,例如任何含有 then 方法的對(duì)象,就算不是 promise 都可以。因此你可以實(shí)現(xiàn)一個(gè)有意思的 類來記錄執(zhí)行時(shí)間的消耗:
class?Sleep?{
??constructor(timeout)?{
????this.timeout?=?timeout;
??}
??then(resolve,?reject)?{
????const?startTime?=?Date.now();
????setTimeout(()?=>?resolve(Date.now()?-?startTime),
???????????????this.timeout);
??}
}
(async?()?=>?{
??const?actualTime?=?await?new?Sleep(1000);
??console.log(actualTime);
})();
一起來看看 V8 規(guī)范 里是如何處理 await 的。下面是很簡(jiǎn)單的 async 函數(shù) foo:
async?function?foo(v)?{
??const?w?=?await?v;
??return?w;
}
執(zhí)行時(shí),它把參數(shù) v 封裝成一個(gè) promise ,然后會(huì)暫停直到 promise 完成,然后 w 賦值為已完成的 promise ,最后 async 返回了這個(gè)值。
神秘的 await
首先,V8 會(huì)把這個(gè)函數(shù)標(biāo)記為可恢復(fù)的,意味著執(zhí)行可以被暫停并恢復(fù)(從 await 角度看是這樣的)。然后,會(huì)創(chuàng)建一個(gè)所謂的 implicit_promise(用于把 async 函數(shù)里產(chǎn)生的值轉(zhuǎn)為 promise)。

然后是有意思的東西來了:真正的 await。首先,跟在 await 后面的值被轉(zhuǎn)為 promise。然后,處理函數(shù)會(huì)綁定這個(gè) promise 用于在 promise 完成后恢復(fù)主函數(shù),此時(shí) async 函數(shù)被暫停了,返回 implicit_promise 給調(diào)用者。一旦 promise 完成了,函數(shù)會(huì)恢復(fù)并拿到從 promise 得到值 w,最后,implicit_promise 會(huì)用 w 標(biāo)記為已接受。
簡(jiǎn)單說,await v 初始化步驟有以下組成:
把 v轉(zhuǎn)成一個(gè) promise(跟在await后面的)。綁定處理函數(shù)用于后期恢復(fù)。 暫停 async 函數(shù)并返回 implicit_promise給掉用者。
我們一步步來看,假設(shè) await 后是一個(gè) promise,且最終已完成狀態(tài)的值是 42。然后,引擎會(huì)創(chuàng)建一個(gè)新的 promise 并且把 await 后的值作為 resolve 的值。借助標(biāo)準(zhǔn)里的 PromiseResolveThenableJob這些 promise 會(huì)被放到下個(gè)周期執(zhí)行。

然后,引擎創(chuàng)建了另一個(gè)叫做 throwaway 的 promise。之所以叫這個(gè)名字,因?yàn)闆]有其它東西鏈過它,僅僅是引擎內(nèi)部用的。throwaway promise 會(huì)鏈到含有恢復(fù)處理函數(shù)的 promise 上。這里 performPromiseThen 操作其實(shí)內(nèi)部就是 Promise.prototype.then()。最終,該 async 函數(shù)會(huì)暫停,并把控制權(quán)交給調(diào)用者。

調(diào)用者會(huì)繼續(xù)執(zhí)行,最終調(diào)用棧會(huì)清空,然后引擎會(huì)開始執(zhí)行微任務(wù):運(yùn)行之前已準(zhǔn)備就緒的 PromiseResolveThenableJob,首先是一個(gè) PromiseReactionJob,它的工作僅僅是在傳遞給 await 的值上封裝一層 promise。然后,引擎回到微任務(wù)隊(duì)列,因?yàn)樵诨氐绞录h(huán)之前微任務(wù)隊(duì)列必須要清空。

然后是另一個(gè) PromiseReactionJob,等待我們正在 await(我們這里指的是 42)這個(gè) promise 完成,然后把這個(gè)動(dòng)作安排到 throwaway promise 里。引擎繼續(xù)回到微任務(wù)隊(duì)列,因?yàn)檫€有最后一個(gè)微任務(wù)。

現(xiàn)在這第二個(gè) PromiseReactionJob 把決定傳達(dá)給 throwaway promise,并恢復(fù) async 函數(shù)的執(zhí)行,最后返回從 await 得到的 42。

總結(jié)下,對(duì)于每一個(gè) await 引擎都會(huì)創(chuàng)建兩個(gè)額外的 promise(即使右值已經(jīng)是一個(gè) promise),并且需要至少三個(gè)微任務(wù)。誰會(huì)想到一個(gè)簡(jiǎn)單的 await 竟然會(huì)有如此多冗余的運(yùn)算?!

我們來看看到底是什么引起冗余。第一行的作用是封裝一個(gè) promise,第二行為了 resolve 封裝后的 promose await 之后的值 v。這兩行產(chǎn)生個(gè)冗余的 promise 和兩個(gè)冗余的微任務(wù)。如果 v 已經(jīng)是 promise 的話就很不劃算了(大多時(shí)候確實(shí)也是如此)。在某些特殊場(chǎng)景 await 了 42 的話,那確實(shí)還是需要封裝成 promise 的。
因此,這里可以使用 promiseResolve 操作來處理,只有必要的時(shí)候才會(huì)進(jìn)行 promise 的封裝:

如果入?yún)⑹?promise,則原封不動(dòng)地返回,只封裝必要的 promise。這個(gè)操作在值已經(jīng)是 promose 的情況下可以省去一個(gè)額外的 promise 和兩個(gè)微任務(wù)。此特性可以通過 --harmony-await-optimization參數(shù)在 V8(從 v7.1 開始)中開啟,同時(shí)我們 向 ECMAScript 發(fā)起了一個(gè)提案,目測(cè)很快會(huì)合并。
下面是簡(jiǎn)化后的 await 執(zhí)行過程:

感謝神奇的 promiseResolve,現(xiàn)在我們只需要傳 v 即可而不用關(guān)心它是什么。之后跟之前一樣,引擎會(huì)創(chuàng)建一個(gè) throwaway promise 并放到 PromiseReactionJob 里為了在下一個(gè) tick 時(shí)恢復(fù)該 async 函數(shù),它會(huì)先暫停函數(shù),把自身返回給掉用者。

當(dāng)最后所有執(zhí)行完畢,引擎會(huì)跑微任務(wù)隊(duì)列,會(huì)執(zhí)行 PromiseReactionJob。這個(gè)任務(wù)會(huì)傳遞 promise結(jié)果給 throwaway,并且恢復(fù) async 函數(shù),從 await 拿到 42。

盡管是內(nèi)部使用,引擎創(chuàng)建 throwaway promise 可能還是會(huì)讓人覺得哪里不對(duì)。事實(shí)證明,throwawaypromise 僅僅是為了滿足規(guī)范里 performPromiseThen 的需要。

這是最近提議給 ECMAScript 的 變更,引擎大多數(shù)時(shí)候不再需要?jiǎng)?chuàng)建 throwaway 了。

對(duì)比 await 在 Node.js 10 和優(yōu)化后(應(yīng)該會(huì)放到 Node.js 12 上)的表現(xiàn):

async/await 性能超過了手寫的 promise 代碼。關(guān)鍵就是我們減少了 async 函數(shù)里一些不必要的開銷,不僅僅是 V8 引擎,其它 JavaScript 引擎都通過這個(gè) 補(bǔ)丁 實(shí)現(xiàn)了優(yōu)化。
開發(fā)體驗(yàn)優(yōu)化
除了性能,JavaScript 開發(fā)者也很關(guān)心問題定位和修復(fù),這在異步代碼里一直不是件容易的事。Chrome DevTools 現(xiàn)在支持了異步棧追蹤:

在本地開發(fā)時(shí)這是個(gè)很有用的特性,不過一旦應(yīng)用部署了就沒啥用了。調(diào)試時(shí),你只能看到日志文件里的 Error#stack 信息,這些并不會(huì)包含任何異步信息。
最近我們搞的 零成本異步棧追蹤 使得 Error#stack 包含了 async 函數(shù)的調(diào)用信息?!噶愠杀尽孤犉饋砗茏屓伺d奮,對(duì)吧?當(dāng) Chrome DevTools 功能帶來重大開銷時(shí),它如何才能實(shí)現(xiàn)零成本?舉個(gè)例子,foo 里調(diào)用 bar,bar 在 await 一個(gè) promise 后拋一個(gè)異常:
async?function?foo()?{
??await?bar();
??return?42;
}
async?function?bar()?{
??await?Promise.resolve();
??throw?new?Error('BEEP?BEEP');
}
foo().catch(error?=>?console.log(error.stack));
這段代碼在 Node.js 8 或 Node.js 10 運(yùn)行結(jié)果如下:
$?node?index.js
Error:?BEEP?BEEP
????at?bar?(index.js:8:9)
????at?process._tickCallback?(internal/process/next_tick.js:68:7)
????at?Function.Module.runMain?(internal/modules/cjs/loader.js:745:11)
????at?startup?(internal/bootstrap/node.js:266:19)
????at?bootstrapNodeJSCore?(internal/bootstrap/node.js:595:3)
注意到,盡管是 foo() 里的調(diào)用拋的錯(cuò),foo 本身卻不在棧追蹤信息里。如果應(yīng)用是部署在云容器里,這會(huì)讓開發(fā)者很難去定位問題。
有意思的是,引擎是知道 bar 結(jié)束后應(yīng)該繼續(xù)執(zhí)行什么的:即 foo 函數(shù)里 await 后。恰好,這里也正是 foo 暫停的地方。引擎可以利用這些信息重建異步的棧追蹤信息。有了以上優(yōu)化,輸出就會(huì)變成這樣:
$?node?--async-stack-traces?index.js
Error:?BEEP?BEEP
????at?bar?(index.js:8:9)
????at?process._tickCallback?(internal/process/next_tick.js:68:7)
????at?Function.Module.runMain?(internal/modules/cjs/loader.js:745:11)
????at?startup?(internal/bootstrap/node.js:266:19)
????at?bootstrapNodeJSCore?(internal/bootstrap/node.js:595:3)
????at?async?foo?(index.js:2:3)
在棧追蹤信息里,最上層的函數(shù)出現(xiàn)在第一個(gè),之后是一些異步調(diào)用棧,再后面是 foo 里面 bar 上下文的棧信息。這個(gè)特性的啟用可以通過 V8 的 --async-stack-traces 參數(shù)啟用。
然而,如果你跟上面 Chrome DevTools 里的棧信息對(duì)比,你會(huì)發(fā)現(xiàn)棧追蹤里異步部分缺失了 foo 的調(diào)用點(diǎn)信息。這里利用了 await 恢復(fù)和暫停位置是一樣的特性,但 Promise#then() 或 Promise#catch()就不是這樣的??梢钥?Mathias Bynens 的文章 await beats Promise#then() 了解更多。
結(jié)論
async 函數(shù)變快少不了以下兩個(gè)優(yōu)化:
移除了額外的兩個(gè)微任務(wù) 移除了 throwawaypromise
除此之外,我們通過 零成本異步棧追蹤 提升了 await 和 Promise.all() 開發(fā)調(diào)試體驗(yàn)。
我們還有些對(duì) JavaScript 開發(fā)者友好的性能建議:
多使用 async 和 await 而不是手寫 promise 代碼,多使用 JavaScript 引擎提供的 promise 而不是自己去實(shí)現(xiàn)。
非常歡迎有激情的你加入 ES2049 Studio,簡(jiǎn)歷請(qǐng)發(fā)送至 caijun.hcj(at)alibaba-inc.com 。
