原文作者:Maya Lekova and Benedikt Meurer
譯者:UC 國際研發 Jothy
寫在最前:歡迎你來到“UC國際技術”公眾號,我們將為大家提供與客戶端、服務端、算法、測試、數據、前端等相關的高質量技術文章,不限于原創與翻譯。
一直以來,JavaScript 的異步處理都因其速度不夠快而名聲在外。 更糟糕的是,調試實時 JavaScript 應用 - 特別是 Node.js 服務器 - 并非易事,特別是在涉及異步編程時。 幸好,這些正在發生改變。 本文探討了我們如何在 V8(某種程度上也包括其他 JavaScript 引擎)中優化異步函數和 promise,并描述了我們如何提升異步代碼的調試體驗。
注意:如果你喜歡邊看演講邊看文章,請欣賞下面的視頻!如果不是,請跳過視頻并繼續閱讀。
視頻地址:
https://www.youtube.com/watch?v=DFP5DKDQfOc
一種新的異步編程方法
>> 從回調(callback)到 promise 再到異步函數 <<
在 JavaScript 還沒實現 promise 之前,要解決異步的問題通常都得基于回調,尤其是在 Node.js 中。 舉個例子?:
我們通常把這種使用深度嵌套回調的模式稱為“回調地獄”,因為這種代碼不易讀取且難以維護。
所幸,現在 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國際技術”致力于與你共享高質量的技術文章
歡迎關注我們的公眾號、將文章分享給你的好友