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


Posted by huli on 2020-02-21

前言

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

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

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

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

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

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

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

{
 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?

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?

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

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

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 裡面,內容如下:

[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 翻成了白話文:

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 的時候。這邊準備的範例程式碼如下:

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 有沒有差別:

[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 開始看吧:

[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 查一下定義:

// 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 去。

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

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

而後半段的:

LdaCurrentContextSlot [4]
Inc [6]
StaCurrentContextSlot [4]

其實就是 i++。

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

[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 的版本:

[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)

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

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 的是這一段:

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 的部分):

// 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 上並沒有差異

總結

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

例如說以下這個範例:

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:

// Good
render() {
 <div onClick={this.onClick} />
}

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

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

再舉最後一個例子:

// 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 的場合裡面,兩者並沒有差異。

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


#javascript









Related Posts

[Day 07] 備忘錄模式,蠅量級模式,拜訪者模式,單元測試

[Day 07] 備忘錄模式,蠅量級模式,拜訪者模式,單元測試

Covariance and Contravariance in Generics

Covariance and Contravariance in Generics

原子習慣:法則 1 - 讓提示顯而易見

原子習慣:法則 1 - 讓提示顯而易見




Newsletter




Comments