我關注的賀老—賀師俊前輩@johnhax 最近發表個這樣一條微博:前端
雖然這條微博沒有引發大範圍的關注和討論,可是做爲新人,我陷入了思考。究竟 V8 引擎作了哪些魔法,達到了極大限度的優化呢?java
這篇文章,將會深刻淺出分析這些優化背後的奧祕。但願大神給予斧正和引導,同時對讀者有所啓發和幫助。git
ECMAScript 2015 語言標準規格介紹了幾種新的數據結構:好比 Maps 和 Sets(固然還有相似 WeakMaps 和 WeakSets等),而這幾個新引入的數據結構有一個共性,那就是均可以根據一樣新引入的遍歷協議(iteration protocol)進行遍歷。這就意味着你可使用 for...of 循環或者擴展運算符進行操做。舉一個 Sets 簡單的例子:github
const s = new Set([1, 2, 3, 4]);
console.log(...s);
// 1 2 3 4
for (const x of s) console.log(x);
// 1
// 2
// 3
// 4複製代碼
一樣對於 Maps:數組
const m = new Map([[1, "1"], [2, "2"], [3, "3"], [4, "4"]]);
console.log(...m);
// (2) [1, "1"] (2) [2, "2"] (2) [3, "3"] (2) [4, "4"]
console.log(...m.keys());
// 1 2 3 4
console.log(...m.values());
// 1 2 3 4
for (const [x, y] of m) console.log(x, "is", y);
// 1 "is" "1"
// 2 "is" "2"
// 3 "is" "3"
// 4 "is" "4"複製代碼
經過這兩個簡單的例子,展現了最基本的用法。感興趣的讀者能夠參考 ECMA 規範: Map Iterator Objects 和 Set Iterator Objects。性能優化
然而不幸的是,這些可遍歷的數據結構在 V8 引擎中並無很好的進行優化。或者說,對於這些實現細節優化程度不好。包括 ECMAScript 成員 Brian Terlson 也曾在 Twitter 上抱怨,指出他使用 Sets 來實現一個正則引擎時遇到了惱人的性能問題。bash
因此,如今是時間來對他們進行優化了!但在着手優化前,咱們須要先完全認清一個問題:這些數據結構處理慢的真實緣由到底是什麼?性能瓶頸到底在哪裏?
爲了弄清這個問題,咱們就須要理解底層實現上,迭代器到底是如何工做的?數據結構
爲此,咱們先從一個簡單的 for...of 循環提及。app
ES6 借鑑 C++、Java、C# 和 Python 語言,引入了 for...of 循環,做爲遍歷全部數據結構的統一方法。函數
一個最簡單的使用:
function sum(iterable) {
let x = 0;
for (const y of iterable) x += y;
return x;
}複製代碼
將這段代碼使用 Babel 進行編譯,咱們獲得:
function sum(iterable) {
var x = 0;
var _iteratorNormalCompletion = true;
var _didIteratorError = false;
var _iteratorError = undefined;
try {
for (var _iterator = iterable[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) {
var y = _step.value;
x += y;
}
} catch (err) {
_didIteratorError = true;
_iteratorError = err;
} finally {
try {
if (!_iteratorNormalCompletion && _iterator.return) {
_iterator.return();
}
} finally {
if (_didIteratorError) {
throw _iteratorError;
}
}
}
return x;
}複製代碼
須要告訴你們的一個事實是:目前現代 JavaScript 引擎在本質上和 Babel 對 for...of 的去語法糖化處理是相同的,僅僅是在具體一些細節上有差異。
這個事實出處:
All modern JavaScript engines essentially perform the same desugaring that Babel does here to implement for-of (details vary)
上文引自 V8 核心成員,谷歌工程師 Benedikt Meurer。
但是上面通過 Babel 編譯後的代碼不太好閱讀。別急,我刪去了煩人的異常處理,只保留了最核心的邏輯供你們參考,以便研究:
function sum(iterable) {
var x = 0;
var iterator = iterable[Symbol.iterator]();
for (;;) {
var iterResult = iterator.next();
if (iterResult.done) break;
x += iterResult.value;
}
return x;
}複製代碼
理解這段代碼須要的預備知識須要清楚 for...of 和 Symbol.iterator 方法關係:
一個數據結構只要部署了 Symbol.iterator 屬性,就被視爲具備 iterator 接口,就能夠用 for...of 循環遍歷它的成員。也就是說,for...of 循環內部調用的是數據結構的 Symbol.iterator 方法。
for...of 循環可使用的範圍包括數組、Set 和 Map 結構、某些相似數組的對象(好比 arguments 對象、DOM NodeList 對象)、Generator 對象,以及字符串。
咱們仔細觀察上段段代碼,即可以發現迭代器性能優化的關鍵是:保證在循環中屢次重複調用的 iterator.next() 能獲得最大限度的優化,同時,最理想的狀況是徹底避免對 iterResult 的內存分配。可以達到這種目的的幾個手段即是使用相似 store-load propagation, escape analysis 和 scalar replacement of aggregates 先進的編譯處理技術。
同時,優化後的編譯還須要徹底消除迭代器自己 —— iterable[Symbol.iterator] 的調用分配,而直接在迭代器 backing-store 上直接操做。
事實上,在數組和字符串迭代器的優化過程當中,就是使用了這樣的技術和理念。具體的實施文檔能夠參考這裏。
那麼具體到 Set 迭代器的性能問題,其實關鍵緣由在與:其實現上混合了 JavaScript 和 C++ 環境。好比,咱們看 %SetIteratorPrototype%.next() 的實現:
function SetIteratorNextJS() {
if (!IS_SET_ITERATOR(this)) {
throw %make_type_error(kIncompatibleMethodReceiver,
'Set Iterator.prototype.next', this);
}
var value_array = [UNDEFINED, UNDEFINED];
var result = %_CreateIterResultObject(value_array, false);
switch (%SetIteratorNext(this, value_array)) {
case 0:
result.value = UNDEFINED;
result.done = true;
break;
case ITERATOR_KIND_VALUES:
result.value = value_array[0];
break;
case ITERATOR_KIND_ENTRIES:
value_array[1] = value_array[0];
break;
}
return result;
}複製代碼
這段代碼實際上循序漸進作了這麼幾件事情:
這就形成什麼樣的後果呢?遍歷的每一步咱們都在不斷地分配兩個對象:value_array 和 result。不論是 V8 的 TurboFan 仍是 Crankshaft (V8的優化編譯器) 咱們都沒法消除這樣的操做。更糟糕的是,每一步遍歷咱們都要在 JavaScript 和 C++ 之間進行切換。下面的圖,簡要表示了一個簡單的 SUM 函數在底層的運行流程:
在 V8 執行時,總處在兩個狀態(事實上更多):執行 C++ 代碼和執行 JavaScript 當中。這兩個狀態之間的轉換開銷巨大。從 JavaScript 到 C++,是依賴所謂的 CEntryStub 完成,CEntryStub 會觸發 C++ 當中指定的 Runtime_Something 函數(本例中爲 Runtime_SetIteratorNext)。因此,是否能夠避免這種轉換,以及避免 value_array 和 result 對象的分配又決定了性能的關鍵。
最新的 %SetIteratorPrototype%.next() 實現正是切中要害,作了這個「關鍵」的處理。咱們想要執行的代碼在調用以前就會變得 hot (熱處理),TurboFan 進而最終得以熱處理優化。藉助所謂的 CodeStubAssembler,baseline implementation,如今已經徹底在 JavaScript 層面實現接入。這樣咱們僅僅只須要調用 C++ 來作垃圾回收(在可用內存耗盡時)以及異常處理的工做。
在遍歷協議中,JavaScript 一樣提供 Set.prototype.forEach 和 Map.prototype.forEach 方法,來接收一個回調函數。這些一樣依賴於 C++ 的處理邏輯,這樣不只僅須要咱們將 JavaScript 轉換爲 C++,還要處理回調函數轉換爲 Javascript,這樣的工做模式以下圖:
因此,上面使用 CodeStubAssembler 的方式只能處理簡單的非回調函數場景。真正徹底意義上的優化,包括 forEach 方法的 TurboFan 化還須要一些待開發的魔法。
咱們使用下面的 benchmark 代碼進行優化程度的評測:
const s = new Set;
const m = new Map;
for (let i = 0; i < 1e7; ++i) {
m.set(i, i);
s.add(i);
}
function SetForOf() {
for (const x of s) {}
}
function SetForOfEntries() {
for (const x of s.entries()) {}
}
function SetForEach() {
s.forEach(function(key, key, set) {});
}
function MapForOf() {
for (const x of m) {}
}
function MapForOfKeys() {
for (const x of m.keys()) {}
}
function MapForOfValues() {
for (const x of m.values()) {}
}
function MapForEach() {
m.forEach(function(val, key, map) {});
}
const TESTS = [
SetForOf,
SetForOfEntries,
SetForEach,
MapForOf,
MapForOfKeys,
MapForOfValues,
MapForEach
];
// Warmup.
for (const test of TESTS) {
for (let i = 0; i < 10; ++i) test();
}
// Actual tests.
for (const test of TESTS) {
console.time(test.name);
test();
console.timeEnd(test.name);
}複製代碼
在 Chrome60 和 Chrome61 版本的對比中,獲得下面圖標結論:
可見,雖然大幅提高,可是咱們仍是獲得了一些不太理想的結果。尤爲體如今 SetForOfEntries 和 MapForOf 上。可是這將會在更長遠的計劃上進行處理。
這篇文章只是在大面上介紹了遍歷器性能所面臨的瓶頸和現有的解決方案。經過賀老的微博,對一個問題進行探究,最終找到 V8 核心成員 Benedikt Meurer 的 Faster Collection Iterators一文,進行參考並翻譯。想要徹底透徹理解原文章中內容,還須要紮實的計算機基礎、v8
引擎工做方式以及編譯原理方面的知識儲備。
因我才疏學淺,入行也不到兩年,更不算計算機科班出身,拾人牙慧的同時不免理解有誤差和疏漏。更多 V8 引擎方面的知識,建議你們更多關注@justjavac,編譯方面的知識關注 Vue 做者@尤雨溪 以及他在 JS Conf 上的演講內容: 前端工程中的編譯時優化。畢竟,咱們關注前端大 V、網紅的根本並非爲了看熱鬧、看撕 b,而是但願在前輩身上學習到更多知識、獲得更多啓發。
最後,隨着 ECMAScript 的飛速發展,讓每一名前端開發者可能都會感受處處在不斷地學習當中,甚至有些疲於奔命。可是在學習新的特性和語法以外,理解更深層的內容纔是學習進階的關鍵。
我一樣不知道什麼是海,
赤腳站在沙灘上,
急切地等待着黎明的到來。
Happy Coding!