Node.js 事件循环详解

Node.js 的世界里,事件循环(Event Loop)是一个核心概念,它使得 Node.js 能够处理高并发和异步 I/O 操作,非常适合处理 I/O 密集型任务。本文将详细解释 Node.js 的事件循环机制,包括其背景、工作原理、六个阶段以及实际应用。

事件循环的背景

首先,我们需要理解为什么 Node.js 需要事件循环。Node.js 采用单线程模型,这意味着所有的 JavaScript 代码都在一个主线程上运行。这种设计虽然简单,但会导致一些问题,比如无法充分利用多核 CPU 的性能。为了解决这个问题,Node.js 引入了事件循环和异步 I/O 模型。

image 2024 11 27 11 50 23 126

在事件循环模型中,主线程将所有任务都放在循环队列中,然后由底层的 libuv 库从循环事件队列中取出任务分配给不同的线程去处理(在事件处理过程中,它会智能地将一些涉及到IO、网络通信等耗时比较长的操作,交由worker threads去执行)。当这些任务完成后,回调函数会被添加到事件队列中等待执行。主线程在空闲时会检查事件队列,如果有回调函数,就将其取出执行。这样,虽然 JavaScript 代码只在一个主线程上运行,但可以通过异步 I/O 和事件循环实现高并发。

上图中阻塞任务被底层线程池处理,然后处理的响应通过回调函数添加到事件队列中等待执行。

事件循环的工作原理

Node.js 的事件循环共有六个阶段,每个阶段都有特定的任务类型。这六个阶段按顺序一直循环执行,直到事件处理完成。这六个阶段分别是:

  • timers:此阶段执行 setTimeout()setInterval() 的回调。

  • pending callbacks:执行延迟到下一个循环迭代的 I/O 回调。

  • idle, prepare:仅供 node 内部使用。

  • poll:检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有回调,除了关闭回调,计时器回调和 setImmediate());在适当的情况下调用 node 在阶段尾部安排的回调。

  • check:在此阶段执行 setImmediate() 的回调。

  • close callbacks:执行关闭回调,如:socket.on('close', …)

当事件循环进入某个阶段时,它会按照特定的顺序执行该阶段的所有任务(每一个阶段都会维护一个事件队列。可以把每一个圈想象成一个事件队列)。例如,在 poll 阶段,它会依次执行所有的 I/O 回调,直到清空队列。然后,事件循环会进入下一个阶段,重复这个过程。

image 2024 11 27 11 32 19 533

上图中,几个色块的含义:

  • main:启动入口文件,运行主函数

  • event loop:检查是否要进入事件循环

    • 检查其他线程里是否还有待处理事项

    • 检查其他任务是否还在进行中(比如计时器、文件读取操作等任务是否完成)

    • 有以上情况,进入事件循环,运行其他任务

      • 事件循环的过程:沿着从 timersclose callbacks 这个流程,走一圈。回到 event loop 看是否结束,没结束再走一圈。

  • over:所有的事情都完毕,结束

图中灰色的圈跟操作系统有关系,不是本章解析重点。重点关注黄色、橙色的圈还有中间橘黄的方框。

我们把每一圈的事件循环叫做「一次循环」、又叫「一次轮询」、又叫「一次Tick」。

Node.js 中的宏任务和微任务

  • macro-task 大概包括:

    • setTimeout

    • setInterval

    • setImmediate

    • script(整体代码)

    • I/O 操作等

  • micro-task 大概包括:

    • process.nextTick(与普通微任务有区别, 在微任务队列执行之前执行)

    • new Promise().then(回调)等.

timers 阶段

timers 并非真正意义上的队列,他内部存放的是计时器。

每次到达这个队列,会检查计时器线程内的所有计时器,计时器线程内部多个计时器按照时间顺序排序。

检查过程:将每一个计时器按顺序分别计算一遍,计算该计时器开始计时的时间到当前时间是否满足计时器的间隔参数设定(比如 1000ms,计算计时器开始计时到现在是否有 1m)。当某个计时器检查通过,则执行其回调函数。

poll 阶段

如果 poll 中有回调函数需要执行,依次执行回调,直到清空队列。如果 poll 中没有回调函数需要执行,已经是空队列了。则会在这里等待,等待其他队列中出现回调,

如果其他队列中出现回调,则从 poll 向下到 over,结束该阶段,进入下一阶段。如果其他队列也都没有回调,则持续在 poll 队列等待,直到任何一个队列出现回调后再进行工作。

check 阶段

检查阶段(使用 setImmediate 的回调会直接进入这个队列)

真正的队列,里边扔的就是待执行的回调函数的集合。类似 [fn,fn] 这种形式的。

每次到达 check 这个队列后,立即按顺序执行回调函数即可【类似于 [fn1,fn2].forEach((fn)=>fn()) 的感觉】

所以说,setImmediate 不是一个计时器的概念。

setImmediatesetTimeout(0) 谁更快。

  • setImmediate 的回调是异步的,和 setTimeout 回调性质一致。

  • setImmediate 回调在 check 队列,setTimeout 回调在 timers 队列(概念意义,实际在计时器线程,只是 setTimeouttimers 队列做检查调用而已。详细看 timers 的工作原理)。

  • setImmediate 函数调用后,回调函数会立即 pushcheck 队列,并在下次 eventloop 时被执行。setTimeout 函数调用后,计时器线程增加一个定时器任务,下次 eventloop 时会在 timers 阶段里检查判断定时器任务是否到达时间,到了则执行回调函数。

  • 综上,setImmediate 的运算速度比 setTimeout(0) 的要快,因为 setTimeout 还需要开计时器线程,并增加计算的开销。

二者的效果差不多。但是执行顺序不定。

只比较这两个函数的情况下,二者的执行顺序最终结果取决于当下计算机的运行环境以及运行速度。

// ------------------setTimeout测试:-------------------
let i= 0;
console.time('setTimeout');
function test(){
    if(i < 1000) {
        setTimeout(test,0)
        i++
    } else {
        console.timeEnd('setTimeout');
    }
}
test();

// ------------------setImmediate测试:-------------------
let i= 0;
console.time('setImmediate');
function test(){
    if (i < 1000) {
        setImmediate(test)
        i++
    } else {
        console.timeEnd('setImmediate');
    }
}
test();

可见 setTimeout 远比 setImmediate 耗时多得多。这是因为 setTimeout 不仅有主代码执行的时间消耗。还有在 timers 队列里,对于计时器线程中各个定时任务的计算时间。

poll 队列的面试题(考察 timerspollcheck 的执行顺序)

// 说说下边代码的执行顺序,先打印哪个?
const fs = require('fs')
fs.readFile('./poll.js', () => {
    setTimeout(() => console.log('setTimeout'), 0)
    setImmediate(() => console.log('setImmediate'))
})

上边这种代码逻辑,不管执行多少次,肯定都是先执行 setImmediate

因为 fs 各个函数的回调是放在 poll 队列的。当程序 holdingpoll 队列后,出现回调立即执行。

回调内执行 setTimeoutsetImmediate 的函数后,check 队列立即增加了回调。

回调执行完毕,轮询检查其他队列有内容,程序结束 poll 队列的 holding 向下执行。

checkpoll 阶段的紧接着的下一个。所以在向下的过程中,先执行 check 阶段内的回调,也就是先打印 setImmediate

到下一轮循环,到达 timers 队列,检查 setTimeout 计时器符合条件,则定时器回调被执行。

nextTick 与 Promise

说完宏任务,接下来说下微任务

  • 二者都是「微队列」,执行异步微任务。

  • 二者不是事件循环的一部分,程序也不会开启额外的线程去处理相关任务。(理解:promise 里发网络请求,那是网络请求开的网络线程,跟 Promise 这个微任务没关系)微队列设立的目的就是让一些任务「马上」、「立即」优先执行。

  • 在每个事件循环的 当前阶段 中,Node.js 会先执行所有的 nextTick 回调,然后执行所有的 微任务,最后才会进入下一个阶段执行 宏任务(例如 setTimeoutsetInterval 等)。

process.nextTick(() => {})
Promise.resolve().then(() => {})

process.nextTick() 与 微任务的执行顺序:

  1. nextTick 在事件循环中的执行优先级

    process.nextTick() 是事件循环中的 最高优先级。它的回调会在当前执行栈(同步代码)执行完毕后立即执行,而不等到事件循环的其他阶段(如 I/O 阶段、setImmediate 等)开始。

  2. nextTick 和 微任务队列的关系

    nextTick 回调的优先级比 微任务队列 中的回调还要高。微任务队列包含了 Promise.then().catch() 回调等。

    nextTick 被调用时,当前的操作栈(执行栈)会先执行 nextTick 队列中的所有回调,然后再处理微任务队列中的回调。

  3. nextTick 在微任务之前执行

    process.nextTick() 会在每次事件循环的 任何微任务之前 执行。如果在某个阶段(如 Promise 回调)中又调用了 process.nextTick(),它会立即执行,不会让出控制权给其他微任务。(这里存在问题,不符合实际执行结果)

    这意味着,如果你在某个 nextTick 回调中再次调用 process.nextTick(),这个新添加的 nextTick 回调会被立即执行,直到 nextTick 队列清空。

如何参与事件循环?

事件循环中,每执行一个回调前,先按序清空一次 nextTickpromise

为什么有了 setImmediate 还要有 nextTickPromise

一开始设计的时候,setImmediate 充当了微队列的作用(虽然他不是)。设计者希望执行完 poll 后立即执行 setImmediate(当然现在也确实是这么表现的)。所以起的名字叫 Immediate,表示立即的意思。但是后来问题是,poll 里可能有 N 个任务连续执行,在执行期间想要执行 setImmediate 是不可能的。因为 poll 队列不停,流程不向下执行。

于是出现 nextTick,真正的微队列概念。但此时,immediate 的名字被占用了,所以名字叫 nextTick(下一瞬间)。事件循环期间,执行任何一个队列之前,都要检查他是否被清空。其次是 Promise

// 执行宏任务
setImmediate(() => {
    console.log('10-setImmediate');
});

// 执行 nextTick 队列中的所有回调后,然后再处理微任务队列中的回调
Promise.resolve().then(() => {
    console.log('5-promise 1 start');

    Promise.resolve().then(() => {
        console.log('8-promise in promise');
    })

    // 注意
    // process.nextTick() 会在每次事件循环的微任务执行中。如果在 Promise 回调中又调用了 process.nextTick(),它不会立即执行,会等其他微任务执行完成后,再执行
    process.nextTick(() => {
        console.log('9-nextTick in promise');
    })
    // 微任务执行
    console.log('6-promise 1 end')
})

// 当前执行栈中的所有同步代码执行完后,Node.js 会先执行 nextTick 队列中的回调
process.nextTick(() => {
    console.log('2-nextTick 1 start');

    Promise.resolve().then(() => {
        console.log('7-promise in nextTick');
    })

    // process.nextTick() 会在每次事件循环的 任何微任务之前执行。如果在 process.nextTick() 中又调用了 process.nextTick(),它会立即执行,不会让出控制权给其他微任务
    process.nextTick(() => {
        console.log('4-nextTick in nextTick');
    })
    console.log('3-nextTick 1 end');
})

// 同步代码执行
console.log('1-global');
// 1-global
// 2-nextTick 1 start
// 3-nextTick 1 end
// 4-nextTick in nextTick
// 5-promise 1 start
// 6-promise 1 end
// 7-promise in nextTick
// 8-nextTick in promise
// 9-promise in promise
// 10-setImmediate

nextTick()nextTick 回调和 Promise 回调中 执行顺序不同

基于上边的说法,有两个问题待思考和解决:

  • 每走一个异步宏任务队列就查一遍 nextTickpromise?还是每执行完 宏任务队列里的一个回调函数就查一遍呢?

    Node.js 的事件循环中,每执行完一个宏任务队列中的回调函数后,都会检查并执行 process.nextTick() 队列和微任务队列(例如 Promise.then() 回调)。因此,每执行完一个宏任务回调后,都会立即查一遍并执行 nextTick 和微任务队列,而不是等到所有宏任务执行完毕后才一次性检查。

    事件循环的执行流程:

    1. 同步代码执行:执行当前执行栈中的同步代码。

    2. 宏任务队列:执行一个宏任务队列中的回调(例如 setTimeoutsetImmediate 等)。

    3. 执行 process.nextTick() 队列中的回调:每执行完一个宏任务回调后,会立即执行 process.nextTick() 队列中的回调,并且会继续执行所有的 nextTick 回调直到队列为空。

    4. 执行微任务队列中的回调(如 Promise.resolve().then()):在执行完所有 nextTick 队列中的回调后,接着执行微任务队列中的所有回调,直到微任务队列为空。

    5. 进入下一个事件循环阶段:当宏任务、nextTick 和微任务队列都清空后,才会进入下一个事件循环阶段,继续执行下一个宏任务。

  • 如果在 pollholding 阶段,插入一个 nextTick 或者 Promise 的回调,会立即停止 poll 队列的 holding 去执行回调吗?

    poll 阶段,Node.js 会尝试执行 I/O 操作的回调,通常这些回调是通过系统底层的 I/O 操作(比如网络请求、文件操作等)触发的。当 poll 阶段 中没有立即要处理的 I/O 事件时,Node.js 会进入一个 "holding" 状态,这时会等待外部事件的到来。如果在这个阶段中,有新的回调(如 nextTick 或微任务)需要执行,事件循环会暂停 poll 阶段的持有状态,转而执行这些回调。

    1. 如果有 nextTick 回调,事件循环会优先执行 nextTick 队列中的回调,而不管当前是否在 poll 阶段的 holding 状态。这意味着,如果在 poll 阶段持有时插入 nextTick 回调,nextTick 回调会立刻被执行,然后事件循环会继续执行其余的任务。

    2. 如果有微任务回调(如 Promise),它们会在 poll 阶段的 holding 状态结束后、进入下一个阶段之前执行。不过,它们的优先级低于 nextTick 回调,但仍然会在宏任务(如 setTimeoutsetImmediate)之前执行。

setTimeout(() => {
    console.log('16-setTimeout 100');

    setTimeout(() => {
        console.log('21-setTimeout 100 - 0');
        process.nextTick(() => {
            console.log('22-nextTick in setTimeout 100 - 0');
        })
    }, 0)

    // 这次setImmediate比setTimeout(0)先执行的原因是:流程从timers向后走到check队列,已经有了setImmediate的回调,立即执行。
    setImmediate(() => {
        console.log('19-setImmediate in setTimeout 100');
        process.nextTick(() => {
            console.log('20-nextTick in setImmediate in setTimeout 100');
        })
    });

    process.nextTick(() => {
        console.log('17-nextTick in setTimeout100');
    })

    Promise.resolve().then(() => {
        console.log('18-promise in setTimeout100');
    })
}, 100)

const fs = require('fs')
fs.readFile('./1.poll.js', () => {
    console.log('14-poll 1');
    process.nextTick(() => {
        console.log('15-nextTick in poll');
    })
})

setTimeout(() => {
    console.log('7-setTimeout 0');

    process.nextTick(() => {
        console.log('8-nextTick in setTimeout');
    })
}, 0)

setTimeout(() => {
    console.log('9-setTimeout 1');

    Promise.resolve().then(() => {
        console.log('11-promise in setTimeout1');
    })

    process.nextTick(() => {
        console.log('10-nextTick in setTimeout1');
    })
}, 1)

setImmediate(() => {
    console.log('12-setImmediate');

    process.nextTick(() => {
        console.log('13-nextTick in setImmediate');
    })
});

process.nextTick(() => {
    console.log('2-nextTick 1');

    process.nextTick(() => {
        console.log('3-nextTick 2');
    })
})

console.log('1-global');

Promise.resolve().then(() => {
    console.log('4-promise 1');

    process.nextTick(() => {
        console.log('6-nextTick in promise');
    })

    // 注意,这里比 nextTick 先执行
    Promise.resolve().then(() => {
        console.log('5-promise in promise');
    })
})

/** 执行顺序如下
 1-global
 2-nextTick 1
 3-nextTick 2
 4-promise 1
 5-promise in promise
 6-nextTick in promise
 7-setTimeout 0
 8-nextTick in setTimeout
 9-setTimeout 1
 10-nextTick in setTimeout1
 11-promise in setTimeout1
 12-setImmediate
 13-nextTick in setImmediate
 14-poll 1
 15-nextTick in poll
 16-setTimeout 100
 17-nextTick in setTimeout100
 18-promise in setTimeout100
 19-setImmediate in setTimeout 100
 20-nextTick in setImmediate in setTimeout 100
 21-setTimeout 100 - 0
 22-nextTick in setTimeout 100 - 0
 */
  1. global :主线程同步任务,率先执行没毛病

  2. nextTick 1:执行异步宏任务之前,清空异步微任务,nextTick 优先级高,先行一步

  3. nextTick 2:执行完上边这句代码,又一个 nextTick 微任务,立即率先执行

  4. promise 1:执行异步宏任务之前,清空异步微任务,Promise 的优先级低,所以在 nextTick 完了以后立即执行

  5. promise in promise:注意,在 Promise 回调中调用 nextTick 不会立即执行。优先级比其它 Promise 回调低

  6. nextTick in promise:清空 Promise 队列的过程中,遇到 nextTick 微任务,需要等 Promise 任务执行完成后,再执行 netxTick 任务。

  7. setTimeout 0:解释第一个问题。没有上边的 nextTick 和 promise,只有 setTimeout 和 setImmediate 时他俩的执行顺序不一定。有了以后肯定是 0 先开始。可见,执行一个宏队列之前,就先按顺序检查并执行了 nextTick 和 promise 微队列。等微队列全部执行完毕,setTimeout(0) 的时机也成熟了,就被执行。

  8. nextTick in setTimeout:执行完上边这句代码,又一个 nextTick 微任务,立即率先执行 【这种回调函数里的微任务,我不能确定是紧随同步任务执行的;还是放到微任务队列,等下一个宏任务执行前再清空的他们。但是顺序看上去和立即执行他们一样。不过我比较倾向于是后者:先放到微任务队列等待,下一个宏任务执行前清空他们。】

  9. setTimeout 1:因为执行微任务耗费时间,导致此时 timers 里判断两个 0 和 1 的 setTimeout 计时器已经结束,所以两个 setTimeout 回调都已加入队列并被执行

  10. nextTick in setTimeout1:执行完上边这句代码,又一个 nextTick 微任务,立即率先执行 【可能是下一个宏任务前清空微任务】

  11. promise in setTimeout1:执行完上边这句代码,又一个 Promise 微任务,立即紧随执行 【可能是下一个宏任务前清空微任务】

  12. setImmediate:poll 队列回调时机未到,先行向下到 check 队列,清空队列,立即执行 setImmediate 回调

  13. nextTick in setImmediate:执行完上边这句代码,又一个 nextTick 微任务,立即率先执行 【可能是下一个宏任务前清空微任务】

  14. poll 1:poll 队列实际成熟,回调触发,同步任务执行。

  15. nextTick in poll :执行完上边这句代码,又一个 nextTick 微任务,立即率先执行 【可能是下一个宏任务前清空微任务】

  16. setTimeout 100:定时器任务到达时间,执行回调。并在回调里往微任务推入了 nextTick、Promise,往宏任务的 check 里推入了 setImmediate 的回调。并且也开启了计时器线程,往 timers 里增加了下一轮回调的可能。

  17. nextTick in setTimeout100:宏任务向下前,率先执行定时器回调内新增的微任务-nextTick 【这里就能确定了,是下一个宏任务前清空微任务的流程

  18. promise in setTimeout100:紧接着执行定时器回调内新增的微任务-Promise 【清空完 nextTick 清空 Promise 的顺序

  19. setImmediate in setTimeout 100:这次 setImmediate 比 setTimeout(0) 先执行的原因是:流程从 timers 向后走到 check 队列,已经有了 setImmediate 的回调,立即执行。

  20. nextTick in setImmediate in setTimeout 100:执行完上边这句代码,又一个 nextTick 微任务,下一个宏任务前率先清空微任务

  21. setTimeout 100 - 0:轮询又一次回到 timers,执行 100-0 的回调。

  22. nextTick in setTimeout 100 - 0:执行完上边这句代码,又一个 nextTick 微任务,下一个宏任务前率先清空微任务