浅谈eval的影响

听闻最近由于老赵在其Wind.js关于eval这一函数使用上与BYVoid进行了一系列的争论,甚至为此写了一篇博客来证明eval对性能的影响可以忽略这一观点。

而不幸由于在这一次讨论中,起于Ericpooneval性能影响的疑问,也不知不觉参与了一些讨论,随后Franky提出要有数据上的事实来说明这一问题,于是在好久没有新的博客出产的时候,决定对这一问题进行一下非常浅薄的挖掘,提供一些客观的数据。

首先在此文中,需要声明的是:

  • 个人倾向于不要计较eval产生的影响。
  • 此文会从eval有负面影响这一角度进行表述和举例,与老赵的博客属于完全相反的观点,但并不是一种对其的驳斥或者反对,仅仅是希望可以提供一些客观的理论依据。
  • 本文只谈V8引擎,由于这一问题是不同引擎的表现会大不相同,不可能完全覆盖,因此数据不可作为权威参考。
  • 本人不懂C或C++,看不懂V8,因此完全是黑盒的推断,各结论也不具权威意义,完全可能是臆测,当然数据保证真实。

以下是本次测试使用的Chrome版本详细信息:

  • Google Chrome: 21.0.1180.79 (Official Build 151411) m
  • OS: Windows
  • WebKit: 537.1 (@124502)
  • JavaScript: V8 3.11.10.18
  • Flash: 11.3.31.227
  • User Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.79 Safari/537.1

eval本身执行的消耗

显然这一话题在文章中会被迅速略过,eval作为一个函数,虽然有其特殊性,但仍旧逃脱不了其是一个函数这一本质,简单地看一下eval函数在执行的时候做了什么:

  1. 建立一个新的执行环境。
  2. 执行环境中的this指向当前执行环境的this
  3. 执行环境中的文法环境(获取变量用)指向当前执行环境的文法环境对象。
  4. 执行环境中的变量环境(声明变量用)指向当前执行环境的变量环境对象。
  5. 正常执行代码。

从这一过程中我们可以看到,执行eval代码的过程中,甚至没有文法环境和变量环境这两个对象的创建,其过程理论上比执行一个函数更为迅速(进入函数时需要创建新的文法环境和变量环境对象)。自然eval需要解析作为参数传入的代码体,但是V8引擎会对eval的解析结果进行缓存,如果并不是每一次调用eval都给予不同的代码,则对速度的影响完全可以忽略不计。

因此,从eval本身执行的消耗上来论述eval对性能的影响,显然是没有太大意义的。然而遗憾的是,大多数坚持eval不影响或极少影响性能这一观点的开发者,都仅仅从eval本身的消耗上对性能进行了测试并得出其希望的答案。

说实话,个人认为这是一个典型的先有结论再推导过程的思维,从一开始就坚持了自己的想法是正确的这一思想,随后通过各种方式努力进行证明。诚然这种做法是正常且普遍的,本人在论述很多问题时,也会不自觉地进行这种方式的推导。但是这种思维,却确实会导致在推导过程中,由于目的导向性的影响,忽略很多应该考虑的事实,如同一个急着赶路的商人,看不到道路两旁美丽的樱树,甚至错过桃色的邂逅,实属一种遗憾。

因而,在后续的文章中,会试图从一些其他的角度出发,来考量一下eval的影响面到底有多广。

对内存使用的影响

当谈话性能这一话题时,很多开发者会立刻将焦点锁定在执行时间这一指标上,然而事实上,性能绝不仅仅是速度,同时内存消耗硬件利用率等因素都需要在考虑之列,虽然在多数情况下,javascript受限于其运行环境(浏览器),对于硬件利用率这一指标很难有所突破,但对于内存的消耗却完全可以作为衡量的指标。

关于eval对内存的影响,主要集中在垃圾回收方面,eval会在特定情况下阻止某些可以被回收的对象被回收,这一话题我在以前就有过比较深入的讨论,老生常谈,参考以下代码:

var fn = (function() {  
    var largeObject = LargeObject.fromSize('100MB');
    var i = 0;

    return function() {
        return ++i;
    };
}());

很显然,在上面的代码执行后,如果引擎足够聪明,largeObject对象是应当被回收的(即使该对象被闭包所引用),可以释放100MB的内存。但如果轻微地改动以上代码:

var fn = (function() {  
    var largeObject = LargeObject.fromSize('100MB');
    var i = 0;

    return function() {
        function neverCalled() {
            eval('');
        }
        return ++i;
    };
}());

注意到在代码中增加了一个neverCalled函数,正如其名,该函数永远不会被调用。但是由于该函数的存在,且该函数的函数体中使用了eval函数,结果导致了largeObject对象无法被回收,100MB的内存会被长期占用。

对于eval之于垃圾回收的影响,我在关于闭包及变量回收问题一文中已经有了较详细的说明,在此就不再赘述。

对V8优化的影响

其实本章节才是本文的核心所在,事实上,eval的影响这一问题,并不是指eval执行很慢或者eval生成的函数执行很慢这样无厘头的概念。eval真正使代码变慢的原因是,它会阻止引擎对代码进行一些优化,下面就2个比较普遍的V8引擎的优化来分析一下eval的负作用。

经过与一些朋友的讨论,对这个问题做一下重新整理。事实上这一问题仅会在Chrome打开开发者工具的情况下,才会出现明显的性能差距。在开发者工具关闭的情况下,并不存在如后文所描述的10倍以上的差距,在个人的PC上,差距一般在2-3倍之间。至于开发者工具为何会对代码产生如此明显的影响,暂时还不得而知。因此本文提供的例子仅能作为较特殊情况下eval的负面作用的个例,以证明eval会影响V8优化这一客观事实,无法说明其产生的负面影响的严重程度,从量的角度无需视为权威性的参考。

对象属性访问

在高级别的前端工程师中,这大概是一个常识,V8针对对象属性的访问有自己的优化方式,而其中最常用的手段应该是隐藏类技术,对于这一技术本文不会做详细的介绍,简而言之V8的对象属性访问是非常快的,以下代码可以作为示例:

var o = {};  
o.x = 1;  
o.y = 2;  
o.z = 3;  
console.time('access');  
for (var i = 0; i < 1e7; i++) {  
    o.y;
}
console.timeEnd('access');  

在我的PC上,这段代码的执行时间始终停留在35ms以内,对于一百万次属性访问(有事实证明V8没有把整个循环优化消失,关于这一点的证明不再赘述),这确实是一个非常快的值。然而如果在这一代码中添加一些其他的东西,比如一个eval,比如一个delete,又会产生什么样的结果呢?

var o = {};  
o.x = 1;  
o.y = 2;  
o.z = 3;  
eval('');  
// delete o.y;
console.time('access');  
for (var i = 0; i < 1e7; i++) {  
    o.y;
}
console.timeEnd('access');  

下表是对这3种代码分别执行5次得到的数据:

普通代码 添加eval 添加delete
第1次 28 383 1415
第2次 25 382 1425
第3次 37 374 1433
第4次 34 386 1431
第5次 33 383 1473
平均 31.4 381.6 1435.4

首先请注意,无论是eval还是delete,都没有在计时的部分执行,因此可以认为计时部分的执行量是相同的,而eval又没有执行任何的代码,完全是一次空的操作,仅仅是eval这4个字母存在于代码中而已。然而仅仅是存在这么简单的事,就对执行时间产生了10倍之多的影响,而delete由于破坏了对象结构,产生的影响就更为严重。

把目光重新放回eval上,从这个例子上,很显然就能发现eval确实对执行速度有着不可忽视的影响,更为糟糕的是,这一影响并不因为eval本身被不断执行才得以累加,仅仅是eval在源码中出现,就会产生这一负面影响。

作用域的处理

以代码说事,看看下面的代码:

var sin = (function() {  
    return function(x) {
        return Math.sin(x);
    };
}());
console.time('inline');  
for (var i = 0; i < 1e7; i++) {  
    sin(i);
}
console.timeEnd('inline');  

其实这代码也没什么,就是执行一百万次的sin操作,在我的Chrome中,执行时间大致在465ms左右,显然这是个快到惊人的数据。

但是这个455ms的数据,是真的因为V8执行Math.sin函数很快吗?一如既往地,为了确定一些其他因素对这一代码的影响,恶作剧式地为这一代码加点东西吧。

比如加个eval试试:

var sin = (function() {  
    eval('');

    return function(x) {
        return Math.sin(x);
    };
}());
console.time('inline');  
for (var i = 0; i < 1e7; i++) {  
    sin(i);
}
console.timeEnd('inline');  

又或者加个with试试:

var sin = (function() {  
    var o = {};

    with (o) {
        return function(x) {
            return Math.sin(x);
        };
    }
}());
console.time('inline');  
for (var i = 0; i < 1e7; i++) {  
    sin(i);
}
console.timeEnd('inline');  

下表就是各测试5次的结果:

普通代码 添加eval 添加with
第1次 441 3459 5540
第2次 451 3469 5601
第3次 465 3505 5469
第4次 442 3483 5491
第5次 453 3481 5476
平均 450.4 3479.4 5515.4

再一次,从数据上看,eval的负面影响被突显出来,与第1个例子相同,只是多了一句eval,甚至eval没有执行任何代码,没有参与计时的循环,就产生了近10倍的执行时间的差距。当然使用with就更加糟糕,几乎达到了14倍的差距。

虽然从黑盒的角度,无法就这一问题得出一个结论性的推断,但是从推理来看,V8在对函数体进行扫描时期,是有一定能力确定一个标识符的引用的,如上面每一段代码,V8确认在当前作用域闭包内并没有Math这一变量,因此对于返回的函数中的Math这一标识符,直接将其与window.Math进行了绑定,而不需要每一次都去查找Math这一标识符。

但是如果存在eval的调用,由于eval可在当前作用域内声明变量,因此V8无法通过静态地分析,确定当前作用域闭包内没有Math这一变量(可能被eval声明),于是放弃了将其与window.Math绑定的优化方案,导致执行速度大幅度下降。

同理,由于with会影响到变量的获取,V8没有办法通过静态分析,得出o这一对象中是否有Math这一属性,同样也必须放弃一些优化手段(从数据看,显然相比eval,放弃了更多的优化),导致执行速度的下降。

结论

本文试图从内存消耗执行速度两个角度来讨论eval这一函数对javascript引擎执行性能的影响,同时不同于其他开发者eval本身执行慢这样的观点,本文从eval对引擎优化的负面影响这一角度进行深入,以期获得一些数据。

在进行了一系列的测试后,本文可以得出的结论大致有以下几点:

  • eval在特定情况下对性能有负面影响。
  • eval对性能的影响主要是因为其会导致引擎的优化功能被关闭。
  • eval对性能的影响不在于其被调用的次数、频率,而是其是否有在源码中出现这一事实,因此这种影响是静态的,与执行过程无关的,影响面相比运行时的影响更为广泛和严重,需要引起重视。
  • 除了eval之外,诸如deletewith之类的关键字也会对性能有所影响,从本文提供的数据中也可以得到有效的结论。

但是需要注意的是,影响性能的是eval这一标识符的直接调用,即我们所说的eval直接调用,其他任何形式地调用eval都不会影响性能,至少在本文关注的这几个角度上并没有其影响性能的实证。因此对于老赵Wind.js项目,最直接的建议是将不需要当前作用域支持的eval改成global.eval(如何获取global对象这一问题就不讨论了),当然这能做的程度是很小的,毕竟如果不需要当前作用域支持,鬼才冒着背莫须有罪名的风险去用eval呢。

最后,再看看整个事件的起源,无非就是Douglas Crockford在其JavaScript: The Good Parts一书中提出的Eval is evil一话。但是个人认为,这句话主要是的还是eval的使用对可读性的破坏、作用域的混淆等一系列逻辑上的问题,而非针对其性能上产生的差异。

诚如winter所说,我们做技术的,一定要会独立思考,拒绝一切含糊其辞。很惭愧本人对eval的性能影响其实一直是有一个结论性的认知,却迟迟不愿意通过数据的方式来给出说明,仅以此文算作对个人没有遵循winter的教导的忏悔之作。

最后补充一些资料: