阅读之前你需具备的知识
- 进程和线程是什么,它们区别又是什么?
- 单线程和多线程的区别?
Node.js特点
-
单线程
同步进行,只有前面的代码执行完了才会往下面执行。但是node.js程序在宏观上看是并行的,这是由于它具有非阻塞I/O和事件驱动的特点。
好处:减少了内存开销,不再有进程创建,销毁的开销;比较简单。
坏处:一个用户造成了线程的崩溃,整个服务就都崩溃了。
-
非阻塞I/O
I/O会阻塞代码的执行,极大地降低了程序的执行效率。
而非阻塞模式下,一个线程永远在执行计算操作,这个线程的CPU核心利用率永远是100%。
-
事件驱动
在node中,在一个时刻只能执行一个事件回调函数,但是在执行一个回调函数的中途,可以转而处理其他事件(I/O事件,网络请求...),这些事件需要消耗比较多的时间,把这些异步操作都先添加到事件队列的后面,然后返回继续执行原事件的回调函数,这称为事件循环机制。
底层C++代码中,近半数都用于事件队列、回调函数队列的构建。
适用场景
从node.js的特点中我们可以发现,它最擅长的就是任务调度,如果业务中过度占用了CPU进行计算,实际上也相当于这个计算阻塞了这个单进程,这就不适合用Node.js开发。
当应用程序需要处理大量并发的I/O,而在向客户端发出响应之前,应用程序内部并不需要进行非常复杂的处理的时候,就比较适合用Node.js开发。
Node.js也适合与Web socket配合,开发长连接的实时应用程序。
事件循环
执行栈和事件队列
-
执行栈
当我们调用一个方法的时候,js会生成一个与这个方法相对应的执行环境。而当一系列方法被依次调用的时候,因为js是单线程的,同一时间只能执行一个方法,于是这些方法被排队在一个单独的地方。这个地方被称为执行栈。
-
事件队列
当我们发起异步请求后,主线程并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。等异步任务返回结果后,该异步任务按照执行顺序,加入到与执行栈不同的另一个队列,这个队列被称为事件队列。
由此我们知道,在Node环境中javascript运行的线程是单线程的,事件循环的线程也是单线程的,但这两个不是一个线程。
JavaScript 事件循环机制分为浏览器和 Node 事件循环机制,浏览器 Event Loop 是 HTML 中定义的规范,Node Event Loop 是由 libuv 库实现。我们先来了解一下浏览器的事件循环~
浏览器事件循环
浏览器中的事件循环,主要就在理解宏任务和微任务这两种异步任务。
- 宏任务(macrotask)
setTimeOut 、 setInterval 、 setImmediate 、 I/O 、 各种callback、 UI渲染 、messageChannel等。
优先级:主代码块 > setImmediate > postMessage > setTimeOut/setInterval
- 微任务(microtask)
process.nextTick 、Promise 、MutationObserver 、async(实质上也是promise)
优先级:process.nextTick > Promise > MutationOberser
执行分区:
我们常常吧EventLoop中分为 内存、执行栈、WebApi、异步回调队列(包括微任务队列和宏任务队列)。 第一次事件循环中,JavaScript 引擎会把整个 script 代码当成一个宏任务执行,执行完成之后,再检测本次循环中是否寻在微任务,存在的话就依次从微任务的任务队列中读取执行完所有的微任务,再读取宏任务的任务队列中的任务执行,再执行所有的微任务,如此循环。JS 的执行顺序就是每次事件循环中的宏任务-微任务。
Node.js事件循环
Node.js采用V8作为js的解析引擎,而I/O处理方面使用了自己设计的libuv。libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现。
根据上图,我们可以了解到Node.js的运行机制分为一下几个步骤:
- 我们写的js代码会交给v8引擎进行处理。
- 解析后的代码会调用NodeApi,再交由libuv库去执行。
- libuv会将不同的任务分配给不同的线程,形成一个事件循环(Event Loop)。
- 任务处理完成后会以异步的方式将执行结果返回给V8引擎、再由v8返回给我们。
Node.js事件循环原理
1.timers 阶段:这个阶段执行timer(setTimeout、setInterval)的回调
2.I/O callbacks 阶段:执行一些系统调用错误,比如网络通信的错误回调
3.idle, prepare 阶段:仅node内部使用
4.poll 阶段:获取新的I/O事件, 适当的条件下node将阻塞在这里
5.check 阶段:执行 setImmediate() 的回调
6.close callbacks 阶段:执行 socket 的 close 事件回调
我们重点看timers、poll、check这3个阶段,因为日常开发中的绝大部分异步任务都是在这3个阶段处理的。
- timers 阶段
timers 是事件循环的第一个阶段,Node 会去检查有无已过期的timer,如果有则把它的回调压入timer的任务队列中等待执行,事实上,Node 并不能保证timer在预设时间到了就会立即执行,因为Node对timer的过期检查不一定靠谱,它会受机器上其它运行程序影响,或者那个时间点主线程不空闲。比如下面的代码,setTimeout() 和 setImmediate() 的执行顺序是不确定的。
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
但是把它们放到一个I/O回调里面,就一定是 setImmediate() 先执行,因为poll阶段后面就是check阶段。
-
poll 阶段
poll 阶段主要有2个功能:
1.处理 poll 队列的事件。
2.当有已超时的 timer,执行它的回调函数。
event loop将同步执行poll队列里的回调,直到队列为空或执行的回调达到系统上限(上限具体多少未详),接下来event loop会去检查有无预设的setImmediate(),分两种情况:
1.若有预设的setImmediate(), event loop将结束poll阶段进入check阶段,并执行check阶段的任务队列。
2.若没有预设的setImmediate(),event loop将阻塞在该阶段等待。
我们发现,没有setImmediate()会导致event loop阻塞在poll阶段,那这样之前设置的timer岂不是执行不了了?
所以,在poll阶段执行的时候,会传入一个timeout超时时间,该超时时间就是poll阶段的最大阻塞时间。timeout时间未到的时候,如果有事件返回,就执行该事件注册的回调函数。timeout超时时间到了,则退出poll阶段,执行下一个阶段。
- check 阶段
setImmediate()的回调会被加入check队列中,check阶段的执行顺序在poll阶段之后。
总结
-
node 的初始化
- 初始化 node 环境。
- 执行输入代码。
- 执行 process.nextTick 回调。
- 执行 microtasks。
-
进入 event-loop
-
进入 timers 阶段
- 检查 timer 队列是否有到期的 timer 回调,如果有,将到期的 timer 回调按照 timerId 升序执行。
- 检查是否有 process.nextTick 任务,如果有,全部执行。
- 检查是否有microtask,如果有,全部执行。
- 退出该阶段。
-
进入IO callbacks阶段。
- 检查是否有 pending 的 I/O 回调。如果有,执行回调。如果没有,退出该阶段。
- 检查是否有 process.nextTick 任务,如果有,全部执行。
- 检查是否有microtask,如果有,全部执行。
- 退出该阶段。
-
-
进入 idle,prepare 阶段:
- 这两个阶段与我们编程关系不大,暂且按下不表。
-
进入 poll 阶段
-
首先检查是否存在尚未完成的回调,如果存在,那么分两种情况。
-
第一种情况:
- 如果有可用回调(可用回调包含到期的定时器还有一些IO事件等),执行所有可用回调。
- 检查是否有 process.nextTick 回调,如果有,全部执行。
- 检查是否有 microtaks,如果有,全部执行。
- 退出该阶段。
-
第二种情况:
- 如果没有可用回调。
- 检查是否有 immediate 回调,如果有,退出 poll 阶段。如果没有,阻塞在此阶段,等待新的事件通知。
-
-
如果不存在尚未完成的回调,退出poll阶段。
-
-
进入 check 阶段。
- 如果有immediate回调,则执行所有immediate回调。
- 检查是否有 process.nextTick 回调,如果有,全部执行。
- 检查是否有 microtaks,如果有,全部执行。
- 退出 check 阶段
-
进入 closing 阶段。
- 如果有immediate回调,则执行所有immediate回调。
- 检查是否有 process.nextTick 回调,如果有,全部执行。
- 检查是否有 microtaks,如果有,全部执行。
- 退出 closing 阶段
-
检查是否有活跃的 handles(定时器、IO等事件句柄)。
- 如果有,继续下一轮循环。
- 如果没有,结束事件循环,退出程序。
在事件循环的每一个子阶段退出之前都会按顺序执行如下过程:
- 检查是否有 process.nextTick 回调,如果有,全部执行。
- 检查是否有 microtaks,如果有,全部执行。
- 退出当前阶段。
?
// 在node环境下,请输出以下代码的执行结果
console.log('1');
process.nextTick(function() {
console.log('2');
});
setTimeout(() => {
console.log('3');
}, 0);
async function async1() {
console.log('4');
await async2();
console.log('5');
}
async function async2() {
console.log('6');
}
async1();
new Promise(function(resolve) {
console.log('7')
resolve();
}).then(function() {
console.log('8')
});
console.log('9');
-
按照js由上到下的执行顺序,先遇到同步任务console输出1。
-
再往下执行遇到process.nextTick,回调加入微任务队列。
-
接着遇到setTimeout,setTimeout是宏任务,会先放到宏任务队列中。
-
promise中的异步体现在then和catch中,所以写在promise中的代码是被当做同步任务立即执行的。而在async/await中,在出现await出现之前,其中的代码也是立即执行的。async修饰的函数,默认返回 new Promise对象的resolve内容(若被async修饰的函数无返回值,则最终无返回值)。async方法内部,当程序执行到await方法时,会阻塞await方法后面的程序,进入await方法内部并执行到return前,然后跳出该async方法,执行与该async方法并列的同步任务。
所以,接下来遇到async/await时,执行async1以及await后面的函数,先立刻输出4,6,然后先跳出async1(等到与async1方法并列的同步代码执行完后,跳回async1内部),此时await返回值为非Promise,继续执行async函数后面的代码。
紧接着遇到promise,先立刻输出7,然后将then回调加到微任务队列。
-
再往下遇到同步任务输出9。
-
此时执行栈中的任务已经清空。由于微任务的优先级高于宏任务,所以会先执行微任务队列中的回调。因为微任务队列中nextTick任务在Promise.next前,所以先执行异步任务process.nextTick,接着是await返回结果后跳回async1内部继续执行async函数后面的代码,最后是then中的回调,所以输出结果依次为2,5,8。
-
微任务队列清空,该轮循环结束,但是发现事件队列中还有任务,开始进入下一轮循环。
-
新的一轮循环由timers阶段开始,发现有对应的setTimeout回调,输出3。
最终输出结果即为:1、4、6、7、9、2、5、8、3
Node.js 与浏览器的 Event Loop 差异
浏览器环境下,microtask的任务队列是每个macrotask执行完之后执行。 Node.js中,microtask会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行microtask队列的任务。
参考
剖析nodejs的事件循环: juejin.cn/post/684490…
深入浅出NodeJS事件循环: juejin.cn/post/691676…
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!