原文作者: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國際技術”致力於與你共享高質量的技術文章
歡迎關注我們的公眾號、將文章分享給你的好友