V8 中更快的非同步函式和 promises

Jothy發表於2018-11-29

V8 中更快的非同步函式和 promises

原文作者:Maya Lekova and Benedikt Meurer

譯者:UC 國際研發 Jothy

寫在最前:歡迎你來到“UC國際技術”公眾號,我們將為大家提供與客戶端、服務端、演算法、測試、資料、前端等相關的高質量技術文章,不限於原創與翻譯。

一直以來,JavaScript 的非同步處理都因其速度不夠快而名聲在外。 更糟糕的是,除錯實時 JavaScript 應用 – 特別是 Node.js 伺服器 – 並非易事,特別是在涉及非同步程式設計時。 幸好,這些正在發生改變。 本文探討了我們如何在 V8(某種程度上也包括其他 JavaScript 引擎)中優化非同步函式和 promise,並描述了我們如何提升非同步程式碼的除錯體驗。

注意:如果你喜歡邊看演講邊看文章,請欣賞下面的視訊!如果不是,請跳過視訊並繼續閱讀。

視訊地址:

https://www.youtube.com/watch?v=DFP5DKDQfOc

V8 中更快的非同步函式和 promises一種新的非同步程式設計方法

>> 從回撥(callback)到 promise 再到非同步函式 <<

在 JavaScript 還沒實現 promise 之前,要解決非同步的問題通常都得基於回撥,尤其是在 Node.js 中。 舉個例子?:

V8 中更快的非同步函式和 promises

我們通常把這種使用深度巢狀回撥的模式稱為“回撥地獄”,因為這種程式碼不易讀取且難以維護。

所幸,現在 promise 已成為 JavaScript 的一部分,我們可以以一種更優雅和可維護的方式實現程式碼:

V8 中更快的非同步函式和 promises

最近,JavaScript 還增加了對非同步函式的支援。 我們現在可以用近似同步程式碼的方式實現上述非同步程式碼:

V8 中更快的非同步函式和 promises

使用非同步函式後,雖然程式碼的執行仍然是非同步的,但程式碼變得更加簡潔,並且更易實現控制和資料流。(請注意,JavaScript 仍在單執行緒中執行,也就是說非同步方法本身並沒有建立物理執行緒。)

>> 從事件監聽回撥到非同步迭代 <<

另一個在 Node.js 中特別常見的非同步正規化是 ReadableStreams。 請看例子:

V8 中更快的非同步函式和 promises

這段程式碼有點難理解:傳入的資料只能在回撥程式碼塊中處理,並且流 end 的訊號也在回撥內觸發。 如果你沒有意識到函式會立即終止,且得等到回撥被觸發才會進行實際處理,就很容易在這裡寫出 bug。

幸好,ES2018 的一項新的炫酷 feature——非同步迭代,可以簡化此程式碼:

V8 中更快的非同步函式和 promises

我們不再將處理實際請求的邏輯放入兩個不同的回撥 – `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 中更快的非同步函式和 promises非同步效能提升

我們已經在 V8 v5.5(Chrome 55 和 Node.js 7)和 V8 v6.8(Chrome 68 和 Node.js 10)之間的版本顯著提升了非同步程式碼的效能。開發者可安全地使用新的程式設計範例,無需擔心速度問題。

V8 中更快的非同步函式和 promises

上圖顯示了 doxbee 的基準測試,它測量了大量使用 promise 程式碼的效能。 注意圖表展示的是執行時間,意味著值越低越好。

並行基準測試的結果,特別強調了 Promise.all() 的效能,更令人興奮:

V8 中更快的非同步函式和 promises

我們將 Promise.all 的效能提高了 8 倍!

但是,上述基準測試是合成微基準測試。 V8 團隊對該優化如何影響真實使用者程式碼的實際效能更感興趣。

V8 中更快的非同步函式和 promises

上面的圖表顯示了一些流行的 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 開始:

V8 中更快的非同步函式和 promises

上面的程式建立了一個 fulfilled 的 promise p,並 await 其結果,但也給它綁了兩個 handler。 你希望 console.log 呼叫以哪種順序執行呢?

由於 p 已經 fulfilled,你可能希望它先列印 `after: await` 然後打 `tick`。 實際上,Node.js 8 會這樣執行:

V8 中更快的非同步函式和 promises

在Node.js 8 中 await bug

雖然這種行為看起來很直觀,但按照規範的規定,它並不正確。 Node.js 10 實現了正確的行為,即先執行鏈式處理程式,然後繼續執行非同步函式。

V8 中更快的非同步函式和 promises

Node.js 10 沒有 await bug

這種“正確的行為”可以說並不是很明顯,也挺令 JavaScript 開發者大吃一驚 ?,所以我們得解釋解釋。 在我們深入 promise 和非同步函式的奇妙世界之前,我們先了解一些基礎。

>> Task VS Microtask <<

JavaScript 中有 task 和 microtask 的概念。 Task 處理 I/O 和計時器等事件,一次執行一個。 Microtask 為 async/await 和 promise 實現延遲執行,並在每個任務結束時執行。 總是等到 microtasks 佇列被清空,事件迴圈執行才會返回。

V8 中更快的非同步函式和 promises

task 和 microtask 的區別

詳情請檢視 Jake Archibald 對瀏覽器中 task,microtask,queue 和 schedule 的解釋。 Node.js 中的任務模型與之非常相似。

文章地址:

https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/

>> 非同步函式<<

MDN 對非同步函式的解釋是,一個使用隱式 promise 進行非同步操作並返回其結果的函式。 非同步函式旨在使非同步程式碼看起來像同步程式碼,為開發者降低非同步處理的複雜性。

最簡單的非同步函式如下所示:

V8 中更快的非同步函式和 promises

當被呼叫時,它返回一個 promise,你可以像呼叫別的 promise 那樣獲得它的值。

V8 中更快的非同步函式和 promises

只有在下次執行 microtask 時才能獲得此 promise 的值。 換句話說,以上程式語義上等同於使用 Promise.resolve 獲取 value:

V8 中更快的非同步函式和 promises

非同步函式的真正威力來自 await 表示式,它使函式執行暫停,直到 promise 完成之後,再恢復函式執行。 await 的值是 promise fulfilled(完成)的結果。 這個示例可以很好地解釋:

V8 中更快的非同步函式和 promises

fetchStatus 在 await 處暫停,在 fetch promise 完成時恢復。 這或多或少等同於將 handler 連結到 fetch 返回的 promise。

V8 中更快的非同步函式和 promises

該 handler 包含 async 函式中 await 之後的程式碼。

一般來說你會 await 一個 Promise,但其實你可以 await 任意的 JavaScript 值。 就算 await 之後的表示式不是 promise,它也會被轉換為 promise。 這意味著只要你想,你也可以 await 42:

V8 中更快的非同步函式和 promises

更有趣的是,await 適用於任何 “thenable”,即任何帶有 then 方法的物件,即使它不是真正的 promise。 因此,你可以用它做一些有趣的事情,例如測量實際睡眠時間的非同步睡眠:

V8 中更快的非同步函式和 promises

讓我們按照規範看看 V8 引擎對 await 做了什麼。 這是一個簡單的非同步函式 foo:

V8 中更快的非同步函式和 promises

當 foo 被呼叫時,它將引數 v 包裝到一個 promise 中,並暫停非同步函式的執行,直到該 promise 完成。完成之後,函式的執行將恢復,w 將被賦予 promise 完成時的值。 然後非同步函式返回此值。

>> V8 如何處理 await <<

首先,V8 將該函式標記為可恢復,這意味著該操作可以暫停並稍後恢復(await 時)。 然後它建立一個叫 implicit_promise 的東西,這是在呼叫非同步函式時返回的 promise,並最終 resolve 為 async 函式的返回值。

V8 中更快的非同步函式和 promises

簡單的非同步函式以及引擎解析結果對比

有趣的地方在於:實際的 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 規範表述的那樣。

V8 中更快的非同步函式和 promises

然後引擎創造了另一個叫 throwaway(一次性)的 promise。 之所以被稱為一次性,是因為它不會由任何鏈式繫結 – 它完全存在引擎內部。 然後 throwaway 會被連結到 promise 上,使用適當的處理程式來恢復非同步函式。 這個 performPromiseThen 操作是 Promise.prototype.then() 隱式執行的。 最後,非同步函式的執行會暫停,並將控制權返回給呼叫者。

V8 中更快的非同步函式和 promises

呼叫程式會繼續執行,直到呼叫棧為空。 然後 JavaScript 引擎開始執行 microtask:它會先執行之前的 PromiseResolveThenableJob,生成新的 PromiseReactionJob 以將 promise 連結到傳遞給 await 的值。 然後,引擎返回處理 microtask 佇列,因為在繼續主事件迴圈之前必須清空 microtask 佇列。

V8 中更快的非同步函式和 promises

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

V8 中更快的非同步函式和 promises

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

V8 中更快的非同步函式和 promises

await 的開銷

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

V8 中更快的非同步函式和 promises

我們來看看這些開銷來自哪裡。 第一行負責封裝 promise。 第二行立即用 await 得到的值 v 解開了封裝。這兩行帶來了一個額外的 promise,同時也帶來了三個 microticks 中的兩個。 在 v 已經是一個 promise 的情況下(這是常見的情況,因為通常 await 的都是 promise),這中操作十分昂貴。 在不太常見的情況下,開發者 await 例如 42 的值,引擎仍然需要將它包裝成一個 promise。

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

V8 中更快的非同步函式和 promises

此操作一樣會返回 promises,並且只在必要時將其他值包裝到 promises 中。 通過這種方式,你可以少用一個額外的 promise,以及 microtask 佇列上的兩個 tick,因為一般來說傳遞給 await 的值會是 promise。 這種新行為目前可以使用 V8 的 –harmony-await-optimization 標誌實現(從 V8 v7.1 開始)。 我們也向 ECMAScript 規範提交了此變更,該補丁會在我們確認它與 Web 相容之後馬上打上。

以下展示了新改進的 await 是如何一步步工作的:

V8 中更快的非同步函式和 promises

讓我們再次假設我們 await 一個返回 42 的 promise。感謝神奇的 promiseResolve,現在 promise 只引用同一個 promise v,所以這一步中沒有任何關係。 之後引擎繼續像以前一樣,建立 throwaway promise,生成 PromiseReactionJob 在 microtask 佇列的下一個 tick 上恢復非同步函式,暫停函式的執行,然後返回給呼叫者。

V8 中更快的非同步函式和 promises

最終當所有 JavaScript 執行完成時,引擎開始執行 microtask,所以 PromiseReactionJob 被執行。 這個工作將 promise 的結果傳播給 throwaway,並恢復 async 函式的執行,從 await 中產生 42。

V8 中更快的非同步函式和 promises

Summary of the reduction in await overhead

如果傳遞給 await 的值已經是一個 promise,那麼這種優化避免了建立 promise 封裝器的需要,這時,我們把最少三個的 microticks 減少到了一個。 這種行為類似於 Node.js 8 的做法,不過現在它不再是 bug 了 – 它是一個正在標準化的優化!

儘管引擎完全內建,但它必須在內部創造 throwaway promise 仍然是錯誤的。 事實證明,throwaway promise 只是為了滿足規範中內部 performPromiseThen 操作的 API 約束。

V8 中更快的非同步函式和 promises

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

V8 中更快的非同步函式和 promises

Comparison of await code before and after the optimizations

將 Node.js 10 中的 await 與可能在 Node.js 12 中得到優化的 await 對比,對效能的影響大致如下:

V8 中更快的非同步函式和 promises

async/await 優於手寫的 promise 程式碼。 這裡的關鍵點是我們通過修補規範[3]顯著減少了非同步函式的開銷 – 不僅在 V8 中,而且在所有 JavaScript 引擎中。

V8 中更快的非同步函式和 promises開發體驗提升

除了效能之外,JavaScript 開發人員還關心診斷和修復問題的能力,這在處理非同步程式碼時並沒那麼簡單。 Chrome DevTool 支援非同步堆疊跟蹤,該堆疊跟蹤不僅包括當前同步的部分,還包括非同步部分:

V8 中更快的非同步函式和 promises

這在本地開發過程中非常有用。 但是,一旦部署了應用,這種方法就無法起作用了。 在事後除錯期間,你只能在日誌檔案中看到 Error#stack 輸出,而看不到任何有關非同步部分的資訊。

我們最近一直在研究零成本的非同步堆疊跟蹤,它使用非同步函式呼叫豐富了 Error#stack 屬性。 “零成本”聽起來很振奮人心是吧? 當 Chrome DevTools 功能帶來重大開銷時,它如何才能實現零成本? 舉個例子?,其中 foo 非同步呼叫了 bar ,而 bar 在 await promise 後丟擲了異常:

V8 中更快的非同步函式和 promises

在 Node.js 8 或 Node.js 10 中執行此程式碼會輸出:

V8 中更快的非同步函式和 promises

請注意,雖然對 foo() 的呼叫會導致錯誤,但 foo 並不是堆疊跟蹤的一部分。 這讓 JavaScript 開發者執行事後除錯變得棘手,無論你的程式碼是部署在 Web 應用程式中還是雲容器內部。

有趣的是,當 bar 完成時,引擎知道它該繼續的位置:就在函式 foo 中的 await 之後。 巧的是,這也是函式 foo 被暫停的地方。 引擎可以使用此資訊來重建非同步堆疊跟蹤的部分,即 await 點。 有了這個變更,輸出變為:

V8 中更快的非同步函式和 promises

在堆疊跟蹤中,最頂層的函式首先出現,然後是同步堆疊跟蹤的其餘部分,然後是函式 foo 中對 bar 的非同步呼叫。此變更在新的 –async-stack-traces 標誌後面的 V8 中實現。

但是,如果將其與上面 Chrome DevTools 中的非同步堆疊跟蹤進行比較,你會注意到堆疊跟蹤的非同步部分中缺少 foo 的實際呼叫點。如前所述,這種方法利用了以下原理:await 恢復和暫停位置是相同的 – 但對於常規的 Promise#then() 或 Promise#catch()呼叫,情況並非如此。更多背景資訊請參閱 Mathias Bynens 關於為什麼 await 能打敗 Promise#then() 的解釋。

V8 中更快的非同步函式和 promises結論

感謝以下兩個重要的優化,使我們的非同步函式更快了:

  • 刪除兩個額外的 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國際技術”致力於與你共享高質量的技術文章

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

V8 中更快的非同步函式和 promises

相關文章