翻譯自:Faster async functions and promisesjavascript
JavaScript 的異步過程一直被認爲是不夠快的,更糟糕的是,在 NodeJS 等實時性要求高的場景下調試堪比噩夢。不過,這一切正在改變,這篇文章會詳細解釋咱們是如何優化 V8 引擎(也會涉及一些其它引擎)裏的 async 函數和 promises 的,以及伴隨着的開發體驗的優化。html
舒適提示: 這裏有個 視頻,你能夠結合着文章看。java
在 promises 正式成爲 JavaScript 標準的一部分以前,回調被大量用在異步編程中,下面是個例子:node
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); }); }); }); }
相似以上深度嵌套的回調一般被稱爲「回調黑洞」,由於它讓代碼可讀性變差且不易維護。react
幸運地是,如今 promises 成爲了 JavaScript 語言的一部分,如下實現了跟上面一樣的功能:git
function handler() { return validateParams() .then(dbQuery) .then(serviceCall) .then(result => { console.log(result); return result; }); }
最近,JavaScript 支持了 async 函數,上面的異步代碼能夠寫成像下面這樣的同步的代碼:github
async function handler() { await validateParams(); const dbResults = await dbQuery(); const results = await serviceCall(dbResults); console.log(results); return results; }
藉助 async 函數,代碼變得更簡潔,代碼的邏輯和數據流都變得更可控,固然其實底層實現仍是異步。(注意,JavaScript 仍是單線程執行,async 函數並不會開新的線程。)web
NodeJS 裏 ReadableStreams 做爲另外一種形式的異步也特別常見,下面是個例子:chrome
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) 開始支持。
從 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) 性能提高了很多。
性能提高取決於如下三個因素:
當咱們在 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 開發者感到吃驚,仍是有必要再詳細解釋下。在解釋以前,咱們先從一些基礎開始。
從某層面上來講,JavaScript 裏存在任務和微任務。任務處理 I/O 和計時器等事件,一次只處理一個。微任務是爲了 async
/await
和 promise 的延遲執行設計的,每次任務最後執行。在返回事件循環(event loop)前,微任務的隊列會被清空。
能夠經過 Jake Archibald 的 tasks, microtasks, queues, and schedules in the browser 瞭解更多。Node.js 裏任務模型與此很是相似。
根據 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
後面的)。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
promise除此以外,咱們經過 零成本異步棧追蹤 提高了 await
和 Promise.all()
開發調試體驗。
咱們還有些對 JavaScript 開發者友好的性能建議:
多使用 async
和 await
而不是手寫 promise 代碼,多使用 JavaScript 引擎提供的 promise 而不是本身去實現。
文章可隨意轉載,但請保留此 原文連接。
很是歡迎有激情的你加入 ES2049 Studio,簡歷請發送至 caijun.hcj(at)alibaba-inc.com 。