简介
虽然js
可以在浏览器中执行又可以在node
中执行,但是它们的事件循环机制并不是一样的。并且有很大的区别。
如果对浏览器事件循环机制还不熟悉的话,可以先看看笔者前面写的都2022年了你不会还没搞懂js执行上下文和事件循环机制吧。今天我们只说Node
中的事件循环机制。
EventLoop机制概述
在说Node
事件循环机制之前,我们先来讨论两个问题
为什么要学习事件循环机制?
学习事件循环可以让开发者明白JavaScript
的运行机制是怎么样的。
事件循环机制做的是什么事情?
事件循环机制用于管理异步API的回调函数什么时候回到主线程中执行。
Node.js采用的是异步IO模型。同步API在主线程中执行,异步API在底层的C++维护的线程中执行,异步API的回调函数也会在主线程中执行。
在Javascript应用运行时,众多异步API的回调函数什么时候能回到主线程中调用呢?这就是事件环环机制做的事情,管理异步API的回调函数什么时候回到主线程中执行。
EventLoop的六个阶段
在Node
中的事件循环分为六个阶段。
在事件循环中的每个阶段都有一个队列,存储要执行的回调函数,事件循环机制会按照先进先出的方式执行他们直到队列为空。
这六个阶段都存储着异步回调函数,所以还是遵循先执行主线程同步代码,当同步代码执行完后再来轮询这六个阶段。
接下来,我们来详细看看这六个阶段里面存储的都是什么
Timers
Timers
:用于存储定时器的回调函数(setlnterval,setTimeout)。
Pendingcallbacks
Pendingcallbacks
:执行与操作系统相关的回调函数,比如启动服务器端应用时监听端口操作的回调函数就在这里调用。
idle,prepare
idle,prepare
:系统内部使用。(这个我们程序员不用管)
Poll
Poll
:存储1/O操作的回调函数队列,比如文件读写操作的回调函数。
在这个阶段需要特别注意,如果事件队列中有回调函数,则执行它们直到清空队列
,否则事件循环将在此阶段停留一段时间以等待新的回调函数进入。
但是对于这个等待并不是一定的,而是取决于以下两个条件:
- 如果setlmmediate队列(check阶段)中存在要执行的调函数。这种情况就不会等待。
- timers队列中存在要执行的回调函数,在这种情况下也不会等待。事件循环将移至check阶段,然后移至Closingcallbacks阶段,并最终从timers阶段进入下一次循环。
Check
Check
:存储setlmmediate的回调函数。
Closingcallbacks
Closingcallbacks
:执行与关闭事件相关的回调,例如关闭数据库连接的回调函数等。
宏任务与微任务
跟浏览器中的js
一样,node
中的异步代码也分为宏任务和微任务,只是它们之间的执行顺序有所区别。
我们再来看看Node
中都有哪些宏任务和微任务
宏任务
- setlnterval
- setimeout
- setlmmediate
- I/O
微任务
- Promise.then
- Promise.catch
- Promise.finally
- process.nextTick
在node
中,对于微任务和宏任务的执行顺序到底是怎样的呢?
微任务和宏任务的执行顺序
在node
中,微任务的回调函数被放置在微任务队列中,宏任务的回调函数被放置在宏任务队列中。
微任务优先级高于宏任务。当微任务事件队列中存在可以执行的回调函数时,事件循环在执行完当前阶段的回调函数后会暂停进入事件循环的下一个阶段,而会立即进入微任务的事件队列中开始执行回调函数,当微任务队列中的回调函数执行完成后,事件循环才会进入到下一个段开始执行回调函数。
对于微任务我们还有个点需要特别注意。那就是虽然nextTick
同属于微任务,但是它的优先级是高于其它微任务,在执行微任务时,只有nextlick
中的所有回调函数执行完成后才会开始执行其它微任务。
总的来说就是当主线程同步代码执行完毕后会优先清空微任务(如果微任务继续产生微任务则会再次清空),然后再到下个事件循环阶段。并且微任务的执行是穿插在事件循环六个阶段中间的,也就是每次事件循环进入下个阶段前会判断微任务队列是否为空,为空才会进入下个阶段,否则先清空微任务队列。
下面我们用代码实操来验证前面所说的。
代码实例
先执行同步再执行异步
在Node
应用程序启动后,并不会立即进入事件循环,而是先执行同步代码,从上到下开始执行,同步API立即执行,异步API交给C++维护的线程执行,异步API的回调函数被注册到对应的事件队列中。当所有同步代码执行完成后,才会进入事件循环。
console.log("start");
setTimeout(() => {
console.log("setTimeout 1");
});
setTimeout(() => {
console.log("setTimeout 2");
});
console.log("end");
我们来看执行结果
可以看到,先执行同步代码,然后才会进入事件循环执行异步代码,在timers
阶段执行两个setTimeout
回调。
setTimeout一定会先于setImmediate执行吗
我们知道setTimeout
是在timers
阶段执行,setImmediate
是在check
阶段执行。并且事件循环是从timers
阶段开始的。所以会先执行setTimeout
再执行setImmediate
。
对于上面的分析一定对吗?
我们来看例子
console.log("start");
setTimeout(() => {
console.log("setTimeout");
});
setImmediate(() => {
console.log("setImmediate");
});
const sleep = (delay) => {
const startTime = +new Date();
while (+new Date() - startTime < delay) {
continue;
}
};
sleep(2000);
console.log("end");
执行上面的代码,输出如下
先执行setTimeout
再执行setImmediate
接下来我们来改造下上面的代码,把延迟器去掉,看看会输出什么
setTimeout(() => {
console.log("setTimeout");
});
setImmediate(() => {
console.log("setImmediate");
});
我们运行了七次,可以看到其中有两次是先运行的setImmediate
怎么回事呢?不是先timers
阶段再到check
阶段吗?怎么会变呢?
其实这就得看进入事件循环的时候,异步回调有没有完全准备好了。对于最开始的例子,因为有2000毫秒的延迟,所以进入事件循环的时候,setTimeout
回调是一定准备好了的。所以执行顺序不会变。但是对于这个例子,因为主线程没有同步代码需要执行,所以一开始就进入事件循环,但是在进入事件循环的时候,setTimeout
的回调并不是一定完全准备好的,所以就会有先到check
阶段执行setImmediate
回调函数,再到下一次事件循环的timers
阶段来执行setTimeout
的回调。
那在什么情况下同样的延迟时间,setImmediate
回调函数一定会优先于setTimeout
的回调呢?
其实很简单,只要将这两者放到timers
阶段和check
阶段之间的Pendingcallbacks、idle,prepare、poll
阶段中任意一个阶段就可以了。因为这些阶段完执行完是一定会先到check
再到timers
阶段的。
我们以poll
阶段为例,将这两者写在IO操作中。
const fs = require("fs");
fs.readFile("./fstest.js", "utf8", (err, data) => {
setTimeout(() => {
console.log("setTimeout");
});
setImmediate(() => {
console.log("setImmediate");
});
});
我们也来执行七次,可以看到,每次都是setImmediate
先执行。
所以总的来说,同样的延迟时间,setTimeout
并不是百分百先于setImmediate
执行。
先微任务再宏任务
主线程同步代码执行完毕后,会先执行微任务再执行宏任务。
我们来看下面的例子
console.log("start");
setTimeout(() => {
console.log("setTimeout");
});
setImmediate(() => {
console.log("setImmediate");
});
Promise.resolve().then(() => {
console.log("Promise.resolve");
});
console.log("end");
我们运行一下看结果,可以看到它是先执行了微任务然后再执行宏任务
nextTick优于其它微任务
在微任务中nextTick
的优先级是最高的。
我们来看下面的例子
console.log("start");
setTimeout(() => {
console.log("setTimeout");
});
setImmediate(() => {
console.log("setImmediate");
});
Promise.resolve().then(() => {
console.log("Promise.resolve");
});
process.nextTick(() => {
console.log("process.nextTick");
});
console.log("end");
我们运行上面的代码,可以看到就算nextTick
定义在resolve
后面,它也是先执行的。
微任务穿插在各个阶段间执行
怎么理解这个穿插呢?其实就是在事件循环的六个阶段每个阶段执行完后会清空微任务队列。
我们来看例子,我们建立了timers、check、poll
三个阶段,并且每个阶段都产生了微任务。
// timers阶段
setTimeout(() => {
console.log("setTimeout");
Promise.resolve().then(() => {
console.log("setTimeout Promise.resolve");
});
});
// check阶段
setImmediate(() => {
console.log("setImmediate");
Promise.resolve().then(() => {
console.log("setImmediate Promise.resolve");
});
});
// 微任务
Promise.resolve().then(() => {
console.log("Promise.resolve");
});
// 微任务
process.nextTick(() => {
console.log("process.nextTick");
Promise.resolve().then(() => {
console.log("nextTick Promise.resolve");
});
});
我们来执行上面的代码
可以看到,先执行微任务,再执行宏任务。先process.nextTick -> Promise.resolve
。并且如果微任务继续产生微任务则会再次清空,所以就又输出了nextTick Promise.resolve
。
接下来到timer
阶段,输出setTimeout
,并且产生了一个微任务,再进入到下个阶段前需要清空微任务队列,所以继续输出setTimeout Promise.resolve
。
接下来到check
阶段,输出setImmediate
,并且产生了一个微任务,再进入到下个阶段前需要清空微任务队列,所以继续输出setImmediate Promise.resolve
。
这也就印证了微任务会穿插在各个阶段之间运行。
总结
所以对于Node
中的事件循环你只需要背好一以下几点就可以了
- 当主线程同步代码执行完毕后才会进入事件循环
- 事件循环总共分六个阶段,并且每个阶段都包括哪些回调需要记清楚。
- 事件循环中会先执行微任务再执行宏任务。
- 微任务会穿插在这六个阶段之间执行,每进入到下个阶段前会清空当前的微任务队列。
- 微任务中
process.nextTick
的优先级最高,会优先执行。
系列文章
Node.js入门之process模块、child_process模块、cluster模块
后记
本文为笔者个人学习笔记,如有谬误,还请告知,万分感谢!如果本文对你有所帮助,还请点个关注点个赞~,您的支持是笔者不断更新的动力。