事件循环阶段
Node.js 10+ 版本后虽然在运行结果上与浏览器一致,但是两者在原理上一个是基于浏览器,一个是基于 libuv 库。浏览器核心的是宏任务和微任务,而在 Node.js 还有阶段性任务执行阶段。
事件循环通俗来说就是一个无限的 while 循环。以下为 Node.js 官网的事件循环原理的核心流程图:

(1)timers:本阶段执行已经被 setTimeout() 和 setInterval() 调度的回调函数,简单理解就是由这两个函数启动的回调函数。
(2)pending callbacks:本阶段执行某些系统操作(如 TCP 错误类型)的回调函数。
(3)idle、prepare:仅系统内部使用,你只需要知道有这 2 个阶段就可以。
(4)poll:检索新的 I/O 事件,执行与 I/O 相关的回调,其他情况 Node.js 将在适当的时候在此阻塞。这也是最复杂的一个阶段,所有的事件循环以及回调处理都在这个阶段执行,接下来会详细分析这个过程。
(5)check:setImmediate() 回调函数在这里执行,setImmediate 并不是立马执行,而是当事件循环 poll 中没有新的事件处理时就执行该部分
(6)close callbacks:执行一些关闭的回调函数,如 socket.on('close', ...)。
实际开发中最常用的是1、4、5阶段,尤其是poll阶段。
事件循环启动
问题一:谁来启动这个循环过程,循环条件是什么?
从图中我们可以看出事件循环的起点是 timers,如下代码所示:
setTimeout(() => {
console.log('1');
}, 0);
console.log('2')
这里setTimeout会开启事件循环,但是实际上会先打印2,原因是Node.js 进程启动后,就发起了一个新的事件循环,也就是事件循环的起点。
总结来说,Node.js 事件循环的发起点有 4 个:
Node.js 启动后;
setTimeout 回调函数;
setInterval 回调函数;
也可能是一次 I/O 后的回调函数。
事件循环中的任务
在上面的核心流程中真正需要关注循环执行的就是 poll 这个过程。在 poll 过程中,主要处理的是异步 I/O 的回调函数,以及其他几乎所有的回调函数,异步 I/O 又分为网络 I/O 和文件 I/O。
事件循环的主要包含微任务和宏任务

微任务:在 Node.js 中微任务包含 2 种——process.nextTick 和 Promise。微任务在事件循环中优先级是最高的,因此在同一个事件循环中有其他任务存在时,优先执行微任务队列。并且process.nextTick 和 Promise 也存在优先级,process.nextTick 高于 Promise。
宏任务:在 Node.js 中宏任务包含 4 种——setTimeout、setInterval、setImmediate 和 I/O。宏任务在微任务执行之后执行,因此在同一个事件循环周期内,如果既存在微任务队列又存在宏任务队列,那么优先将微任务队列清空,再执行宏任务队列。
在图左侧,我们可以看到有一个核心的主线程,它的执行阶段主要处理三个核心逻辑。
同步代码。
将异步任务插入到微任务队列或者宏任务队列中。
执行微任务或者宏任务的回调函数。在主线程处理回调函数的同时,也需要判断是否插入微任务和宏任务。根据优先级,先判断微任务队列是否存在任务,存在则先执行微任务,不存在则判断在宏任务队列是否有任务,有则执行。
const fs = require('fs');
// 首次事件循环执行
console.log('start');
/// 将会在新的事件循环中的阶段执行
fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => {
if (err) throw err;
console.log('read file success');
});
setTimeout(() => { // 新的事件循环的起点
console.log('setTimeout');
}, 0);
/// 该部分将会在首次事件循环中执行
Promise.resolve().then(()=>{
console.log('Promise callback');
});
/// 执行 process.nextTick
process.nextTick(() => {
console.log('nextTick callback');
});
// 首次事件循环执行
console.log('end');
//start
//end
//nextTick callback
//Promise callback
//setTimeout
//read file success
根据上面介绍的执行过程,我们来分析下上面代码的执行过程:
第一个事件循环主线程发起,因此先执行同步代码,所以先输出 start,然后输出 end;
再从上往下分析,遇到微任务,插入微任务队列,遇到宏任务,插入宏任务队列,分析完成后,微任务队列包含:Promise.resolve 和 process.nextTick,宏任务队列包含:fs.readFile 和 setTimeout;
先执行微任务队列,但是根据优先级,先执行 process.nextTick 再执行 Promise.resolve,所以先输出 nextTick callback 再输出 Promise callback;
再执行宏任务队列,根据宏任务插入先后顺序执行 setTimeout 再执行 fs.readFile,这里需要注意,先执行 setTimeout 由于其回调时间较短,因此回调也先执行,并非是 setTimeout 先执行所以才先执行回调函数,但是它执行需要时间肯定大于 1ms,所以虽然 fs.readFile 先于 setTimeout 执行,但是 setTimeout 执行更快,所以先输出 setTimeout ,最后输出 read file success。
单线程/多线程
主线程是单线程执行的,但是 Node.js 存在多线程执行,多线程包括 setTimeout(setTimeout的实现,比如延迟多少时间放入宏任务队列,而不是回调中的代码) 和异步 I/O 事件。其实 Node.js 还存在其他的线程,包括垃圾回收、内存优化等。