字节码剖析 try-return-finally 执行顺序

前言

一个经典问题,来自 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 the try 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 的值,为何对最终 returncount 没有任何影响?二者从 JS 代码层面看起来就是一个 count

而且该文章第二条评论,也指出了该问题:finally 并不是在 return 之后执行,并且说 hold 即将返回的值,所以返回的值仍然是 0,就如同拍了一个快照。

image.png

这就是我们要论证的,到底顺序如何?到底是什么 hold 了这个返回值 0,两个 count 究竟是否是同一个。

What Hold the returned Value

首先要有个常识 JS 代码最终执行的代码很可能不是你所看到的。What your see is not what the V8 actually executed!因为 JS 是解释性语言,实际运行的代码会经历

JavaScript Code => V8 Bytecode => Machine Code

即这一张著名的图:

high-level-language-2-byte-code-2-machine-code

来自 dailyjs/understanding-v8s-bytecode

所以接下来我将把上面的代码以字节码的形式掰开揉碎,看看到底底层执行了什么?到底两个 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 使用普通寄存器 r0r1r2,…… 以及一个累加器寄存器 accumulator register,累加寄存器和普通寄存器几乎一样,但一般用作临时变量存储,我们在写指令的时候都会特意省略它,因为几乎所有的字节码指令都会操作累加器寄存器,通过省略累加器寄存器,可让字节码变得紧凑以及节省内存。比如 Add r1 将寄存器 r1 内的值和累加器中的相加并放到累加器中,通过省略累加器代码更短。

来自 v8.dev/blog/igniti…

大多是字节码以 LdaSta 开头,此 aLdaSta 中代表 accumulator,即累加寄存器。例如 LdaSmi [42] 将 small integer (Smi) 42 加载到累加器寄存器中(Load the Small Integer (Smi) 42 into the accumulator register),42 => aStar r0 将累加器中的值存到寄存器 r0 中(Stores the value currently in the accumulator in register r0),a => r0

来自 dailyjs/understanding-v8s-bytecode

我们的示例需要理解更多字节码,需要翻阅 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] // 因为 countlet 声明的,故需要判断累加器中是否是 hole,如果是则抛错,即所谓的『暂时性死区』TDZ 检查,若是 var 则无需检查
         0x1477b9ae18de @    8 : 40 07 00          AddSmi [7], [0] // 1️⃣ 对应 count += 7; 并将结果 20 放到累加器中(a=200x1477b9ae18e1 @   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
复制代码

注解:

执行顺序和代码对照图:

image.png

故执行顺序是:

let count = 13;

function foo() {
  try {
    count += 7; // 1️⃣ 临时存储到 r1

    return count; // 3️⃣ 返回 r1 中的值
  }
  finally {
    count += 10; // 2️⃣ 修改 context 中的值
  }
}
复制代码

returncountfinally 中的 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 专门有一条规则禁止 finallyreturn rules/no-unsafe-finally

JavaScript suspends the control flow statements of try and catch blocks until the execution of finally block finishes. So, when return, throw, break, or continue is used in finally, control flow statements inside try and catch 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 性能

参考

这一路翻阅了太多的资料,很多资料很宝贵,故记载之。

  1. medium.com/dailyjs/und…
  2. v8.dev/blog/igniti…
  3. github.com/v8/v8/blob/…
  4. v8docs.nodesource.com/node-0.8/d4…
  5. 2ality.com/2013/03/try…
  6. eslint.org/docs/rules/…
  7. www.coderbridge.com/series/817c…
  8. stackoverflow.com/questions/6…
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享