原文做者:Maya Lekova and Benedikt Meurer前端
譯者:UC 國際研發 Jothy算法
寫在最前:歡迎你來到「UC國際技術」公衆號,咱們將爲你們提供與客戶端、服務端、算法、測試、數據、前端等相關的高質量技術文章,不限於原創與翻譯。編程
一直以來,JavaScript 的異步處理都因其速度不夠快而名聲在外。 更糟糕的是,調試實時 JavaScript 應用 - 特別是 Node.js 服務器 - 並不是易事,特別是在涉及異步編程時。 幸虧,這些正在發生改變。 本文探討了咱們如何在 V8(某種程度上也包括其餘 JavaScript 引擎)中優化異步函數和 promise,並描述了咱們如何提高異步代碼的調試體驗。promise
注意:若是你喜歡邊看演講邊看文章,請欣賞下面的視頻!若是不是,請跳過視頻並繼續閱讀。瀏覽器
視頻地址:安全
https://www.youtube.com/watch?v=DFP5DKDQfOc服務器
>> 從回調(callback)到 promise 再到異步函數 <<框架
在 JavaScript 還沒實現 promise 以前,要解決異步的問題一般都得基於回調,尤爲是在 Node.js 中。 舉個例子🌰:
異步
咱們一般把這種使用深度嵌套回調的模式稱爲「回調地獄」,由於這種代碼不易讀取且難以維護。async
所幸,如今 promise 已成爲 JavaScript 的一部分,咱們能夠以一種更優雅和可維護的方式實現代碼:
最近,JavaScript 還增長了對異步函數的支持。 咱們如今能夠用近似同步代碼的方式實現上述異步代碼:
使用異步函數後,雖然代碼的執行仍然是異步的,但代碼變得更加簡潔,而且更易實現控制和數據流。(請注意,JavaScript 仍在單線程中執行,也就是說異步方法自己並無建立物理線程。)
>> 從事件監聽回調到異步迭代 <<
另外一個在 Node.js 中特別常見的異步範式是 ReadableStreams。 請看例子:
這段代碼有點難理解:傳入的數據只能在回調代碼塊中處理,而且流 end 的信號也在回調內觸發。 若是你沒有意識到函數會當即終止,且得等到回調被觸發纔會進行實際處理,就很容易在這裏寫出 bug。
幸虧,ES2018 的一項新的炫酷 feature——異步迭代,能夠簡化此代碼:
咱們再也不將處理實際請求的邏輯放入兩個不一樣的回調 - 'data' 和 ' end ' 回調中,相反,咱們如今能夠將全部內容放入單個異步函數中,並使用新的 for await...of 循環實現異步迭代了。 咱們還添加了 try-catch 代碼塊以免 unhandledRejection 問題[1]。
你如今已經能夠正式使用這些新功能了! Node.js 8(V8 v6.2/Chrome 62)及以上版本已徹底支持異步方法,而 Node.js 10(V8 v6.8/Chrome 68)及以上版本已徹底支持異步迭代器(iterator)和生成器(generator)!
咱們已經在 V8 v5.5(Chrome 55 和 Node.js 7)和 V8 v6.8(Chrome 68 和 Node.js 10)之間的版本顯着提高了異步代碼的性能。開發者可安全地使用新的編程範例,無需擔憂速度問題。
上圖顯示了 doxbee 的基準測試,它測量了大量使用 promise 代碼的性能。 注意圖表展現的是執行時間,意味着值越低越好。
並行基準測試的結果,特別強調了 Promise.all() 的性能,更使人興奮:
咱們將 Promise.all 的性能提升了 8 倍!
可是,上述基準測試是合成微基準測試。 V8 團隊對該優化如何影響真實用戶代碼的實際性能更感興趣。
上面的圖表顯示了一些流行的 HTTP 中間件框架的性能,這些框架大量使用了 promises 和異步函數。 注意此圖表顯示的是每秒請求數,所以與以前的圖表不一樣,數值越高越好。 這些框架的性能在 Node.js 7(V8 v5.5)和 Node.js 10(V8 v6.8)之間的版本獲得了顯着提高。
這些性能改進產出了三項關鍵成就:
TurboFan,新的優化編譯器 🎉
Orinoco,新的垃圾回收器 🚛
一個致使 await 跳過 microticks 的 Node.js 8 bug 🐛
在 Node.js 8 中啓用 TurboFan 後,咱們的性能獲得了全面提高。
咱們一直在研究一款名爲 Orinoco 的新垃圾回收器,它能夠從主線程中剝離出垃圾回收工做,從而顯著改善請求處理。
最後亦不得不提的是,Node.js 8 中有一個簡單的錯誤致使 await 在某些狀況下跳過了 microticks,從而產生了更好的性能。 該錯誤始於無心的違背規範,但卻給了咱們優化的點子。 讓咱們從解釋該 bug 開始:
上面的程序建立了一個 fulfilled 的 promise p,並 await 其結果,但也給它綁了兩個 handler。 你但願 console.log 調用以哪一種順序執行呢?
因爲 p 已經 fulfilled,你可能但願它先打印 'after: await' 而後打 'tick'。 實際上,Node.js 8 會這樣執行:
在Node.js 8 中 await
bug
雖然這種行爲看起來很直觀,但按照規範的規定,它並不正確。 Node.js 10 實現了正確的行爲,即先執行鏈式處理程序,而後繼續執行異步函數。
這種「正確的行爲」能夠說並非很明顯,也挺令 JavaScript 開發者大吃一驚 🐳,因此咱們得解釋解釋。 在咱們深刻 promise 和異步函數的奇妙世界以前,咱們先了解一些基礎。
>> Task VS Microtask <<
JavaScript 中有 task 和 microtask 的概念。 Task 處理 I/O 和計時器等事件,一次執行一個。 Microtask 爲 async/await 和 promise 實現延遲執行,並在每一個任務結束時執行。 老是等到 microtasks 隊列被清空,事件循環執行纔會返回。
task 和 microtask 的區別
詳情請查看 Jake Archibald 對瀏覽器中 task,microtask,queue 和 schedule 的解釋。 Node.js 中的任務模型與之很是類似。
文章地址:
https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
>> 異步函數<<
MDN 對異步函數的解釋是,一個使用隱式 promise 進行異步操做並返回其結果的函數。 異步函數旨在使異步代碼看起來像同步代碼,爲開發者下降異步處理的複雜性。
最簡單的異步函數以下所示:
當被調用時,它返回一個 promise,你能夠像調用別的 promise 那樣得到它的值。
只有在下次運行 microtask 時才能得到此 promise 的值。 換句話說,以上程序語義上等同於使用 Promise.resolve 獲取 value:
異步函數的真正威力來自 await 表達式,它使函數執行暫停,直到 promise 完成以後,再恢復函數執行。 await 的值是 promise fulfilled(完成)的結果。 這個示例能夠很好地解釋:
fetchStatus 在 await 處暫停,在 fetch promise 完成時恢復。 這或多或少等同於將 handler 連接到 fetch 返回的 promise。
該 handler 包含 async 函數中 await 以後的代碼。
通常來講你會 await 一個 Promise,但其實你能夠 await 任意的 JavaScript 值。 就算 await 以後的表達式不是 promise,它也會被轉換爲 promise。 這意味着只要你想,你也能夠 await 42:
更有趣的是,await 適用於任何 「thenable」,即任何帶有 then 方法的對象,即便它不是真正的 promise。 所以,你能夠用它作一些有趣的事情,例如測量實際睡眠時間的異步睡眠:
讓咱們按照規範看看 V8 引擎對 await 作了什麼。 這是一個簡單的異步函數 foo:
當 foo 被調用時,它將參數 v 包裝到一個 promise 中,並暫停異步函數的執行,直到該 promise 完成。完成以後,函數的執行將恢復,w 將被賦予 promise 完成時的值。 而後異步函數返回此值。
>> V8 如何處理 await <<
首先,V8 將該函數標記爲可恢復,這意味着該操做能夠暫停並稍後恢復(await 時)。 而後它建立一個叫 implicit_promise 的東西,這是在調用異步函數時返回的 promise,並最終 resolve 爲 async 函數的返回值。
有趣的地方在於:實際的 await。首先,傳遞給 await 的值會被封裝到 promise 中。而後,在 promise 後帶上 handler 處理函數(以便在 promise 完成後恢復異步函數),而異步函數的執行會被掛起,將 implicit_promise 返回給調用者。一旦 promise 完成,其生成的值 w 會返回給異步函數,異步函數恢復執行,w 也便是 implicit_promise 的完成(resolved)結果。
簡而言之,await v 的初始步驟是:
1. 封裝 v - 傳遞給 await 的值 - 轉換爲 promise。
2. 將處理程序附加到 promise 上,以便稍後恢復異步函數。
3. 掛起異步函數並將 implicit_promise 返回給調用者。
讓咱們一步步來完成操做。假設正在 await 的已是一個已完成且會返回 42 的 promise。而後引擎建立了一個新的 promise 並完成了 await 操做。這確實推遲了這些 promise 下一輪的連接,正如 PromiseResolveThenableJob 規範表述的那樣。
而後引擎創造了另外一個叫 throwaway(一次性)的 promise。 之因此被稱爲一次性,是由於它不會由任何鏈式綁定 - 它徹底存在引擎內部。 而後 throwaway 會被連接到 promise 上,使用適當的處理程序來恢復異步函數。 這個 performPromiseThen 操做是 Promise.prototype.then() 隱式執行的。 最後,異步函數的執行會暫停,並將控制權返回給調用者。
調用程序會繼續執行,直到調用棧爲空。 而後 JavaScript 引擎開始運行 microtask:它會先運行以前的 PromiseResolveThenableJob,生成新的 PromiseReactionJob 以將 promise 連接到傳遞給 await 的值。 而後,引擎返回處理 microtask 隊列,由於在繼續主事件循環以前必須清空 microtask 隊列。
await
的開銷
總結以上所學,對於每一個 await,引擎都必須建立兩個額外的 promise(即便右邊的表達式已是 promise)而且它須要至少三個 microtask 隊列執行。 誰知道一個簡單的 await 表達式會引發這麼多的開銷呢?!
事實證實,規範中已經有 promiseResolve 操做,只在必要時執行封裝:
此操做同樣會返回 promises,而且只在必要時將其餘值包裝到 promises 中。 經過這種方式,你能夠少用一個額外的 promise,以及 microtask 隊列上的兩個 tick,由於通常來講傳遞給 await 的值會是 promise。 這種新行爲目前可使用 V8 的 --harmony-await-optimization 標誌實現(從 V8 v7.1 開始)。 咱們也向 ECMAScript 規範提交了此變動,該補丁會在咱們確認它與 Web 兼容以後立刻打上。
如下展現了新改進的 await 是如何一步步工做的:
最終當全部 JavaScript 執行完成時,引擎開始運行 microtask,因此 PromiseReactionJob 被執行。 這個工做將 promise 的結果傳播給 throwaway,並恢復 async 函數的執行,從 await 中產生 42。
await
overhead
若是傳遞給 await 的值已是一個 promise,那麼這種優化避免了建立 promise 封裝器的須要,這時,咱們把最少三個的 microticks 減小到了一個。 這種行爲相似於 Node.js 8 的作法,不過如今它再也不是 bug 了 - 它是一個正在標準化的優化!
儘管引擎徹底內置,但它必須在內部創造 throwaway promise 仍然是錯誤的。 事實證實,throwaway promise 只是爲了知足規範中內部 performPromiseThen 操做的 API 約束。
最近的 ECMAScript 規範解決了這個問題。 引擎再也不須要建立 await 的 throwaway promise - 大部分狀況下[2]。
await
code before and after the optimizations
將 Node.js 10 中的 await 與可能在 Node.js 12 中獲得優化的 await 對比,對性能的影響大體以下:
除了性能以外,JavaScript 開發人員還關心診斷和修復問題的能力,這在處理異步代碼時並沒那麼簡單。 Chrome DevTool 支持異步堆棧跟蹤,該堆棧跟蹤不只包括當前同步的部分,還包括異步部分:
這在本地開發過程當中很是有用。 可是,一旦部署了應用,這種方法就沒法起做用了。 在過後調試期間,你只能在日誌文件中看到 Error#stack 輸出,而看不到任何有關異步部分的信息。
咱們最近一直在研究零成本的異步堆棧跟蹤,它使用異步函數調用豐富了 Error#stack 屬性。 「零成本」聽起來很振奮人心是吧? 當 Chrome DevTools 功能帶來重大開銷時,它如何才能實現零成本? 舉個例子🌰,其中 foo 異步調用了 bar ,而 bar 在 await promise 後拋出了異常:
在 Node.js 8 或 Node.js 10 中運行此代碼會輸出:
請注意,雖然對 foo() 的調用會致使錯誤,但 foo 並非堆棧跟蹤的一部分。 這讓 JavaScript 開發者執行過後調試變得棘手,不管你的代碼是部署在 Web 應用程序中仍是雲容器內部。
有趣的是,當 bar 完成時,引擎知道它該繼續的位置:就在函數 foo 中的 await 以後。 巧的是,這也是函數 foo 被暫停的地方。 引擎可使用此信息來重建異步堆棧跟蹤的部分,即 await 點。 有了這個變動,輸出變爲:
在堆棧跟蹤中,最頂層的函數首先出現,而後是同步堆棧跟蹤的其他部分,而後是函數 foo 中對 bar 的異步調用。此變動在新的 --async-stack-traces 標誌後面的 V8 中實現。
可是,若是將其與上面 Chrome DevTools 中的異步堆棧跟蹤進行比較,你會注意到堆棧跟蹤的異步部分中缺乏 foo 的實際調用點。如前所述,這種方法利用瞭如下原理:await 恢復和暫停位置是相同的 - 但對於常規的 Promise#then() 或 Promise#catch()調用,狀況並不是如此。更多背景信息請參閱 Mathias Bynens 關於爲何 await 能戰勝 Promise#then() 的解釋。
感謝如下兩個重要的優化,使咱們的異步函數更快了:
刪除兩個額外的 microticks;
取消 throwaway promise;
最重要的是,咱們經過零成本的異步堆棧跟蹤改進了開發體驗,這些跟蹤在異步函數的 await 和 Promise.all() 中運行。
咱們還爲 JavaScript 開發人員提供了一些很好的性能建議:
多用異步函數和 await 來替代手寫的 promise;
堅持使用 JavaScript 引擎提供的原生 promise 實現,避免 await 使用兩個 microticks;
英文原文:https://v8.dev/blog/fast-async
好文推薦:
React 16.x 路線圖公佈,包括服務器渲染的 Suspense 組件及Hooks等
「UC國際技術」致力於與你共享高質量的技術文章
歡迎關注咱們的公衆號、將文章分享給你的好友