Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

從 V8 bytecode 看 let 與 var 的效能問題 #54

Open
aszx87410 opened this issue Feb 21, 2020 · 1 comment
Open

從 V8 bytecode 看 let 與 var 的效能問題 #54

aszx87410 opened this issue Feb 21, 2020 · 1 comment
Labels
JS JavaScript

Comments

@aszx87410
Copy link
Owner

前言

在我以前寫過的兩篇文章:我知道你懂 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 的字节码

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

bytecode

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

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

@aszx87410 aszx87410 added the JS JavaScript label Feb 21, 2020
@Rplus
Copy link

Rplus commented Feb 21, 2020

啊啊~ 原來是這樣呀~~
啊啊啊 看不懂呀 XDDD

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
JS JavaScript
Projects
None yet
Development

No branches or pull requests

2 participants