【翻译】深入理解V8闭包:趣味探索(还有实用技巧?)

24 阅读14分钟

原文链接:mrale.ph/blog/2012/0…

作者:me@mrale.ph | @mraleph

我曾考虑撰写一篇短文,总结我对闭包变量与实例字段性能差异的思考,以回应 Marijn Haverbeke 那篇提出初始谜题的文章。随后我意识到,这正适合作为一篇长文主题,用以阐述 V8 如何处理闭包,以及这些设计决策如何影响性能。

上下文环境

如果你编写 JavaScript 程序,很可能知道每个函数都携带一个词法环境,用于在执行函数体时将变量解析为具体值。这听起来有些抽象,但实际上可以归结为一个非常简单的机制:

function makeF() {
  var x = 11;
  var y = 22;
  return function (what) {
    switch (what) {
      case "x": return x;
      case "y": return y;
    }
  }
}

var f = makeF();
f("x");

函数f需要某种附加存储空间来保存变量xy,因为当f被调用时,最初创建这些变量的makeFs激活记录已不复存在。

V8正是这样处理的:它会创建一个名为Context(上下文)的对象,并将其附加到闭包上(闭包在内部以JSFunction类的实例表示)。【从这里开始,我将用 context variable 来指代 captured variable。】然而,这里有几个重要的细节需要注意。首先,V8是在我们进入makeF时创建Context,而不是像许多人预期的那样在创建闭包时才创建。这一点在绑定那些在热循环中也会使用的变量时尤为重要。优化编译器将无法将这些变量分配到寄存器中,每次加载和存储都会变成内存操作。

[有一种称为寄存器提升的优化技术,允许编译器跨循环转发加载到存储操作,并尽可能延迟将值存储到内存中,但V8目前尚未实现类似功能。]

function foo() {
  var sum = 0;  // sum is promoted to the Context because it used by a closure below.
  for (var i = 0; i < 1000; i++) {
    // Here V8 will store sum into memory on every interation.
    // This will also cause allocation of a new box (HeapNumber) for
    // a floating point value.
    sum += Math.sqrt(i);
  }

  if (baz()) {
    setTimeout(function () { alert(sum); }, 1000);
  }
}

还需注意另一个要点:如果需要使用上下文,它会在进入作用域时立即被创建,并由该作用域内创建的所有闭包共享。如果作用域本身嵌套在闭包内,新创建的上下文将包含一个指向父级的指针。这可能导致令人意外的内存泄漏。例如:

function outer() {
  var x = HUGE;  // huge object
  function inner() {
    var y = GIANT;  // giant object :-)

    use(x);  // usage of x cause it to be allocated to the context

    function innerF() {
      use(y);  // usage of y causes it to be allocated to the context
    }

    function innerG() {
      /* use nothing */
    }

    return innerG;
  }

  return inner();
}

var o = outer();  // o will retain HUGE and GIANT.

在这段代码中,存储在变量o中的闭包innerG将保留以下内容:

  • 通过指向共享上下文的链接保留GIANT,该上下文曾被innerF用于访问变量y
  • 通过指向父级上下文的链接保留HUGE,该父级上下文是为inner创建的。

2013年5月25日更新 实际情况更为复杂:上下文会保留创建它们的闭包。对于上述代码,这意味着只要o指向innerGinner闭包就会持续存活,因为它被为innerFinnerG创建的上下文所保留。同样的逻辑也适用于outer闭包(不同之处在于outer还通过全局变量被引用,因此不存在泄漏):为inner创建的上下文会反向引用outer。对于深度嵌套回调的异步代码,这可能意味着最外层的回调将一直保持存活状态,直到最内层回调消亡。这会增加垃圾回收的压力,最坏情况下外层回调甚至可能被提升至老生代。在热点代码区域避免深度回调嵌套,可以通过减轻GC压力来提升应用程序性能。

function f(a) {  // a is context allocated
  var x = 10;  // x is context allocated
  function g(b) {  // b is context allocated
    var y = 10;  // y is context allocated
    function h(c) {  // c is context allocated
      with (obj) {
        z = c;
      }
    }
    h(b);
  }
  g(a);
}

function k(x, y) {  // x and y are context allocated
  return arguments[0] + arguments[1];
}

function sk(x, y) {  // x and y are not context allocated
  "use strict";
  return arguments[0] * arguments[1];
}

从上述最后一点观察可以得知,使用arguments对象的热点函数应当要么保持形式参数列表为空,要么声明为严格模式,因为这样可以避免内存分配并支持函数内联(需要分配上下文的函数无法被内联)。

生成代码对比

现在让我们来看看V8编译器(包括两个编译器)为读写上下文变量生成的代码,并与单态实例字段访问生成的代码进行对比。

【要查看V8生成的机器码,您可以:从代码仓库获取V8源代码,构建独立的shell工具d8,并使用--print-code --print-code-stubs --code-comments参数调用它。具体操作可参考下方速查表。】

∮ svn co http://v8.googlecode.com/svn/branches/bleeding_edge v8
∮ cd v8
∮ make dependencies
∮ make ia32.release objectprint=on disassembler=on
∮ out/ia32.release/d8 --print-code --print-code-stubs --code-comments test.js
function ClassicObject() {
  this.x = 10;
}

ClassicObject.prototype.getX = function () {
  return this.x; // (1)
};

function ClosureObject() {
  var x = 10;
  return {
    getX: function () {
      return x;  // (2)
    }
  };
}

var classic_object = new ClassicObject();
var closure_object = new ClosureObject();

// Now lets loop them to force compilation and optimization.
for (var i = 0; i < 1e5; i++) classic_object.getX();
for (var i = 0; i < 1e5; i++) closure_object.getX();

毫不意外,实例字段加载(1)被非优化编译器编译为一次内联缓存调用:

mov eax, [ebp+0x8] ;; load this from the stack
mov edx, eax       ;; receiver in edx
mov ecx, "x"       ;; property name in ecx
call LoadIC_Initialize  ;; invoke IC stub

在执行多次后,该内联缓存调用会被以下存根代码修补:

test_b dl, 0x1  ;; check that receiver is an object not a smi (SMall Integer)
jz miss         ;; otherwise fallthrough to miss
cmp [edx-1], 0x2bb0ece1  ;; check hidden class of the object
jnz miss                 ;; otherwise fallthrough to miss
mov eax, [edx+0xb]  ;; inline cache hit, load field by fixed offset and return
ret
miss:
jmp LoadIC_Miss  ;; jump to runtime to handle inline cache miss.

实例字段加载过程这里没有什么意外之处,这是内联缓存的经典实现方式。如果您想了解内联缓存和隐藏类工作原理的高层解析,可以查阅我撰写的关于JavaScript中内联缓存实现的文章

如果我们观察上下文变量的加载过程(2),会发现即使是未经优化的编译器也会将其编译成更为简洁的形式:

mov eax, esi          ;; move context to eax
mov eax, [eax + 0x17] ;; load variable from a fixed offset in the context.

这里有两点需要注意:

  • V8 使用专用寄存器 esi 来存储当前上下文,以避免从栈帧或闭包对象本身加载;
  • 编译器能够在编译期间将变量解析为固定索引,因此无需后期绑定、没有查找开销,也不需要使用内联缓存。

如果我们观察优化后的代码,会发现上下文变量的加载方式基本相同,但从优化代码中加载实例字段则略有不同:

;;; @11: gap.
mov eax,[ebp+0x8]
;;; @12: check-non-smi.
test eax,0x1
jz 0x3080a00a               ;; deoptimization bailout 1
;;; @13: gap.
;;; @14: check-maps.
cmp [eax-1],0x2bb0ece1    ;; object: 0x2bb0ece1 <Map(elements=3)>
jnz 0x3080a014              ;; deoptimization bailout 2
;;; @15: gap.
;;; @16: load-named-field.
mov eax,[eax+0xb]
;;; @17: gap.
;;; @18: return.
mov esp,ebp
pop ebp
ret 0x4

[注:此处注释 ;;; @N: 指向Crankshaft底层IR(即锂层IR)中的指令]

Crankshaft针对特定对象类型对该加载点进行了特化处理,并插入了类型守卫——当守卫条件失败时会触发去优化并切换至未优化代码。可以说,Crankshaft实质上内联了IC存根,将其分解为独立操作(检查非小整数标志、检查隐藏类、加载字段),并通过去优化机制处理慢路径(未命中)情况。V8实际上并非通过存根内联来实现类型特化,但这种理解方式非常便于思考,特别是因为V8当前使用的主要且唯一的类型信息来源就是内联缓存。【请留意这一点,我将在下文讨论其影响。】

通过拆分守卫检查与实际操作(如加载),优化编译器能够消除冗余。让我们看看在类中添加另一个字段时会发生什么(此处省略预热代码):

function ClassicObject() {
  this.x = 10;
  this.y = 20;
}

ClassicObject.prototype.getSum = function () {
  return this.x + this.y;
};

非优化与优化代码对比getSum的非优化版本将包含三个内联缓存(每个属性加载对应一个,加上混合了后期绑定的+运算符),但其优化版本则更为紧凑:

;;; @11: gap.
mov eax,[ebp+0x8]
;;; @12: check-non-smi.
test eax,0x1
jz 0x5950a00a               ;; deoptimization bailout 1
;;; @13: gap.
;;; @14: check-maps.
cmp [eax-1],0x24f0ed01    ;; object: 0x24f0ed01 <Map(elements=3)>
jnz 0x5950a014              ;; deoptimization bailout 2
;;; @15: gap.
;;; @16: load-named-field.
mov ecx,[eax+0xb]
;;; @17: gap.
;;; @18: load-named-field.
mov edx,[eax+0xf]

优化版本的优势与保留两次检查非小整数和两次检查隐藏类的守卫不同,编译器通过公共子表达式消除(CSE)优化移除了冗余的守卫检查。这段代码看起来很出色,而上下文变量的加载表现同样出色,且不需要任何守卫,因为它们的绑定关系是静态解析的。那么,为什么基于闭包的面向对象设计最终反而比传统方式更慢呢?

让我们回到之前的示例,为其添加更多面向对象的特性(毕竟面向对象的核心就是方法调用方法再调用方法……):

function ClassicObject() {
  this.x = 10;
  this.y = 20;
}

ClassicObject.prototype.getSum = function () {
  return this.getX() + this.getY();
};

ClassicObject.prototype.getX = function () { return this.x; };
ClassicObject.prototype.getY = function () { return this.y; };


function ClosureObject() {
  var x = 10;
  var y = 10;
  function getX() { return x; }
  function getY() { return y; }
  return {
    getSum: function () {
      return getX() + getY();
    }
  };
}

var classic_object = new ClassicObject();
var closure_object = new ClosureObject();

for (var i = 0; i < 1e5; i++) classic_object.getSum();
for (var i = 0; i < 1e5; i++) closure_object.getSum();

我现在不打算查看非优化代码,而是直接分析ClassicObject.prototype.getSum的优化后代码:

;;; @11: gap.
mov eax,[ebp+0x8]
;;; @12: check-non-smi.
test eax,0x1
jz 0x2b20a00a               ;; deoptimization bailout 1
;;; @14: check-maps.
cmp [eax-1],0x5380ed01    ;; object: 0x5380ed01 <Map(elements=3)>
jnz 0x2b20a014              ;; deoptimization bailout 2
;;; @16: check-prototype-maps.
mov ecx,[0x5400a694]        ;; global property cell
cmp [ecx-1],0x5380ece1    ;; object: 0x5380ece1 <Map(elements=3)>
jnz 0x2b20a01e              ;; deoptimization bailout 3
;;; @18: load-named-field.
mov ecx,[eax+0xb]
;;; @24: load-named-field.
mov edx,[eax+0xf]

这段代码看起来和我们之前的几乎一样……等等!那些调用getXgetY的方法都去哪了?这个check-prototype-maps是什么东西?字段加载怎么会直接出现在getSum的代码里?

事实上,Crankshaft将这两个小函数都内联到了getSum中,完全消除了方法调用。显然,如果有人替换了ClassicObject.prototype上的getXgetY,这个内联决策就会失效,因此Crankshaft生成了一个针对原型隐藏类的守卫——这就是那个有用的check-prototype-maps机制。这个检查背后还有另一个有趣的技巧:V8的隐藏类不仅编码对象的结构(对象在哪些偏移量有哪些属性),还编码了附加到对象上的方法(哪个闭包附加到哪个属性)。这使得面向对象程序能够运行得更快,仅需一次守卫就能验证关于对象的多个假设。

如果我们现在查看附加到closure_object.getSum的优化代码,可能会有点失望:

;;; @12: load-conq

优化编译器虽然能够内联getXgetY,但未能识别以下几个关键点:

  • 即使在解析器层面,已经可以明显看出getXgetY是运行时不修改的不可变常量,因此不需要通过闭包身份检查来守卫内联代码;
  • getXgetYgetSum共享相同的上下文,但优化器未考虑这一点,反而将getXgetY的上下文作为常量嵌入代码中,导致通过单元(cell)产生了不必要的间接访问(参见指令@20和@32)。

出现这种情况是因为Crankshaft未能充分利用解析器可提供的静态信息,而是依赖于类型反馈——而如果你创建两个ClosureObject对象,这种类型反馈实际上很容易丢失。

为了观察这种问题,让我们稍微修改一下预热代码:

var classic_objects = [new ClassicObject(), new ClassicObject()];
var closure_objects = [new ClosureObject(), new ClosureObject()];

for (var i = 0; i < 1e5; i++) classic_objects[i % 2].getSum();
for (var i = 0; i < 1e5; i++) closure_objects[i % 2].getSum();

优化对比结果ClassicObject.prototype.getSum的优化代码完全未变,因为两个经典对象的隐藏类与其构造方式保持一致。然而,ClosureObjectgetSum的代码质量却显著下降。【半年前,V8甚至可能为每个getSum闭包生成多份优化代码副本,但现在它对同一函数字面量产生的闭包之间的优化代码共享给予了更多关注。】

;;; @12: load-context-slot.
mov edi,[eax+0x1f]  ;; edi <- getX
;;; @14: global-object.
mov ecx,[eax+0x13]
;;; @16: global-receiver.
mov ecx,[ecx+0x13]
;;; @17: gap.
mov [ebp+0xec],ecx
;;; @18: push-argument.
push ecx
;;; @19: gap.
mov esi,eax
;;; @20: call-function.
call CallFunctionStub  ;; invoke edi (contains getX)
;;; @21: gap.
mov [ebp+0xe8],eax  ;; spill x returned from getX
;;; @23: gap.
mov esi,[ebp+0xf0]
;;; @24: load-context-slot.
mov edi,[esi+0x23]  ;; edi <- getY
;;; @26: push-argument.
push [ebp+0xec]  ;; push this
;;; @28: call-function.
call CallFunctionStub ;; invoke edi (contains getY)

getXgetY的调用不再被内联,更严重的是——V8无法识别它们保证是函数的事实。这些调用不再是直接调用,而是通过通用的CallFunctionStub进行处理,该存根会检查目标(通过寄存器edi传递)是否确实是函数。

为何会发生这种情况?简而言之:来自两个getSum实例的类型反馈混合在一起,导致getXgetY的调用点变成了多态调用。最容易的解释方式是通过图示来说明: V8会为同一函数字面量产生的所有闭包共享未优化代码。与此同时,收集类型反馈的内联缓存和其他机制都附着在这份未优化代码上。因此,类型反馈会被共享和混合。当类型反馈基于隐藏类时问题不大,因为隐藏类能捕获对象的结构特征——以相同方式构造的对象具有相同的隐藏类。然而对于getXgetY的调用点,V8收集的是调用目标信息。如果只有一个包含单个getSumClosureObject,对V8而言一切看似单态,因为getXgetY始终相同。但若创建并开始使用另一个ClosureObject,这些调用点就会变为多态:调用目标的身份特征不再匹配。

V8在此处的改进空间与性能影响
V8在多个方面本可以做得更好,例如利用解析器能从源代码中提取的绑定不可变性静态信息,以及改用SharedFunctionInfo身份而非闭包身份作为调用目标反馈和内联守卫的依据(参见Issue 2206)。

另一个可能影响闭包代码性能的问题是,V8仅在ia32架构上支持跨上下文切换边界的内联。在其他架构中,如果调用方与被调用方具有不同的上下文,被调用方永远无法内联到调用方中。
更新于2014年4月3日:跨上下文切换的内联现已在所有架构上得到支持。在这些问题解决之前,若追求可预测的性能,传统对象可能是更稳妥的选择。当然,每个具体场景都需要仔细评估(例如:是单例模式还是会产生多个对象?是否处于热点路径?等等)。请记住核心原则:在没有明确需求时,切勿过早优化 :-)