本来只是遇到了 https://www.v2ex.com/t/596547 这个 closure 捕获局部变量的问题, 后来查了资料感觉解决了, 但是在爆栈网上回答自己问题被人👎了, 这就清楚自己对这个问题还是没掌握, 查了许多资料, 越查越困扰, 我把问题总结下, 希望能够有大佬帮忙解答下:
原文 https://v8.dev/blog/background-compilation 这里一句:
V8 ’ s Ignition bytecode compiler takes the abstract syntax tree (AST) produced by the parser as input and produces a stream of bytecode (BytecodeArray) along with associated meta-data which enables the Ignition interpreter to execute the JavaScript source.
也就是在下图中在 execute 阶段是 Ignition 在负责 JIT 编译
function foo() {
var a = 1;
return function() {
console.log(a);
}
}
var f = foo()
foo()虽然从运行栈中 pop 出去了, 但是内部变量 a 却没有被释放, 是因为闭包捕获了 a. 这里的捕获网上大多数说法是因为内部函数的 lexical environment 中有 a 的记录. 我的疑惑是这个 LexicalEnvironment 是产生在问题 2 中的预编译阶段还是问题 1 中 Ignition 的编译?
1
secondwtq 2019-09-06 02:03:36 +08:00 2
JS 节点的帖子貌似不会被显示在“全部”里面,是我的设置问题么?
首先,上一个帖子看了一下,我不是很明白楼主要弄懂什么东西,是 closure 这个通用的概念以及 closure 一般性的实现原理?还是某个特定的 ECMA 标准?还是 V8 这一个具体实现? 1. 这个问题没有意义。因为什么 Ignition 本来就是一个纯用来做 marketing 的名字,V8 的 codebase 里面名字包含 ignition 这个词的文件一只手就能数过来,代码里面也出现的不多,人家直接叫 “ interpreter ”。所以这个问题应该去问 V8 团队,而答案实际完全取决于你问的那个人当时拍的是左边的屁股还是右边的屁股(或者可能拍到了旁边同事的),和实际的实现毫无关系。楼主应该关注的是 bytecode interpreter -> baseline compiler -> optimizing compiler 的总体架构,而不是 Ignition 这个名字指的是哪个部分。V8 团队完全可以藏个彩蛋,在微软的某个 Demo 运行一段时间之后就禁用全部优化,然后把这个东西叫 Ignition,原因是可以“点燃微软的怒火” 非要说的话,https://v8.dev/docs/ignition 这里很明确了:Ignition is a fast low-level register-based interpreter. 楼主那个链接上也说了“ Ignition ’ s bytecode compiler ”,说明 compiler 是 Ignition 的一部分。 2. “好多资料”指什么资料?我貌似没看到过有资料这么说,官方解释直接去找 ECMA-262。标准貌似没对实现有这种要求 3. 问题不明,“预编译阶段” 算是个半通用没有明确定义的概念,“ Ignition 的编译” 是个实现细节。 按照我对标准的理解,最后一行在 evaluate 的时候会调用 foo,这次调用会产生一个 LexicalEnvironment,设为 bar,bar 里面有个 a,在 evaluate 那个 return 的时候会创建一个函数,这个函数对 bar 有引用,bar 对这个 a 有引用。 换句话说是运行时产生的 LexicalEnvironment,或者从反证法的角度,考虑这个例子: for (var i = 0; i < Date.now() % 1000; i++) array.push(foo()) “问题 2 中的预编译阶段还是问题 1 中 Ignition 的编译”无论哪个都连该创建几个 LexicalEnvironment 都不知道,因此都不是。 |
2
mcfog 2019-09-06 06:59:27 +08:00 via Android 1
0. v8 是一个实现,Ecma 是一个标准,他们两者的内部概念并不一定有直接的联系或关系
1.你贴的英文原文里有 2 个东西,一个是字节码编译器,另一个是解释器,解释器负责解释执行没问题。但你后面的“也就是”部分就不知所云了,怎么就跳跃到 jit 问题上去了 jit 是字节码等工作全部做完以后,在运行时发生的旁路逻辑,至少你贴的图和英文原文里没有任何涉及 2. 我不知道你说的很多资料是什么资料,也不知道你说的官方是 V8 官方还是 ECMA 官方 3. 所以你到底在说 V8 还是 ECMA? 上个帖子我应该贴过 ecma 标准的相关描述了,和问题 1 的 V8 没有关系 |
3
FaiChou OP @secondwtq @mcfog 关于预编译 看过很多中文个人资料 会有一些这个概念
https://tva1.sinaimg.cn/large/006y8mN6ly1g6pirewda9j30u00zgqh1.jpg @mcfog 我在 telegram 和 email 中关于本帖的问题对你提问过, 抱歉打扰哈.. 原来你经常混 v 站呀 |
4
behanga 2019-09-06 10:48:52 +08:00 1
pre-compile 应该是 Ignition 把 js 代码快速解释编译成可以运行的字节码,等到真正 js 运行该段代码的时候,再通过 TurboFan 对其进行优化。
|
5
secondwtq 2019-09-08 01:59:13 +08:00
@FaiChou
第一这些资料大多数感觉是不知哪个地方先写出一个版本然后其他人抄的 ... 我不得不说我似乎低估了大家对已有内容进行演绎的能力与热情 第二就是我看他们这么热情啊,什么都不说也不好,从描述的作用来看,感觉有点像 ES6 标准里面的 *DeclarationInstantiation ( https://www.ecma-international.org/ecma-262/6.0/index.html#sec-functiondeclarationinstantiation) 貌似鸟哥也谈过这个?一看 09 年的文章了 但是我感觉这个应该是中文圈特有的术语,我找不到英文的对应 ... 如果是某个英文术语的翻译的话也很奇怪,太模糊了 最后,原则上讲,JS 现在有这种行为是因为历史原因加上后续语言上的一些设计选择。不是因为它是怎样实现的,V8 表现出这种行为是因为标准的规定,V8 必须实现标准,而不是因为它做了或者没做“预编译”。V8 实现中就算做了“预编译”,和标准也没有关系(如果标准没有规定的话)。并且 JS 这种高级语言的标准是不能单纯从实现的角度去理解的( C++ 是反面 ...) |
6
FaiChou OP @secondwtq
谢谢回复. 看了一些资料, 发现自己进入了一个误区, 关于函数里的变量为什么没有被 GC, 一开始的猜想是和 LexicalEnvironment 相关, 于是跑去查了一堆资料, ecma 文档看了好一会, 但是终于发现, ecma 根本没有规定堆里变量什么时候释放, 内存管理是引擎的工作, ecma 根本不去规定. 引擎怎么知道一个变量应不应该回收可以看下 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_Management , 在本帖的问题中, 引擎可能想去回收变量 a, 但是它要确保闭包里没有使用这个 a, 于是跑去 evaluate 闭包函数, 发现它有引用, 所以还是放过了变量 a. 再比如: ``` function foo() { var a = 1; } foo(); console.log('~') // line 5 ``` 这个例子当中, 执行完 foo(), 到了第五行, 大家都知道 a 会被回收, 但是 js 引擎到底有没有回收它, 我不敢保证, 它回收的时机在什么时候, 我没有去研究, 能保证的是「 a 在第五行被回收了」这么说是没有问题的, 大家都懂. |
7
FaiChou OP 再举个例子:
chrome 的一个 bug https://bugs.chromium.org/p/chromium/issues/detail?id=315190 ``` var someClass = function() { console.log('some'); }; function getter() { var some = new someClass(); if(true) { return function() { //I'm done with some and don't need it return null; }; } else { return function() { return some; }; } } window.f = getter(); ``` 执行完后, 通过 devtools 里 memory - [heap snapshot] 可以发现 变量 some 没有被 GC. 理想情况下, 函数执行完, 里面的变量如果没有闭包引用, 那么就会被释放, 但是引擎也是按照代码执行的, 有 bug 也在所难免. |
8
secondwtq 2019-09-09 02:59:49 +08:00 1
@FaiChou 这个其实很难说是一个 bug。“a 在第五行被回收了”也完全没有保证。
另外 GC 这个东西很难被定义,我至今没见过哪个语言成功地在自己的 spec 中把 GC 以确实有用的形式定义出来。 https://en.wikipedia.org/wiki/Tracing_garbage_collection#Determinism:Tracing garbage collection is not deterministic in the timing of object finalization. An object which becomes eligible for garbage collection will usually be cleaned up eventually, but there is no guarantee when (or **even if**) that will happen. 意思是就算一个对象实际不被引用,Tracing GC 依然可以**根本不回收**它 这个不仅仅是 wiki 这么说,Scheme 的 spec 更有意思:www.r6rs.org/final/r6rs.pdf 第一页就说了:No Scheme object is ever destroyed. The reason that implementations of Scheme do not (usually!) run out of storage is that they are **permitted** to reclaim the storage occupied by an object if they can prove that the object cannot possibly matter to any future computation ... "Permitted" 意思是 Scheme 实现可以根本没有 GC ...(关于 Scheme 对内存这个东西的定义后面还有更多的描述) JLS 写得很模糊:When an object is no longer referenced, it **may** be reclaimed by the garbage collector. 但是 JLS 里面还隐藏了另外一个有趣的东西,就是 finalizer,紧接着上面一句话:“If an object declares a finalizer, the finalizer **is** executed before the object is reclaimed to give the object a last chance to clean up resources that would not otherwise be released.”,后面还有一节专门讲 finalizer 的同样说:"Before the storage for an object is reclaimed by the garbage collector, the Java Virtual Machine **will** invoke the finalizer of that object.",但是后面又说“A finalizable object has never had its finalizer automatically invoked, but the Java Virtual Machine **may** **eventually** automatically invoke its finalizer.” 这个定义就算是把 may 去掉了也没用 ... 比如考虑我定义成对象 eventually 会被回收,但是同时实现成“无限长”之后会被回收,在实际情况中依然是一个非常受限的实现 这就相当于根本就没有办法从 spec 的角度 enforce 任何 GC 的行为。而我们一般意义上研究的 GC,从这个角度来说都属于编译器的“优化”,“优化”就是 spec 没有强制要求(实际上正常的 spec 不会有任何相关内容),而实现所添加的在保持 standard conformance 的同时为了增强某些方面性能做得的额外的 feature。这就是说为什么这个 Chromium 的 issue 是个 feature 不是个 bug。 有一个很有意思的例子就是 PTC (proper tail call),同样是 R6RS 的要求:A Scheme implementation is properly tail-recursive if it supports an unbounded number of active tail calls.(在内存受限的前提下,这个其实等价于 tail call 的 space complexity 是 O(1) 的) 有意思的是,C/C++ 标准里至今没有这样的内容,但是主流的编译器都做了这样的优化。ES6 做了类似的要求(不过标准的表达有些区别),但是好像现在只有 JavaScriptCore 的实现是可用的? 当 PTC 这条不在标准里面的时候,它就属于编译器的一个优化,优化不仅仅是实现相关的,更是没有保证的,编译器可以选择做这个优化,也可以选择不做这个优化。C/C++ 标准没有 PTC 是一件十分细思恐极的事情——背后的 implication 是:我无法使用标准 C/C++ 的函数调用实现一个符合 Scheme 标准的解释器( ES6 同理),我必须自己维护函数调用的 activation record (或者依赖于特定的实现提供了这样的优化的这个前提 (这就超出标准 C/C++ 的范畴了)),这会让实现 DSL 更麻烦。而如果使用 ES6 来写,在实现符合标准的前提下就没有这个问题,而至于 V8 不符合 ES6 标准,就是另外一个问题了 ... 需要注意的是虽然 PTC 有若干其实挺 well-known 的实现方式,但是标准(尤其是 Scheme 标准)并没有规定该怎样实现 PTC,它仅仅规定实现应该满足的一个 性质。和其他的优化一样,就算标准里面没有这个东西,实现者也可以选择做这样一个优化(确实也很有用),但是这东西最后能进到标准里面,前提之一是这个行为可以清晰地被定义。在实现这一行为的过程中,某些实现可能会让 tail call 在时间上更慢,某些实现则会在保证 PTC 的同时试图让 tail call 的性能最优,但是这些都不在标准规定的范围内。 |