Node.js事件循环

Node.js 10+ 版本后 在运行结果上与浏览器是一致的,但两者在原理上一个是基于libuv库上,一个是基于浏览器。浏览器的核心是宏任务和微任务,而 Node.js 还有阶段性任务执行。

事件循环就类似一个无限的while循环,假设我们要开发一个业务涉及到while循环,我们可能需要思考以下几个问题:

  • 循环条件是什么?首次循环由什么启动?
  • 循环执行的任务是什么?
  • 任务是否存在优先级?
  • 循环有没有终点?

带着这些问题,我们来瞧瞧 Node.js 10+ 官网的事件循环原理的核心流程图

一、Node.js事件循环原理

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事件循环有一个核心的主线程,它的执行阶段主要处理三个核心逻辑:

  • 同步代码
  • 将异步任务插入到微任务队列或宏任务队列中
  • 执行微任务或宏任务的回调函数。在主线程处理回调函数的同时,也需判断是否插入微任务和宏任务 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;
        }
    }
}

Node.js事件循环

从输出现象会发现,fs.readFile虽已处理完且通知回调到主线程,但主线程由于在处理回调时被阻塞了,导致无法处理fs.readFile。接下来,我们来印证一下,将 setTimeout 的时间更改为 10ms,则输出

Node.js事件循环

你会优先看到fs.readFile的回调函数,这是因为fs.readFile执行完成了,还没启动下一个事件循环

四、异步事件驱动的好处

Node.js不善于处理CPU密集型的业务,易导致性能问题,我们分别执行主线程和异步I/O处理一个耗时CPU的计算(计算从0到1,000,000,000之间的和),比对各自的效果

1、主流程执行

Node.js事件循环

执行时间 total为1.084-1.090

2、异步网络I/O

Node.js事件循环

执行时间 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、业务网关

处理业务相关的通用逻辑,比如通用的协议转化、通用的鉴权处理以及其他一些业务安全处理

Node.js事件循环

在上面开放API的应用场景中,粉色框内的功能都是基于缓存来处理业务逻辑,大部分是网络I/O,并未涉及CPU密集逻辑。因此这类轻CPU运算服务在技术选型上可考虑Node.js作为服务端语言

2、运营系统

运营系统往往逻辑复杂,需根据业务场景进行多次迭代、优化,并发高,但可不涉及底层数据库的读写,更多的是缓存数据的处理,如投票活动

3、中台服务

中台的概念是将应用中一些通用的业务服务进行集中,其着重关注:网络I/O(高低都可)、并发(高低都可)、通用性(必须好)以及业务复杂度,一般情况下不涉及复杂的CPU运算(低运算),比如常见的中台业务系统

系统 通用性 CPU计算 网络I/O 并发
前端配置系统
反馈系统
推送系统
系统工具

这样的系统在Node.js主线程中,可快速处理各类业务场景,不会存在阻塞的情况

 

上一篇:题目⑥ 说一下js的执行机制--事件循环EventLoop


下一篇:JavaScript — 运行机制