在 javascript 中递归构建Promise链 - 内存注意事项

IT技术 javascript recursion promise
2021-01-24 22:55:19

这个答案中,一个Promise链是递归构建的。

稍微简化一下,我们有:

function foo() {
    function doo() {
        // always return a promise
        if (/* more to do */) {
            return doSomethingAsync().then(doo);
        } else {
            return Promise.resolve();
        }
    }
    return doo(); // returns a promise
}

大概这会产生一个调用堆栈一个Promise链——即“深”和“宽”。

我预计内存峰值会比单独执行递归或构建Promise链要大。

  • 是这样吗?
  • 有没有人考虑过这样建链的内存问题?
  • Promise库之间的内存消耗会有所不同吗?
5个回答

一个调用栈和一个Promise链——即“深”和“宽”。

实际上,没有。正如我们所知道的那样,这里没有Promise链doSomeThingAsynchronous.then(doSomethingAsynchronous).then(doSomethingAsynchronous).…如果以这种方式编写,这就是Promise.eachPromise.reduce可能会顺序执行处理程序)。

我们在这里面临的是一个解析链1 - 最终会发生什么,当满足递归的基本情况时,类似于Promise.resolve(Promise.resolve(Promise.resolve(…))). 如果你想这样称呼它,这只是“深”,而不是“宽”。

我预计内存峰值会比单独执行递归或构建Promise链要大。

实际上不是尖峰。随着时间的推移,你会慢慢地构建大量的Promise,并用最里面的Promise解决,所有Promise都代表相同的结果。当在你的任务结束时,条件得到满足并且最内层的Promise用实际值解决时,所有这些Promise都应该用相同的值解决。这最终会O(n)导致遍历解析链的成本(如果天真地实现,这甚至可能以递归方式完成并导致堆栈溢出)。之后,除了最外层的所有Promise都可以成为垃圾收集。

相比之下,由类似的东西构建的Promise链

[…].reduce(function(prev, val) {
    // successive execution of fn for all vals in array
    return prev.then(() => fn(val));
}, Promise.resolve())

会显示一个尖峰,同时分配npromise 对象,然后一个一个慢慢地解决它们,垃圾收集之前的那些,直到只有已解决的 end promise 还活着。

memory
  ^     resolve      promise "then"    (tail)
  |      chain          chain         recursion
  |        /|           |\
  |       / |           | \
  |      /  |           |  \
  |  ___/   |___     ___|   \___     ___________
  |
  +----------------------------------------------> time

是这样吗?

不必要。如上所述,该批量中的所有Promise最终都以相同的值2解析,因此我们需要的只是一次存储最外层和最内层的Promise。所有中间Promise可能会尽快被垃圾收集,我们希望在恒定的空间和时间中运行这个递归。

事实上,这种递归结构对于具有动态条件(没有固定步数)的异步循环是完全必要的,你无法真正避免它。在 Haskell 中,它一直用于IOmonad,因此对其进行了优化。它与尾调用递归非常相似,后者通常被编译器消除。

有没有人考虑过这样建链的内存问题?

是的。例如,这已在 promises/aplus 上讨论过,但还没有结果。

许多Promise库确实支持迭代助手来避免Promisethen的峰值,例如 Bluebirdeachmap方法。

我自己的Promise库3,4没有引入内存或运行时开销的特性解析链。当一个Promise采用另一个Promise时(即使仍然未决),它们变得不可区分,并且中间Promise不再在任何地方被引用。

Promise库之间的内存消耗会有所不同吗?

是的。虽然这种情况可以优化,但很少。具体来说,ES6 规范确实要求 Promise 在每次resolve调用时检查值,因此不可能折叠链。链中的Promise甚至可以用不同的值来解决(通过构造一个滥用 getter 的示例对象,而不是在现实生活中)。该问题已在 esdiscuss 上提出,但仍未解决。

因此,如果您使用泄漏实现,但需要异步递归,那么您最好切换回回调并使用延迟反模式将最内层的Promise结果传播到单个结果Promise。

[1]:没有官方术语
[2]:好吧,它们是相互解决的。但是,我们希望用相同的值来解决这些问题,我们期待的是
[3]:无证操场,通过Aplus的。阅读代码请自担风险:https : //github.com/bergus/F-Promise
[4]:也在此拉取请求中为 Creed 实现

@BenjaminGruenbaum:是的,这种模式就像一个while带有异步主体循环。这就是为什么它如此有用:-)
2021-03-12 22:55:19
@BenjaminGruenbaum:我不太担心单个Promise的内存大小(而且 Bluebird 在那里确实做得很好),但是 10K Promise根本需要分配(同时)。解析链的内存增长不是必需的(因为需要一次将 10K 的Promise设置为“已完成”状态)。
2021-03-13 22:55:19
由于在 bluebird 中分配 promise 的数据点比分配空数组占用更少的内存,因此如果您认为分配 10K 空数组是合理的,那么那里的 promise 也是如此-其他库也不会这样做。
2021-03-28 22:55:19
从技术上讲,这可以优化掉,但在实践中,这将是一个困难的优化(检测链中的函数相等性,并创建一个 CompositePromise 执行类似 TCO 的递归操作,检测没有进一步的处理程序添加到每个临时Promise并分解链, 很多注意事项),但库却公开了诸如.each:) 之类的方法
2021-04-02 22:55:19
@BenjaminGruenbaum:但.each根本不包括这种情况。它仅适用于对数组进行固定次数的迭代,不适用于可变深度的递归(其中基本情况是动态确定的,仅在完成时)
2021-04-02 22:55:19

免责声明:过早优化是不好的,找出性能差异的真正方法是对您的代码进行基准测试,您不必担心这一点(我只需要一次,并且我已经为至少 100 个项目使用了 promise) .

是这样吗?

是的,Promise必须“记住”他们所遵循的内容,如果您为 10000 个Promise执行此操作,您将拥有 10000 个长的Promise链,如果您不这样做,那么您将不会(例如,使用递归) - 对于任何排队流控制都是如此。

如果您必须跟踪 10000 个额外的事情(操作),那么您需要为它保留内存,这需要时间,如果这个数字是一百万,它可能不可行。这因图书馆而异。

有没有人考虑过这样建链的内存问题?

当然,这是一个大问题,并且是Promise.each在诸如 bluebird 之的库中使用类似then链接的用例。

我个人在我的代码中避免了这种风格,用于一次遍历 VM 中所有文件的快速应用程序 - 但在绝大多数情况下,这不是问题。

Promise库之间的内存消耗会有所不同吗?

是的,大大。例如,如果 bluebird 3.0 检测到 promise 操作已经是异步的(例如,如果它以 Promise.delay 开头),则不会分配额外的队列,并且只会同步执行(因为已经保留了异步保证)。

这意味着我在第一个问题的回答中所声称的并不总是正确的(但在常规用例中是正确的):) 除非提供内部支持,否则本机Promise将永远无法做到这一点。

再说一遍 - 这并不奇怪,因为 Promise 库彼此之间存在数量级的不同。

请注意,Promise.each这不包括递归。
2021-03-17 22:55:19
@BenjaminGruenbaum:我在那个 jsperf 中没有看到任何蓝鸟?
2021-03-22 22:55:19
将它包含在第一行中,(作为<script>html 准备中的一个)jsperf 把我的东西搞砸了:/ 奖励点对比 2.9 和 3.0(大不同):)
2021-03-27 22:55:19
谢谢@Bergi :)。顺便说一下,bluebird 执行此操作的速度似乎比原生 promise 快 100 倍。检查 jsperf(在jsperf.com/native-promises-vs-recursion-chaining - 删除 bluebird 脚本行并进行比较)
2021-03-28 22:55:19
Promise.each适用于预先知道迭代次数的情况。您不知道的一种情况是,例如,当您通过 REST API 获取对象时,页面不止一页,并且它不会告诉您有多少页。
2021-04-07 22:55:19

我刚刚提出了一个可能有助于解决问题的 hack:不要在 last 中进行递归then,而是在 last 中进行catch,因为catch它超出了解析链。使用您的示例,它会是这样的:

function foo() {
    function doo() {
        // always return a promise
        if (/* more to do */) {
            return doSomethingAsync().then(function(){
                        throw "next";
                    }).catch(function(err) {
                        if (err == "next") doo();
                    })
        } else {
            return Promise.resolve();
        }
    }
    return doo(); // returns a promise
}
这个问题并不寻求“解决方案”——而是引发关于内存使用的讨论。
2021-03-11 22:55:19
现在讨论已经得出结论了,为什么不进一步解决问题呢?我自己也遇到了这个问题,所以我通过搜索到达了这个页面,也许还有其他读者关于如何解决这个问题,为什么不让他们在这里找到答案?既然这是你的话题?这是社区,下次提问的时候看看条款,内容属于谁?
2021-04-03 22:55:19

为了补充令人敬畏的现有答案,我想说明表达式,这是这种异步递归的结果。为简单起见,我使用一个简单的函数来计算给定底数和指数的幂。递归和基本情况等效于 OP 示例中的那些:

const powerp = (base, exp) => exp === 0 
 ? Promise.resolve(1)
 : new Promise(res => setTimeout(res, 0, exp)).then(
   exp => power(base, exp - 1).then(x => x * base)
 );

powerp(2, 8); // Promise {...[[PromiseValue]]: 256}

在一些替换步骤的帮助下,递归部分可以被替换。请注意,可以在您的浏览器中评估此表达式:

// apply powerp with 2 and 8 and substitute the recursive case:

8 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 8)).then(
  res => 7 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 7)).then(
    res => 6 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 6)).then(
      res => 5 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 5)).then(
        res => 4 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 4)).then(
          res => 3 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 3)).then(
            res => 2 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 2)).then(
              res => 1 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 1)).then(
                res => Promise.resolve(1)
              ).then(x => x * 2)
            ).then(x => x * 2)
          ).then(x => x * 2)
        ).then(x => x * 2)
      ).then(x => x * 2)
    ).then(x => x * 2)
  ).then(x => x * 2)
).then(x => x * 2); // Promise {...[[PromiseValue]]: 256}

解释:

  1. new Promise(res => setTimeout(res, 0, 8))所述执行器被立即调用并执行非bllocking计算(模仿带setTimeout)。然后一个未解决Promise的返回。这等效doSomethingAsync()于 OP 的示例。
  2. 解析回调Promise通过与此相关联.then(...注意:此回调的主体已替换为powerp.
  3. 重复第 2) 点并then构建嵌套处理程序结构,直到达到递归的基本情况。基本情况返回一个已Promise解决的1
  4. 嵌套的then处理程序结构通过相应地调用关联的回调来“展开”。

为什么生成的结构是嵌套的而不是链接的?因为then处理程序中的递归情况会阻止它们在达到基本情况之前返回值。

没有堆栈如何工作?关联的回调形成了一个“链”,它连接了主事件循环的连续微任务。

这种Promise模式将生成一个递归链。因此,每个 resolve() 都会创建一个新的堆栈帧(带有自己的数据),使用一些内存。这意味着使用这种Promise模式的大量链式函数会产生堆栈溢出错误。

为了说明这一点,我将使用我编写的一个名为 Sequence的小型Promise 库它依靠递归来实现链式函数的顺序执行:

var funcA = function() { 
    setTimeout(function() {console.log("funcA")}, 2000);
};
var funcB = function() { 
    setTimeout(function() {console.log("funcB")}, 1000);
};
sequence().chain(funcA).chain(funcB).execute();

Sequence 适用于 0-500 函数范围内的中小型链。然而,在大约 600 个链时,序列开始退化并经常产生堆栈溢出错误。

底线是:目前,基于递归的 promise 库更适合中小型函数链,而基于 reduce 的 promise 实现适用于所有情况,包括更大的链。

这当然并不意味着基于递归的Promise是不好的。我们只需要在使用它们时考虑到它们的局限性。此外,您很少需要通过 promise 链接这么多调用 (>=500)。我通常发现自己将它们用于大量使用 ajax 的异步配置。但即使是最复杂的情​​况,我也没有看到超过 15 个链的情况。

在旁注...

这些统计数据是从我的另一个库 - provisnr执行的测试中检索到的,它捕获了给定时间间隔内实现的函数调用次数。