「譯」更快的 async 函式和 promises
翻譯自: ofollow,noindex">Faster async functions and promises
JavaScript 的非同步過程一直被認為是不夠快的,更糟糕的是,在 NodeJS 等實時性要求高的場景下除錯堪比噩夢。不過,這一切正在改變,這篇文章會詳細解釋我們是如何優化 V8 引擎(也會涉及一些其它引擎)裡的 async 函式和 promises 的,以及伴隨著的開發體驗的優化。
溫馨提示:這裡有個視訊,你可以結合著文章看。
非同步程式設計的新方案
從 callbacks 到 promises,再到 async 函式
在 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); }); }); }); } 複製程式碼
類似以上深度巢狀的回撥通常被稱為「回撥黑洞」,因為它讓程式碼可讀性變差且不易維護。
幸運地是,現在 promises 成為了 JavaScript 語言的一部分,以下實現了跟上面同樣的功能:
function handler() { return validateParams() .then(dbQuery) .then(serviceCall) .then(result => { console.log(result); return result; }); } 複製程式碼
最近,JavaScript 支援了async 函式,上面的非同步程式碼可以寫成像下面這樣的同步的程式碼:
async function handler() { await validateParams(); const dbResults = await dbQuery(); const results = await serviceCall(dbResults); console.log(results); return results; } 複製程式碼
藉助 async 函式,程式碼變得更簡潔,程式碼的邏輯和資料流都變得更可控,當然其實底層實現還是非同步。(注意,JavaScript 還是單執行緒執行,async 函式並不會開新的執行緒。)
從事件監聽回撥到 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 裡的資料流,而且資料流的結束也必須在回撥裡處理。如果你沒能理解到函式是立即結束但實際處理必須在回撥裡進行,可能就會引入 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); 複製程式碼
你可以把所有資料處理邏輯都放到一個 async 函式裡使用 for await…of
去迭代 chunks,而不是分別在 'data'
和 'end'
回撥裡處理,而且我們還加了 try-catch
塊來避免 unhandledRejection
問題。
以上這些特性你今天就可以在生成環境使用!async 函式 從 Node.js 8 (V8 v6.2 / Chrome 62) 開始就已全面支援 ,async 迭代器 從 Node.js 10 (V8 v6.8 / Chrome 68) 開始支援 。
async 效能優化
從 V8 v5.5 (Chrome 55 & Node.js 7) 到 V8 v6.8 (Chrome 68 & Node.js 10),我們致力於非同步程式碼的效能優化,目前的效果還不錯,你可以放心地使用這些新特性。

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

Promise.all
的效能提高了 八倍 !
然後,上面的測試僅僅是小的 DEMO 級別的測試,V8 團隊更關心的是 實際使用者程式碼的優化效果 。

上面是基於市場上流行的 HTTP 框架做的測試,這些框架大量使用了 promises 和 async
函式,這個表展示的是每秒請求數,所以跟之前的表不一樣,這個是數值越大越好。從表可以看出,從 Node.js 7 (V8 v5.5) 到 Node.js 10 (V8 v6.8) 效能提升了不少。
效能提升取決於以下三個因素:
- TurboFan,新的優化編譯器 :tada:
- Orinoco,新的垃圾回收器 :articulated_lorry:
- 一個 Node.js 8 的 bug 導致 await 跳過了一些微 tick(microticks) :bug:
當我們在Node.js 8 裡啟用 TurboFan 的後,效能得到了巨大的提升。
同時我們引入了一個新的垃圾回收器,叫作 Orinoco,它把垃圾回收從主執行緒中移走,因此對請求響應速度提升有很大幫助。
最後,Node.js 8 中引入了一個 bug 在某些時候會讓 await
跳過一些微 tick,這反而讓效能變好了。這個 bug 是因為無意中違反了規範導致的,但是卻給了我們優化的一些思路。這裡我們稍微解釋下:
const p = Promise.resolve(); (async () => { await p; console.log('after:await'); })(); p.then(() => console.log('tick:a')) .then(() => console.log('tick:b')); 複製程式碼
上面程式碼一開始建立了一個已經完成狀態的 promise p
,然後 await
出其結果,又同時鏈了兩個 then
,那最終的 console.log
列印的結果會是什麼呢?
因為 p
是已完成的,你可能認為其會先列印 'after:await'
,然後是剩下兩個 tick
, 事實上 Node.js 8 裡的結果是:

雖然以上結果符合預期,但是卻不符合規範。Node.js 10 糾正了這個行為,會先執行 then
鏈裡的,然後才是 async 函式。

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

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

然後是有意思的東西來了:真正的 await
。首先,跟在 await
後面的值被轉為 promise。然後,處理函式會繫結這個 promise 用於在 promise 完成後恢復主函式,此時 async 函式被暫停了,返回 implicit_promise
給呼叫者。一旦 promise
完成了,函式會恢復並拿到從 promise
得到值 w
,最後, implicit_promise
會用 w
標記為已接受。
簡單說, await v
初始化步驟有以下組成:
- 把
v
轉成一個 promise(跟在await
後面的)。 - 繫結處理函式用於後期恢復。
- 暫停 async 函式並返回
implicit_promise
給掉用者。
我們一步步來看,假設 await
後是一個 promise,且最終已完成狀態的值是 42
。然後,引擎會建立一個新的 promise
並且把 await
後的值作為 resolve 的值。藉助標準裡的 PromiseResolveThenableJob 這些 promise 會被放到下個週期執行。

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

呼叫者會繼續執行,最終呼叫棧會清空,然後引擎會開始執行微任務:執行之前已準備就緒的 PromiseResolveThenableJob ,首先是一個PromiseReactionJob,它的工作僅僅是在傳遞給 await
的值上封裝一層 promise
。然後,引擎回到微任務佇列,因為在回到事件迴圈之前微任務佇列必須要清空。

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

現在這第二個PromiseReactionJob 把決定傳達給 throwaway
promise,並恢復 async 函式的執行,最後返回從 await
得到的 42
。

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

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

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

感謝神奇的promiseResolve,現在我們只需要傳 v
即可而不用關心它是什麼。之後跟之前一樣,引擎會建立一個 throwaway
promise 並放到PromiseReactionJob 裡為了在下一個 tick 時恢復該 async 函式,它會先暫停函式,把自身返回給掉用者。

當最後所有執行完畢,引擎會跑微任務佇列,會執行PromiseReactionJob。這個任務會傳遞 promise
結果給 throwaway
,並且恢復 async 函式,從 await
拿到 42
。

儘管是內部使用,引擎建立 throwaway
promise 可能還是會讓人覺得哪裡不對。事實證明, throwaway
promise 僅僅是為了滿足規範裡 performPromiseThen
的需要。

這是最近提議給 ECMAScript 的 變更 ,引擎大多數時候不再需要建立 throwaway
了。

對比 await
在 Node.js 10 和優化後(應該會放到 Node.js 12 上)的表現:

async
/ await
效能超過了手寫的 promise 程式碼 。關鍵就是我們減少了 async 函式裡一些不必要的開銷,不僅僅是 V8 引擎,其它 JavaScript 引擎都通過這個 補丁 實現了優化。
開發體驗優化
除了效能,JavaScript 開發者也很關心問題定位和修復,這在非同步程式碼裡一直不是件容易的事。Chrome DevTools 現在支援了非同步棧追蹤:

在本地開發時這是個很有用的特性,不過一旦應用部署了就沒啥用了。除錯時,你只能看到日誌檔案裡的 Error#stack
資訊,這些並不會包含任何非同步資訊。
最近我們搞的零成本非同步棧追蹤 使得 Error#stack
包含了 async 函式的呼叫資訊。「零成本」聽起來很讓人興奮,對吧?當 Chrome DevTools 功能帶來重大開銷時,它如何才能實現零成本?舉個例子, 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
本身卻不在棧追蹤資訊裡。如果應用是部署在雲容器裡,這會讓開發者很難去定位問題。
有意思的是,引擎是知道 bar
結束後應該繼續執行什麼的:即 foo
函式裡 await
後。恰好,這裡也正是 foo
暫停的地方。引擎可以利用這些資訊重建非同步的棧追蹤資訊。有了以上優化,輸出就會變成這樣:
$ 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) 複製程式碼
在棧追蹤資訊裡,最上層的函數出現在第一個,之後是一些非同步呼叫棧,再後面是 foo
裡面 bar
上下文的棧資訊。這個特性的啟用可以通過 V8 的 --async-stack-traces
引數啟用。
然而,如果你跟上面 Chrome DevTools 裡的棧資訊對比,你會發現棧追蹤裡非同步部分缺失了 foo
的呼叫點資訊。這裡利用了 await
恢復和暫停位置是一樣的特性,但Promise#then() 或Promise#catch() 就不是這樣的。可以看 Mathias Bynens 的文章 await beats Promise#then() 瞭解更多。
結論
async 函式變快少不了以下兩個優化:
throwaway
除此之外,我們通過零成本非同步棧追蹤 提升了 await
和 Promise.all()
開發除錯體驗。
我們還有些對 JavaScript 開發者友好的效能建議:
多使用 async
和 await
而不是手寫 promise 程式碼,多使用 JavaScript 引擎提供的 promise 而不是自己去實現。
文章可隨意轉載,但請保留此原文連結。 非常歡迎有激情的你加入ES2049 Studio,簡歷請傳送至 caijun.hcj(at)alibaba-inc.com 。