巨大提升!更快的 async 函數(shù)和 promises「譯」

來源:https://www.yuque.com/es2049/blog
譯自:Faster async functions and promises
JavaScript 的異步過程一直被認為是不夠快的,更糟糕的是,在 NodeJS 等實時性要求高的場景下調試堪比噩夢。不過,這一切正在改變,這篇文章會詳細解釋我們是如何優(yōu)化 V8 引擎(也會涉及一些其它引擎)里的 async 函數(shù)和 promises 的,以及伴隨著的開發(fā)體驗的優(yōu)化。
溫馨提示: 這里有個 視頻:https://www.youtube.com/watch?v=DFP5DKDQfOc,你可以結合著文章看。
異步編程的新方案
從 callbacks 到 promises,再到 async 函數(shù)
在 promises 正式成為 JavaScript 標準的一部分之前,回調被大量用在異步編程中,下面是個例子:
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);
??????});
????});
??});
}
類似以上深度嵌套的回調通常被稱為「回調黑洞」,因為它讓代碼可讀性變差且不易維護。
幸運地是,現(xiàn)在 promises 成為了 JavaScript 語言的一部分,以下實現(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ù),代碼變得更簡潔,代碼的邏輯和數(shù)據(jù)流都變得更可控,當然其實底層實現(xiàn)還是異步。(注意,JavaScript 還是單線程執(zhí)行,async 函數(shù)并不會開新的線程。)
從事件監(jiān)聽回調到 async 迭代器
NodeJS 里 ReadableStreams 作為另一種形式的異步也特別常見,下面是個例子:
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);
這段代碼有一點難理解:只能通過回調去拿 chunks 里的數(shù)據(jù)流,而且數(shù)據(jù)流的結束也必須在回調里處理。如果你沒能理解到函數(shù)是立即結束但實際處理必須在回調里進行,可能就會引入 bug。
同樣很幸運,ES2018 特性里引入的一個很酷的 async 迭代器 可以簡化上面的代碼:
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ù)處理邏輯都放到一個 async 函數(shù)里使用 for await…of 去迭代 chunks,而不是分別在 'data' 和 'end' 回調里處理,而且我們還加了 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)化,目前的效果還不錯,你可以放心地使用這些新特性。

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

Promise.all 的性能提高了八倍!
然后,上面的測試僅僅是小的 DEMO 級別的測試,V8 團隊更關心的是 實際用戶代碼的優(yōu)化效果。

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

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

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

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

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

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

調用者會繼續(xù)執(zhí)行,最終調用棧會清空,然后引擎會開始執(zhí)行微任務:運行之前已準備就緒的 PromiseResolveThenableJob,首先是一個 PromiseReactionJob,它的工作僅僅是在傳遞給 await 的值上封裝一層 promise。然后,引擎回到微任務隊列,因為在回到事件循環(huán)之前微任務隊列必須要清空。

然后是另一個 PromiseReactionJob,等待我們正在 await(我們這里指的是 42)這個 promise 完成,然后把這個動作安排到 throwaway promise 里。引擎繼續(xù)回到微任務隊列,因為還有最后一個微任務。

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

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

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

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

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

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

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

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

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

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

在本地開發(fā)時這是個很有用的特性,不過一旦應用部署了就沒啥用了。調試時,你只能看到日志文件里的 Error#stack 信息,這些并不會包含任何異步信息。
最近我們搞的 零成本異步棧追蹤 使得 Error#stack 包含了 async 函數(shù)的調用信息?!噶愠杀尽孤犉饋砗茏屓伺d奮,對吧?當 Chrome DevTools 功能帶來重大開銷時,它如何才能實現(xiàn)零成本?舉個例子,foo 里調用 bar,bar 在 await 一個 promise 后拋一個異常:
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 運行結果如下:
$?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() 里的調用拋的錯,foo 本身卻不在棧追蹤信息里。如果應用是部署在云容器里,這會讓開發(fā)者很難去定位問題。
有意思的是,引擎是知道 bar 結束后應該繼續(xù)執(zhí)行什么的:即 foo 函數(shù)里 await 后。恰好,這里也正是 foo 暫停的地方。引擎可以利用這些信息重建異步的棧追蹤信息。有了以上優(yōu)化,輸出就會變成這樣:
$?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)在第一個,之后是一些異步調用棧,再后面是 foo 里面 bar 上下文的棧信息。這個特性的啟用可以通過 V8 的 --async-stack-traces 參數(shù)啟用。
然而,如果你跟上面 Chrome DevTools 里的棧信息對比,你會發(fā)現(xiàn)棧追蹤里異步部分缺失了 foo 的調用點信息。這里利用了 await 恢復和暫停位置是一樣的特性,但 Promise#then() 或 Promise#catch()就不是這樣的??梢钥?Mathias Bynens 的文章 await beats Promise#then() 了解更多。
結論
async 函數(shù)變快少不了以下兩個優(yōu)化:
移除了額外的兩個微任務 移除了 throwawaypromise
除此之外,我們通過 零成本異步棧追蹤 提升了 await 和 Promise.all() 開發(fā)調試體驗。
我們還有些對 JavaScript 開發(fā)者友好的性能建議:
多使用 async 和 await 而不是手寫 promise 代碼,多使用 JavaScript 引擎提供的 promise 而不是自己去實現(xiàn)。
非常歡迎有激情的你加入 ES2049 Studio,簡歷請發(fā)送至 caijun.hcj(at)alibaba-inc.com 。
最后
如果你覺得這篇內容對你挺有啟發(fā),我想邀請你幫我三個小忙:
點個「在看」,讓更多的人也能看到這篇內容(喜歡不點在看,都是耍流氓 -_-)
歡迎加我微信「qianyu443033099」拉你進技術群,長期交流學習...
關注公眾號「前端下午茶」,持續(xù)為你推送精選好文,也可以加我為好友,隨時聊騷。

