Node.js 10+ 版本后 在运行结果上与浏览器是一致的,但两者在原理上一个是基于libuv库上,一个是基于浏览器。浏览器的核心是宏任务和微任务,而 Node.js 还有阶段性任务执行。
事件循环就类似一个无限的while循环,假设我们要开发一个业务涉及到while循环,我们可能需要思考以下几个问题:
- 循环条件是什么?首次循环由什么启动?
- 循环执行的任务是什么?
- 任务是否存在优先级?
- 循环有没有终点?
带着这些问题,我们来瞧瞧 Node.js 10+ 官网的事件循环原理的核心流程图
一、Node.js事件循环原理
1. timers
该阶段执行由setTimeout()和setInterval()这两个函数启动的回调函数
2. pending callbacks
该阶段执行某些系统操作的回调函数,如TCP错误类型
3. idle prepare
仅系统内部使用
4. poll
主要处理异步 I/O(网络 I/O和文件 I/O)的回调函数,以及其它回调函数
5.check
该阶段执行setImmediate()的回调函数。setImmediate并不是立马执行,而是当事件循环 poll 中没有新的事件处理时才执行该部分,即先执行回调函数,再执行setImmediate
6.close callbacks
执行一些关闭的回调函数,如 socket.on('close',...)
二、运行起点
Node.js事件循环的发起点有如下四个:
- 1、Node.js启动后
- 2、setTimeout 回调函数
- 3、setInterval 回调函数
- 4、I/O后的回调函数
换句话说:当Node.js 进程启动后,就发起了一个新的事件循环,即事件循环的起点。可为何?下面的代码在执行时先输出2再输出1呢?
setTimeout(() => {
console.log('1'); // 该回调函数是新一轮事件循环的起点
}, 0);
console.log('2');
这里有个小点需要注意,当Node.js启动后,会初始化事件循环,处理已提供的输入脚本,它可能会先调用一些异步的API、调度定时器,或者 process.nextTick(),然后再处理事件循环
三、Node.js事件循环
Node.js事件循环有一个核心的主线程,它的执行阶段主要处理三个核心逻辑:
- 同步代码
- 将异步任务插入到微任务队列或宏任务队列中
- 执行微任务或宏任务的回调函数。在主线程处理回调函数的同时,也需判断是否插入微任务和宏任务
const fs = require('fs');
// 主流程执行完成后,超过1ms时,会将setTimeout回调函数逻辑插入到待执行回调函数 poll 队列中
setTimeout(() => {
console.log("setTimeout100")
// 文件 I/O
fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => {
if (err) throw err;
console.log('read file sync100 success');
});
}, 100);
// setTimeout 如果不设置时间或者设置时间为0,则会默认为1ms
setTimeout(() => {
console.log("setTimeout0")
// 文件 I/O
fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => {
if (err) throw err;
console.log('read file sync0 success');
});
}, 0);
// 文件 I/O 优先级高于 setTimeout,但处理事件长于1ms
fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => {
if (err) throw err;
console.log('read file success');
});
// 微任务
Promise.resolve().then(()=>{
console.log('Promise callback');
});
// 微任务,process.nextTick 优先级高于 Promise
process.nextTick(()=>{
console.log("process callback")
})
// 主流程
console.log('start');
- 第一个事件循环主线程发起,先执行同步代码,所以输出 start
- 再从上往下分析,遇到微任务,插入微任务队列,遇到宏任务,插入宏任务队列。微任务队列包含:Promise.resolve 和 process.nextTick。宏任务队列包含:setTimeout(100)、setTimeout(0) 和 fs.readFile。
- 先执行微任务,根据优先级,先执行 process.nextTick 再执行 Promise.resolve,因此先输出process callback再输出Promise callback。
- 再执行宏任务队列,根据宏任务插入先后顺序执行 setTimeout(100),接着执行setTimeout(0),最后执行fs.readFile。由于setTimeout(100)延迟100ms,因此先执行setTimeout(0)和fs.readFile,由于fs.readFile优先级高于setTimeout(0),先执行fs.readFile,但其处理时间大于1ms,因此可能会先执行setTimeout(0),输出setTimeout0,而它新产生的宏任务将插入宏任务队列
- 最后执行宏任务 setTimeout(100),并等待其完成后的回调
1、循环没有终点
当所有的微任务和宏任务都清空的时候,即当前没有任务可执行,也无法代表循环结束,可能存在当前还未回调的异步I/O,因此该循环时没有终点的,只要进程在,且新的任务存在,就会去执行
2、主线程会因回调函数的执行而被阻塞
假设我们在setTimeout中新增一个阻塞逻辑,只有等待当前事件循环结束后,才执行fs.readFile回调函数
const fs = require('fs');
setTimeout(() => {
// 新的事件循环的起点
console.log('1');
sleep(10000)
console.log('sleep 10s');
}, 0);
// 将会在 poll 阶段执行
fs.readFile('./config/test.conf', {encoding: 'utf-8'}, (err, data) => {
if (err) throw err;
console.log('read file success');
});
// 阻塞逻辑
function sleep ( n ) {
var start = new Date().getTime() ;
while ( true ) {
if ( new Date().getTime() - start > n ) {
break;
}
}
}
从输出现象会发现,fs.readFile虽已处理完且通知回调到主线程,但主线程由于在处理回调时被阻塞了,导致无法处理fs.readFile。接下来,我们来印证一下,将 setTimeout 的时间更改为 10ms,则输出
你会优先看到fs.readFile的回调函数,这是因为fs.readFile执行完成了,还没启动下一个事件循环
四、异步事件驱动的好处
Node.js不善于处理CPU密集型的业务,易导致性能问题,我们分别执行主线程和异步I/O处理一个耗时CPU的计算(计算从0到1,000,000,000之间的和),比对各自的效果
1、主流程执行
执行时间 total为1.084-1.090
2、异步网络I/O
执行时间 total为 0.562-0.597
3、响应分析
异步网络I/O充分利用了Node.js的异步事件驱动能力,将耗时CPU计算逻辑分配给其它进程处理,因此主线程可直接处理其它请求逻辑,而在主流程执行耗时CPU计算,导致其无法处理其他逻辑,进而影响性能,因此上面服务的执行时间相差甚远
4、单线程/多线程
遍历Node.js事件循环当前事件是在主线程,而主线程是单线程执行的,而异步I/O事件、setTimeout以及垃圾回收、内存优化等则是多线程执行。
五、应用场景
基于Node.js事件循环的原理,我们在使用Node.js时应减少或者避免在Node.js主线程中被阻塞以及进行一些大内存(V8 内存上限三1.4G)和CPU密集的场景,比如图片处理、大字符串、大数组类处理、大文件读写处理等等。
Node.js的优势在于其异步事件驱动能力较强,能够处理更高的并发,因此我们可以寻找网络I/O处理多、CPU计算少,业务复杂度高的服务
1、业务网关
处理业务相关的通用逻辑,比如通用的协议转化、通用的鉴权处理以及其他一些业务安全处理
在上面开放API的应用场景中,粉色框内的功能都是基于缓存来处理业务逻辑,大部分是网络I/O,并未涉及CPU密集逻辑。因此这类轻CPU运算服务在技术选型上可考虑Node.js作为服务端语言
2、运营系统
运营系统往往逻辑复杂,需根据业务场景进行多次迭代、优化,并发高,但可不涉及底层数据库的读写,更多的是缓存数据的处理,如投票活动
3、中台服务
中台的概念是将应用中一些通用的业务服务进行集中,其着重关注:网络I/O(高低都可)、并发(高低都可)、通用性(必须好)以及业务复杂度,一般情况下不涉及复杂的CPU运算(低运算),比如常见的中台业务系统
系统 | 通用性 | CPU计算 | 网络I/O | 并发 |
---|---|---|---|---|
前端配置系统 | 是 | 否 | 低 | 高 |
反馈系统 | 是 | 否 | 高 | 低 |
推送系统 | 是 | 否 | 低 | 低 |
系统工具 | 是 | 否 | 低 | 低 |
这样的系统在Node.js主线程中,可快速处理各类业务场景,不会存在阻塞的情况