前言
js从诞生之日起就是一门单线程的非阻塞脚本语言,这里先介绍一下这两个基本概念。
首先是单线程。这意味着只有一个主线程来处理所有的任务。这是由js最初的用途来决定的:与浏览器交互。单线程是必要的,也是js这门语言的基石。在js最初也是最主要的执行环境——浏览器中,我们需要进行各种各样的dom操作。试想一下 如果js是多线程的,那么当两个线程同时对dom进行一项操作,例如一个向其添加事件,而另一个删除了这个dom,此时该如何处理这种冲突呢?因此,为了保证不会发生类似上述例子中的情景,js选择只用一个主线程来执行代码,这样就保证了程序执行的一致性。
其次是非阻塞。当代码需要进行一项异步任务(无法立刻返回结果,需要花一定时间才能返回的任务,如I/O事件、网络请求等)的时候,主线程会挂起这个任务,然后在异步任务返回结果的时候再根据一定规则去执行相应的回调。js的引擎就是通过事件循环(event loop)来实现非阻塞的。
事件循环机制用于管理异步任务的回调任务什么时候回到主线程中执行。nodejs中的事件循环与浏览器环境下传统js的事件循环虽然名称相同,但两者间其实也大有不同,本文将从传统js的事件循环引入,对二者分别进行介绍。
一、浏览器环境下传统js的事件循环
1、执行环境、执行栈
当我们调用一个方法时,会生成一个与该方法对应的执行环境(context),又叫执行上下文。执行环境中存储方法的私有作用域、上层作用域的指向、方法参数、方法作用域中定义的变量、方法作用域的this对象等。
由于js是单线程的,同一时间只能执行一个方法,因此当一系列方法被依次调用的时候,js就将这些方法依次存放在一个单独的地方——执行栈。执行一段仅包含同步代码的js脚本时,js引擎会按照执行顺序将任务加入执行栈中,加入后的任务立即执行,执行完毕出栈,接着进行下一段代码的执行。
当执行一个方法时,js会向执行栈中添加方法的执行环境,然后进入执行环境继续执行其中的任务,当执行环境中的任务执行完毕并返回结果后,js会退出执行环境并把这个执行环境销毁(方法执行结束)。接着回到执行栈中继续执行剩余的任务。在一个方法的执行环境中还可以调用其他方法,甚至是自己,其结果不过是在执行栈中再添加一个执行环境。这个过程可以无限进行下去,直到栈溢出为止(即超过了所能使用内存的最大值)。
上述过程反复进行,直到所有代码都执行完毕,一段代码就运行完成了。(栈的数据结构就可以直观的体现上述的过程)
2、事件队列
只有同步代码的情况下,整个过程并不复杂,那么当代码中不仅有同步代码还有异步代码时该如何处理呢?前文提过,js的另一大特点是非阻塞,实现这一点的关键就在于下文要介绍一项机制——事件队列。
遇到一个异步方法时主线程并不会一直等待其返回结果,而是会将其挂起(异步方法本身的任务交由辅助线程处理,如定时器线程、http请求线程等,辅助线程执行完毕后返回),继续执行执行栈中的其他任务。当该异步方法返回结果后,js会将这个方法的回调事件放入一个先入先出(FIFO)队列中,这个队列就称为事件队列。
值得注意的是,js引擎不会立刻执行事件队列中的回调事件,而是会等待执行栈中的所有执行环境都执行完毕、主线程处于闲置状态时才去执行。此时主线程会查找事件队列中是否有回调事件,如果有就从中取出排在第一位的回调事件,并把其对应的执行环境放入执行栈中,主线程继续遵循先同步后异步回调的原则处理执行环境中的任务;如果没有就持续查找。如此反复就形成了一个可以无限进行的循环,因此这个过程被称为事件循环(Event Loop)。
根据上文所述,一般js代码执行的大致流程图如下所示:
3、宏任务、微任务
上述的事件循环过程是一个宏观的表述,实际上异步任务的执行优先级也有区别。不同的异步任务被分为两类:宏任务(macro task)和微任务(micro task)。宏任务由宿主(浏览器、Node)发起,包括script (可以理解为每次执行的代码,因此这种类型的宏任务可能会包含可执行的微任务)、setTimeout / setInterval、UI rendering / UI事件、I/O(NodeJS)等;微任务由JS引擎发起,包括Promise、async / await 等。
在上文的介绍中,我们提到过异步事件的回调事件会被放到事件队列中,但实际上事件队列不止有一个,根据异步任务的类型,事件队列也分为宏任务队列和微任务队列,不同类型异步任务对应的回调事件也会被放入相应的宏任务队列或者微任务队列中。因此一轮事件循环的实际过程大致为:
(1)执行一个宏任务(栈中没有就从事件队列中获取)
(2)执行过程中如果遇到微任务,将其添加到微任务队列中
(3)宏任务执行完毕后:
① 若微任务队列中存在微任务,立即执行队列中的所有微任务(依次执行)
② 若微任务队列中没有微任务,开始下一个宏任务(从事件队列中获取)
4、传统js事件循环流程
综上所述,传统js中的事件循环流程图可以细化为如下所示:
需要注意的是,这里容易产生一个误解,就是*微任务先于宏任务执行 *(错误的!!!)。由于存在script类型的宏任务,一个js脚本本身就是一个大的宏任务,因此实际的过程是:**一个宏任务,所有微任务;一个宏任务,所有微任务…**如此循环。这里举一个比较形象的例子模拟这个过程:
银行柜台前排着一条队伍,都是存钱的人(这条队伍就是宏任务队列,存钱是宏任务,其他业务都是微任务)。这时一个“宏大爷”被叫号了,他就上前去办理存钱业务,办理时“宏大爷”突然想改个密码,那么银行职员就将改密码列为待办事项(微任务进入微任务队列),当存钱宏任务完成后就可以处理衍生出来的改密码微任务,大爷不用为了改密码而再次排队。改密码时大爷又说办个信用卡,那就再加到微任务队列里。但要是“宏大爷”说他老伴也要存钱(这也是宏任务),那么不好意思,需要再次取号到后面排队(宏任务进入宏任务队列)。
5、传统js事件循环示例
setTimeout(()=>{
new Promise(resolve =>{
resolve();
}).then(()=>{
console.log('test');
});
console.log(4);
});
new Promise(resolve => {
resolve();
console.log(1)
}).then( () => {
console.log(3);
Promise.resolve().then(() => {
console.log('before timeout');
}).then(() => {
Promise.resolve().then(() => {
console.log('also before timeout')
})
})
})
console.log(2);
// 结果:1 -> 2 -> 3 -> before timeout -> also before timeout -> 4
执行顺序:
① 遇到setTimeout,异步宏任务,将() => {console.log(4)}放入宏任务队列中;
② 遇到new Promise,new Promise在实例化的过程中所执行的代码都是同步进行的,所以输出1;
③ 而Promise.then中注册的回调才是异步执行的,将其放入微任务队列中
④ 遇到同步任务console.log(2),输出2;主线程中同步任务执行完
⑤ 从微任务队列中取出任务到主线程中,输出3,此微任务中又有微任务,Promise.resolve().then(微任务a).then(微任务b),将其依次放入微任务队列中;
⑥ 从微任务队列中取出任务a到主线程中,输出 before timeout;
⑦ 从微任务队列中取出任务b到主线程中,任务b又注册了一个微任务c,放入微任务队列中;
⑧ 从微任务队列中取出任务c到主线程中,输出 also before timeout;微任务队列为空
⑨ 从宏任务队列中取出任务到主线程,此任务中注册了一个微任务d,将其放入微任务队列中,接下来遇到输出4,宏任务队列为空
⑩ 从微任务队列中取出任务d到主线程 ,输出test,微任务队列为空。
二、NodeJS的事件循环
浏览器中的事件循环由HTML规范来定义,之后由各浏览器厂商实现,而node中事件循环的定义与实现均由libuv引擎完成。
node使用chrome v8引擎作为js解释器,v8引擎分析js代码后,主线程立即执行同步任务,而异步任务则由libuv引擎驱动执行,而且不同异步任务的回调事件会放在不同的队列中等待主线程执行(不再是简单的宏任务队列与微任务队列)。 因此在nodejs中,虽然程序运行表现出的整体状态与浏览器中传统js大致相同——先同步后异步,但是对于异步的部分,node则依靠libuv引擎来进行更复杂的管理。
1、6个基本阶段(6个宏任务队列)
node中的事件循环共分为6各阶段,如下图所示:
这6个阶段每个都有一个回调任务的FIFO事件队列。 尽管每个阶段都有其自己的特殊方式,但是通常当事件循环进入每个阶段时,将依次执行该阶段事件队列中的回调任务,直到队列耗尽或执行的回调任务数量到达系统设定的阈值为止,接着事件循环将移至下一个阶段。所有的阶段都处理完成后即完成一次事件循环的迭代,接着进入下一次迭代,依此类推。每个阶段具体会处理的任务内容如下所示:
(1)timers
执行定时器——setlnterval(),setTimeout() 的回调函数。
(2)pending callbacks
执行某些系统级操作的回调函数,例如TCP错误等。
(3)idle,prepare
仅node内部使用,可以忽略。
(4)poll
poll是一个重要的阶段,这个阶段支撑了整个消息循环机制。该阶段执行与 I/O操作的回调、并检索是否有新的 I/O操作的回调进入。
-
如果poll阶段的事件队列中有回调任务,则依次执行直到清空队列。
-
当事件队列空时,事件循环可能会在此阶段阻塞一段时间以等待新的 I/O操作回调进入,以下两种情况不会阻塞:
① 若check阶段的事件队列中有回调任务,则结束poll阶段,进入check阶段;
② 若有一个或多个定时器的回调准备就绪(timers阶段的事件队列中进入了回调任务),则结束poll阶段,经过check阶段、close callbacks阶段,最终进入timers阶段执行回调任务,从而开启下一次循环迭代。
-
若上述两种不阻塞的情况都不满足,则会一直在poll阶段阻塞。
(5)check
执行 setlmmediate() 的回调函数
(6)close callbacks
执行与关闭事件相关的回调函数,例如关闭数据库连接、关闭网络连接等,用于资源清理。
2、nextTick队列(微任务队列)
该事件队列独立于6个阶段的事件队列之外,用于存储 process.nextTick() 的回调函数。
3、microTask队列(微任务队列)
该事件队列也独立于6个阶段的事件队列之外,用于存储 Promise(Promise.then()、Promise.catch()、Promise.finally())的回调函数。
4、NodeJS事件循环流程
上述的6个基本阶段和两个独立的事件队列构成了node事件循环的核心部分,在一次循环迭代的流程中有以下几点需要注意:
① nextTick队列、microTask队列中的任务穿插于6个阶段之间进行,每个阶段进行前会先执行并清空nextTick队列、microTask队列中的回调任务(可以理解为一次循环迭代会至少处理6次nextTick队列和microTask队列中是否有任务);
② nextTick队列、microTask队列执行的次数在Node v11.x版本前后有一些差异(上文使用了**“至少”**的原因),具体如下:
- Node版本 < v11.x,nextTick队列、microTask队列中的任务只会在6个阶段之间进行,因此一次循环迭代中最多处理6次这两个队列;
- Node版本 > v11.x,任何一个阶段的事件队列中的任务之间都会处理一次这两个队列,因此一次循环迭代中至少处理6次这两个队列,上限则受各阶段总任务数影响而不固定;
- 上述的这个版本之间的区别,被认为是一个应该要修复的Bug,因此在v11.x之后node修改了nextTick队列、microTask队列的处理时机。从宏、微任务的角度看,修复后的流程与传统js的事件循环保持了一致,该修复点也可以在GitHub中Node项目的issues/22257中浏览更详细的讨论。
③ nextTick队列中任务的优先度高于microTask队列。
综上所述,node中的事件循环流程图大致为如下所示:
5、setTimeout() 与 setlmmediate() 的特殊情况
我们知道 setTimeout()的回调是在 timers阶段执行,setImmediate()的回调是在 check阶段执行,并且事件循环是从 timers阶段开始的,那么 setTimeout()的回调一定会先于 setImmediate()的回调执行吗?答案是不一定。在只有这两个函数且近乎同时触发的情况下,他们回调的执行顺序不是固定的(受调用时机、计算机性能影响)。下面是一个例子:
// 示例1(node v12.16.3)
setTimeout(() => {
console.log("setTimeout");
});
setImmediate(() => {
console.log("setImmediate");
});
// 结果:
// setTimeout -> setImmediate
// 或
// setImmediate -> setTimeout
上面示例1中的这段代码输出结果就是不固定的,这是因为这种情况下回调不一定完全准备好了。因为主线程没有同步代码需要执行,程序一开始就进入了事件循环。这时setTimeout()的回调并不是一定完全准备好了,因此就可能会在第一次循环迭代的check阶段中执行setImmediate()的回调,再到第二次循环迭代的timers阶段执行setTimeout()的回调;同时也有可能setTimeout()的回调一开始就准备好了,这样就会按照先setTimeout()再setImmediate()的顺序执行回调。由此就造成了输出结果不固定的现象。
有以下两种方法可以使输出顺序固定:
① 人为添加同步代码的延时器,保证回调都准备好了(延时器的时长设定可能会受机器运行程序时的性能影响,因此该方法严格意义上并不能100%固定顺序)。
② 将这两个方法放入pending callbacks、idle,prepare、poll阶段中任意一个阶段即可,因为这些阶段执行完后是一定会先到check再到下一个迭代的timers。由于pending callbacks、idle,prepare阶段都偏向于系统内部,因此一般可以放入poll阶段中使用。
如下示例2,我们人为加上一个2000ms的延时器,输出的结果就固定了,如下所示:
//示例2(node v12.16.3)
setTimeout(() => {
console.log("setTimeout");
});
setImmediate(() => {
console.log("setImmediate");
});
const sleep = (delay) => {
const startTime = +new Date();
while (+new Date() - startTime < delay) {
continue;
}
};
sleep(2000);
// 结果:setTimeout -> setImmediate
如下示例3,我们将函数放入文件I/O的回调中,输出的结果也就固定了,如下所示:
//示例3(node v12.16.3)
const fs = require("fs");
fs.readFile("./fstest.js", "utf8", (err, data) => {
setTimeout(() => {
console.log("setTimeout");
});
setImmediate(() => {
console.log("setImmediate");
});
});
// 结果:setTimeout -> setImmediate
6、NodeJS事件循环示例
console.log('1'); //1层同步
//1层timers,setTimeout1
setTimeout(function() {
console.log('2'); //2层同步
process.nextTick(function() {
console.log('3'); //2层nextTick队列
})
new Promise(function(resolve) {
console.log('4'); //2层同步
resolve();
}).then(function() {
console.log('5'); //2层microTask队列
})
})
process.nextTick(function() {
console.log('6'); //1层nextTick队列
})
new Promise(function(resolve) {
console.log('7'); //1层同步
resolve();
}).then(function() {
console.log('8'); //1层microTask队列
})
//1层timers,setTimeout2
setTimeout(function() {
console.log('9'); //2层同步
process.nextTick(function() {
console.log('10'); //2层nextTick队列
})
new Promise(function(resolve) {
console.log('11'); //2层同步
resolve();
}).then(function() {
console.log('12'); //2层microTask队列
})
})
console.log('13'); //1层同步
//(node v12.16.3)结果:1 -> 7 -> 13 -> 6 -> 8 -> 2 -> 4 -> 3 -> 5 -> 9 -> 11 -> 10 -> 12
//(node v8.16.0)结果:1 -> 7 -> 13 -> 6 -> 8 -> 2 -> 4 -> 9 -> 11 -> 3 -> 10 -> 5 -> 12
执行顺序(基于node v12.16.3分析):
① 首先是1层同步代码部分直接执行,输出1、7、13;
② 进入事件循环;
③ 执行1层的nextTick队列,输出6;
④ 执行1层的microTask队列,输出8;
⑤ 进入timers阶段,由于setTimeout1的回调任务先进入队列,因此先执行setTimeout1的2层代码;
⑥ setTimeout1的2层同步代码部分直接执行,输出2、4;
⑦ 执行 setTimeout1的2层nextTick队列,输出3;
⑧ 执行 setTimeout1的2层microTask队列,输出5;
⑨ setTimeout1的2层代码均执行完毕,再执行setTimeout2的2层代码;
⑩ setTimeout2的2层同步代码部分直接执行,输出9、11;
⑪ 执行 setTimeout2的2层nextTick队列,输出10;
⑫ 执行 setTimeout1的2层microTask队列,输出12。
PS:node版本低于v11.x的执行结果已在上述注释中列出,这里不做赘述,可结合上文中的介绍自行分析。
参考资料:
Node官网——事件循环
形象化解读js事件循环以及node事件循环和浏览器事件循环的区别
JS 执行机制 详解(附例题)
一文深入了解 Node 中的事件循环
这么通俗易懂的Node事件循环,背就完了