等待 process.nextTick 循环结束

Posted

技术标签:

【中文标题】等待 process.nextTick 循环结束【英文标题】:Awaiting the end of a process.nextTick loop 【发布时间】:2021-11-20 06:02:22 【问题描述】:

上下文

我正在编写一个涉及一些繁重计算的 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 只包含一个条目,然后继续递归。将调用包装在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 调用,该调用会触发该函数,您可以在 codeandbox nodejs 终端中看到发生了什么。

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

额外问题:

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

【问题讨论】:

【参考方案1】:

我知道这只是虚拟代码,但据我所见,递归根本没有理由。我是递归的忠实粉丝,即使在 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>

这会获取所有可以找到递归链接的项目,从id1 开始。 (如果我们从id8 开始,我们只会得到83,但使用1 我们会得到除8 之外的所有硬编码数据。)

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

【讨论】:

这是一个公平的观点。虽然我的代码比虚拟代码复杂一点,但重构使用这种模式相当容易。那确实解决了这个问题。它还解除了对这一步的阻碍,让我发现在我的应用程序的许多其他地方都有同样的问题 - 所以我有很多工作要做,就在 nodejs 应用程序中编写迭代的、计算繁重的代码。您的答案提供了在循环中使用 process.nextTick 的替代方法,但它并没有真正回答如何在执行下一步之前使用 process.nexTick 等待循环结束。但我很欣赏你的洞察力! @SethLutske:添加了更新以显示其他技术。它可能与您的具体情况无关,但可以显示如何处理更复杂的工作队列。 “一旦你混合了异步处理,它接触的所有代码也需要是异步的”——谢谢你提到这一点。这是我一直在考虑很多的事情,因为真正的代码是一个大型算法,具有很深的调用堆栈,其中一些是异步的。我需要想出一个策略来在需要时等待异步部分,但允许同步部分不受阻碍地运行,最好不必在堆栈async 中创建 every 函数。几周以来,我一直在脑海中构思那个问题,很快就会发布。感谢您的宝贵时间! @SethLutske 请注意,我所说的可能过于强烈。如果A 调用BCD。而C 调用E,如果E 是异步的,那么C 也必须是异步的,因此A 也必须是异步的。但是BD 仍然可以是同步的。或许更好的说法是,“任何直接或间接调用异步代码的东西都必须自己异步。” 我想我明白你的意思,但它是 javascript 中一个有趣的话题,即当某些逻辑分支是异步的而其他逻辑分支不是时,如何在算法中组织函数堆栈。例如,如果A 调用BD,并且BD 都是同步的,但是运行循环最终可能触发异步C 运行,那么最好的写法是什么?我将在该主题上发布另一个问题,因为它是一个不同的主题,我会在这里发表评论,以便您有兴趣可以看看。不过可能需要一段时间。感谢您的洞察力【参考方案2】:

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

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 解决了*** 问题,但它是有代价的。 IE。您阻止事件循环进入下一阶段,这意味着,例如,该节点将无法为您的服务器提供其他传入请求。这些请求以及实际上所有其他准备好推送到调用堆栈的回调必须等到您的计算完成。

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

【讨论】:

谢谢你。我知道我需要某种else 块来触发算法的下一部分,我只是不确定如何组织它。将整个循环包装在一个 Promise 中效果很好。我的代码已经涉及很多异步,所以在其中添加另一个异步进程就可以了。至于阻塞 api 调用的问题,我的程序变得越来越复杂,以至于我需要开始考虑更大规模的架构,以及如何管理它,无论是工作线程、事件发射器还是第二层服务器,或它们的一些组合。感谢您的宝贵时间 很高兴我能帮上忙 :)

以上是关于等待 process.nextTick 循环结束的主要内容,如果未能解决你的问题,请参考以下文章

译Node.js 事件循环, 定时器, 和 process.nextTick()

js的事件循环机制:同步与异步任务(setTimeout,setInterval)宏任务,微任务(Promise,process.nextTick)

node中定时器, process.nextTick(), setImediate()的区别与联系

译Node.js的eventloop,timers和process.nextTick()

Node.js中有个Fibonacci的异步例子,疑问process.nextTick()作用。

Node.js 的事件循环机制