实例讲解Node事件循环

605 阅读7分钟

年底了冲一波绩效。走过路过的朋友们点点高贵的小手手,帮我的mini-react冲上400。

Node.js架构

node_01.jpg

  • 最上层是我们自己写的javascript代码
  • 中间的NodeJS层是Node提供给我们的一些内置模块,比如fs、path等。
  • v8引擎。开源的js引擎。能够在浏览器之外执行javascript代码。因此这里是真正执行我们自己写的javascript代码的地方
  • libuv库。C++开源项目,允许node.js访问操作系统、底层文件系统。允许访问网络,还可以处理并发性

事件循环是libuv的一部分,它是C语言实现的,负责协调执行Node.js中的同步和异步代码。

注意,后面所讲的timer queue、I/O queue、check queue、close queue都是libuv的一部分,而microtask queue并不是libuv的一部分

事件循环简介

事件循环详细介绍可以看Node.js官方文档:The Node.js Event Loop, Timers, and process.nextTick()

在Nodejs中,事件循环分为6个阶段。每个阶段都有一个任务队列。当Node启动时,会创建一个事件循环线程,并依次按照下图所示顺序进入每个阶段,执行每个阶段的回调。

even_01.jpg

  • timers。执行setTimeout和setInterval的回调。
  • pending callbacks。执行系统操作相关或者线程池相关的回调,例如tcp udp。这里面执行的是伪代码中的pendingOSTasks以及pendingOperations
  • idle,prepare。只在内部使用。
  • poll。检索新的I/O事件;执行与I/O相关的回调(除了关闭回调、计时器调度的回调和setImmediate()之外,几乎所有回调都执行);node将在适当的时候在这个阶段阻塞。
  • check。执行setImmediate的回调
  • close callbacks。执行close事件的回调, 比如socket.on('close', ...).

事件循环流程

Nodejs事件循环流程图:

node_66.jpg

在事件循环的6个阶段中,我们只关心4个阶段的队列:timer queue、IO queue、check queue、close queue,这4个队列的回调都是宏任务。同时还有2个微任务队列:Promise queue和nextTick queue。

如上图的流程所示:

  • 事件循环依次进入并执行timer queue、IO queue、check queue、close queue。
  • 事件循环进入下一阶段前,都需要先清空微任务队列。
  • 每个宏任务执行完,都需要检查微任务队列是否有任务,如果有微任务,则先清空微任务,再执行下一个宏任务。
  • 在node 11以前,每个阶段都需要先执行完宏任务,切换到下一阶段前,才会清空微任务队列。
  • 微任务队列中,nextTick的优先级比Promise高。

实验

Microtask Queue

微任务队列执行顺序是:

  • 优先执行process.nextTick的回调,只有清空了process.nextTick队列,才会将控制权交给Promise队列
  • 然后执行Promise队列,如果在Promise回调中调度了process.nextTick,那也只能等到Promsie队列清空,才会将控制权交给process.nextTick队列。
process.nextTick(() => {
    console.log('next tick1')
})

process.nextTick(() => {
    console.log('next tick2')
    process.nextTick(() => {
        console.log('next tick3')
    })
})

process.nextTick(() => {
    console.log('next tick4')
})

Promise.resolve().then(() => {
    console.log('promise 1')
})

Promise.resolve().then(() => {
    console.log('promise 2')
    process.nextTick(() => {
        console.log('next tick5')
    })
})

Promise.resolve().then(() => {
    console.log('promise 3')
})

输出:

next tick1
next tick2
next tick4
next tick3
promise 1
promise 2
promise 3
next tick5

在这个案例中,虽然promise2又调用了process.nextTick,但控制权仍在promise队列中,此时nodejs依旧是先执行完promise 3,然后检查nextTick队列发现还有一个next tick5的回调需要执行。

微任务队列的执行,首先是检查process.nextTick队列,只有process.nextTick队列全部清空后。再检查promise队列,只有promise队列全部清空后,再检查process.nextTick队列。只有当process.nextTick队列和promise队列都完全清空后,控制权才交给事件循环。

Timer Queue

我们知道事件循环在进入下一阶段前,会先清空微任务队列。那如果在事件循环的某个阶段中,有个任务又调度了微任务,那微任务又是咋样执行的?

实际上,在Node 11及以上,如果在宏任务中又调度了微任务,那么宏任务执行完后,会先清空微任务队列,再回来接着继续执行宏任务。

而在Node11以前,如果在宏任务中又调度了微任务,那么只有等到所有的宏任务都执行完后,才会清空微任务队列。

下面的例子,在宏任务中又调度了微任务。

setTimeout(() => {
    console.log('timer1')
}, 0);

setTimeout(() => {
    console.log('timer2')
    Promise.resolve().then(res => {
        console.log('promise1')
    })
    Promise.resolve().then(res => {
        console.log('promise2')
    })
}, 0);


setTimeout(() => {
    console.log('timer3')
}, 0);

在node11及以上版本中,输出为

image.png

在node11以前的版本中,输出为

image.png

可以看出,在node 11以前,node对宏任务和微任务的执行是不同的

I/O Queue

const fs = require('fs')

setTimeout(() => {
    console.log('timer 1')
}, 0);

fs.readFile('test.js', () => {
    console.log('read file')
})

如果多执行几次,会发现控制台输出的顺序不确定,如下:

node_69.jpg

实际上,当我们设置setTimeout第二个参数为0时,理论上表示的是0毫秒的延迟,也就是需要立即执行。

但是在chromium的底层实现中,为了避免调度过于频繁,setTimeout(cb, 0),会有1毫秒的延迟。具体源码可以看这里chromium.googlesource.com/chromium/bl…

image.png

image.png

double intervalMilliseconds = std::max(oneMillisecond, interval * oneMillisecond);

可以看出,如果我们传递的interval为0,那么intervalMilliseconds的值为oneMillisecond,也就是1毫秒。因此setTimeout(cb, 0)也会存在1毫秒的延迟。

如果事件循环在0.05毫秒时进入计时器,此时setTimeout的回调还没到时执行,timer queue为空。因此事件循环依次进入I/O queue。执行fs.readFile的回调。如果CPU繁忙,在1.01毫秒时事件循环才开始,并进入timer queue,此时timer queue有一个任务需要执行,打印timer 1。然后依次进入I/O queue。

因为CPU繁忙程度的不确定性,以及setTimeout(cb, 0)有1毫秒的延迟,因此我们没法保证在第一轮事件循环开始时,能否立即执行setTimeout(cb, 0)的回调。

IO Polling

Node事件循环中,会依次经过Timer Queue、IO Queue、Check Queue、close queue。因此,理论上来说,IO Queue会先执行。以下面的例子为例,输出顺序是怎样的?

const fs = require('fs')

fs.readFile('test.js', () => {
    console.log('read file')
})

process.nextTick(() => {
    console.log('next tick')
})
Promise.resolve().then(() => {
    console.log('promise')
})
setTimeout(() => {
    console.log('timer 1')
}, 0);
setImmediate(() => {
    console.log('setImmediate')
})

const startTime = Date.now();

while (Date.now() - startTime < 2) { }

实际上,上面代码的打印为:

next tick
promise
timer 1
setImmediate
read file

read file在setImmediate之后执行,而不是在setImmediate之前执行。

这是因为当我们调用fs.readFile(pathname, cb)时,cb并不是立即就进入IO queue队列中的。因为IO操作是比较耗时的,因此事件循环必须轮询以检查IO操作是否完成(IO Polling阶段),只有IO操作完成后,fs.readFile(pathname, cb)的回调cb才会加入IO Queue等待事件循环执行。当事件循环第一次进入IO Queue时,IO操作还没完成,此时IO Queue为空,然后依次进入check queue阶段,执行setImmediate的回调。当事件循环第二次进入IO Queue时,IO操作完成并已经添加cb到IO Queue中了。

总结:IO事件需要轮询的,只有当IO操作完成后,fs.readFile的回调函数才会被添加到IO queue中执行。

check queue

setTimeout(() => {
    console.log('timer1')
}, 0);

setImmediate(() => {
    console.log('setImmediate')
})

多次执行上面代码,可以发现输出如下:

node_72.jpg

可以发现,输出顺序是随机的。这个原理和上面的例子一样,都是因为setTimeout(cb, 0)有1ms的延迟。

close queue

const fs = require('fs')

const readableStream = fs.createReadStream('test.js')
readableStream.close();
readableStream.on('close', () => {
    console.log('close')
})

setTimeout(() => {
    console.log('timer1')
}, 0);

setImmediate(() => {
    console.log('setImmediate')
})

Promise.resolve().then(() => {
    console.log('promise')
})


process.nextTick(() => {
    console.log('next tick')
})

小结

  • 事件循环是一个C语言程序,负责协调Node.js中同步和异步代码的执行
  • 事件循环负责协调执行6个不同队列中的回调:nextTick、Promise、Timer、I/O、check以及close队列。
    • 使用process.nextTick方法添加一个回调到nextTick队列中
    • 使用Promise添加一个回调到Promise队列中
    • 使用setTimeout或者setInterval添加一个回调到timer队列中
    • 使用fs.readFile添加一个回调到IO队列中
    • 使用setImmediate添加一个回调到check队列中
    • 监听close事件添加一个回调到close队列中
  • nextTick和Promise队列在事件循环切换到下一阶段前执行,或者在事件循环某个阶段的任务之间执行。

Node与浏览器事件循环的区别

  • 任务队列数不同
    • 浏览器只有宏任务和微任务队列
    • Node有6个事件队列:timer queue、IO queue、check queue、close queue,以及nextTick queue和Promise queue
  • 微任务执行时机不同
    • 浏览器中先执行完所有微任务,再执行宏任务
    • Node在事件队列切换时会去清空微任务