为啥带有 setTimeout 的函数不会导致堆栈溢出

Posted

技术标签:

【中文标题】为啥带有 setTimeout 的函数不会导致堆栈溢出【英文标题】:why does a function with setTimeout not lead to a stack overflow为什么带有 setTimeout 的函数不会导致堆栈溢出 【发布时间】:2020-09-11 03:56:23 【问题描述】:

我正在编写一个处理大量数据的测试。令我惊讶的是,如果我在我的函数中添加了一个 setTimeout,它将不再导致堆栈溢出(对于这个站点来说多么合适)。 这怎么可能,代码似乎真的是递归的。 每个 setTimeout 调用都会创建自己的堆栈吗?

有没有办法在不增加所需内存的情况下实现这种行为(异步并按顺序处理巨大的数组/数字)?


function loop(
    left: number,
    callbackFunction: (callback: () => void) => void,
) 
    if (left === 0) 
        return
    
    console.log(left)
    callbackFunction(() => 
        loop(left - 1, callbackFunction)
    )


function setTimeoutCallback(callback: () => void) 
    setTimeout(
        () => 
            callback()
        ,
        Math.random() * 5
    )


function nonSetTimeoutCallback(callback: () => void) 
    callback()


loop(100000, setTimeoutCallback) //no stack overflow

loop(100000, nonSetTimeoutCallback) //stack overflow

【问题讨论】:

另见Why the function called by setTimeout has no callstack limit?、Leaving recursive functions running forever?和Why such recursion not getting stack-overflowed? 【参考方案1】:

因为它不再是递归的。至少在技术上不是。

源代码看起来确实是递归的,因此程序员可以编写这样的代码,就好像它是递归的,但从 CPU 的角度来看,它不再是递归的。它在一个循环中按顺序处理。

递归和栈

递归函数调用自身。当这种情况发生时,堆栈会不断增加,直到最后一个函数返回。在函数返回之前,函数的堆栈框架不会从堆栈中删除(现在让我们忽略闭包),因此因为递归函数调用自身,它不会返回,直到对自身的调用返回。这就是导致堆栈增长的原因。

尾递归

Lisp、Haskell 和 Scala 等语言认识到,在某些情况下,可以在执行递归时释放堆栈帧。通常,如果递归调用是函数中的最后一条指令,并且没有对返回值进行其他处理,则可以删除当前堆栈帧,因为递归函数返回后将不再使用它。因此,此类语言实现了所谓的尾递归:在不增加堆栈的情况下无限递归的能力。

这对于非常纯粹的函数式语言特别有用,在这种语言中,你唯一的编程结构是函数,因为没有语句,你就不能有循环语句或条件语句等。尾递归使 Lisp 中的无限循环成为可能。

然而,javascript 没有尾递归。所以这不会影响递归在 Javascript 中的行为方式。我提到这一点是为了说明并非所有递归都需要增加堆栈。

调度

setTimeout()setInterval() 等定时器函数不会调用传递给它们的函数。他们不仅不立即打电话给他们,而且根本不打电话给他们。他们所做的只是将函数以及何时调用该函数的信息传递给事件循环。

事件循环本质上是 javascript 的核心。当且仅当没有更多的 javascript 可以执行时,解释器才会进入事件循环。您可以将事件循环视为解释器的空闲状态。事件循环不断检查事件(I/O、UI、计时器等)并执行附加到事件的相关功能。这是您传递给setTimeout() 的函数。

设置超时

所以根据上面给出的事实,我们可以看到通过setTimeout 的“递归”并不是真正的递归。

    首先,您的函数调用setTimeout 并将自身传递给它。

    setTimeout 将函数引用保存到事件侦听器列表并设置计时器以触发将触发函数的事件

    您的函数继续并返回,请注意“递归”函数尚未调用。 由于您的函数返回,它的堆栈帧被从堆栈中删除

    Javascript 进入事件循环(没有更多的 javascript 需要处理)。

    函数的定时器到期,事件循环调用它。重复直到你停止呼叫setTimeout

【讨论】:

谢谢。我有一个后续问题:我已经重构了问题中的代码以使其更加清晰。 “循环”函数不知道它得到什么类型的回调。有没有办法写循环函数来保证不会出现栈溢出? 回答我自己的后续问题:使用 Promise 代替 setTimeout 也可以正常工作,并且在 Node 中速度几乎无法衡量:10.000 次使用 setTimeout 需要 14 秒,而使用 Promise 则不到一秒跨度> 【参考方案2】:

第一个不是递归的(虽然乍一看很像)。

让我们简化并想象一个递归方法f,我们将调用深度限制为5。第二个例子是递归的,类似于

f() f() f() f() f() 

在另一个示例中,我们使用f 作为回调创建了 5 个超时(数组并不完全准确,它更像是具有随机超时值的优先级队列 - 但这种抽象有助于理解问题)

[f, f, f, f, f]

javascript 不是多线程的,因此所有 5 个函数一个接一个地被调用。最终,一个函数将创建另一个超时,当这种情况发生时,新的f 回调被添加到列表(或队列)中。

所以基本上,超时在这里将递归序列化。请注意,在这种情况下,f 将被调用近 200 万次,因为除了最后一次之外,所有其他人都会将另一个 f 添加到执行 get 的列表中。

【讨论】:

以上是关于为啥带有 setTimeout 的函数不会导致堆栈溢出的主要内容,如果未能解决你的问题,请参考以下文章

使用 setTimeout 避免堆栈溢出

为啥在函数中用作局部变量时数组不会沿堆栈方向增长?

为啥增加递归深度会导致堆栈溢出错误?

为啥框架集中两个子页面不能同时运行settimeout或者setInterval函数

为啥这个 BigInteger 值会导致堆栈溢出异常? C#

使用setTimeout函数解决栈溢出问题