
六、Node中的事件循环
一、浏览器中的事件循环
在浏览器中,遇到js代码
- 首先会执行同步代码
- 在执行任何一个宏任务之前,都会检查微任务队列是否有任务需要执行
- 不为空,执行微任务,直到微任务队列清空
- 若为空,执行宏任务
1 | setTimeout(function () { |
代码执行:
- 遇见定时器,加入宏任务队列
- 遇到Promise,Promise里面的是同步代码,立即执行 输出pr1
- .then属于微任务加入微任务队列
- 遇见定时器加入宏任务队列
- 同步代码立即执行输出 2
- 遇见queueMiicrotask 加入微任务队列
- Promise.then加入微任务队列
- 清空微任务,输出then1 queueMicrotask1 then3
- 执行宏任务 输出set1,遇到promise.then依次加入微任务
- 执行第二个宏任务,看微任务队列不为空,执行微任务,输出then2 then4
- 执行宏任务 输出set2
1 | async function async1 () { |
遇到async await就记住两个问题
- await相当于promise中的同步代码,立即执行
- await后的代码相当于.then,要加入到微任务队列
二、Node架构分析
浏览器中的EventLoop是根据HTML5定义的规范来实现的,不同的浏览器可能会有不同的实现,而Node中是由libuv实现的。
来看Node架构图:
- 会发现libuv中主要维护了一个EventLoop和worker threads(线程池);
- EventLoop负责调用系统的一些其他操作:文件的IO、Network、child-processes等
- libuv是一个多平台的专注于异步非阻塞IO的库,它最初是为Node开发的,但是现在也被使用到Luvit、Julia、pyuv等其他地方;
阻塞与非阻塞
我们任何程序中的文件操作都是需要进行系统调用(操作系统的文件系统);
事实上对文件的操作,是一个操作系统的IO操作(输入、输出);
操作系统为我们提供了阻塞式调用和非阻塞式调用:
- 阻塞式调用: 调用结果返回之前,当前线程处于阻塞态(阻塞态CPU是不会分配时间片的),调用线程只有在得到调用结果之后才会继续执行。
- 非阻塞式调用: 调用执行之后,当前线程不会停止执行,只需要过一段时间来检查一下有没有结果返回即可。
所以我们开发中的很多耗时操作,都可以基于这样的 非阻塞式调用:
- 比如网络请求本身使用了Socket通信,而Socket本身提供了select模型,可以进行非阻塞方式的工作;
- 比如文件读写的IO操作,我们可以使用操作系统提供的基于事件的回调机制;
非阻塞io的问题
但是非阻塞IO也会存在一定的问题:我们并没有获取到需要读取(我们以读取为例)的结果,那么就意味着为了可以知道是否读取到了完整的数据,我们需要频繁的去确定读取到的数据是否是完整的;
- 这个过程我们称之为轮询操作;
- 那么这个轮训的工作由谁来完成呢?
- 如果我们的主线程频繁的去进行轮训的工作,那么必然会大大降低性能;
- 并且开发中我们可能不只是一个文件的读写,可能是多个文件;
- 而且可能是多个功能:网络的IO、数据库的IO、子进程调用;
- libuv提供了一个线程池(Thread Pool):
- 线程池会负责所有相关的操作,并且会通过轮训等方式等待结果;
- 当获取到结果时,就可以将对应的回调放到事件循环(某一个事件队列)中;
- 事件循环就可以负责接管后续的回调工作,告知JavaScript应用程序执行对应的回调函数;
阻塞和非阻塞,同步和异步
如果是阻塞的话, 那么该线程就会一直等到这个请求完成之后才能被释放用于其他请求。
如果是非阻塞的话, 那么该线程就可以发起请求后而不用等请求完成继续做其他事情。
Node中的事件循环
多个任务队列
Node种一次完整的事件循环Tick分成很多个阶段:
定时器(Timers):本阶段执行已经被 setTimeout() 和 setInterval() 的调度回调函数。
待定回调(Pending Callback):对某些系统操作(如TCP错误类型)执行回调,比如TCP连接时接收到
ECONNREFUSED。
idle, prepare:仅系统内部使用。
轮询(Poll):检索新的 I/O 事件;执行与 I/O 相关的回调;
检测:setImmediate() 回调函数在这里执行。
关闭的回调函数:一些关闭的回调函数,如:socket.on(‘close’, …)
Node中也有宏任务微任务,但是却有很多任务队列
微任务有些不同:
- next tick queue:process.nextTick;
- other queue:Promise的then回调、queueMicrotask;
- process.nextTick最先执行。
在Node中代码从上到下同步执行,在执行过程中会将不同的任务添加到相应的队列中
- 比如说setTimeout就会放在timers中, 如果遇到文件读写就放在poll里面
等到整个同步代码执行完毕之后就会去执行满足条件的微任务。可以假想有一个队列用于存放微任务,这个队列和前面的六种没有任何关系。
当同步代码执行完成之后会去执行满足条件的微任务,一旦所有的微任务执行完毕就会按照上面列出的顺序去执行队列当中满足条件的宏任务。
- 首先会执行timers当中满足条件的宏任务,当他将timers中满足的任务执行完成之后就会去执行队列的切换,在切换之前会先去清空微任务列表中的微任务。
所以微任务执行是有两个时机的,第一个时机是所有的同步代码执行完毕,第二个时机队列切换前。
注意在微任务中nextTick的执行优先级要高于Promise,这个只能死记了。
show me code:
1 | async function async1() { |
执行代码开始:
首先遇见async函数,定义没执行,继续往下走
遇见同步代码输出script start
遇到第一个定时器,放入times队列 这个0是尽快放入将回调放入times队列
遇到第二个定时器,等延迟完再放入times队列,这里不会
遇到setImmediate放到check队列
遇到process.nextTick放入微任务队列
async1执行,输出async start 遇到await输出async2,await后面代码放入微任务队列
遇到process.nextTick放入微任务队列
遇到Promise,执行里面的同步代码promise1,promise2 .then放入微任务队列
遇到同步代码输出script end
执行宏任务之前要清空微任务队列
process.nextTick优先级最高 输出nextTick1 nextTick2
下一个微任务输出async1 end promise3
微任务清空,执行宏任务,输出setTimeout0
进入check队列 输出setImmediate
进入宏任务队列输出setTimeout2
注意:times队列放的是定时器的回调函数,放的不是整个定时器,第二个定时器有延迟,不会立即放入队列,也就不会在第一个输出为紧接着输出。
1 | setTimeout(() => { |
代价执行:
遇到定时器放到timers队列
.then放入微任务队列
同步代码输出start
遇到定时器放到timers队列
同步代码输出end
清空微任务队列,输出p2
执行宏任务输出s1 ,遇到微任务放入微任务队列
执行宏任务输出s2,遇到微任务放入微任务队列
清空微任务,输出t1 t2 p1 p3
Node与浏览器的事件循环不同
浏览器在执行第一个宏任务输出s1,遇到微任务也是放到微任务队列
但是执行第二个宏任务,会先清空微任务队列
所以输出完s1 紧接着输出p1 (浏览器中没有process)
这就是相差点:
- 微任务执行时机不同,不过他们也有相同的地方就是在同步任务执行完毕之后都会去看一下微任务是否存在可执行的。
- 对浏览器来说每当一个宏任务执行完成之后就会清空一次微任务队列。在Node中只有在事件队列切换时才会去清空微任务队列。
- 最后在Node平台下微任务执行是有优先级的,nextTick优先于Promise.then, 而浏览器中则是先进先出。
setTimeout与setImmediate
这两个在Node中执行顺序不确定:
这个其实跟setTimeout的延迟,与事件循环的调用事件有关系
setTimeout设置为0只是尽快执行,这期间可能会被耽搁,假设耽搁10ms
那么如果时间循环在这10ms前执行了,此时timers队列没有任务,那么就会往下输出setImmediate
如果事件循环在10ms后执行了,那么timers有任务直接输出,再往下走输出setImmediate
程序的底层运行是不可知的。
像理解的透彻可参考:https://juejin.cn/post/6844904136144584711#heading-9