无限递归 promise 解析链的问题

本章的此时,您应该对 Promise 的工作原理以及如何使用它们来实现最常见的控制流结构有深入的了解。 因此,现在是讨论每个专业 Node.js 开发人员都应该了解和理解的高级主题的最佳时机。 这个高级主题是关于无限 Promise 解析链导致的内存泄漏。 该错误似乎会影响实际的 Promises/A+ 规范,因此任何合规的实现都无法幸免。

在编程中,任务没有预定义的结束或将可能无限的数据数组作为输入是很常见的。 我们可以在此类别中包括实时音频/视频流的编码/解码、实时加密货币市场数据的处理以及物联网传感器的监控。 但我们可能会遇到比这些更琐碎的情况,例如,在大量使用函数式编程时。

举一个简单的例子,让我们考虑以下代码,它使用 Promise 定义了一个简单的无限操作:

function leakingLoop () {
    return delay(1)
        .then(() => {
            console.log(`Tick ${Date.now()}`)
            return leakingLoop()
        })
}

我们刚刚定义的 leakingLoop() 函数使用 delay() 函数(我们在本章开头创建的)来模拟异步操作。 当给定的毫秒数过去后,我们打印当前时间戳,并递归调用 leakingLoop() 以重新开始操作。 有趣的是,leakingLoop() 返回的 Promise 永远不会解析,因为它的状态取决于下一次调用 leakingLoop(),而下一次调用又取决于下一次调用 leakingLoop(),依此类推。 这种情况会创建一个永远不会解决的 Promise 链,并且会导致严格遵循 Promises/A+ 规范的 Promise 实现(包括 JavaScript ES6 Promise)出现内存泄漏。

为了演示泄漏,我们可以尝试多次运行 leakingLoop() 函数来强调泄漏的影响:

for (let i = 0; i < 1e6; i++) {
    leakingLoop()
}

然后,我们可以使用我们最喜欢的进程检查器查看进程的内存占用量,并注意它如何无限增长,直到(几分钟后)进程完全崩溃。

解决问题的办法就是打破 Promise 解析链。 我们可以通过确保 leakingLoop() 返回的 Promise 的状态不依赖于下一次调用 leakingLoop() 返回的 promise 来做到这一点。

我们可以通过简单地删除返回指令来确保:

function nonLeakingLoop () {
    delay(1)
        .then(() => {
            console.log(`Tick ${Date.now()}`)
            nonLeakingLoop()
        })
}

现在,如果我们在示例程序中使用这个新函数,我们应该看到进程的内存占用量将按照垃圾收集器的各种运行时间表上下波动,这意味着没有内存泄漏。

然而,我们刚刚提出的解决方案从根本上改变了原始 leakingLoop() 函数的行为。 特别是,这个新函数不会传播递归中深层产生的最终错误,因为各种 Promise 的状态之间没有联系。 通过在函数中添加一些额外的日志记录可以减轻这种不便。 但有时新行为本身可能不是一种选择。 因此,一个可能的解决方案是使用 Promise 构造函数包装递归函数,如以下代码示例所示:

function nonLeakingLoopWithErrors () {
    return new Promise((resolve, reject) => {
        (function internalLoop () {
            delay(1)
                .then(() => {
                    console.log(`Tick ${Date.now()}`)
                    internalLoop()
                })
                .catch(err => {
                    reject(err)
                })
        })()
    })
}

在这种情况下,我们在递归的各个阶段创建的 Promise 之间仍然没有任何联系; 然而,如果任何异步操作失败,则 nonLeakingLoopWithErrors() 函数返回的 Promise 仍将拒绝,无论发生在递归的哪个深度。

第三种解决方案利用 async/await。 事实上,通过 async/await 我们可以用一个简单的无限 while 循环来模拟一个递归的 Promise 链,如下所示:

async function nonLeakingLoopAsync () {
    while (true) {
        await delay(1)
        console.log(`Tick ${Date.now()}`)
    }
}

在这个函数中,我们也保留了原始递归函数的行为,异步任务(在本例中为 delay())抛出的任何错误都会传播到原始函数调用者。

我们应该注意,如果我们选择使用实际的异步递归步骤来实现 async/await 解决方案而不是 while 循环,则仍然会出现内存泄漏,如下所示:

async function leakingLoopAsync () {
    await delay(1)
    console.log(`Tick ${Date.now()}`)
    return leakingLoopAsync()
}

上面的代码仍然会创建一个永远无法解决的无限承诺链,因此它仍然受到等效基于承诺的实现的相同内存泄漏问题的影响。

如果您有兴趣了解有关本节中讨论的内存泄漏的更多信息,您可以在 nodejsdp.link/node-6673 上查看相关的 Node.js 问题,或者在 Promises/A+ GitHub 存储库上查看相关问题(nodejsdp.link/promisesaplusmemleak) 。