前言
一个经典问题,来自 2ality.com/try-finally。
下面代码输出结果是什么?
var count = 0;
function foo() {
try {
return count;
} finally {
count++;
}
}
console.log(foo());
console.log(count);
复制代码
输出:
0
1
复制代码
因此作者断言:
- The
finally
clause is always executed, no matter what happens inside thetry
clause (return, exception, break, normal exit).『finally 总会被执行,无论 try 语句内执行了何种操作,比如 return、抛错、break、正常退出』 - However, it is executed after the return statement.『然而 finally 在 return 之后执行』
第一点是对的。第二点从结果看起来确实是先执行了 return
返回 0
,然后再 count++
,count 变成 1。但其实不然,return
不应该永远是函数最后执行的语句吗?因为函数一旦退出调用栈被摧毁,不可能再执行函数内任何代码了,这是函数的底层的工作机制,不是语法层能违背的。那么假如 finally
先执行,修改了 count
的值,为何对最终 return
的 count
没有任何影响?二者从 JS 代码层面看起来就是一个 count
!
而且该文章第二条评论,也指出了该问题:finally
并不是在 return
之后执行,并且说 hold 即将返回的值,所以返回的值仍然是 0
,就如同拍了一个快照。
这就是我们要论证的,到底顺序如何?到底是什么 hold 了这个返回值 0
,两个 count
究竟是否是同一个。
What Hold the return
ed Value
首先要有个常识 JS 代码最终执行的代码很可能不是你所看到的。What your see is not what the V8 actually executed!因为 JS 是解释性语言,实际运行的代码会经历
JavaScript Code => V8 Bytecode => Machine Code
即这一张著名的图:
所以接下来我将把上面的代码以字节码的形式掰开揉碎,看看到底底层执行了什么?到底两个 count
是不是同一个。
将代码稍微变化下,方便观察字节码:
let count = 13;
function foo() {
try {
count += 7;
return count;
}
finally {
count += 10;
}
}
console.log('return:', foo()); // 13 + 7 = 20
console.log('count:', count); // 13 + 7 + 10 = 30
复制代码
返回
return: 20
count: 30
复制代码
确实和之前的题目保持一致。转成字节码:
node --print-bytecode --print-bytecode-filter=foo finally-countpp.js
复制代码
输出:请➡️右滑
[generated bytecode for function: foo]
Parameter count 1
Register count 4
Frame size 32
29 E> 0x1477b9ae18d6 @ 0 : a5 StackCheck
0x1477b9ae18d7 @ 1 : 27 ff f9 Mov <context>, r2
46 S> 0x1477b9ae18da @ 4 : 1a 04 LdaCurrentContextSlot [4]
0x1477b9ae18dc @ 6 : aa 00 ThrowReferenceErrorIfHole [0]
0x1477b9ae18de @ 8 : 40 07 00 AddSmi [7], [0]
0x1477b9ae18e1 @ 11 : 26 f8 Star r3
0x1477b9ae18e3 @ 13 : 1a 04 LdaCurrentContextSlot [4]
52 E> 0x1477b9ae18e5 @ 15 : aa 00 ThrowReferenceErrorIfHole [0]
0x1477b9ae18e7 @ 17 : 25 f8 Ldar r3
0x1477b9ae18e9 @ 19 : 1d 04 StaCurrentContextSlot [4]
63 S> 0x1477b9ae18eb @ 21 : 1a 04 LdaCurrentContextSlot [4]
0x1477b9ae18ed @ 23 : aa 00 ThrowReferenceErrorIfHole [0]
0x1477b9ae18ef @ 25 : 26 fa Star r1
0x1477b9ae18f1 @ 27 : 0c 01 LdaSmi [1]
0x1477b9ae18f3 @ 29 : 26 fb Star r0
0x1477b9ae18f5 @ 31 : 8b 07 Jump [7] (0x1477b9ae18fc @ 38)
0x1477b9ae18f7 @ 33 : 26 fa Star r1
0x1477b9ae18f9 @ 35 : 0b LdaZero
0x1477b9ae18fa @ 36 : 26 fb Star r0
0x1477b9ae18fc @ 38 : 0f LdaTheHole
0x1477b9ae18fd @ 39 : a6 SetPendingMessage
0x1477b9ae18fe @ 40 : 26 f9 Star r2
97 S> 0x1477b9ae1900 @ 42 : 1a 04 LdaCurrentContextSlot [4]
0x1477b9ae1902 @ 44 : aa 00 ThrowReferenceErrorIfHole [0]
0x1477b9ae1904 @ 46 : 40 0a 01 AddSmi [10], [1]
0x1477b9ae1907 @ 49 : 26 f8 Star r3
0x1477b9ae1909 @ 51 : 1a 04 LdaCurrentContextSlot [4]
103 E> 0x1477b9ae190b @ 53 : aa 00 ThrowReferenceErrorIfHole [0]
0x1477b9ae190d @ 55 : 25 f8 Ldar r3
0x1477b9ae190f @ 57 : 1d 04 StaCurrentContextSlot [4]
0x1477b9ae1911 @ 59 : 25 f9 Ldar r2
0x1477b9ae1913 @ 61 : a6 SetPendingMessage
0x1477b9ae1914 @ 62 : 25 fb Ldar r0
0x1477b9ae1916 @ 64 : 9f 01 02 00 SwitchOnSmiNoFeedback [1], [2], [0] { 0: @70, 1: @73 }
0x1477b9ae191a @ 68 : 8b 08 Jump [8] (0x1477b9ae1922 @ 76)
0x1477b9ae191c @ 70 : 25 fa Ldar r1
0x1477b9ae191e @ 72 : a8 ReThrow
0x1477b9ae191f @ 73 : 25 fa Ldar r1
114 S> 0x1477b9ae1921 @ 75 : a9 Return
0x1477b9ae1922 @ 76 : 0d LdaUndefined
114 S> 0x1477b9ae1923 @ 77 : a9 Return
Constant pool (size = 3)
Handler Table (size = 16)
from to hdlr (prediction, data)
( 4, 33) -> 33 (prediction=0, data=2)
return: 20
count: 30
复制代码
怎么阅读字节码
V8 的 Ignition 解释器采用 Register Machine 即寄存器机器架构。寄存器或栈这是几乎所有虚拟机的架构选型,为什么 V8 采用寄存器架构先不展开。V8 Ignition 使用普通寄存器 r0
、r1
、r2
,…… 以及一个累加器寄存器 accumulator register
,累加寄存器和普通寄存器几乎一样,但一般用作临时变量存储,我们在写指令的时候都会特意省略它,因为几乎所有的字节码指令都会操作累加器寄存器,通过省略累加器寄存器,可让字节码变得紧凑以及节省内存。比如 Add r1
将寄存器 r1
内的值和累加器中的相加并放到累加器中,通过省略累加器代码更短。
大多是字节码以 Lda
或 Sta
开头,此 a
在 Lda
和 Sta
中代表 accumulator,即累加寄存器。例如 LdaSmi [42]
将 small integer (Smi) 42
加载到累加器寄存器中(Load the Small Integer (Smi) 42
into the accumulator register),42 => a
。Star r0
将累加器中的值存到寄存器 r0
中(Stores the value currently in the accumulator in register r0
),a => r0
。
我们的示例需要理解更多字节码,需要翻阅 V8 源码 interpreter/interpreter-generator.cc,里面有详细的注释,阅读难度中等偏下。
开始阅读
通过增加注释方式阅读,并摘取关键字节码段落。
step1: count += 7
请➡️右滑
46 S> 0x1477b9ae18da @ 4 : 1a 04 LdaCurrentContextSlot [4] // 将当前上下文中的 count 13 加载到累加器(a=13)
...
0x1477b9ae18de @ 8 : 40 07 00 AddSmi [7], [0] // ? 1️⃣ 对应 count += 7; 并将结果 20 放到累加器中(a=20)
0x1477b9ae18e1 @ 11 : 26 f8 Star r3 // 将累加器中的值放到 r3 中。(r3=20)
...
0x1477b9ae18e7 @ 17 : 25 f8 Ldar r3 // 将 r3 中的值加载到累加器中,此时 a=20
0x1477b9ae18e9 @ 19 : 1d 04 StaCurrentContextSlot [4] // 将累加器中的值存储到上下文中,即保存上下文(context[4]=20),为切换上下文到 finally 做准备
63 S> 0x1477b9ae18eb @ 21 : 1a 04 LdaCurrentContextSlot [4] // a=20
0x1477b9ae18ed @ 23 : aa 00 ThrowReferenceErrorIfHole [0]
0x1477b9ae18ef @ 25 : 26 fa Star r1 // ? r1=20,请记住这个 r1 寄存器
复制代码
acc | r0 | r1 | r2 | r3 | CurrentContextSlot |
---|---|---|---|---|---|
13+7=20 | 20(记住 r1) | 20 | 20 |
step 2: count += 10
请➡️右滑
0x1477b9ae18f5 @ 31 : 8b 07 Jump [7] (0x1477b9ae18fc @ 38) // 跳到第 38 行,即跳到 finally
...
0x1477b9ae18fd @ 39 : a6 SetPendingMessage // keep context alive
...
97 S> 0x1477b9ae1900 @ 42 : 1a 04 LdaCurrentContextSlot [4] // 保活故能取到 a=20
...
0x1477b9ae1904 @ 46 : 40 0a 01 AddSmi [10], [1] // ? 2️⃣ 对应 `count += 10` a=30
0x1477b9ae1907 @ 49 : 26 f8 Star r3 // r3=a=30
...
0x1477b9ae190d @ 55 : 25 f8 Ldar r3 // a=r3=30
0x1477b9ae190f @ 57 : 1d 04 StaCurrentContextSlot [4] // ? 3️⃣ 保存上下文,context[4]=30
复制代码
acc | r0 | r1 | r2 | r3 | CurrentContextSlot |
---|---|---|---|---|---|
13+7=20 | 20(记住 r1) | 20 | 20 | ||
20+10=30 | 20(记住 r1) | 30 | 30 |
step 3: return count
请➡️右滑
// throw 则跳转到 70,否则到 73
0x1477b9ae1916 @ 64 : 9f 01 02 00 SwitchOnSmiNoFeedback [1], [2], [0] { 0: @70, 1: @73 }
0x1477b9ae191a @ 68 : 8b 08 Jump [8] (0x1477b9ae1922 @ 76)
0x1477b9ae191c @ 70 : 25 fa Ldar r1
0x1477b9ae191e @ 72 : a8 ReThrow
0x1477b9ae191f @ 73 : 25 fa Ldar r1 // ? 4️⃣ a=r1=20 上面注意的 `r1` 此处出现了
114 S> 0x1477b9ae1921 @ 75 : a9 Return // ? 最终返回 20
0x1477b9ae1922 @ 76 : 0d LdaUndefined
114 S> 0x1477b9ae1923 @ 77 : a9 Return
复制代码
acc | r0 | r1 | r2 | r3 | CurrentContextSlot |
---|---|---|---|---|---|
13+7=20 | 20(记住 r1) | 20 | 20 | ||
20+10=30 | 20(记住 r1) | 30 | 30 |
将 r1
返回,故最终返回 20
,但上下文中的 count
变成了 30
,故输出 30
。
return: 20
count: 30
复制代码
完整注释版:请➡️右滑
[generated bytecode for function: foo]
Parameter count 1 // 共 1 个参数,即隐式的 this,形参为 0 个
Register count 4 // 涉及普通寄存器 4 个 r0 r1 r2 r3
Frame size 32
29 E> 0x1477b9ae18d6 @ 0 : a5 StackCheck // 检查是否栈溢出
0x1477b9ae18d7 @ 1 : 27 ff f9 Mov <context>, r2 // r2=context=13
46 S> 0x1477b9ae18da @ 4 : 1a 04 LdaCurrentContextSlot [4] // 将当前上下文中的 count 13 加载到累加器(a=13)
0x1477b9ae18dc @ 6 : aa 00 ThrowReferenceErrorIfHole [0] // 因为 count 是 let 声明的,故需要判断累加器中是否是 hole,如果是则抛错,即所谓的『暂时性死区』TDZ 检查,若是 var 则无需检查
0x1477b9ae18de @ 8 : 40 07 00 AddSmi [7], [0] // 1️⃣ 对应 count += 7; 并将结果 20 放到累加器中(a=20)
0x1477b9ae18e1 @ 11 : 26 f8 Star r3 // 将累加器中的值放到 r3 中。(r3=20)
0x1477b9ae18e3 @ 13 : 1a 04 LdaCurrentContextSlot [4] // 将上下文中的 count 13 加载到累加器(a=13)
52 E> 0x1477b9ae18e5 @ 15 : aa 00 ThrowReferenceErrorIfHole [0] // TDZ 检查
0x1477b9ae18e7 @ 17 : 25 f8 Ldar r3 // 将 r3 中的值加载到累加器中,此时 a=20
0x1477b9ae18e9 @ 19 : 1d 04 StaCurrentContextSlot [4] // 将累加器中的值存储到上下文中,即保存上下文(context[4]=20),为切换上下文到 finally 做准备
63 S> 0x1477b9ae18eb @ 21 : 1a 04 LdaCurrentContextSlot [4] // a=20
0x1477b9ae18ed @ 23 : aa 00 ThrowReferenceErrorIfHole [0]
0x1477b9ae18ef @ 25 : 26 fa Star r1 // r1=20
0x1477b9ae18f1 @ 27 : 0c 01 LdaSmi [1] // a=1
0x1477b9ae18f3 @ 29 : 26 fb Star r0 // r0=1
0x1477b9ae18f5 @ 31 : 8b 07 Jump [7] (0x1477b9ae18fc @ 38) // 跳到第 38 行,即跳到 finally
0x1477b9ae18f7 @ 33 : 26 fa Star r1
0x1477b9ae18f9 @ 35 : 0b LdaZero
0x1477b9ae18fa @ 36 : 26 fb Star r0
0x1477b9ae18fc @ 38 : 0f LdaTheHole // a=<the_hole> hole 是一种 undefined 但稍不同下一篇讲
0x1477b9ae18fd @ 39 : a6 SetPendingMessage // keep context alive
0x1477b9ae18fe @ 40 : 26 f9 Star r2 // r2=<the_hole>
97 S> 0x1477b9ae1900 @ 42 : 1a 04 LdaCurrentContextSlot [4] // 保活故能取到 a=20
0x1477b9ae1902 @ 44 : aa 00 ThrowReferenceErrorIfHole [0]
0x1477b9ae1904 @ 46 : 40 0a 01 AddSmi [10], [1] // 2️⃣ 对应 `count += 10` a=30
0x1477b9ae1907 @ 49 : 26 f8 Star r3 // r3=a=30
0x1477b9ae1909 @ 51 : 1a 04 LdaCurrentContextSlot [4] // a=20
103 E> 0x1477b9ae190b @ 53 : aa 00 ThrowReferenceErrorIfHole [0]
0x1477b9ae190d @ 55 : 25 f8 Ldar r3 // a=r3=30
0x1477b9ae190f @ 57 : 1d 04 StaCurrentContextSlot [4] // 3️⃣ 保存上下文,context[4]=30
0x1477b9ae1911 @ 59 : 25 f9 Ldar r2 // a=<the_hole>
0x1477b9ae1913 @ 61 : a6 SetPendingMessage // a=<pending_message>
0x1477b9ae1914 @ 62 : 25 fb Ldar r0 // a=r0=1
// throw 则跳转到 70,否则到 73
0x1477b9ae1916 @ 64 : 9f 01 02 00 SwitchOnSmiNoFeedback [1], [2], [0] { 0: @70, 1: @73 }
0x1477b9ae191a @ 68 : 8b 08 Jump [8] (0x1477b9ae1922 @ 76)
0x1477b9ae191c @ 70 : 25 fa Ldar r1
0x1477b9ae191e @ 72 : a8 ReThrow // 抛异常, V8: https://v8docs.nodesource.com/node-0.8/d4/dc6/classv8_1_1_try_catch.html
0x1477b9ae191f @ 73 : 25 fa Ldar r1 // 4️⃣ a=r1=20
114 S> 0x1477b9ae1921 @ 75 : a9 Return // 最终返回 20
0x1477b9ae1922 @ 76 : 0d LdaUndefined
114 S> 0x1477b9ae1923 @ 77 : a9 Return
Constant pool (size = 3)
Handler Table (size = 16)
from to hdlr (prediction, data)
( 4, 33) -> 33 (prediction=0, data=2)
return: 20
count: 30
复制代码
注解:
- SetPendingMessage: Sets the pending message to the value in the accumulator, and returns the previous pending message in the accumulator. pending message 导致 context 保持 alive 是 bug 而非特性,pending exception message 是 V8 实现 try-catch 的机制。
- 函数结果将存储在累加器中,一般
Return
之前会执行Lda
然后将累加器中的值return
。
执行顺序和代码对照图:
故执行顺序是:
let count = 13;
function foo() {
try {
count += 7; // 1️⃣ 临时存储到 r1
return count; // 3️⃣ 返回 r1 中的值
}
finally {
count += 10; // 2️⃣ 修改 context 中的值
}
}
复制代码
故 return
的 count
和 finally
中的 count
不是一个,前者是 r1
,后者是上下文 context
中的 count
。
结论
- finally 在 return 之前执行。
- 非同一个 count。
return
中的count
临时存储在寄存器r1
中,finally
中改变的只是上下文中的。因为有寄存器的存在所以可以做到finally
修改不会导致最终返回值受到影响,因为return
前一刻已经将返回值保存在寄存器中了,就如同将其拍了一个快照。
这不就正好验证了 finally
的作用吗?finally
必定在 try
里面的 return
前执行,用来保证某些资源必定会被释放。
最后引申
有些知识你以为已经掌握了,某个契机再次遇到竟然发现和当初的认知有差异,我愿称之为『薛定谔的学习』
继续看评论:
If the finally doesn’t return or throw, then the function returns the try’s return value.
However, the finally can override that return value with it’s own return value or the finally can stop any return value from being returned by throwing.
The proof is logically the same as what a previous commenter wrote:
其中说到『the finally
can override that return
value』也就是若 finally
中有 return
则以其 return
为准,但是因为这一不符合直觉的写法, eslint 专门有一条规则禁止 finally
中 return
rules/no-unsafe-finally。
JavaScript suspends the control flow statements of
try
andcatch
blocks until the execution offinally
block finishes. So, whenreturn
,throw
,break
, orcontinue
is used infinally
, control flow statements insidetry
andcatch
are overwritten, which is considered as unexpected behavior. Such as:
var count = 0; function foo() { try { return count + 100;} finally { return ++count;} } console.log(foo()); console.log(count); 复制代码
输出:
1 1 复制代码
可以写的文章:
- 为何 v8 选择 Register Machine 而非 Stack Machine
- let const 到底是否存在变量提升?先出结论存在
- 字节码看 let var 性能
参考
这一路翻阅了太多的资料,很多资料很宝贵,故记载之。