什么是Node.js事件循环的tick?

113

我越来越深入地了解Node.js架构内部,一个经常出现的术语是“tick”,比如“事件循环的下一个tick”或函数nextTick()

但我没有看到对“tick”具体含义的确切定义。根据各种文章(例如这篇文章),我已经能够在脑海中构建一个概念,但我不确定它有多准确。

我可以得到一个关于Node.js事件循环tick的精确和详细描述吗?


由于它是“循环”,所以“下一次循环”就意味着一个“tick”是一个完整的循环,当没有事件被触发并且Node.js已经循环检查是否有任何事件被触发时,它就会结束,“nextTick”表示当前循环之后的下一个循环。 - Gntem
5个回答

183
请记住,虽然JavaScript是单线程的,但所有节点的I/O和对本机API的调用都是异步的(使用特定于平台的机制),或在单独的线程上运行。(这全部通过libuv处理。)
因此,当套接字上有可用数据或本机API函数已返回时,我们需要一种同步的方式来调用对刚刚发生的特定事件感兴趣的JavaScript函数。
从本地事件发生的线程调用JS函数并不安全,原因与常规多线程应用程序中遇到的相同——竞态条件、非原子内存访问等。
所以我们以线程安全的方式将事件放置在队列中。在过度简化的伪代码中,大致如下:
lock (queue) {
    queue.push(event);
}

然后,在JavaScript的主线程上(但在C端),我们做如下操作:

while (true) {
    // this is the beginning of a tick

    lock (queue) {
        var tickEvents = copy(queue); // copy the current queue items into thread-local memory
        queue.empty(); // ..and empty out the shared queue
    }

    for (var i = 0; i < tickEvents.length; i++) {
        InvokeJSFunction(tickEvents[i]);
    }

    // this the end of the tick
}

while (true)(实际上不存在于Node的源代码中;这仅是为了说明)代表了事件循环。内部的for调用JS函数处理队列中的每个事件。

这就是一个tick:同步调用与任何外部事件相关联的零个或多个回调函数。一旦队列清空并且最后一个函数返回,tick就结束了。我们回到开头(下一个tick)并检查从其他线程添加到队列中的事件,而我们的JavaScript正在运行

什么可以将事物添加到队列中呢?

  • process.nextTick:在当前执行栈的末尾添加一个任务,以便在下一个事件循环迭代中运行。它比setTimeoutsetInterval更快。
  • setTimeout/setInterval:用于在一定时间后执行函数或在一定时间间隔内重复执行函数。
  • I/O(来自fsnet等):与文件系统、网络等相关的输入输出操作。
  • crypto的处理器密集型函数,如加密流、pbkdf2和PRNG(实际上是一个示例...)
  • 任何使用libuv工作队列使同步C/C++库调用看起来异步的本地模块。

4
没错,你做得很好。复制队列并在副本上运行所有事件就是我特别想知道的。现在非常清楚明白了。谢谢。 - d512
这是著名的“异步迭代模式”算法吗? - Stef
1
@sanjeev,你所说的“常规工作”是什么意思?持续运行的JavaScript应用程序唯一要做的就是处理事件。 - josh3736
2
我想补充一下,在0.10.x版本中,setImmediate也会将一个函数加入到队列中。 - DanielKhan
3
“tick”指的是事件循环中的一个阶段吗? - faressoft
显示剩余16条评论

14

对于刚接触JavaScript的人来说,这是一个更简单的答案:

首先要理解的是JavaScript是“单线程环境”。这意味着JavaScript会在单个线程上从“事件循环”一次执行你的代码块。下面是Kyle Simpson的书《ydkJS》中提取出的一个基本的事件循环实现,以及后面的解释:

// `eventLoop` is an array that acts as a queue (first-in, first-out)
var eventLoop = [ ];
var event;

// keep going "forever"
while (true) {
    // perform a "tick"
    if (eventLoop.length > 0) {
        // get the next event in the queue
        event = eventLoop.shift();

        // now, execute the next event
        try {
            event();
        }
        catch (err) {
            reportError(err);
        }
    }
}

第一个 while 循环模拟事件循环。 一个 tick 是从“事件循环队列”中出队一个事件并执行该事件。

请参见 “Josh3796” 的回复,以了解关于事件出队和执行的更详细说明。

此外,我建议那些有兴趣深入了解 JavaScript 的人阅读 Kyle Simpson 的书籍。这本书完全免费且开源,可以在以下链接找到:https://github.com/getify/You-Dont-Know-JS

我引用的具体章节可以在此处找到:https://github.com/getify/You-Dont-Know-JS/blob/2nd-ed/sync-async/ch1.md


5
这有点让我感到困惑,因为这个回答让我觉得只有一个队列,而只有在那个队列中取出一个事件被视为一个“tick”。然而,来自互联网的其他一些来源表明,“tick”意味着处理单个阶段队列中的所有事件。 - Ayon Nahiyan

6
                                                              
                          ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐             
                              THE EVENT LOOP                  
                          └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘             
                                                              
                     ┌───────────────────────────────┐        
                     │             poll              │        
                  ┌─▶│                               │──┐     
                  │  └───────────────┬───────────────┘  │     
                  │                  │                 tick   
                  │  ┌───────────────▼───────────────┐  │     
                  │  │             check             │  │     
                  │  │                               │◀─┘     
                  │  └───────────────┬───────────────┘        
                  │                  │                        
                  │  ┌───────────────▼───────────────┐        
                  │  │        close callbacks        │        
                  │  │                               │        
                loop └───────────────┬───────────────┘        
                  │                  │                        
                  │  ┌───────────────▼───────────────┐        
                  │  │            timers             │        
                  │  │                               │        
                  │  └───────────────┬───────────────┘        
                  │                  │                        
                  │  ┌───────────────▼───────────────┐        
                  │  │       pending callbacks       │        
                  │  │                               │        
                  │  └───────────────┬───────────────┘        
                  │                  │                        
                  │  ┌───────────────▼───────────────┐        
                  │  │         idle, prepare         │        
                  └──│                               │        
                     └───────────────────────────────┘        

在Node.js中,事件循环是一种执行模型,其中脚本的各个方面按照定义的时间表循环执行。

事件循环由许多阶段(如上图所示)组成。每个阶段都包含(1)调用栈 和 (2) 回调队列。调用栈是代码执行的地方(按照后进先出LIFO原则),而回调队列是代码预定执行的地方(按照先进先出FIFO原则),稍后会安排进入调用栈以供执行。

此回调队列可以分成两个队列:一个微任务队列和一个宏任务队列。一旦预定了微任务,它将立即在当前阶段中正在运行的脚本之后执行,而一旦预定了宏任务,则它将在该阶段的下一个循环中执行(在该阶段中的任何微任务之后执行)。

事件循环通过所有阶段的循环运行,直到没有更多工作需要完成为止。每个循环(通过所有阶段)可以称为一个“循环(loop)”,而在给定队列中完全调用脚本可以称为一个“tick(滴答)”。

这个“tick”通常会在一个阶段之间发生,但是当微任务和宏任务队列都不为空时,也可能在阶段内发生,例如当Promise在运行脚本中得到解决时,它的“then(然后)”方法会向微任务队列添加项目。

当您编写代码(比如一个名为“mycode.js”的文件),并通过node mycode.js调用它时,此代码将按照其编写方式使用事件循环执行。

下面是一个示例脚本:

process.nextTick(function() {
  console.log('next tick - 1 [scheduled from poll]');
});

console.log('poll phase - 1');

setImmediate(function() {
  console.log('check phase - 1');

  process.nextTick(function() {
    console.log('next tick - 2 [scheduled from check]');
  });

  Promise.resolve()
    .then(function() {
      console.log(`check phase - 1.1 [microTask]`);
    })
    .then(function() {
      console.log(`check phase - 1.2 [microTask]`);
    })
    .then(function() {
      setTimeout(function() {
        console.log('timers phase [scheduled from Promise in check]');
      });
      process.nextTick(function() {
        console.log('next tick - 3 [scheduled from Promise in check]');
      });
    });

  console.log('check phase - 2');
});

setTimeout(function() {
  console.log('timers phase - 1');

  setImmediate(function() {
    console.log('check phase [scheduled from timers]');
  });

  Promise.resolve()
    .then(function() {
      console.log('timers phase - 1.1 [microTask]');
    })
    .then(function() {
      console.log('timers phase - 1.2 [microTask]');
    })
    .then(function() {
      setTimeout(function() {
        console.log('timers phase [scheduled from Promise in timers]');
      });
    });
});

process.nextTick(function() {
  console.log('next tick - 4 [scheduled from poll]');
});

console.log('poll phase - 2');

将以下内容复制(或输入)到.js文件中,并使用node调用它。
您应该得到以下输出:
poll phase - 1
poll phase - 2
next tick - 1 [scheduled from poll]
next tick - 4 [scheduled from poll]
check phase - 1
check phase - 2
next tick - 2 [scheduled from check]
check phase - 1.1 [microTask]
check phase - 1.2 [microTask]
next tick - 3 [scheduled from Promise in check]
timers phase - 1
timers phase - 1.1 [microTask]
timers phase - 1.2 [microTask]
timers phase [scheduled from Promise in check]
check phase [scheduled from timers]
timers phase [scheduled from Promise in timers]

注意:使用Node.js版本16.15.0

在解释之前,请记住以下几点规则:

  • setImmediate 将脚本安排在事件循环的下一个check 阶段(宏任务队列中)中运行
  • setTimeout将脚本安排在事件循环的下一个timers阶段(宏任务队列中)中运行
  • process.nextTick将脚本安排在下一次tick之前运行,即(1)在当前脚本运行后但在微任务队列运行之前运行[如果该队列不为空],或者(2)在事件循环从一个阶段遍历到下一个阶段之前运行[如果微任务队列为空]
  • Promise.prototype.then将脚本安排在当前的微任务队列中运行,即在当前脚本之后,但在下一阶段计划的脚本之前运行
  • 微任务队列在宏任务队列之前运行

这是一个事件时间轴的解释:

A. 来自POLL PHASE (LOOP 1)

  1. console.log('poll phase - 1')console.log('poll phase - 2')是同步代码,将立即在当前阶段运行
  2. console.log('next tick - 1 [scheduled from poll]')console.log('next tick - 4 [scheduled from poll]')process.nextTick调度,以在下一次tick之前运行,即在check 阶段之前(因为微任务队列中没有任何内容)。
  3. setImmediate的回调函数(第7行)被安排在check 阶段运行
  4. setTimeout的回调函数(第33行)被安排在timers阶段运行

B. 在CHECK PHASE之前 (LOOP 1) 5. 执行console.log('next tick - 1 [scheduled from poll]')console.log('next tick - 4 [scheduled from poll]')

C. 来自 CHECK 阶段(第一次循环) 6. console.log('check phase - 1')console.log('check phase - 2') [来自之前由 setImmediate (第7行) 调度的回调] 立即执行,因为它们是同步的。 7. console.log('next tick - 2 [scheduled from check]')process.nextTick 调度。 8. 第15、18和21行的回调被安排在 microTask 队列中运行。 9. console.log('next tick - 2 [scheduled from check]') 被执行 (因为这是在下一个 tick 之前,即在当前脚本之后但在 microTask 队列之前)。 10. 第15和18行的回调被执行 (因为 microTask 在运行脚本后立即执行)。 11. 第21行的回调被执行并调度 (1) console.log('timers phase [scheduled from Promise in check]') 在下一个 timers 阶段运行,以及 (2) console.log('next tick - 3 [scheduled from Promise in check]') 在下一个 tick 之前运行,即在从当前阶段 (check) 到下一个活动阶段 (timers) 的遍历之前运行。
D. 在 TIMERS 阶段之前 (第一次循环) 12. console.log('next tick - 3 [scheduled from Promise in check]') 被执行。
E. 来自 TIMERS 阶段(第一次循环) 13. console.log('timers phase - 1') 被执行。 14. setImmediate (第36行) 调度其回调在下一个 check 阶段运行。 15. Promise (第40行) 调度三个回调在 microTask 队列中运行。 16. console.log('timers phase - 1.1 [microTask]')console.log('timers phase - 1.2 [microTask]') 如第15点所安排的那样被执行。 17. console.log('timers phase [scheduled from Promise in check]') 被执行。它先前由 setTimeout (第22行) 调度。它现在运行 (在上面的代码16之后) 因为它是一个 macroTask (所以它在 microTask 队列运行后运行)。
E. 来自下一个 CHECK 阶段(第二次循环) 18. console.log('check phase [scheduled from timers]') 被执行。它先前是由 setImmediate (第36行) 在第一次循环中的 timers 阶段中调度的。
F. 从下一个定时器阶段开始(循环2) 19. 执行console.log('timers phase [scheduled from Promise in timers]'),它先前是由setTimeout(第1次循环中的timers阶段)在timers阶段中安排的(第48行)。 参考资料

我有不同的执行结果:轮询阶段-1 轮询阶段-2 下一个时钟周期-1 [从轮询中调度] 下一个时钟周期-4 [从轮询中调度] 定时器阶段-1 定时器阶段-1.1 [微任务] 定时器阶段-1.2 [微任务] 检查阶段-1 检查阶段-2 下一个时钟周期-2 [从检查中调度] 检查阶段-1.1 [微任务] 检查阶段-1.2 [微任务] 下一个时钟周期-3 [从检查中的Promise中调度] 检查阶段[从定时器中调度] 定时器阶段[从定时器中的Promise中调度] 定时器阶段[从检查中的Promise中调度] - towry
@towry 抱歉回复晚了。你使用的是哪个版本的Node.js? - ObiHill
1
谢谢你详细的回答;除了最近版本的Node有所变化(我使用v21.2和你使用v16.15得到了相同的结果),我认为这里有一个小错误,就是你标记为“poll”阶段的操作实际上是在事件循环开始之前发生的 - 很难找到好的文档,但我目前的印象是Node同步执行脚本,将任何异步工作推到各种队列中,然后由事件循环处理(例如:https://blog.logrocket.com/complete-guide-node-js-event-loop)。 - undefined

1

事件循环的简单且短暂的表述是:

这是Node内部机制使用的方法,在处理队列上的一组请求后,将启动Tick,表示任务已完成。


你能提供一些你回答的来源吗? - Kick Buttowski

0
“Tick”指的是事件循环的完整遍历。令人困惑的是,setImmediate()需要一个tick才能运行,而process.nextTick()更加即时,因此这两个函数应该交换名称。

“完整地通过事件循环”,难道不应该称为循环吗? - towry
我认为“tick”是指循环内从一个阶段到另一个阶段的移动,而不是整个循环的迭代。 - Nunchuk

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接