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