Node.js 事件循环
Node.js 事件循环,定时器和 process.nextTick()
什么是事件循环?
事件循环是 Node.js 处理非阻塞 I/O 操作的机制——尽管 JavaScript 是单线程处理的——当有可能的时候,它们会把操作转移到系统内核中去。
因为目前大多数内核都是多线程的,所以它们可以在后台处理多种操作。当其中的一个操作完成的时候,内核通知 Node.js 将适合的回调函数添加到 轮询 队列中等待时机执行
事件循环机制解析
当 Node.js 启动后,它会初始化事件循环,处理已提供的输入脚本(或丢入 REPL,本文不涉及到),它可能会调用一些异步的 API、调度定时器,或者调用 process.nextTick()
,然后开始处理事件循环。
下面的图表展示了事件循环操作顺序的简化概览。
1 | ┌───────────────────────────┐ |
事件循环的六个阶段
- timers: 此阶段执行由
setTimeout()
和setInterval()
排序。 - pending callbacks: 执行 I/O 回调推迟到下一个循环 迭代。
- idle, prepare: 仅在内部使用。
- poll: 检索新的 I/O 事件; 执行与 I/O 相关的几乎任何回调(由“计时器”或 “
setImmediate()
”所设的紧邻回调除外); node 将在适当时机在此处暂停。 - check:
setImmediate()
回调在此处被调用。 - close callbacks:一些关闭的回调函数,如:
socket.on('close', ...)
。
工作原理
由于这些操作中的任何一个都可能调度 更多的 操作并且在 轮询(poll) 阶段被处理的新事件会被内核排列, 并且在处理轮询中的事件时,轮询事件可以排队。因此,长时间运行的回调可以允许轮询阶段运行长于计时器的 阈值(threshold)。
每一个阶段都会维护一个事件队列。可以把每一个圈想象成一个事件队列。这就和浏览器不一样了,浏览器最多两个队列(宏队列、微队列)。但是在node里边有六个队列。到达一个队列后,检查队列内是否有任务(也就是看下是否有回调函数)需要执行。如果有,就依次执行,直到全部执行完毕、清空队列。如果没有任务,进入下一个队列去检查。直到所有队列检查一遍,算一个轮询。其中,timers、pending callback、idle prepare等执行完毕后,到达poll队列。
各阶段详解
timers阶段
timers并非真正意义上的队列,他内部存放的是计时器。每次到达这个队列,会检查计时器线程内的所有计时器,计时器线程内部多个计时器按照时间顺序排序。
检查过程:将每一个计时器按顺序分别计算一遍,计算该计时器开始计时的时间到当前时间是否满足计时器的间隔参数设定(比如1000ms,计算计时器开始计时到现在是否有1m)。当某个计时器检查通过,则执行其回调函数。
从技术上讲, 轮询 阶段 控制执行计时器的时间。
示例:
1 | const startTime = new Date(); |
上述代码解析:
- 执行全局上下文,打印「node 生命周期 + 时间」
- 询问是否有event loop
- 有,进入timers队列,检查没有计时器(cpu处理速度可以,这时还没到200ms)
- 轮询进入到poll,读文件还没读完(比如此时才用了20ms),因此poll队列是空的,也没有任务回调
- 在poll队列等待……不断轮询看有没有回调
- 文件读完,poll队列有了fsFunc回调函数,并且被执行,输出「fs + 时间」
- 在while死循环那里卡300毫秒,
- 死循环卡到200ms的时候,f1回调进入timers队列。但此时poll队列很忙,占用了线程,不会向下执行。
- 直到300ms后poll队列清空,输出「结束死循环 + 时间」
- event loop赶紧向下走
- 再来一轮到timers,执行timers队列里的f1回调。于是看到「setTimeout + 时间」
- timers队列清空,回到poll队列,没有任务,等待一会。
- 等待时间够长后,向下回到event loop。
- event loop检查没有其他异步任务了,结束线程,整个程序over退出。
pending callback 阶段
此阶段对某些系统操作(如 TCP 错误类型)执行回调。例如,如果 TCP 套接字在尝试连接时接收到 ECONNREFUSED
,则某些 *nix 的系统希望等待报告错误。这将被排队以在 挂起的回调 阶段执行。
Poll 轮询阶段
如果poll中有回调函数需要执行,依次执行回调,直到清空队列。
如果poll中没有回调函数需要执行,已经是空队列了。则会在这里等待,等待其他队列中出现回调,
- 如果其他队列中出现回调,则从poll向下到over,结束该阶段,进入下一阶段。
- 如果其他队列也都没有回调,则持续在poll队列等待,直到任何一个队列出现回调后再进行工作。(是个小懒虫的处事方式)
示例:
1 | setTimeout(() => { |
以上代码的事件流程梳理:
- 进入主线程,执行setTimeout(),回调函数作为异步任务被放入异步队列timers队列中,暂时不执行。
- 继续向下,执行定时器后边的console,打印“node”。
- 判断是否有事件循环。是,走一圈轮询:从timers - pending callback - idle prepare……
- 到poll队列停下循环并等待。
- 由于这时候没到5秒,timers队列无任务,所以一直在poll队列卡着,同时轮询检查其他队列是否有任务。
- 等5秒到达,setTimeout的回调塞到timers内,例行轮询检查到timers队列有任务,则向下走,经过check、close callbacks后到达timers。将timers队列清空。
- 继续轮询到poll等待,询问是否还需要event loop,不需要,则到达over结束。
Check阶段
检查阶段(使用 setImmediate 的回调会直接进入这个队列)
check队列的实际工作原理
真正的队列,里边扔的就是待执行的回调函数的集合。类似[fn,fn]这种形式的。每次到达check这个队列后,立即按顺序执行回调函数即可【类似于[fn1,fn2].forEach((fn)=>fn())的感觉】
setImmediate() 与 setTimeout(0) 的对比
- setImmediate的回调是异步的,和setTimeout回调性质一致。
- setImmediate回调在
check
队列,setTimeout回调在timers
队列(概念意义,实际在计时器线程,只是setTimeout在timers队列做检查调用而已。详细看timers的工作原理)。 - setImmediate函数调用后,回调函数会立即push到check队列,并在下次eventloop时被执行。setTimeout函数调用后,计时器线程增加一个定时器任务,下次eventloop时会在timers阶段里检查判断定时器任务是否到达时间,到了则执行回调函数。
- 综上,setImmediate的运算速度比setTimeout(0)的要快,因为setTimeout还需要开计时器线程,并增加计算的开销。
示例 1:
1 | setTimeout(() => { |
打印顺序不定,这是因为setTimeout的间隔数最小填1,虽然下边代码填了0。但实际计算机执行当1ms算。(这里注意和浏览器的计时器区分。在浏览器中,setInterval的最小间隔数为10ms,小于10ms则会被设置为10;设备供电状态下,间隔最小为16.6ms。)
以上代码,主线程运行的时候,setTimeout函数调用,计时器线程增加一个定时器任务。setImmediate函数调用后,其回调函数立即push到check队列。主线程执行完毕。
eventloop判断时,发现timers和check队列有内容,进入异步轮询:
第一种情况:等到了timers里这段时间,可能还没有1ms的时间,定时器任务间隔时间的条件不成立所以timers里还没有回调函数。继续向下到了check队列里,这时候setImmediate的回调函数早已等候多时,直接执行。而再下次eventloop到达timers队列,定时器也早已成熟,才会执行setTimeout的回调任务。于是顺序就是「setImmediate -> setTimeout」。
第二种情况:但也有可能到了timers阶段时,超过了1ms。于是计算定时器条件成立,setTimeout的回调函数被直接执行。eventloop再向下到达check队列执行setImmediate的回调。最终顺序就是「setTimeout -> setImmediate」了。
所以,只比较这两个函数的情况下,二者的执行顺序最终结果取决于当下计算机的运行环境以及运行速度。
示例2:
1 | ------------------setTimeout测试:------------------- |
结果:可见setTimeout远比setImmediate耗时多得多,这是因为setTimeout不仅有主代码执行的时间消耗。还有在timers队列里,对于计时器线程中各个定时任务的计算时间。
示例3:
1 | // 说说下边代码的执行顺序,先打印哪个? |
结果:setImmediate
先于 `setTimeout,因为fs各个函数的回调是放在poll队列的。当程序holding在poll队列后,出现回调立即执行。回调内执行setTimeout和setImmediate的函数后,check队列立即增加了回调。回调执行完毕,轮询检查其他队列有内容,程序结束poll队列的holding向下执行。check是poll阶段的紧接着的下一个。所以在向下的过程中,先执行check阶段内的回调,也就是先打印setImmediate。到下一轮循环,到达timers队列,检查setTimeout计时器符合条件,则定时器回调被执行。
nextTick 与 Promise
说完宏任务,接下来说下微任务
- 二者都是「微队列」,执行异步微任务。
- 二者不是事件循环的一部分,程序也不会开启额外的线程去处理相关任务。(理解:promise里发网络请求,那是网络请求开的网络线程,跟Promise这个微任务没关系)
- 微队列设立的目的就是让一些任务「马上」、「立即」优先执行。
- nextTick与Promise比较,nextTick的级别更高。
示例1:
1 | // 先思考下列代码的执行顺序 |
最终顺序:
- global
- nextTick 1
- nextTick 2
- promise 1
- nextTick in promise
- setImmediate
综合示例:
1 | setTimeout(() => { |
执行顺序及具体原因说明如下:
global
:主线程同步任务,率先执行没毛病nextTick 1
:执行异步宏任务之前,清空异步微任务,nextTick优先级高,先行一步nextTick 2
:执行完上边这句代码,又一个nextTick微任务,立即率先执行promise 1
:执行异步宏任务之前,清空异步微任务,Promise的优先级低,所以在nextTick完了以后立即执行nextTick in promise
:清空Promise队列的过程中,遇到nextTick微任务,立即执行、清空setTimeout 0
: 解释第一个问题. 没有上边的nextTick和promise,只有setTimeout和setImmediate时他俩的执行顺序不一定。有了以后肯定是0先开始。可见,执行一个宏队列之前,就先按顺序检查并执行了nextTick和promise微队列。等微队列全部执行完毕,setTimeout(0)的时机也成熟了,就被执行。nextTick in setTimeout
:执行完上边这句代码,又一个nextTick微任务,立即率先执行 【这种回调函数里的微任务,我不能确定是紧随同步任务执行的;还是放到微任务队列,等下一个宏任务执行前再清空的他们。但是顺序看上去和立即执行他们一样。不过我比较倾向于是后者:先放到微任务队列等待,下一个宏任务执行前清空他们。】setTimeout 1
:因为执行微任务耗费时间,导致此时timers里判断两个0和1的setTimeout计时器已经结束,所以两个setTimeout回调都已加入队列并被执行nextTick in setTimeout1
:执行完上边这句代码,又一个nextTick微任务,立即率先执行 【可能是下一个宏任务前清空微任务】promise in setTimeout1
:执行完上边这句代码,又一个Promise微任务,立即紧随执行 【可能是下一个宏任务前清空微任务】setImmediate
:poll队列回调时机未到,先行向下到check队列,清空队列,立即执行setImmediate回调nextTick in setImmediate
:执行完上边这句代码,又一个nextTick微任务,立即率先执行 【可能是下一个宏任务前清空微任务】poll 1
:poll队列实际成熟,回调触发,同步任务执行。nextTick in poll
:执行完上边这句代码,又一个nextTick微任务,立即率先执行 【可能是下一个宏任务前清空微任务】setTimeout 100
:定时器任务到达时间,执行回调。并在回调里往微任务推入了nextTick、Promise,往宏任务的check里推入了setImmediate的回调。并且也开启了计时器线程,往timers里增加了下一轮回调的可能。nextTick in setTimeout100
:宏任务向下前,率先执行定时器回调内新增的微任务-nextTick 【这里就能确定了,是下一个宏任务前清空微任务的流程】promise in setTimeout100
:紧接着执行定时器回调内新增的微任务-Promise 【清空完nextTick清空Promise的顺序】setImmediate in setTimeout 100
:这次setImmediate比setTimeout(0)先执行的原因是:流程从timers向后走到check队列,已经有了setImmediate的回调,立即执行。nextTick in setImmediate in setTimeout 100
:执行完上边这句代码,又一个nextTick微任务,下一个宏任务前率先清空微任务setTimeout 100 - 0
:轮询又一次回到timers,执行100-0的回调。nextTick in setTimeout 100 - 0
:执行完上边这句代码,又一个nextTick微任务,下一个宏任务前率先清空微任务。
综合示例2:
1 | async function async1() { |
脑图