基于 Node.js 10+ 版本,为你讲解事件循环的原理,不过要注意这个事件循环原理和浏览器的原理是不同的,Node.js 10+ 版本后虽然在运行结果上与浏览器一致,但是两者在原理上一个是基于浏览器,一个是基于 libev 库。浏览器核心的是宏任务和微任务,而在 Node.js 还有阶段性任务执行阶段。
node.js启动过程可以分为以下步骤:
- 调用platformInit方法 ,初始化 nodejs 的运行环境。
- 调用 performance_node_start 方法,对 nodejs 进行性能统计。
- openssl设置的判断。
- 调用v8_platform.Initialize,初始化 libuv 线程池。
- 调用 V8::Initialize,初始化 V8 环境。
- 创建一个nodejs运行实例。
- 启动上一步创建好的实例。
- 开始执行js文件,同步代码执行完毕后,进入事件循环。
- 在没有任何可监听的事件时,销毁 nodejs 实例,程序执行完毕。
以上就是 nodejs 执行一个js文件的全过程。接下来着重介绍第八个步骤,事件循环。
Node.js 事件循环
事件循环通俗来说就是一个无限的 while 循环。现在假设你对这个 while 循环什么都不了解,你一定会有以下疑问。
- 谁来启动这个循环过程,循环条件是什么?
- 循环的是什么任务呢?
- 循环的任务是否存在优先级概念?
- 什么进程或者线程来执行这个循环?
- 无限循环有没有终点?
带着这些问题,我们先来看看 Node.js 官网提供的事件循环原理图。
Node.js 循环原理
下面为 Node.js 官网的事件循环原理的核心流程图。
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
从上面可以看到,这一流程包含 6 个阶段,每个阶段代表的含义如下所示:
(1)timers:本阶段执行已经被 setTimeout() 和 setInterval() 调度的回调函数,简单理解就是由这两个函数启动的回调函数。一个timer指定一个下限时间而不是准确时间,在达到这个下限时间后执行回调。在指定时间过后,timers会尽可能早地执行回调,但系统调度或者其它回调的执行可能会延迟它们。
注意:
- 技术上来说,poll 阶段控制 timers 什么时候执行
- 这个下限时间有个范围:[1, 2147483647],如果设定的时间不在这个范围,将被设置为1。单位为毫秒
(2)I/O pending callbacks:本阶段执行某些系统操作(如 TCP 错误类型)的回调函数。
如一个TCP socket在想要连接时收到ECONNREFUSED, 类unix系统会等待以报告错误,这就会放到 I/O callbacks 阶段的队列执行. 名字会让人误解为执行I/O回调处理程序, 实际上I/O回调会由poll阶段处理.
(3)idle、prepare:仅系统内部使用,你只需要知道有这 2 个阶段就可以。
(4)poll:检索新的 I/O 事件,执行与 I/O 相关的回调,其他情况 Node.js 将在适当的时候在此阻塞。这也是最复杂的一个阶段,所有的事件循环以及回调处理都在这个阶段执行,
poll 阶段有两个主要功能
- (1)执行下限时间已经达到的timers的回调
- (2)然后处理 poll 队列里的事件。 当event loop进入 poll 阶段,并且 没有设定的 timers(there are no timers scheduled),会发生下面两件事之一:
(5)check:setImmediate() 回调函数在这里执行,setImmediate 并不是立马执行,而是当事件循环 poll 中没有新的事件处理时就执行该部分,如下代码所示:
const fs = require('fs');
setTimeout(() => {
console.log('1');
}, 0);
setImmediate( () => {
console.log('setImmediate 1');
});
fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => {
if (err) throw err;
console.log('read file success');
});
Promise.resolve().then(()=>{
console.log('poll callback');
});
console.log('2');
在这一代码中有一个非常奇特的地方,就是 setImmediate 会在 setTimeout 之后输出。有以下几点原因:
- setTimeout 如果不设置时间或者设置时间为 0,则会默认为 1ms;
- 主流程执行完成后,超过 1ms 时,会将 setTimeout 回调函数逻辑插入到待执行回调函数 poll 队列中;
- 由于当前 poll 队列中存在可执行回调函数,因此需要先执行完,待完全执行完成后,才会执行check:setImmediate。
因此这也验证了这句话,先执行回调函数,再执行 setImmediate。
(6)close callbacks:执行一些关闭的回调函数,如 socket.on(‘close’, …)。
以上就是循环原理的 6 个过程,针对上面的点,我们再来解答上面提出的 5 个疑问。
运行起点
从图 1 中我们可以看出事件循环的起点是 timers,如下代码所示:
setTimeout(() => {
console.log('1');
}, 0);
console.log('2')
在代码 setTimeout 中的回调函数就是新一轮事件循环的起点,看到这里有很多同学会提出非常合理的疑问:“为什么会先输出 2 然后输出 1,不是说 timer 的回调函数是运行起点吗?”
这里有一个非常关键点,当 Node.js 启动后,会初始化事件循环,处理已提供的输入脚本,它可能会先调用一些异步的 API、调度定时器,或者 process.nextTick(),然后再开始处理事件循环。因此可以这样理解,Node.js 进程启动后,就发起了一个新的事件循环,也就是事件循环的起点。
总结来说,Node.js 事件循环的发起点有 4 个:
- Node.js 启动后;
- setTimeout 回调函数;
- setInterval 回调函数;
- 也可能是一次 I/O 后的回调函数。
以上就解释了我们上面提到的第 1 个问题。
Node.js 事件循环
在了解谁发起的事件循环后,我们再来回答第 2 个问题,即循环的是什么任务。在上面的核心流程中真正需要关注循环执行的就是 poll 这个过程。在 poll 过程中,主要处理的是异步 I/O 的回调函数,以及其他几乎所有的回调函数,异步 I/O 又分为网络 I/O 和文件 I/O。这是我们常见的代码逻辑部分的异步回调逻辑。
事件循环的主要包含微任务和宏任务。具体是怎么进行循环的呢?如图 2 所示。
图 2 事件循环过程
在解释上图之前,我们先来解释下两个概念,微任务和宏任务。
微任务:在 Node.js 中微任务包含 2 种——process.nextTick 和 Promise。微任务在事件循环中优先级是最高的,因此在同一个事件循环中有其他任务存在时,优先执行微任务队列。并且 process.nextTick 和 Promise 也存在优先级,process.nextTick 高于 Promise。
宏任务:在 Node.js 中宏任务包含 4 种——setTimeout、setInterval、setImmediate 和 I/O。宏任务在微任务执行之后执行,因此在同一个事件循环周期内,如果既存在微任务队列又存在宏任务队列,那么优先将微任务队列清空,再执行宏任务队列。这也解释了我们前面提到的第 3 个问题,事件循环中的事件类型是存在优先级。
在图 2 的左侧,我们可以看到有一个核心的主线程,它的执行阶段主要处理三个核心逻辑。
- 同步代码。
- 将异步任务插入到微任务队列或者宏任务队列中。
- 执行微任务或者宏任务的回调函数。在主线程处理回调函数的同时,也需要判断是否插入微任务和宏任务。根据优先级,先判断微任务队列是否存在任务,存在则先执行微任务,不存在则判断在宏任务队列是否有任务,有则执行。
如果微任务和宏任务都只有一层时,那么看起来是比较简单的,比如下面的例子:
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(() => {
console.log('nextTick callback');
});
console.log('end');
根据上面介绍的执行过程,我们来分析下上面代码的执行过程:
- 第一个事件循环主线程发起,因此先执行同步代码,所以先输出 start,然后输出 end;
- 再从上往下分析,遇到微任务,插入微任务队列,遇到宏任务,插入宏任务队列,分析完成后,微任务队列包含:Promise.resolve 和 process.nextTick,宏任务队列包含:fs.readFile 和 setTimeout;
- 先执行微任务队列,但是根据优先级,先执行 process.nextTick 再执行 Promise.resolve,所以先输出 nextTick callback 再输出 Promise callback;
- 再执行宏任务队列,根据优先级先执行 fs.readFile 再执行 setTimeout,但是这里需要注意,即使先执行 fs.readFile,但是它执行需要时间肯定大于 1ms,所以虽然 fs.readFile 先于 setTimeout 执行,但是 setTimeout 执行更快,所以先输出 setTimeout ,最后输出 read file success。
根据上面的分析,我们可以得到如下的执行结果:
start
end
nextTick callback
Promise callback
setTimeout
read file success
但是当微任务和宏任务又产生新的微任务和宏任务时,又应该如何处理呢?如下代码所示:
const fs = require('fs');
setTimeout(() => {
console.log('1');
fs.readFile('./config/test.conf', {encoding: 'utf-8'}, (err, data) => {
if (err) throw err;
console.log('read file sync success');
});
}, 0);
fs.readFile('./config/test.conf', {encoding: 'utf-8'}, (err, data) => {
if (err) throw err;
console.log('read file success');
});
Promise.resolve().then(()=>{
console.log('poll callback');
});
console.log('2');
在上面代码中,有 2 个宏任务和 1 个微任务,宏任务是 setTimeout 和 fs.readFile,微任务是 Promise.resolve。
- 整个过程优先执行主线程的第一个事件循环过程,所以先执行同步逻辑,先输出 2。
- 接下来执行微任务,输出 poll callback。
- 再执行宏任务中的 fs.readFile 和 setTimeout,由于 fs.readFile 优先级高,先执行 fs.readFile。但是处理时间长于 1ms,因此会先执行 setTimeout 的回调函数,输出 1。这个阶段在执行过程中又会产生新的宏任务 fs.readFile,因此又将该 fs.readFile 插入宏任务队列。
- 最后由于只剩下宏任务了 fs.readFile,因此执行该宏任务,并等待处理完成后的回调,输出 read file sync success。
根据上面的分析,我们可以得出最后的执行结果,如下所示:
2
poll callback
1
read file success
read file sync success
在上面的例子中,我们来思考一个问题,主线程是否会被阻塞,具体我们来看一个代码例子:
const fs = require('fs');
setTimeout(() => {
console.log('1');
sleep(10000)
console.log('sleep 10s');
}, 0);
fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => {
if (err) throw err;
console.log('read file success');
});
console.log('2');
function sleep ( n ) {
var start = new Date().getTime() ;
while ( true ) {
if ( new Date().getTime() - start > n ) {
break;
}
}
}
我们在 setTimeout 中增加了一个阻塞逻辑,这个阻塞逻辑的现象是,只有等待当次事件循环结束后,才会执行 fs.readFile 回调函数。这里会发现 fs.readFile 其实已经处理完了,并且通知回调到了主线程,但是由于主线程在处理回调时被阻塞了,导致无法处理 fs.readFile 的回调。因此可以得出一个结论,主线程会因为回调函数的执行而被阻塞,这也符合图 2 中的执行流程图。
如果把上面代码中 setTimeout 的时间修改为 10 ms,你将会优先看到 fs.readFile 的回调函数,因为 fs.readFile 执行完成了,并且还未启动下一个事件循环,修改的代码如下:
setTimeout(() => {
console.log('1');
sleep(10000)
console.log('sleep 10s');
}, 10);
最后我们再来回答第 5 个问题,当所有的微任务和宏任务都清空的时候,虽然当前没有任务可执行了,但是也并不能代表循环结束了。因为可能存在当前还未回调的异步 I/O,所以这个循环是没有终点的,只要进程在,并且有新的任务存在,就会去执行。
实践分析
了解了整个原理流程,我们再来实践验证下 Node.js 的事件驱动,以及 I/O 到底有什么效果和为什么能提高并发处理能力。我们的实验分别从同步和异步的代码性能分析对比,从而得出两者的差异。
Node.js 不善于处理 CPU 密集型的业务,就会导致性能问题,如果要实现一个耗时 CPU 的计算逻辑,处理方法有 2 种:
- 直接在主业务流程中处理;
- 通过网络异步 I/O 给其他进程处理。
接下来,我们用 2 种方法分别计算从 0 到 1000000000 之间的和,然后对比下各自的效果。
主流程执行
为了效果,我们把两部分计算分开,这样能更好地形成对比,没有异步驱动计算的话,只能同步的去执行两个函数 startCount 和 nextCount,然后将两部分计算结果相加。
const http = require('http');
*
* 创建 http 服务,简单返回
*/
const server = http.createServer((req, res) => {
res.write(`${startCount() + nextCount()}`);
res.end();
});
* 从 0 计算到 500000000 的和
*/
function startCount() {
let sum = 0;
for(let i=0; i<500000000; i++){
sum = sum + i;
}
return sum;
}
* 从 500000000 计算到 1000000000 之间的和
*/
function nextCount() {
let sum = 0;
for(let i=500000000; i<1000000000; i++){
sum = sum + i;
}
return sum;
}
*
* 启动服务
*/
server.listen(4000, () => {
console.log('server start http://127.0.0.1:4000');
});
接下来使用下面命令启动该服务:
启动成功后,再在另外一个命令行窗口执行如下命令,查看响应时间,运行命令如下:
运行完成以后可以看到如下的结果:
499999999075959400
real 0m1.100s
user 0m0.004s
sys 0m0.005s
启动第一行是计算结果,第二行是执行时长。经过多次运行,其结果基本相近,都在 1.1s 左右。接下来我们利用 Node.js 异步事件循环的方式来优化这部分计算方式。
异步网络 I/O
异步网络 I/O 对比主流程执行,优化的思想是将上面的两个计算函数 startCount 和 nextCount 分别交给其他两个进程来处理,然后主进程应用异步网络 I/O 的方式来调用执行。
我们先看下主流程逻辑,如下代码所示:
const http = require('http');
const rp = require('request-promise');
*
* 创建 http 服务,简单返回
*/
const server = http.createServer((req, res) => {
Promise.all([startCount(), nextCount()]).then((values) => {
let sum = values.reduce(function(prev, curr, idx, arr){
return parseInt(prev) + parseInt(curr);
})
res.write(`${sum}`);
res.end();
})
});
* 从 0 计算到 500000000 的和
*/
async function startCount() {
return await rp.get('http://127.0.0.1:5000');
}
* 从 500000000 计算到 1000000000 之间的和
*/
async function nextCount() {
return await rp.get('http://127.0.0.1:6000');
}
*
* 启动服务
*/
server.listen(4000, () => {
console.log('server start http://127.0.0.1:4000');
});
代码中使用到了 Promise.all 来异步执行两个函数 startCount 和 nextCount,待 2 个异步执行结果返回后再计算求和。其中两个函数 startCount 和 nextCount 中的 rp.get 地址分别是:
其实是两个新的进程分别计算两个求和的逻辑,具体以 5000 端口的逻辑为例看下,代码如下:
const http = require('http');
*
* 创建 http 服务,简单返回
*/
const server = http.createServer((req, res) => {
let sum = 0;
for(let i=0; i<500000000; i++){
sum = sum + i;
}
res.write(`${sum}`);
res.end();
});
*
* 启动服务
*/
server.listen(5000, () => {
console.log('server start http://127.0.0.1:5000');
});
接下来我们分别打开三个命令行窗口,使用以下命令分别启动三个服务:
node startServer.js
node nextServer.js
node async.js
启动成功后,再运行如下命令,查看执行时间:
运行成功后,你可以看到如下结果:
499999999075959400
real 0m0.575s
user 0m0.004s
sys 0m0.005s
结果还是一致的,但是运行时间缩减了一半,大大地提升了执行效率。
响应分析
两个服务的执行时间相差一半,因为异步网络 I/O 充分利用了 Node.js 的异步事件驱动能力,将耗时 CPU 计算逻辑给到其他进程来处理,而无须等待耗时 CPU 计算,可以直接处理其他请求或者其他部分逻辑。第一种同步执行的方式就无法去处理其逻辑,导致性能受到影响。
如果使用压测还可以使对比效果更加明显,我将在第 12 讲为你详细介绍关于压测使用以及分析过程。
单线程 / 多线程
在面试过程中,面试官经常会问这个问题 “Node.js 是单线程的还是多线程的”。
学完上面的内容后,你就可以回答了。
主线程是单线程执行的,但是 Node.js 存在多线程执行,多线程包括 setTimeout 和异步 I/O 事件。其实 Node.js 还存在其他的线程,包括垃圾回收、内存优化等。
这里也可以解释我们前面提到的第 4 个问题,主要还是主线程来循环遍历当前事件。