V8 中更快的異步函數和 promises


原文做者: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 實現了正確的行爲,即先執行鏈式處理程序,而後繼續執行異步函數。

Node.js 10 沒有 await bug

這種「正確的行爲」能夠說並非很明顯,也挺令 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 隊列。


接下來是 PromiseReactionJob,它用咱們 await 的 promise 返回的值 - 此時是 42 - 完成了 promise,並將該反應處理到 throwaway 上。 而後引擎再次返回 microtask 循環,循環中是最終待處理的 microtask。



接着,第二個 PromiseReactionJob 將結果傳遞迴 throwaway promise,並恢復暫停執行的異步函數,從 await 返回值 42。


await 的開銷

總結以上所學,對於每一個 await,引擎都必須建立兩個額外的 promise(即便右邊的表達式已是 promise)而且它須要至少三個 microtask 隊列執行。 誰知道一個簡單的 await 表達式會引發這麼多的開銷呢?!

咱們來看看這些開銷來自哪裏。 第一行負責封裝 promise。 第二行當即用 await 獲得的值 v 解開了封裝。這兩行帶來了一個額外的 promise,同時也帶來了三個 microticks 中的兩個。 在 v 已是一個 promise 的狀況下(這是常見的狀況,由於一般 await 的都是 promise),這中操做十分昂貴。 在不太常見的狀況下,開發者 await 例如 42 的值,引擎仍然須要將它包裝成一個 promise。

事實證實,規範中已經有 promiseResolve 操做,只在必要時執行封裝:

此操做同樣會返回 promises,而且只在必要時將其餘值包裝到 promises 中。 經過這種方式,你能夠少用一個額外的 promise,以及 microtask 隊列上的兩個 tick,由於通常來講傳遞給 await 的值會是 promise。 這種新行爲目前可使用 V8 的 --harmony-await-optimization 標誌實現(從 V8 v7.1 開始)。 咱們也向 ECMAScript 規範提交了此變動,該補丁會在咱們確認它與 Web 兼容以後立刻打上。


如下展現了新改進的 await 是如何一步步工做的:


讓咱們再次假設咱們 await 一個返回 42 的 promise。感謝神奇的 promiseResolve,如今 promise 只引用同一個 promise v,因此這一步中沒有任何關係。 以後引擎繼續像之前同樣,建立 throwaway promise,生成 PromiseReactionJob 在 microtask 隊列的下一個 tick 上恢復異步函數,暫停函數的執行,而後返回給調用者。


最終當全部 JavaScript 執行完成時,引擎開始運行 microtask,因此 PromiseReactionJob 被執行。 這個工做將 promise 的結果傳播給 throwaway,並恢復 async 函數的執行,從 await 中產生 42。


Summary of the reduction in await overhead


若是傳遞給 await 的值已是一個 promise,那麼這種優化避免了建立 promise 封裝器的須要,這時,咱們把最少三個的 microticks 減小到了一個。 這種行爲相似於 Node.js 8 的作法,不過如今它再也不是 bug 了 - 它是一個正在標準化的優化!


儘管引擎徹底內置,但它必須在內部創造 throwaway promise 仍然是錯誤的。 事實證實,throwaway promise 只是爲了知足規範中內部 performPromiseThen 操做的 API 約束。



最近的 ECMAScript 規範解決了這個問題。 引擎再也不須要建立 await 的 throwaway promise - 大部分狀況下[2]

Comparison of await code before and after the optimizations


將 Node.js 10 中的 await 與可能在 Node.js 12 中獲得優化的 await 對比,對性能的影響大體以下:

async/await 優於手寫的 promise 代碼。 這裏的關鍵點是咱們經過修補規範 [3]顯着減小了異步函數的開銷 - 不只在 V8 中,並且在全部 JavaScript 引擎中。



開發體驗提高


除了性能以外,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國際技術」致力於與你共享高質量的技術文章

歡迎關注咱們的公衆號、將文章分享給你的好友

相關文章
相關標籤/搜索