try catch引起的性能优化深度思考

image

关键代码拆解成以下图所示(无关部分已省略):node

demo

起初我认为多是这个 getRowDataItemNumberFormat 函数里面某些方法执行太慢,从 formatData.replaceunescape(已废弃,官方建议使用 decodeURI 或者 decodeURIComponent 替代) 方法都怀疑了一遍,发现这些方法都不是该函数运行慢的缘由。为了深究缘由,我给 style.formatData 传入了不一样的值,发现这个函数的运行效率出现不一样的表现。开始有点疑惑为何 style.formatData 的值致使这个函数的运行效率差异如此之大。git

进一步最终定位发现若是 style.formatData 为 undefined 的时候,效率骤降,若是 style.formatData 为合法的字符串的时候,效率是正常值。我开始意识到这个问题的缘由在那里了,把目光转向了 try catch 代码块,这是一个很可疑的地方,在很早以前曾经据说过不合理的 try catch 是会影响性能的,可是以前从没遇到过,结合了一些资料,我发现比较少案例去探究这类代码片断的性能,我决定写代码去验证下:github

window.a = 'a';
window.c = undefined;
function getRowDataItemNumberFormatTryCatch() {
    console.time('getRowDataItemNumberFormatTryCatch');
    for (let i = 0; i < 3000; i++) {
        try {
            a.replace(/%022/g, '"');
        }
        catch (error) {
        }
    }
    console.timeEnd('getRowDataItemNumberFormatTryCatch');
}

我尝试把 try catch 放入一个 for 循环中,让它运行 3000 次,看看它的耗时为多少,个人电脑执行该代码的时间大概是 0.2 ms 左右,这是一个比较快的值,可是这里 a.replace 是正常运行的,也就是 a 是一个字符串能正常运行 replace 方法,因此这里的耗时是正常的。我对他稍微作了一下改变,以下:浏览器

function getRowDataItemNumberFormatTryCatch2() {
    console.time('getRowDataItemNumberFormatTryCatch');
    for (let i = 0; i < 3000; i++) {
        try {
            c.replace(/%022/g, '"');
        }
        catch (error) {
        }
    }
    console.timeEnd('getRowDataItemNumberFormatTryCatch');
}

这段代码跟上面代码惟一的区别是,c.replace 此时应该是会报错的,由于 cundefined,这个错误会被 try catch 捕捉到,而上面的代码耗时出现了巨大的变化,上升到 40 ms,相差了将近 200 倍!而且上述代码和首图的 getRowDataItemNumberFormat 函数代码均出现了 Minor GC,注意这个 Minor GC 也是会耗时的。异步

demo

这能够解释一部分缘由了,咱们上面运行的代码是一个性能比较关键的部分,不该该使用 try catch 结构,由于该结构是至关独特的。与其余构造不一样,它运行时会在当前做用域中建立一个新变量。每次 catch 执行该子句都会发生这种状况,将捕获的异常对象分配给一个变量。async

即便在同一做用域内,此变量也不存在于脚本的其余部分中。它在 catch 子句的开头建立,而后在子句末尾销毁。由于此变量是在运行时建立和销毁的(这些都须要额外的耗时!),而且这是 JavaScript 语言的一种特殊状况,因此某些浏览器不能很是有效地处理它,而且在捕获异常的状况下,将捕获处理程序放在性能关键的循环中可能会致使性能问题,这是咱们为何上面会出现 Minor GC 而且会有严重耗时的缘由。函数

若是可能,应在代码中的较高级别上进行异常处理,在这种状况下,异常处理可能不会那么频繁发生,或者能够经过首先检查是否容许所需的操做来避免。上面的 getRowDataItemNumberFormatTryCatch2 函数示例显示的循环,若是里面所需的属性不存在,则该循环可能引起多个异常,为此性能更优的写法应该以下:性能

function getRowDataItemNumberFormatIf() {
    console.time('getRowDataItemNumberFormatIf');
    for (let i = 0; i < 3000; i++) {
        if (c) {
            c.replace(/%022/g, '"');
        }
    }
    console.timeEnd('getRowDataItemNumberFormatIf')
}

上面的这段代码语义上跟 try catch 实际上是类似的,但运行效率迅速降低至 0.04ms,因此 try catch 应该经过检查属性或使用其余适当的单元测试来彻底避免使用此构造,由于这些构造会极大地影响性能,所以应尽可能减小使用它们。单元测试

若是一个函数被重复调用,或者一个循环被重复求值,那么最好避免其中包含这些构造。它们最适合仅执行一次或仅执行几回且不在性能关键代码内执行的代码。尽量将它们与其余代码隔离,以避免影响其性能。测试

例如,能够将它们放在顶级函数中,或者运行它们一次并存储结果,这样你之后就能够再次使用结果而没必要从新运行代码。

demo

getRowDataItemNumberFormat 在通过上述思路改造后,运行效率获得了质的提高,在实测 300 屡次循环中减小的时间以下图,足足优化了将近 2s 多的时间,若是是 3000 次的循环,那么它的优化比例会更高:

demo
demo

因为上面的代码是从项目中改造出来演示的,可能并不够直观,因此我从新写了另一个类似的例子,代码以下,这里面的逻辑和上面的 getRowDataItemNumberFormat 函数讲道理是一致的,可是我让其发生错误的时候进入 catch 逻辑执行任务。

事实上 plus1plus2 函数的代码逻辑是一致的,只有代码语义是不相同,一个是返回 1,另外一个是错误抛出1,一个求和方法在 try 片断完成,另外一个求和方法再 catch 完成,咱们能够粘贴这段代码在浏览器分别去掉不一样的注释观察结果。

咱们发现 try 片断中的代码运行大约使用了 0.1 ms,而 catch 完成同一个求和逻辑却执行了大约 6 ms,这符合咱们上面代码观察的预期,若是把计算范围继续加大,那么这个差距将会更加明显,实测若是计算 300000 次,那么将会由原来的 60 倍差距扩大到 500 倍,那就是说咱们执行的 catch 次数越少折损效率越少,而若是咱们执行的 catch 次数越多那么折损的效率也会越多。

因此在不得已的状况下使用 try catch 代码块,也要尽可能保证少进入到 catch 控制流分支中。

const plus1 = () => 1;
const plus2 = () => { throw 1 };
console.time('sum');
let sum = 0;
for (let i = 0; i < 3000; i++) {
    try {
        // sum += plus1(); // 正确时候 约 0.1ms
        sum += plus2(); // 错误时候 约 6ms
    } catch (error) {
        sum += error;
    }
}
console.timeEnd('sum');

上面的种种表现进一步引起了我对项目性能的一些思考,我搜了下咱们这个项目至少存在 800 多个 try catch,糟糕的是咱们没法保证全部的 try catch 是不损害代码性能而且有意义的,这里面确定会隐藏着不少上述类的 try catch 代码块。

从性能的角度来看,目前 V8 引擎确实在积极的经过 try catch 来优化这类代码片断,在之前浏览器版本中上面整个循环即便发生在 try catch 代码块内,它的速度也会变慢,由于之前浏览器版本会默认禁用 try catch 内代码的优化来方便咱们调试异常。

try catch 须要遍历某种结构来查找 catch 处理代码,而且一般以某种方式分配异常(例如:须要检查堆栈,查看堆信息,执行分支和回收堆栈)。尽管如今大部分浏览器已经优化了,咱们也尽可能要避免去写出上面类似的代码,好比如下代码:

try {
    container.innerHTML = "I'm alloyteam";
}
catch (error) {
    // todo
}

上面这类代码我我的更建议写成以下形式,若是你实际上抛出并捕获了一个异常,它可能会变慢,可是因为在大多数状况下上面的代码是没有异常的,所以总体结果会比异常更快。

这是由于代码控制流中没有分支会下降运行速度,换句话说就是这个代码执行没错误的时候,没有在 catch 中浪费你的代码执行时间,咱们不该该编写过多的 try catch 这会在咱们维护和检查代码的时候提高没必要要的成本,有可能分散并浪费咱们的注意力。

当咱们预感代码片断有可能出错,更应该是集中注意力去处理 successerror 的场景,而非使用 try catch 来保护咱们的代码,更多时候 try catch 反而会让咱们忽略了代码存在的致命问题。

if (container) container.innerHTML = "I'm alloyteam";
else // todo

在简单代码中应当减小甚至不用 try catch ,咱们能够优先考虑 if else 代替,在某些复杂不可测的代码中也应该减小 try catch(好比异步代码),咱们看过不少 asyncawait 的示例代码都是结合 try catch 的,在不少性能场景下我认为它并不合理,我的以为下面的写法应该是更干净,整洁和高效的。

由于 JavaScript 是事件驱动的,虽然一个错误不会中止整个脚本,但若是发生任何错误,它都会出错,捕获和处理该错误几乎没有任何好处,代码主要部分中的 try catch 代码块是没法捕获事件回调中发生的错误。

一般更合理的作法是在回调方法经过第一个参数传递错误信息,或者考虑使用 Promisereject() 来进行处理,也能够参考 node 中的常见写法以下:

;(async () => {
    const [err, data] = await readFile();
    if (err) {
        // todo
    };
})()

fs.readFile('<directory>', (err, data) => {
    if (err) {
        // todo
    }
});

结合了上面的一些分析,我本身作出一些浅显的总结:

    1. 若是咱们经过完善一些测试,尽可能确保不发生异常,则无需尝试使用 try catch 来捕获异常。
    1. 非异常路径不须要额外的 try catch,确保异常路径在须要考虑性能状况下优先考虑 if else,不考虑性能状况请君随意,而异步能够考虑回调函数返回 error 信息对其处理或者使用 Promse.reject()
    1. 应当适当减小 try catch 使用,也不要用它来保护咱们的代码,其可读性和可维护性都不高,当你指望代码是异常时候,不知足上述1,2的情景时候可考虑使用。

最后,笔者但愿这篇文章能给到你我一些方向和启发吧,若有疏漏不妥之处,还请不吝赐教!

附笔记连接,阅读往期更多优质文章可移步查看,但愿对你有些许的帮助,你的点赞是对我最大的鼓励:

相关文章
相关标签/搜索