October 20, 2019

浅析浏览器与nodejs的event-loop相同与区别

这篇文章主要来聊一下事件循环;什么是事件循环?通常情况下,js是单线程处理主要任务,而除了同步逻辑之外,还有大部分异步逻辑;事件循环的规则就用于协调同步与异步任务的调用。js的事件循环在浏览器与nodejs不太一样,后面会展开说一下。

异步任务分类比较多,在浏览器端,有DOM事件,也有定时器,Promise等。在nodejs,也有process.nextTicksetImmediate等。下面会逐步介绍一下

浏览器

先从一个比较简单的例子入手,该例子运行于浏览器端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
setTimeout(() => {
    console.log('timeout')
}, 0)
new Promise((resolve) => {
    console.log('before resolve')
    resolve()
})
.then(() => {
    console.log('after resolve')
})

const now = Date.now()

// 避免setTimeout受最小延迟4ms影响
while (Date.now() - now < 10) {}

console.log('end')

输出结果是什么?

1
2
3
4
before resolve
end
after resolve
timeout

为什么是这样子的输出?这个例子大概是:先执行一个0ms延迟执行的定时器,紧接定义一个马上resolvepromise,然后就是一个10ms的循环block掉主线程;这个block是为了避免受定时器最小延迟因素影响。

首先输出的值是:before resolve,这是因为传入Promise的构造函数会被同步执行,至于为什么,可以查找一下Promise的实现原理。接下来输出的值是end,这也是同步的一部分,问题不大。

这里主要的疑惑是在定时器与Promise,按照最直观的感受是,定时器到时间而且先注册,Promise后面才注册,应该是先输出timeout,再输出after resolve

对于这个处理,需要引入一个macro task(宏任务)micro task(微任务)的概念;现在知道有这回事,js把异步任务分成两种;而这两种任务的优先级不同;事件循环的时候,可以想象:线程快速轮询,判断是否有这两种任务,有则执行;那么执行的时候,总是优先执行微任务,用代码简单描述:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let macroTaskQueue = []
let microTaskQueue = []

while (macroTaskQueue.length) {
    // 执行宏任务,按照FIFO顺序执行
    execute(macroTaskQueue[0])

    // 执行完毕检查是否有微任务,也是按照FIFO顺序执行
    while (microTaskQueue.length) {
        execute(microTaskQueue[0])
        microTaskQueue.shift()
    }

    // 执行完该宏任务,则从队列删除
    macroTaskQueue.shift()
}

宏任务会首先执行,因为主线程开始是属于宏任务;执行完同步任务后,检查是否有微任务,有则一直执行,直到把微任务的队列清空,然后再检查是否有宏任务,有则执行...一直轮询这个过程。

promise属于微任务,setTimeout属于宏任务;回去刚才的例子,当执行完输出end之后,相当于第一个宏任务已经完成;而这个时候setTimeout也到了设定时间,需要加入到macroTaskQueue的队列中;promise也从pending变成resolved状态了,也需要加入到microTaskQueue队列中;因为microTaskQueue有任务,所以需要先执行,输出after resolve之后;microTaskQueue队列就为空了;然后就检查macroTaskQueue队列,发现有任务,这个时候就输出timeout

浏览器中的微任务通常有:

  1. promise
  2. mutation observer 说明链接

宏任务通常除了以上的,基本都是,例如:

  1. setTimeout
  2. ajax
  3. requestAnimationFrame 与 requestIdleCallback (这两个后面会说到)
  4. dom事件回调等

requestAnimationFrame 与 requestIdleCallback

requestAnimationFrame简称为rAF,表示:

告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画

具体的api说明可以看mdn说明

requestIdleCallback简称rIC,表示浏览器渲染一帧的空闲时间进行调用;若执行没有超过给定的时间,则不会影响浏览器关键事件,例如动画和输入响应的。具体的api说明可以看mdn说明

我们来看一个稍微复杂一点的例子:

1
2
<!-- dom 结构-->
<div id="outer"></div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
document.getElementById('outer').addEventListener('click', () => {
  console.log('start')
  requestIdleCallback(deadline => {
    console.log('rIC', deadline.didTimeout)
  })
  requestAnimationFrame(() => {
    console.log('rAF')
  })
  setTimeout(() => {
    console.log('setTimeout')
  }, 0)
  Promise.resolve().then(() => {
    console.log('promise')
  })
  console.log('end')
}

大概的逻辑就是,对#outer的元素进行一个点击绑定,在点击的回调函数里面有各种异步操作,求输出的顺序。

按照我们之前的微/宏任务的划分,可以大概知道顺序是:

1
2
3
4
1. start
2. end
3. promise
?. rIC/rAF/setTimeout

输出顺序的1,2,3很好理解,主线的宏任务与微任务优先级比较高;但是rAF, rIC, setTimeout的输出是怎样的呢?按照之前我们的定义,这三个都属于宏任务,如果这三个按顺序进入macroTaskQueue的队列的话,也就是逐个输出;那么这个时候输出是:rIC => rAF => setTimeout吗?当多次点击触发的时候会发现,顺序并不固定。

这个时候就需要了解简单的原理: image.png 图片来源

上面这张图会简单描述了,浏览器中刷新每一帧所做的事情;每一帧中,都会按顺序处理js事件,timer,rAF,渲染等。从这里我们可以看到,rAF是在PaintLayout阶段之前处理的;

再看下一张图: image.png 图片来源

在一帧当中,如果动画渲染完毕,就会进入这一帧的Idle阶段,这个时候就会回调我们的rIC函数,这个函数接收的参数就可以知道当前帧是否有空闲时间、剩余多少空闲时间。

从上面的图大概可以知道每一帧所处理的主要事情;这个时候我们回到输出代码的例子:

1
?. rIC/rAF/setTimeout

这个顺序的不确定性,与浏览器中每一帧的处理有关系;如果在下一帧开始之前,setTimeout已经加入到macroTaskQueue队列中,则这个时候,rAFrIC也已经在这之后进入队列,这种情况所输出的顺序是:setTimeout => rAF => rIC

如果在下一帧开始之前,setTimeout的回调还没有被加入队列中,但是rAFrIC也已经进队了;这个时候就会先输出rAF => rIC;而setTimeout则在这一帧完成之后,才被加入到队列,所以会下一帧处理timer的过程输出值;这种情况最后的输出值为:rAF => rIC => setTimeout

还有一种情况是,这一帧处理已经开始了,setTimeout的回调还没有加入到队列中,当执行完rAF之后,setTimeout才加入到队列,那么这个时候是会执行timer还是rIC;根据测试发现(chrome 77.0),这个时候会去执行timer,再执行rIC;这种情况最后的输出值为:rAF => setTimeout => rIC.

小结:

  1. rAFsetTimeout的执行顺序取决于进入macroTaskQueue的顺序,而加入macroTaskQueue的顺序可能取决于这一帧的运行情况与setTimeout的时机;
  2. 在执行rIC之前,总是把队列中的任务都执行完,包括microTaskQueuemacroTaskQueue

dom事件顺序与队列关系

通常情况下,触发dom的回调事件除了在人工触发之外,还可以通过代码触发。例如点击事件,可以在元素上点击与主动触发$element.click();我们看下面的例子:

1
2
3
4
<!-- dom 结构 -->
<div id="outer">
  <div id="inner"></div>
</div>
1
2
3
4
5
6
7
8
9
function click (ev) {
  console.log('start', ev.currentTarget.id)
  Promise.resolve().then(() => {
    console.log('promise', ev.currentTarget.id)
  })
  console.log('end', ev.currentTarget.id)
}
document.getElementById('outer').addEventListener('click', click)
document.getElementById('inner').addEventListener('click', click)

当在界面点击#inner的时候,输出的顺序为:

1
2
3
4
5
6
1. start inner
2. end inner
3. promise inner
4. start outer
5. end outer
6. promise outer

上面输出问题不大,因为冒泡的事件触发也属于宏任务,Promise的触发属于微任务,队列变化的简单过程为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 0. 点击inner
macroTaskQueue = ['click(inner)', 'click(outer)']
microTaskQueue = []

// 1. inner的回调函数处理完毕,并出队
macroTaskQueue = ['click(outer)']
microTaskQueue = ['promise(inner)']

// 2. 微任务先执行,并出队
macroTaskQueue = ['click(outer)']
microTaskQueue = []

// 3. 再执行剩下outer,并出队
macroTaskQueue = []
microTaskQueue = ['promise(outer)']

// 4. 执行微任务
macroTaskQueue = []
micoTaskQueue = []

如果使用代码主动触发:$inner.click(),输出的顺序为:

1
2
3
4
5
6
1. start inner
2. end inner
3. start outer
4. end outer
5. promise inner
6. promise outer

这个时候任务队列的初始情况就变成了:

1
2
macroTaskQueue = ['click(inner);click(outer);']
microTaskQueue = []

冒泡的事件都变成到一个宏任务,具体为什么,就后续再做研究了...浏览器的事件循环就暂时到这里。

小结:

  1. 通过点击处理的dom回调函数,事件冒泡属于多个宏任务
  2. 通过主动代码触发的dom回调函数,事件冒泡则单个宏任务

nodejs

nodejs端的事件循环比浏览器会稍微复杂一点点,除了微/宏任务之外,还有不同的事件阶段,下面我们来看这张图 image.png 图片来源

nodejs中的事件分为6个阶段:

  1. timers
  2. pending callbacks
  3. idle, prepare
  4. poll
  5. check
  6. close callbacks

每个阶段都有一个 FIFO 队列来执行回调。虽然每个阶段都是特殊的,但通常情况下,当事件循环进入给定的阶段时,它将执行特定于该阶段的任何操作,然后在该阶段的队列中执行回调,直到队列用尽或最大回调数已执行。当该队列已用尽或达到回调限制,事件循环将移动到下一阶段,等等。

简单的说是,执行到每个阶段,都需要把该阶段的队列回调执行完或者达到最大的执行数量,所以nodejs的队列至少有6个。为什么说说至少呢,因为这个截图中没有把procese.nextTick()Promise相关列出来;这两种情况都会在进行下一阶段执行之前执行,而nextTick的优先级要比Promise要高,因此这个图就变成了:

image.png

上图没有把所有都用箭头标出来,只是列举了前三个,实际上所有阶段都会有这个检查

每个阶段对应的回调类型

nodejs把“宏任务”分成了6种情况,这6种分别对应:

  1. timer 专门执行定时器相关的回调函数:setTimeout,setInterval
  2. pending callbacks: 此阶段通常对系统操作执行回调(例如 TCP错误),例如,如果 TCP 套接字在尝试连接时接收到 ECONNREFUSED,则某些 *nix 的系统希望等待报告错误。这将被排队到这个阶段执行
  3. idle, prepare: 系统相关执行,不了解,忽略
  4. poll: 执行大部分I/O callback,除了process.nextTick,microtasktimer, pending callbacks, close event之外的所有回调都是在这里,例如,读取文件的回调等
  5. check: 专门执行setImmediate的回调
  6. close callback: 例如网络的socket close的回调事件在这里处理

我们先看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
Promise.resolve().then(() => {
    console.log('promise')
})
process.nextTick(() => {
    console.log('nexttick')
})
setTimeout(() => {
    console.log('timeout')
})
setImmediate(() => {
    console.log('immediate')
})
console.log('finish')
  1. 输出finish,这个问题不大,因为刚开始的同步执行
  2. 输出nexttick,在进入timers阶段之前执行,可以认为是微任务
  3. 输出promise,与nexttick类似,也属于微任务,但是优先级相对低一点
  4. 输出timeoutimmediate,这个输出不稳定,两个先后顺序不确定

在上面的输出结果可以看出,前3个输出都每啥问题;而对于timeoutimmediate,为什么会不稳定呢?

是因为js在准备进入检查队列的时候,timeout的回调函数不确定是否进入了timers阶段的队列;尽管设置了setTimeout的时间为0,实际上只至少1ms,因此有可能在1ms之后,js进程已经运行过了timers阶段,那么这个时候的输出结果就是immediate优先;若进入timers阶段已经耗费超过1ms,那么就会出现timeout的输出。

再看下一个例子:

1
2
3
4
5
6
7
8
9
10
11
const fs = require('fs');

fs.readFile('./file.js', () => {
  console.log('file callback')
  setImmediate(() => {
    console.log('immediate')
  })
  setTiemout(() => {
    console.log('timeout')
  })
})

输出顺序为:

  1. file callback
  2. immediate
  3. timeout

因为fs.readFile的回调属于poll阶段,而在这个阶段中,把timeoutimmediate都加入不同的队列中;当poll阶段完成之后,进入check阶段,这个时候刚才添加的immediate已经进入队列,所以输出是immediate;当事件循环下次经过timers的时候,再把timeout输出。

小结:

  1. nodejs把“宏任务”分为6个不同阶段,每个阶段都有对应类型的队列
  2. process.nextTickPromise属于微任务,优先级相对较高,其中process.nextTick对于Promise优先级更高;在进入每个事件阶段之前,都先执行微任务

参考文章