等待 process.nextTick 循环结束

Awaiting the end of a process.nextTick loop

上下文

我正在编写一个涉及一些繁重计算的 nodejs 应用程序。底层算法涉及大量迭代和递归。我的代码的快速摘要可能如下所示:

// routes.js - express.js routes

router.get('/api/calculate', (req, res) => {

  const calculator = new Calculator();
  calculator.heavyCalculation();
  console.log('About to send response');
  res.send(calculator.records);

});
// Calculator.js

class Calculator {

  constructor() {
    this.records = [];
  }

  heavyCalculation() {
    console.log('About to perform a heavy calculation');
    const newRecord = this.anotherCalculationMethod();
    this.records.push(newRecord);

    if (records.length < max) {
      this.heavyCalculation();
    }
  };

  anotherCalculationMethod() {
    // more logic and deeper function calls 
    // to methods on this and other class instances
  };

};

在大多数情况下,我希望 max 值永远不会超过 9,000 或 10,000,在某些情况下,它可能会少得多。正如我所说,这是一个计算量很大的应用程序,我正试图找到正确的方法来执行计算,而不会使应用程序正在运行的机器崩溃。运行

第一期 - Maximum call stack size exceeded error

尽管您在上面看到的不是无限循环,但它很快就会遇到 Maximum call stack size exceeded 错误(原因很明显)。

Here is a codesandbox demonstrating that issue。单击按钮以调用 api 函数,其中 运行 是函数。如果 max 足够低,我们会得到预期的行为 - heavyCalculation 运行 递归地填充 Calculator.records,然后当达到限制时,完整的结果会被发回到前端。日志如下所示:

About to perform a heavy calculation
About to perform a heavy calculation
About to perform a heavy calculation
// repeats max times
About to send response

但是如果您将 max 变量设置得足够高,您将超出堆栈限制,应用程序会崩溃。

正在尝试用 process.nextTicksetImmediate

解决问题

我阅读了文章 The Call Stack is Not an Infinite Resource — How to Avoid a Stack Overflow in JavaScript,并选择尝试其中的一些方法。我喜欢使用 process.nextTick 的想法,因为它有助于离散化 heavyCalculation 的递归循环中的每个步骤。调整方法,我试过这个:

  heavyCalculation() {
    // ...

    if (records.length < max) {
      process.nexTick(() => {
        this.heavyCalculation();
      });
    }
  };

这解决了超出最大调用堆栈大小的问题,但产生了另一个问题。而不是递归 运行 heavyCalculation,然后等待递归达到极限,它 运行 函数一次,然后将响应发送到前端 records 包含只有一个条目,then 继续递归。在 setImmediate 中包装调用时也会发生同样的情况,即 setImmediate(this.heavyCalculation())。日志如下所示:

About to perform a heavy calculation
About to send response
About to perform a heavy calculation
About to perform a heavy calculation
About to perform a heavy calculation
// repeats max - 1 times

Codesandbox demonstrating the issue

和以前一样,单击按钮进行 api 调用以触发函数,您可以在 codesandbox nodejs 终端中看到发生了什么。

如何正确管理事件循环,使我的递归函数不会造成堆栈溢出,而是按正确的顺序触发?我可以利用 process.nextTick 吗?如何为递归函数的每次迭代分离调用堆栈,但在返回响应之前仍然等待循环结束?

加分题:

我发布的显然是我的应用程序的简化版本。实际上,anotherCalculationMethod 本身就是一个复杂的函数,具有自己的深层调用堆栈。它遍历一系列数组,这些数组也可能会变得非常大(大约数千)。假设它遍历 Calculator.records,并且 Calculator.records 增长到数千个项目。在 heavyCalculation 的单次迭代中,我如何应用可能解决主要问题的相同流程逻辑来避免在 anotherCalculationMethod?

这样的子例程中发生相同的问题

我知道这只是伪代码,但据我所知,根本没有理由进行递归。我是递归的忠实拥护者,即使在 JS 中也是如此。但正如您所见,它在语言方面确实存在局限性。

此外,递归与函数式编程密切相关,因为它坚持不可变数据并且没有副作用。这里你的递归函数没有返回任何东西——一个主要的警告信号。而且它唯一的工作似乎是调用另一个方法并使用结果添加到 this.records.

对我来说,while 循环在这里更有意义。

heavyCalculation() {
  while (this.records.length < max) {
     console.log('About to perform a heavy calculation');
     const newRecord = this.anotherCalculationMethod();
     this.records.push(newRecord);
  }
};

更新

一条评论询问如何使用 process.nextTick 执行此操作。正如 eol 指出的那样,处理这个问题的方法是使用 Promises 或 async/await 的语法糖。一旦混合了异步处理,它涉及的所有代码也将需要是异步的。这就是野兽的本性,它至少在 the article mentioned.

中有所提及

而且您的队列似乎仅由最大大小值控制,在这种情况下,eol 建议的方法可以正常工作。

但在其他情况下,当前迭代可能会向您的队列中添加工作,而您想继续直到没有更多工作要做。想想抓取一个网站。您想要跟踪所有相关链接,以及来自这些页面的所有链接,以及来自这些页面的所有链接,尽可能深入,但无论有多少链接,每个页面只加载一次。这可以使用非常相似的过程,只需管理要访问的队列和一组已访问的队列。这是一个示例,其中包含 fetch 的虚拟替换和一个非常简单的模型,其中所有链接都被跟踪。 (我也跳过 HTML 抓取;我们不要去那里!)

const spider = async (id) => {
  const queue = [id], 
        processed = []
  const items = {}
  while (queue .length > 0) {
    const id = queue .pop ()
    const item = await fetch (`http://dummy.url/item/${id}`)
    items [id] = item
    processed .push (id)
    item .links .forEach (id => processed .includes (id) || queue .push (id))
  }
  return items
}

spider (1)
  .then (console .log)
  .catch (console .warn)
.as-console-wrapper {max-height: 100% !important; top: 0}
<script>/* Dummy version of `fetch` */ const fetch = ((data) => (url, t = url .slice (url.lastIndexOf('/') + 1), item = data .find (({id}) => id == t)) => new Promise ((res, rej) => item ? res (item) :rej (`Item ${t} not found`)))([
  /* using this data */
  {id: 1, name: 'foo', links: [2, 3]}, 
  {id: 2, name: 'bar', links: [4]}, 
  {id: 3, name: 'baz', links: []}, 
  {id: 4, name: 'qux', links: [5]}, 
  {id: 5, name: 'corge', links: [6]}, 
  {id: 6, name: 'grault', links: [1, 2]}, 
  {id: 8, name: 'waldo', links: [3]}
])</script>

这会获取所有可以找到的以 1id 开头的递归链接的项目。 (如果我们从 8id 开始,我们只会得到 83,但是从 1 我们得到所有硬编码数据,除了8.)

需要注意的重点是对 fetch 的调用是异步的,因此 spider 本身也是异步的,但我们可以在内部以同步方式工作,使用 await

回答您关于“如何等待下一次报价”的问题: 您可以将回调函数传递给计算函数并将其包装在一个承诺中,然后您可以等待它,例如:

heavyCalculation(cb) {
        console.log("About to perform a heavy calculation");
        const newRecord = this.anotherCalculationMethod();
        this.records.push(newRecord);

        if (this.records.length < max) {            
            process.nextTick(() => {
               this.heavyCalculation(done);
            });
        } else {
            cb(this.records);
        }
    }

在您的请求处理程序中,您可以这样调用它:

const result = await new Promise((resolve, reject) => {
    calculator.heavyCalculation((result) => {
        resolve(result);
    });
});
console.log("about to send calculator.records, which has length:", result);
res.json(result);

但是: 您必须记住,虽然使用 process.nextTick 解决了 Whosebug 问题,但它是有代价的。 IE。您阻止事件循环进入下一阶段,这意味着,例如,该节点将无法为您的服务器提供其他传入请求。这些请求以及实际上所有其他准备好被推送到调用堆栈的回调必须等到您的计算完成。

你可以,例如使用 setTimeout 作为替代方案来解决这个问题,但是 如果你必须依赖如此繁重的计算,你最好使用 worker-threads 并将这些计算移到那里。