警惕JS数组解构转参数导致爆栈问题
发布于 19 天前 作者 zy445566 2556 次浏览 最后一次编辑是 18 天前 来自 分享

起源是在写webscoket服务的时候,发现开发工具偶尔报Maximum call stack size exceeded的问题。由于当时没时间,就草草把BUG修复了,并未深究原因。现在复盘工作的时候,又想起这个问题,于是再把这个问题拿出来研究。

出错代码大概是如下:

new Array().push(...Buffer.allocUnsafe(2**17))

一开始看到超出调用栈以为是Buffer的迭代器递归出现了问题,但事实并不是这样。

排查出错原因

由于出错代码是这样的:

/*
* 而且存在临界点,不同机器略有差异
* 本人的机器大于2**17就必报
*/
new Array().push(...Buffer.allocUnsafe(2**17))

当时认为是Buffer的迭代器问题,所以就尝试使用普通数组

/*
* 这段代码在浏览器也报错
* 所以排除nodejs原因,应该是V8造成的
*/
new Array().push(...new Array(2**17).fill(0xff))

发现普通数组也存在类似问题,接下来就是排除迭代器

[...new Array(2**17).fill(0xff)]

发现迭代居然没有异常,难道是push方法?那就换个方法试试

console.log(...new Array(2**17).fill(0xff))

居然log也报错,那真相只有一个,那就是参数超载

那为什么会报超过最大调用堆栈大小,而不是其它错误?

大家都知道,函数再调用函数的时候,是通过存储在调用栈中来保持执行顺序的,而栈是有一定大小,比如递归上数百万次后也会出现爆栈。

那么是否真的是因为栈不够用了?还是说参数对调用栈也存在一些影响?

  1. 接下来我们就来逐一排查。首先确定是否是真的因为栈不够用了
# 通过调整栈大小,来判断是否是栈耗尽了,stack-size的单位是KB,默认是984
node --stack-size=2048 -e "new Array().push(...Buffer.allocUnsafe(2**17))"

发现果然运行正常,所以可以确定是栈耗尽了

  1. 排查参数的数量对栈的影响
function recursionDepth(paramLen) {
    let deepth = 0;
    function f(...paramList) {
        deepth++;
        Math.random() + f(...paramList); // 防止尾递归优化
    }
    try{
        f(...Buffer.allocUnsafe(paramLen))
    } catch (err) {
        console.log(`当参数长度为${paramLen},最大深度则为:${deepth}`)
    }
}

recursionDepth(2**4)
recursionDepth(2**8)
recursionDepth(2**12)
recursionDepth(2**16)
recursionDepth(2**20)

输出结果:

当参数长度为16,最大深度则为:3489
当参数长度为256,最大深度则为:455
当参数长度为4096,最大深度则为:30
当参数长度为65536,最大深度则为:1
当参数长度为1048576,最大深度则为:0

所以由此确定参数的数量也是需要暂用调用栈的空间,而当参数长度达到足够长,即使1帧也可以压垮整个调用栈,超出调用栈空间。

12 回复

就不该用push方法

一楼说得对,本来就不该用push。 写下一行代码应该对对代码有复杂度的粗略估计。 解构也是有迭代耗时的。 解构一个超过10长度的数组本来就值得警惕

======================

参数栈这个如果c学得扎实应该有点概念,以前写c还有入栈顺序,返回值放哪的协议呢 传递1w个指针,💥 不过js解释器聪明点这块说不定能自己优化掉,

楼主问题是把解构出来的值当作函数参数, 那如果有10k个元素就会10k个参数,肯定会爆栈的。 使用解构方法一定得注意这个问题。

还有apply也有类似陷阱: https://github.com/ruanyf/es6tutorial/pull/520

@waitingsong 嗯,我现在思考的是调用栈只是为了保证方法的执行顺序,按照道理来说数据都已经在内存中,根本没必要在调用栈中存参数,所以我认为这是一个设计失误,下次假期抽点时间看看它为什么非要存参数。

@zy445566 我猜测可能是因为解构数组导致函数参数个数不定,于是执行环境无法做某些(针对固定参数个数的)优化。于是…… 要不你试试看把例子改成固定超大个数参数(比如你测试环境下面参数个数),看看是否也爆栈

length 2**17 这么大的数组,感觉还有其他坑在等着

@waitingsong 我觉得apply这个陷阱比解构这个更加值得注意,比较容易忽略和失去警惕性

@AnzerWall 所以过于精妙的奇技淫巧还是少用。

@chenkai0520 我觉得这个跟push没有关系,他问题的关键是解构之后参数数量太多导致栈爆了,如果楼主用for循环,然后每次push时候只只传一个参数应该没有问题

@youth7 对的,即使是console. log也有相同的问题。其实不管是不是解构,只要参数够多,目前爆栈就一定存在。

@zy445566 那是当然,关键原理就是x86的计算模型就是通过寄存器/栈来传递参数,寄存器放不下了就放栈,栈也放不小了就栈爆了

@youth7 学习了,估计V8自己实现了一套,但是基本原理应该不变。

回到顶部