在javascript中递归建立一个promise链 - 内存考虑因素

时间:2015-04-28 17:21:29

标签: javascript recursion promise

this answer中,递归建立了一个承诺链。

稍微简化,我们有:

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
}

据推测,这会产生一个调用堆栈一个承诺链 - 即" deep"和"宽"。

我预计内存峰值会大于执行递归或单独建立一个promise链。

  • 是这样吗?
  • 有没有人考虑过以这种方式建立连锁店的记忆问题?
  • promise libs之间的内存消耗会有所不同吗?

5 个答案:

答案 0 :(得分:42)

  

调用堆栈和承诺链 - 即“深”和“宽”。

实际上,没有。这里没有承诺链,因为我们从doSomeThingAsynchronous.then(doSomethingAsynchronous).then(doSomethingAsynchronous).…知道它(如果以这种方式编写的话,Promise.eachPromise.reduce可以执行处理程序。{/ p>

我们在这里面临的是一个解析链 1 - 当满足递归的基本情况时,最终会发生什么,就像{{1 }}。这只是“深度”,而不是“宽”,如果你想称之为。

  

我预计内存峰值会大于执行递归或单独建立一个promise链。

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

相反,一个由

之类的东西构建的承诺链
O(n)

会显示一个峰值,同时分配[…].reduce(function(prev, val) { // successive execution of fn for all vals in array return prev.then(() => fn(val)); }, Promise.resolve()) 个promise对象,然后逐个慢慢解析它们,垃圾收集前一个,直到只有已确定的最终保证为止。

n
  

是这样吗?

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

事实上,对于具有动态条件(没有固定步数)的asynchronous loops,这个递归构造是完全必要的,你无法真正避免它。在Haskell中,memory ^ resolve promise "then" (tail) | chain chain recursion | /| |\ | / | | \ | / | | \ | ___/ |___ ___| \___ ___________ | +----------------------------------------------> time monad始终使用它,因此仅针对此情况实现了对它的优化。它与tail call recursion非常相似,编译器通常会将其删除。

  

有没有人考虑过以这种方式建立连锁店的记忆问题?

是。例如,这是discussed at promises/aplus,但尚无结果。

许多promise库都支持迭代助手,以避免promise IO链的峰值,如Bluebird的theneach方法。

我自己的promise库 3,4 确实具有解析链,但不会引入内存或运行时开销。当一个承诺采用另一个承诺时(即使仍然悬而未决),它们变得难以区分,中间承诺不再在任何地方被引用。

  

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

是。虽然这种情况可以优化,但很少。具体来说,ES6规范确实要求Promises在每次map调用时检查值,因此无法折叠链。链中的承诺甚至可以用不同的值来解决(通过构造滥用getter的示例对象,而不是在现实生活中)。问题was raised on esdiscuss但仍未解决。

因此,如果您使用泄漏实现,但需要异步递归,那么最好切换回回调并使用deferred antipattern将最内层的promise结果传播到单个结果承诺。

[1]:没有官方术语
[2]:好吧,他们彼此解决了。但我们希望以相同的价值解决它们,我们期待那个 [3]:无证操场,通过aplus。阅读代码需要您自担风险:https://github.com/bergus/F-Promise
[4]:也在this pull request

中为Creed实现

答案 1 :(得分:15)

免责声明:过早优化很糟糕,了解性能差异的真正方法是对您的代码进行基准测试,您不应该担心这一点(我'我只需要一次,我已经使用了至少100个项目的承诺。)

  

是这样吗?

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

如果你必须跟踪10000个额外的东西(操作),那么你需要为它保留内存,这需要时间,如果这个数字是一百万,它可能是不可行的。这在图书馆之间有所不同。

  

有没有人考虑过以这种方式建立连锁店的记忆问题?

当然,这是一个很大的问题,也是在Promise.each能够链接的蓝鸟等库中使用类似then的用例的用例。

我个人已经在我的代码中避免使用这种风格来快速浏览VM中的所有文件 - 但在绝大多数情况下,这都不是问题。

  

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

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

这意味着我在第一个问题的答案中声称并不总是正确的(但在常规用例中也是如此):)除非提供内部支持,否则本机承诺将永远无法执行此操作。

然后再次 - 这并不奇怪,因为承诺库彼此之间存在数量级的差异。

答案 2 :(得分:5)

我刚出来一个可能有助于解决问题的黑客:不要在最后then进行递归,而是在最后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
}

答案 3 :(得分:1)

为了补充令人敬畏的现有答案,我想说明表达式,这是这种异步递归的结果。为简单起见,我使用一个简单的函数来计算给定基数和指数的幂。递归和基本情况等同于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。这相当于OP示例中的doSomethingAsync()
  2. 解析回调通过Promise与此.then(...相关联。注意:此回调的正文已替换为powerp的正文。
  3. 重复
  4. 点2)并构建嵌套的then处理程序结构,直到达到递归的基本情况。基本案例返回使用Promise解决的1
  5. 嵌套的then处理程序结构是"解开"通过相应地调用相关的回调。
  6. 为什么生成的结构是嵌套的而不是链接的?因为then处理程序中的递归情况会阻止它们返回值,直到达到基本情况。

    如果没有堆叠,这怎么办?相关的回调形成一个"链",它连接主事件循环的连续微任务。

答案 4 :(得分:0)

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

为了说明这一点,我将使用我写过的小promise library called Sequence。它依赖于递归来实现链式函数的顺序执行:

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

序列适用于小型/中型链,范围为0-500个函数。但是,在大约600个链上,序列开始降级并且经常产生堆栈溢出错误。

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

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

旁注......

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