從 V8 bytecode 探討 let 與 var 的效能問題

前言

在我以前寫過的兩篇文章:我知道你懂 hoisting,可是你了解到多深?以及所有的函式都是閉包:談 JS 中的作用域與 Closure 裡面,都談到了 let 與 var 作用域的不同。

let 的作用域是 block,而 var 則是 fucntion,像這個就是經典案例:

1
2
3
4
5
for(var i=1; i<=10; i++) {
setTimeout(function() {
console.log(i)
})
}

原本預期會依序輸出 1~10,沒想到卻輸出了 10 個 11。背後原因就是因為第三行那個 i 永遠都只有一個,就是 for 迴圈宣告的那個 var i,從頭到尾都是同一個變數。

而經典解法也很簡單,把 var 改成 let 就搞定了:

1
2
3
4
5
for(let i=1; i<=10; i++) {
setTimeout(function() {
console.log(i)
})
}

搞定的原因是,可以把上面程式碼看作是下面這種形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
let i=1
setTimeout(function() {
console.log(i)
})
}

{
let i=2
setTimeout(function() {
console.log(i)
})
}

...

{
let i=10
setTimeout(function() {
console.log(i)
})
}

由於 let 的作用域是 block,所以在每一圈迴圈裡面,其實都是一個新的 i,因此迴圈跑 10 圈就有了 10 個不同的 i,最後當然是輸出 10 個不同的數字。

因此 var 跟 let 在這個範例最大的區別就在於變數的數量,前者只有 1 個,後者卻有了 10 個。

好,既然知道了 let 與 var 的差別以後,就可以來看看這篇最主要想討論的問題。

其實這問題是來自於 YDKJS(You Dont Know JS,中譯本翻做:你不知道的JavaScript) 的作者 @getify 在他的推特上所提出的:

question for JS engines devs…
is there an optimization in place for this kind of code?

1
2
3
for (let i = 0; i < 10; i++) {
// no closure
}

IOW, where the behavior of creating a new i per iteration is not needed nor observable… does JS skip doing it?

若是沒有看得很懂,可以繼續看延伸的另外一則推

here’s a variation on the question… will JS engines exhibit much performance difference between these two loops?

1
2
3
4
5
6
7
for (var i = 0; i < 100000000; i++) {
// do some stuff, but not closure
}

for (let i = 0; i < 100000000; i++) {
// do the same stuff (no closure)
}

簡單來說呢,平常用 let 搭配迴圈的時候,不是如我們上面所說的,每一圈都會有一個新的i嗎?既然是這樣的話,那 var 與 let 應該就會有效能上的差異,因為 let 必須每一圈都 new 一個新的變數出來,所以 let 會比較慢。

那如果迴圈裡面並不需要每一圈都有新的 i,JS 引擎會做優化嗎?這個問題就是這樣,主要是想探討 JS 引擎會不會針對這種行為去做優化。

那要怎麼知道呢?要嘛你是 JS 引擎的開發者,要嘛你去看 JS 引擎的原始碼,但這兩種難度都有點太高。不過別擔心,還有第三種:看 JS bytecode。

JavaScript Bytecode

若是不知道 bytecode 是什麼,可以參考這一篇很經典的文章:Understanding V8’s Bytecode,中譯版:理解 V8 的字节码

先來看文章裡面解釋得最清楚的一張圖片:

在執行 JavaScript 的時候,V8 會先把程式碼編譯成 bytecode,然後再把 bytecode 編譯成 machine code,最後才執行。

舉個現實生活中的範例好了,若是你想把一篇英文文章翻譯成文言文,通常會先把英文文章翻譯成白話文,再從白話文翻譯成文言文。因為直接從英文翻譯成文言文難度過高,先翻成白話文會比較好翻譯;同時,在翻譯成白話文的時候也可以先做一些優化,這樣會比較好翻成文言文。

在這個比喻中,白話文就是我們這篇的主角:bytecode。

在以前寫 C/C++ 的時候,若是想知道編譯器會不會針對某一段程式碼做優化,最直接的方法就是輸出編譯過後的 assembly code,從組合語言裡面反推回原本的程式碼,就可以知道編譯器有沒有做事。

而 bytecode 也是一樣的,可以從產生出來的 bytecode 往回推,就知道 V8 有沒有做事情了。

那要怎麼看 V8 產生出來的 bytecode 呢?最簡單的方式就是使用 Node.js 的指令:node --print-bytecode a.js,只要加上--print-bytecode這個 flag 就行了。

但如果你真的去試了,會發現輸出了一大堆東西,這很正常。因為除了你寫的程式碼以外,本來就還有一大堆內建的東西,所以我們可以用--print-bytecode-filter去過濾 function 的名稱。

var 與 let:Round 1

我準備的測試程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function find_me_let_for(){
for (let i = 0; i < 10; i++) {
console.log(i)
}
}

function find_me_var_for() {
for (var i = 0; i < 10; i++) {
console.log(i)
}
}

find_me_let_for()
find_me_var_for()

接著就可以用指令:node --print-bytecode --print-bytecode-filter="find_me*" a.js > byte_code.txt,把結果存到 byte_code.txt 裡面,內容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
[generated bytecode for function: find_me_let_for]
Parameter count 1
Frame size 24
86 E> 0x77191b56622 @ 0 : a0 StackCheck
105 S> 0x77191b56623 @ 1 : 0b LdaZero
0x77191b56624 @ 2 : 26 fb Star r0
110 S> 0x77191b56626 @ 4 : 0c 0a LdaSmi [10]
110 E> 0x77191b56628 @ 6 : 66 fb 00 TestLessThan r0, [0]
0x77191b5662b @ 9 : 94 1c JumpIfFalse [28] (0x77191b56647 @ 37)
92 E> 0x77191b5662d @ 11 : a0 StackCheck
127 S> 0x77191b5662e @ 12 : 13 00 01 LdaGlobal [0], [1]
0x77191b56631 @ 15 : 26 f9 Star r2
135 E> 0x77191b56633 @ 17 : 28 f9 01 03 LdaNamedProperty r2, [1], [3]
0x77191b56637 @ 21 : 26 fa Star r1
135 E> 0x77191b56639 @ 23 : 57 fa f9 fb 05 CallProperty1 r1, r2, r0, [5]
117 S> 0x77191b5663e @ 28 : 25 fb Ldar r0
0x77191b56640 @ 30 : 4a 07 Inc [7]
0x77191b56642 @ 32 : 26 fb Star r0
0x77191b56644 @ 34 : 85 1e 00 JumpLoop [30], [0] (0x77191b56626 @ 4)
0x77191b56647 @ 37 : 0d LdaUndefined
146 S> 0x77191b56648 @ 38 : a4 Return
Constant pool (size = 2)
Handler Table (size = 0)
0
1
2
3
4
5
6
7
8
9
[generated bytecode for function: find_me_var_for]
Parameter count 1
Frame size 24
173 E> 0x77191b60d0a @ 0 : a0 StackCheck
193 S> 0x77191b60d0b @ 1 : 0b LdaZero
0x77191b60d0c @ 2 : 26 fb Star r0
198 S> 0x77191b60d0e @ 4 : 0c 0a LdaSmi [10]
198 E> 0x77191b60d10 @ 6 : 66 fb 00 TestLessThan r0, [0]
0x77191b60d13 @ 9 : 94 1c JumpIfFalse [28] (0x77191b60d2f @ 37)
180 E> 0x77191b60d15 @ 11 : a0 StackCheck
215 S> 0x77191b60d16 @ 12 : 13 00 01 LdaGlobal [0], [1]
0x77191b60d19 @ 15 : 26 f9 Star r2
223 E> 0x77191b60d1b @ 17 : 28 f9 01 03 LdaNamedProperty r2, [1], [3]
0x77191b60d1f @ 21 : 26 fa Star r1
223 E> 0x77191b60d21 @ 23 : 57 fa f9 fb 05 CallProperty1 r1, r2, r0, [5]
205 S> 0x77191b60d26 @ 28 : 25 fb Ldar r0
0x77191b60d28 @ 30 : 4a 07 Inc [7]
0x77191b60d2a @ 32 : 26 fb Star r0
0x77191b60d2c @ 34 : 85 1e 00 JumpLoop [30], [0] (0x77191b60d0e @ 4)
0x77191b60d2f @ 37 : 0d LdaUndefined
234 S> 0x77191b60d30 @ 38 : a4 Return
Constant pool (size = 2)
Handler Table (size = 0)
0
1
2
3
4
5
6
7
8
9

第一行都有標明是哪一個 function,方便我們做辨識:[generated bytecode for function: find_me_let_for],再來就是實際的 bytecode 了。

在看 bytecode 以前有一個預備知識非常重要,那就是在 bytecode 執行的環境底下,有一個叫做 accumulator 的暫存器。通常指令裡面如果有 a 這個字,就是 accumulator 的簡寫(以下簡稱 acc)。

例如說 bytecode 的第二三行:LdaZeroStar r0,前者就是:LoaD Accumulator Zero,設置 acc register 為 0,接著下一行 Star r0 就是 Store Accumulator to register r0,就是 r0=acc,所以 r0 會變成 0。

我把上面的find_me_let_for 翻成了白話文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
StackCheck                    // 檢查 stack
LdaZero // acc = 0
Star r0 // r0 = acc
LdaSmi [10] // acc = 10
TestLessThan r0, [0] // test if r0 < 10
JumpIfFalse [28] // if false, jump to line 17
StackCheck // 檢查 stack
LdaGlobal [0], [1] // acc = console
Star r2 // r2 = acc
LdaNamedProperty r2, [1], [3] // acc = r2.log
Star r1 // r1 = acc (也就是 console.log)
CallProperty1 r1, r2, r0, [5] // console.log(r0)
Ldar r0 // acc = r0
Inc [7] // acc++
Star r0 // r0 = acc
JumpLoop [30], [0] // 跳到 line 4
LdaUndefined // acc = undefined
Return // return acc

若是看不習慣這種形式的人,可能是沒有看過組合語言(實際上組合語言比這個難多了就是了…),多看幾次就可以習慣了。

總之呢,上面的程式碼就是一個會一直 log r0 的迴圈,直到 r0>=10 為止。而這個 r0 就是我們程式碼裡面的 i。

仔細看的話,會發現 let 跟 var 的版本產生出來的 bytecode 是一模一樣的,從頭到尾都只有一個變數 r0。因此呢,就可以推測出 V8 的確會對這種情形做優化,不會真的每一圈迴圈都新建一個 i,用 let 的時候不需要擔心跟 var 會有效能上的差異。

var 與 let:Round 2

再來我們可以試試看「一定需要每一圈新建一個 i」的場合,那就是當裡面有 closure 需要存取 i 的時候。這邊準備的範例程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function find_me_let_timeout() {
for (let i = 0; i < 10; i++) {
setTimeout(function find_me_let_timeout_inner() {
console.log(i)
})
}
}

function find_me_var_timeout() {
for (var i = 0; i < 10; i++) {
setTimeout(function find_me_var_timeout_inner() {
console.log(i)
})
}
}

find_me_let_timeout()
find_me_var_timeout()

用跟剛剛同樣的指令,一樣可以看到產生出來的 bytecode,我們先來看一下那兩個 inner function 有沒有差別:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
[generated bytecode for function: find_me_let_timeout_inner]
Parameter count 1
Frame size 24
177 E> 0x25d2f37dbb2a @ 0 : a0 StackCheck
188 S> 0x25d2f37dbb2b @ 1 : 13 00 00 LdaGlobal [0], [0]
0x25d2f37dbb2e @ 4 : 26 fa Star r1
196 E> 0x25d2f37dbb30 @ 6 : 28 fa 01 02 LdaNamedProperty r1, [1], [2]
0x25d2f37dbb34 @ 10 : 26 fb Star r0
0x25d2f37dbb36 @ 12 : 1a 04 LdaCurrentContextSlot [4]
200 E> 0x25d2f37dbb38 @ 14 : a5 02 ThrowReferenceErrorIfHole [2]
0x25d2f37dbb3a @ 16 : 26 f9 Star r2
196 E> 0x25d2f37dbb3c @ 18 : 57 fb fa f9 04 CallProperty1 r0, r1, r2, [4]
0x25d2f37dbb41 @ 23 : 0d LdaUndefined
207 S> 0x25d2f37dbb42 @ 24 : a4 Return
Constant pool (size = 3)
Handler Table (size = 0)

[generated bytecode for function: find_me_var_timeout_inner]
Parameter count 1
Frame size 24
332 E> 0x25d2f37e6cf2 @ 0 : a0 StackCheck
343 S> 0x25d2f37e6cf3 @ 1 : 13 00 00 LdaGlobal [0], [0]
0x25d2f37e6cf6 @ 4 : 26 fa Star r1
351 E> 0x25d2f37e6cf8 @ 6 : 28 fa 01 02 LdaNamedProperty r1, [1], [2]
0x25d2f37e6cfc @ 10 : 26 fb Star r0
0x25d2f37e6cfe @ 12 : 1a 04 LdaCurrentContextSlot [4]
0x25d2f37e6d00 @ 14 : 26 f9 Star r2
351 E> 0x25d2f37e6d02 @ 16 : 57 fb fa f9 04 CallProperty1 r0, r1, r2, [4]
0x25d2f37e6d07 @ 21 : 0d LdaUndefined
362 S> 0x25d2f37e6d08 @ 22 : a4 Return
Constant pool (size = 2)
Handler Table (size = 0)

可以看到唯一的差別是 let 的版本多了一個:ThrowReferenceErrorIfHole,這一個在我知道你懂 hoisting,可是你了解到多深?裡面有提過,其實就是 TDZ(Temporal Dead Zone)在 V8 上的實作。

最後就是我們的主菜了,先從 var 開始看吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
[generated bytecode for function: find_me_var_timeout]
Parameter count 1
Frame size 24
0x25d2f37d8d22 @ 0 : 7f 00 01 CreateFunctionContext [0], [1]
0x25d2f37d8d25 @ 3 : 16 fb PushContext r0
245 E> 0x25d2f37d8d27 @ 5 : a0 StackCheck
265 S> 0x25d2f37d8d28 @ 6 : 0b LdaZero
265 E> 0x25d2f37d8d29 @ 7 : 1d 04 StaCurrentContextSlot [4]
270 S> 0x25d2f37d8d2b @ 9 : 1a 04 LdaCurrentContextSlot [4]
0x25d2f37d8d2d @ 11 : 26 fa Star r1
0x25d2f37d8d2f @ 13 : 0c 0a LdaSmi [10]
270 E> 0x25d2f37d8d31 @ 15 : 66 fa 00 TestLessThan r1, [0]
0x25d2f37d8d34 @ 18 : 94 1b JumpIfFalse [27] (0x25d2f37d8d4f @ 45)
252 E> 0x25d2f37d8d36 @ 20 : a0 StackCheck
287 S> 0x25d2f37d8d37 @ 21 : 13 01 01 LdaGlobal [1], [1]
0x25d2f37d8d3a @ 24 : 26 fa Star r1
0x25d2f37d8d3c @ 26 : 7c 02 03 02 CreateClosure [2], [3], #2
0x25d2f37d8d40 @ 30 : 26 f9 Star r2
287 E> 0x25d2f37d8d42 @ 32 : 5b fa f9 04 CallUndefinedReceiver1 r1, r2, [4]
277 S> 0x25d2f37d8d46 @ 36 : 1a 04 LdaCurrentContextSlot [4]
0x25d2f37d8d48 @ 38 : 4a 06 Inc [6]
277 E> 0x25d2f37d8d4a @ 40 : 1d 04 StaCurrentContextSlot [4]
0x25d2f37d8d4c @ 42 : 85 21 00 JumpLoop [33], [0] (0x25d2f37d8d2b @ 9)
0x25d2f37d8d4f @ 45 : 0d LdaUndefined
369 S> 0x25d2f37d8d50 @ 46 : a4 Return
Constant pool (size = 3)
Handler Table (size = 0)

在開頭的時候就先用CreateFunctionContext新建了一個 function context,接著可以看到存取變數的方式也與之前單純用暫存器不同,這邊用的是:StaCurrentContextSlotLdaCurrentContextSlot,碰到看不懂的指令都可以去 /src/interpreter/interpreter-generator.cc 查一下定義:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// StaCurrentContextSlot <slot_index>
//
// Stores the object in the accumulator into |slot_index| of the current
// context.
IGNITION_HANDLER(StaCurrentContextSlot, InterpreterAssembler) {
Node* value = GetAccumulator();
Node* slot_index = BytecodeOperandIdx(0);
Node* slot_context = GetContext();
StoreContextElement(slot_context, slot_index, value);
Dispatch();
}

// LdaCurrentContextSlot <slot_index>
//
// Load the object in |slot_index| of the current context into the accumulator.
IGNITION_HANDLER(LdaCurrentContextSlot, InterpreterAssembler) {
Node* slot_index = BytecodeOperandIdx(0);
Node* slot_context = GetContext();
Node* result = LoadContextElement(slot_context, slot_index);
SetAccumulator(result);
Dispatch();
}

簡單來說呢,StaCurrentContextSlot 就是把 acc 的東西存到現在的 context 的某個 slot_index,而 LdaCurrentContextSlot 則是相反,把東西取出來放到 acc 去。

因此可以先看開頭這幾行:

1
2
3
4
5
6
7
LdaZero 
StaCurrentContextSlot [4]
LdaCurrentContextSlot [4]
Star r1
LdaSmi [10]
TestLessThan r1, [0]
JumpIfFalse [27] (0x25d2f37d8d4f @ 45)

就是把 0 放到 current context 的 slot_index 4 裡面去,接著再拿去放到 r1,然後再去跟 10 比較。這一段其實就是 for 迴圈裡面的 i<10

而後半段的:

1
2
3
LdaCurrentContextSlot [4]
Inc [6]
StaCurrentContextSlot [4]

其實就是 i++。

所以 i 會存在 current context slot 的 index 為 4 的位置。再來我們回顧一下前面所說的 inner function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[generated bytecode for function: find_me_var_timeout_inner]
Parameter count 1
Frame size 24
332 E> 0x25d2f37e6cf2 @ 0 : a0 StackCheck
343 S> 0x25d2f37e6cf3 @ 1 : 13 00 00 LdaGlobal [0], [0]
0x25d2f37e6cf6 @ 4 : 26 fa Star r1
351 E> 0x25d2f37e6cf8 @ 6 : 28 fa 01 02 LdaNamedProperty r1, [1], [2]
0x25d2f37e6cfc @ 10 : 26 fb Star r0
0x25d2f37e6cfe @ 12 : 1a 04 LdaCurrentContextSlot [4]
0x25d2f37e6d00 @ 14 : 26 f9 Star r2
351 E> 0x25d2f37e6d02 @ 16 : 57 fb fa f9 04 CallProperty1 r0, r1, r2, [4]
0x25d2f37e6d07 @ 21 : 0d LdaUndefined
362 S> 0x25d2f37e6d08 @ 22 : a4 Return
Constant pool (size = 2)
Handler Table (size = 0)

有沒有注意到 LdaCurrentContextSlot [4] 這一行?這一行就呼應了我們上面所說的,在 inner function 用這一行把 i 給拿出來。

所以在 var 的範例裡面,開頭就會先新增一個 function context,然後從頭到尾都只有這一個 context,會把 i 放在裡面 slot index 為 4 的位置,而 inner function 也會從這個位置把 i 拿出來。

因此從頭到尾 i 都只有一個。

最後來看看複雜許多的 let 的版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
[generated bytecode for function: find_me_let_timeout]
Parameter count 1
Register count 7
Frame size 56
179 E> 0x2725c3d70daa @ 0 : a5 StackCheck
199 S> 0x2725c3d70dab @ 1 : 0b LdaZero
0x2725c3d70dac @ 2 : 26 f8 Star r3
0x2725c3d70dae @ 4 : 26 fb Star r0
0x2725c3d70db0 @ 6 : 0c 01 LdaSmi [1]
0x2725c3d70db2 @ 8 : 26 fa Star r1
293 E> 0x2725c3d70db4 @ 10 : a5 StackCheck
0x2725c3d70db5 @ 11 : 82 00 CreateBlockContext [0]
0x2725c3d70db7 @ 13 : 16 f7 PushContext r4
0x2725c3d70db9 @ 15 : 0f LdaTheHole
0x2725c3d70dba @ 16 : 1d 04 StaCurrentContextSlot [4]
0x2725c3d70dbc @ 18 : 25 fb Ldar r0
0x2725c3d70dbe @ 20 : 1d 04 StaCurrentContextSlot [4]
0x2725c3d70dc0 @ 22 : 0c 01 LdaSmi [1]
0x2725c3d70dc2 @ 24 : 67 fa 00 TestEqual r1, [0]
0x2725c3d70dc5 @ 27 : 99 07 JumpIfFalse [7] (0x2725c3d70dcc @ 34)
0x2725c3d70dc7 @ 29 : 0b LdaZero
0x2725c3d70dc8 @ 30 : 26 fa Star r1
0x2725c3d70dca @ 32 : 8b 08 Jump [8] (0x2725c3d70dd2 @ 40)
211 S> 0x2725c3d70dcc @ 34 : 1a 04 LdaCurrentContextSlot [4]
0x2725c3d70dce @ 36 : 4c 01 Inc [1]
211 E> 0x2725c3d70dd0 @ 38 : 1d 04 StaCurrentContextSlot [4]
0x2725c3d70dd2 @ 40 : 0c 01 LdaSmi [1]
0x2725c3d70dd4 @ 42 : 26 f9 Star r2
204 S> 0x2725c3d70dd6 @ 44 : 1a 04 LdaCurrentContextSlot [4]
0x2725c3d70dd8 @ 46 : 26 f6 Star r5
0x2725c3d70dda @ 48 : 0c 0a LdaSmi [10]
204 E> 0x2725c3d70ddc @ 50 : 69 f6 02 TestLessThan r5, [2]
0x2725c3d70ddf @ 53 : 99 04 JumpIfFalse [4] (0x2725c3d70de3 @ 57)
0x2725c3d70de1 @ 55 : 8b 06 Jump [6] (0x2725c3d70de7 @ 61)
0x2725c3d70de3 @ 57 : 17 f7 PopContext r4
0x2725c3d70de5 @ 59 : 8b 33 Jump [51] (0x2725c3d70e18 @ 110)
0x2725c3d70de7 @ 61 : 0c 01 LdaSmi [1]
0x2725c3d70de9 @ 63 : 67 f9 03 TestEqual r2, [3]
0x2725c3d70dec @ 66 : 99 1c JumpIfFalse [28] (0x2725c3d70e08 @ 94)
186 E> 0x2725c3d70dee @ 68 : a5 StackCheck
221 S> 0x2725c3d70def @ 69 : 13 01 04 LdaGlobal [1], [4]
0x2725c3d70df2 @ 72 : 26 f6 Star r5
0x2725c3d70df4 @ 74 : 81 02 06 02 CreateClosure [2], [6], #2
0x2725c3d70df8 @ 78 : 26 f5 Star r6
221 E> 0x2725c3d70dfa @ 80 : 5d f6 f5 07 CallUndefinedReceiver1 r5, r6, [7]
0x2725c3d70dfe @ 84 : 0b LdaZero
0x2725c3d70dff @ 85 : 26 f9 Star r2
0x2725c3d70e01 @ 87 : 1a 04 LdaCurrentContextSlot [4]
0x2725c3d70e03 @ 89 : 26 fb Star r0
0x2725c3d70e05 @ 91 : 8a 1e 01 JumpLoop [30], [1] (0x2725c3d70de7 @ 61)
0x2725c3d70e08 @ 94 : 0c 01 LdaSmi [1]
293 E> 0x2725c3d70e0a @ 96 : 67 f9 09 TestEqual r2, [9]
0x2725c3d70e0d @ 99 : 99 06 JumpIfFalse [6] (0x2725c3d70e13 @ 105)
0x2725c3d70e0f @ 101 : 17 f7 PopContext r4
0x2725c3d70e11 @ 103 : 8b 07 Jump [7] (0x2725c3d70e18 @ 110)
0x2725c3d70e13 @ 105 : 17 f7 PopContext r4
0x2725c3d70e15 @ 107 : 8a 61 00 JumpLoop [97], [0] (0x2725c3d70db4 @ 10)
0x2725c3d70e18 @ 110 : 0d LdaUndefined
295 S> 0x2725c3d70e19 @ 111 : a9 Return
Constant pool (size = 3)
Handler Table (size = 0)

因為這程式碼有點太長而且不容易閱讀,所以我刪改了一下,改寫了一個比較白話的版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
r1 = 1
r0 = 0

loop:
r4.push(new BlockContext())
CurrentContextSlot = r0
if (r1 === 1) {
r1 = 0
} else {
CurrentContextSlot++
}
r2 = 1
r5 = CurrentContextSlot
if (!(r5 < 10)) { // end loop
PopContext r4
goto done
}

loop2:
if (r2 === 1) {
setTimeout()
r2 = 0
r0 = CurrentContextSlot
goto loop2
}

if (r2 === 1) {
PopContext r4
goto done
}
PopContext r4
goto loop

done:
return undefined

第一個重點是每一圈迴圈都會呼叫CreateBlockContext,新建一個 context,然後再迴圈結束前把 CurrentContextSlot(也就是 i)的值存到 r0,下一圈迴圈時再讓新的 block context slot 的值從 r0 讀取出來然後 +1,藉此來實作不同 context 值的累加。

然後你可能會很好奇,那這個 block context 到底會用在哪裡?

上面的 bytecode 裡面,呼叫 setTimeout 的是這一段:

1
2
3
4
5
LdaGlobal [1], [4]
Star r5 // r5 = setTimeout
CreateClosure [2], [6], #2
Star r6 // r6 = new function(...)
CallUndefinedReceiver1 r5, r6, [7] // setTimeout(r6)

在我們呼叫CreateClosure把這個 closure 傳給 setTimeout 的時候,就一起傳進去了(非完整程式碼,只保留 context 的部分):

1
2
3
4
5
6
7
8
9
10
11
12
// CreateClosure <index> <slot> <tenured>
//
// Creates a new closure for SharedFunctionInfo at position |index| in the
// constant pool and with the PretenureFlag <tenured>.
IGNITION_HANDLER(CreateClosure, InterpreterAssembler) {
Node* context = GetContext();

Node* result =
CallRuntime(Runtime::kNewClosure, context, shared, feedback_cell);
SetAccumulator(result);
Dispatch();
}

因此,在 inner function 裡面呼叫 LdaCurrentContextSlot 的時候,就會載入到正確的 context 以及正確的 i。

結論:

  1. var 的版本是 CreateFunctionContext,從頭到尾就一個 context
  2. let 的版本每一圈迴圈都會 CreateBlockContext,總共會有 10 個 context
  3. 在不需要 closure 的場合裡,let 與 var 在 V8 上並沒有差異

總結

有些你以為答案「顯而易見」的問題,其實並不一定。

例如說以下這個範例:

1
2
3
4
5
6
7
8
9
10
11
12
13
function v1() {
var a = 1
for(var i=1;i<10; i++){
var a = 1
}
}

function v2() {
var a = 1
for(var i=1; i<10; i++) {
a = 1
}
}

v1 跟 v2 哪一個比較快?

「v1 裡面每一圈迴圈都會重新宣告一次 a 並且賦值,v2 只會在外面宣告一次,裡面迴圈只有賦值而已,所以 v1 比較快」

答案是兩個一模一樣,因為如果你夠瞭解 JS,就會知道根本沒有什麼「重新宣告」這種事,宣告早在編譯階段就被處理掉了。

就算效能真的有差,可是到底差多少?是值得我們投注心力在上面的差異嗎?

例如說在寫 React 的時候,常常被教導要避免掉 inline function:

1
2
3
4
5
6
7
8
9
// Good
render() {
<div onClick={this.onClick} />
}

// Bad
render() {
<div onClick={() => { /* do something */ }} />
}

用頭腦想一想十分合理,下面那個每跑一次 render 就會產生一個新的 function,上面那個則是永遠都共用同一個。儘管他們的確有效能上的差異,但這個差異或許比你想的還要小

再舉最後一個例子:

1
2
3
4
5
// A
var obj = {a:1, b:2, ...} // 非常大的 object

// B
var obj = JSON.parse('{"a": 1, "b": 2, ...}') // JSON.parse 搭配很長的字串

若是 A 跟 B 都表示同一個很大的物件,哪一個會比較快?

以直覺來看,顯然是 A,因為 B 看起來就是多此一舉,先把 object 變成字串然後再丟給 JSON.parse,多了一個步驟。但事實上,B 比較快,而且快了 1.5 倍以上

很多東西以直覺來看是一回事,實際上又是另外一回事。因為直覺歸直覺,但是底層牽涉到了 compiler 或甚至是作業系統幫你做的優化,把這些考慮進來的話,很可能又是另外一回事。

就如同這篇文章裡面在探討的題目,以直覺來看 let 是會比 var 還要慢的。但事實證明了在不需要用到 closure 的場合裡面,兩者並沒有差異。

針對這些問題,你當然可以猜測,但你要知道的是這些僅僅只是猜測。想知道正確答案為何,必須要有更科學的方法,而不只是「我覺得」。

自己架一個 Online Judge 系統 webpack 新手教學之淺談模組化與 snowpack

評論

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×