0%

Event Loop 学习笔记

Event Loop

JavaScript 是单线程的,在 NodeJS 和 浏览器中都引入了消息队列和事件循环系统来解决单线程带来的一些问题。

如果没有 Event Loop 会发生什么?

如果没有 Event Loop ,因为 JavaScript 是单线程的,所有任务都在一个线程上完成。一旦遇到大量任务或者一个耗时的任务,浏览器网页将出现”假死”,因为 JavaScript 不能停下来去响应用户的行为 。

浏览器中的 Event Loop

浏览器的 Event Loop 是在 HTML5 规范定义的模型,不同浏览器可能有不同的实现。要注意的是在 JS 中并没有什么 Event Loop, Event Loop 是浏览器去实现的。

img

浏览器为什么要引入 Event Loop

因为在浏览器中,各种任务比如页面的渲染,I/O操作的完成,执行 JavaScript 脚本等随时都可能产生。在线程的运行过程中,能接收并执行新的任务,就需要采用事件循环机制。

浏览器单线程面临的问题

如何处理高优先级的任务

比如一个典型的场景是监控DOM 节点的变化情况(节点的插入、修改、删除等动态变化),然后根据这些变化来处理相应的业务逻辑。一个通用的设计的是,利用 JavaScript 设计一套监听接口,当变化发生时,渲染引擎同步调用这些接口,这是一个典型的观察者模式。

不过这个模式有个问题,因为 DOM 变化非常频繁,如果每次发生变化的时候,都直接调用相应的 JavaScript 接口,那么这个当前的任务执行时间会被拉长,从而导致执行效率的下降。

如果将这些 DOM 变化做成异步的消息事件,添加到消息队列的尾部,那么又会影响到监控的实时性,因为在添加到消息队列的过程中,可能前面就有很多任务在排队了。

这也就是说,如果 DOM 发生变化,采用同步通知的方式,会影响当前任务的执行效率;如果采用异步方式,又会影响到监控的实时性。

为了平衡效率和实时性,就出现了微任务

通常我们把消息队列中的任务称为宏任务(Task,官方文档并没有宏任务这种说法),每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果 DOM 有变化,那么就会将该变化添加到微任务列表中,这样就不会影响到宏任务的继续执行,因此也就解决了执行效率的问题。等宏任务中的主要功能都直接完成之后,这时候,渲染引擎并不着急去执行下一个宏任务,而是执行当前宏任务中的微任务。

因为 DOM 变化的事件都保存在这些微任务队列中,这样也就解决了实时性问题。

如何解决单个任务执行时长过久的问题

因为所有的任务都是在单线程中执行的,所以每次只能执行一个任务,而其他任务就都处于等待状态。如果其中一个任务执行时间过久,那么下一个任务就要等待很长时间。可以参考下图:

img

从图中你可以看到,如果在执行动画过程中,其中有个 JavaScript 任务因执行时间过久,占用了动画单帧的时间,这样会给用户制造了卡顿的感觉,这当然是极不好的用户体验。针对这种情况,JavaScript 可以通过回调功能来规避这种问题,也就是让要执行的 JavaScript 任务滞后执行。

宏任务(Task)

页面中的大部分任务都是在主线程上执行的,包括

  • 渲染事件(解析DOM、计算布局、绘制)
  • 用户交互事件(如鼠标点击、滚动页面、放大缩小等)
  • JavaScript 脚本执行事件
  • setTimeout 触发的回调函数
  • I/O 事件

宏任务可以满足我们大部分的日常需求,不过如果有对时间精度要求较高的需求,宏任务就难以胜任了,下面我们就来分析下为什么宏任务难以满足对时间精度要求较高的任务。

前面我们说过,页面的渲染事件、各种 IO 的完成事件、执行 JavaScript 脚本的事件、用户交互的事件等都随时有可能被添加到消息队列中,而且添加事件是由系统操作的,JavaScript 代码不能准确掌控任务要添加到队列中的位置,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间。为了直观理解,你可以看下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<body>
<div id='demo'>
<ol>
<li>test</li>
</ol>
</div>
</body>
<script type="text/javascript">
function timerCallback2(){
console.log(2)
}
function timerCallback(){
console.log(1)
setTimeout(timerCallback2,0)
}
setTimeout(timerCallback,0)
</script>
</html>

在这段代码中,我的目的是想通过 setTimeout 来设置两个回调任务,并让它们按照前后顺序来执行,中间也不要再插入其他的任务,因为如果这两个任务的中间插入了其他的任务,就很有可能会影响到第二个定时器的执行时间了。

但实际情况是我们不能控制的,比如在你调用 setTimeout 来设置回调任务的间隙,消息队列中就有可能被插入很多系统级的任务。

img

如果中间被插入了的任务执行时间过久的话,将会影响到后面任务的执行。所以说宏任务的时间粒度比较大。执行的时间间隔是不能精确控制的,对一些高实时性的需求就不太符合了,比如后面要介绍的监听 DOM 变化的需求。

微任务

异步回调的两种实现方式
  • 第一种是把异步回调函数封装成一个宏任务,添加到消息队列尾部,当循环系统执行到该任务的时候执行回调函数。setTimeout 和 XMLHttpRequest 的回调函数使用了这种方式
  • 第二种方式的执行时机是在主函数结束之后、当前宏任务结束之前执行回调函数。这通常都是以微任务形式体现。

而微任务其实就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。

产生微任务
  • MutationObserver 监控 DOM节点,通过 Javascript 修改节点。当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务
  • 调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务
微任务的执行时机

在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。

如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。

总结
img
  • 微任务和宏任务是绑定的,每个宏任务在执行时会创建自己的微任务队列
  • 先执行宏任务,再执行微任务
  • 执行宏任务的过程中产生了新的宏任务就放在宏任务队列中,产生了新的微任务就放在绑定的微任务队列中
  • 执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。
  • 宏任务是一个一个执行的;执行完一个宏任务前,会执行该宏任务队列中的所有微任务

浏览器 Event Loop 的两个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
setTimeout(()=>{
//fn1
console.log('time1');
Promise.resolve().then(data=>{
//fn2
console.log('then1')
})
},0)
Promise.resolve().then(data=>{
//fn3
console.log('then2')
setTimeout(()=>{
//fn4
console.log('time2');
},0)
})

// then2
// time1
// then1
// time2
  1. 整个 JS 脚本执行是一个宏任务,首先将 fn1 放入 task 队列, fn3 放入 microTask 队列
  2. JS 脚本执行完成前先清空 microTask 队列,打印 then2,将 fn4 放入 task 队列
  3. 执行 task 队列中的 fn1 打印 time1,将 fn2 放入 microTask 队列,执行 fn2,打印 then1,
  4. 执行 task 队列中的 fn4,打印 time2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
async function async1(){
console.log(1);
await async2(); // 改写成 async2().then(()=>{console.log(2)})
console.log(2);
}
async function async2(){
console.log(3)
}
async1();
new Promise(function(resolve){
console.log(4);
resolve();
}).then(fuction(){
console.log(5);
})
// 1
// 3
// 4
// 2
// 5

async 和 await 其实就是 Promise 的语法糖,需要转换成 Promise。resolve 决定的是要执行的是then的第一个函数还是第二个函数,reject 就执行第二个函数。.then 会把回调函数放入微任务队列。

  1. 执行 async1 打印出 1
  2. 将 async1 后面的部分改写成 Promise 的写法,执行 async2 打印出 3 ,将 async2.then 的回调函数放入微任务队列
  3. 执行 newPromise 中的函数 打印出 4, 将 then 中的回调函数放入微任务队列
  4. 执行微任务队列中的微任务,依次打印出 2 , 5

监听 DOM 变化方法演变

许多 Web 应用都利用 HTML 与 JavaScript 构建其自定义控件,与一些内置控件不同,这些控件不是固有的。为了与内置控件一起良好地工作,这些控件必须能够适应内容更改、响应事件和用户交互。因此,Web 应用需要监视 DOM 变化并及时地做出响应。

早期页面没有提供对监听的支持,观察 DOM 变化只能进行轮询检测。比如使用 setTimeout 或者 setInterval 来定时检测 DOM 是否有改变。但是会遇到两个问题:如果时间间隔设置过长,DOM 变化响应不够及时;反过来如果时间间隔设置过短,又会浪费很多无用的工作量去检查 DOM,会让页面变得低效。

2000 年时引入了 Mutation Event,Mutation Events 采用了观察者的设计模式,当 DOM 有变动时就会立刻触发相应的事件,这种方式属于同步回调。采用 Mutation Event 解决了实时性的问题,因为 DOM 一旦发生变化,就会立即调用 JavaScript 接口。但也正是这种实时性造成了严重的性能问题,因为每次 DOM 变动,渲染引擎都会去调用 JavaScript,这样会产生较大的性能开销。比如利用 JavaScript 动态创建或动态修改 50 个节点内容,就会触发 50 次回调,而且每个回调函数都需要一定的执行时间,这里我们假设每次回调的执行时间是 4 毫秒,那么 50 次回调的执行时间就是 200 毫秒,若此时浏览器正在执行一个动画效果,由于 Mutation Event 触发回调事件,就会导致动画的卡顿。

HTML5 提出了新的 API Mutation Observer,Mutation Observer 做了以下改进:

  • 将响应函数变成异步调用,而且可以不在每次 DOM 变化都触发异步调用。而是等多次 DOM 变化后,一次触发异步调用,并且还会使用一个数据结构来记录这期间所有的 DOM 变化。这样即使频繁地操纵 DOM,也不会对性能造成太大的影响。
  • 使用微任务来触发回调,在每次 DOM 节点发生变化的时候,渲染引擎将变化记录封装成微任务,并将微任务添加进当前的微任务队列中。

综上所述, MutationObserver 采用了“异步 + 微任务”的策略。

  • 通过异步操作解决了同步操作的性能问题

  • 通过微任务解决了实时性的问题

Vue.nextTick

vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。

1
<div id="example">{{message}}</div>
1
2
3
4
5
6
7
8
9
10
11
var vm = new Vue({
el: '#example',
data: {
message: '123'
}
})
vm.message = 'new message' // 更改数据
vm.$el.textContent === 'new message' // false
Vue.nextTick(function () {
vm.$el.textContent === 'new message' // true
})

nextTick 可以确保回调函数在DOM更新完成后才被调用。nextTick 优先使用 microTask 保证回调函数可以在 DOM 更新后及时调用,具体见下图。

img

在不支持微任务 API 的情况下才会去使用 setTimeout 产生宏任务来实现。使用宏任务不能保证回调函数及时执行,flushBatcherQueue 和 nextTick 的回调函数在宏任务队列中执行的时机不能确定(前面有提到过,宏任务队列可能会插入系统级的任务),会导致 DOM 更新和回调函数执行的延迟。

NodeJS 中的 Event Loop

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘

只需要关注 timers poll check Eventloop 三个阶段

  • setImmediate -> check阶段执行

  • setTimeout -> timers 阶段执行

  • nextTick -> 放在当前阶段的后面执行,优先级高于 Promise.then 的微任务队列

setTimeout 和 setImmediate 执行顺序

如果在主模块中运行下面的脚本,那么两个回调的执行顺序是无法判断的

1
2
3
4
5
6
7
8
// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout');
}, 0);

setImmediate(() => {
console.log('immediate');
});

运行结果:

1
2
3
4
5
6
7
$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout

原因是执行JS 和 开启 eventloop 先后顺序不确定,如果进入 timers 时 setTimeout 就已经存在,那么 setTimeout(fn,0) 的回调函数先执行。否则 setImmediate 的回调函数先执行。

但是如果把整个代码放在 setTimeout 或 I/O 操作回调中,setImmediate 的回调总是优先于 setTimeout 的回调。

1
2
3
4
5
6
7
8
9
10
setTimeout(()=>{
setTimeout(()=>{
console.log('timeout')
},0);
setImmediate(()=>{
console.log('immediate')
});
},0)
// immediate
// timeout

process.nextTick

process.nextTick 不属于 eventloop 的任一阶段 实际上,不管 eventloop 在哪个阶段,nextTick 队列都是在当前阶段后就被执行了。nextTick 队列的优先级队列要高于 Promise.then 的微任务队列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
setTimeout(() => {
//setTimeout1
setTimeout(() => {
console.log('setTimeout')
}, 0)
setImmediate(() => {
console.log('setImmediate')
})
process.nextTick(() => {
console.log('nextTick')
})
Promise.resolve(1).then(() => {
console.log('then')
})
}, 1000)
// nextTick
// then
// setImmediate
// setTimeout
  1. 1s 后将 setTimeout 的回调推入 timer 阶段对应的 task queue,执行回调函数创建 setTimeout1 下在一轮循环执行
  2. 将 setTimeout1 的回调函数 推入 timer 阶段对应的 task queue
  3. 将 setImmediate 的回调函数推入 check 阶段对应的 task queue
  4. 清空 Timers阶段的 NextTick Queue 和 Microtask Queue 在执行 nextTick 和 resolve(1).then 的回调函数打印出 nextTick 和 then
  5. 进入 check 阶段执行 setImmediate 的回调函数打印出 setImmediate
  6. 回到 timers 阶段执行 setTimout1 的回调函数打印出 setTimeout

NodeJS 不同版本的差别

1
2
3
4
5
6
7
8
9
10
11
12
setTimeout(() => {
console.log('1.setTimeout');
Promise.resolve().then(() => {
console.log('1.Promise.resolve');
})
});
setTimeout(() => {
console.log('2.setTimeout');
Promise.resolve().then(() => {
console.log('2.Promise.resolve');
})
});

上面的这段代码在 NodeJS 10 以下版本的执行顺序是

1
2
3
4
// 1.setTimeout
// 2.setTimeout
// 1.Promise.resolve
// 2.Promise.resolve

10 以上版本则是

1
2
3
4
// 1.setTimeout
// 1.Promise.resolve
// 2.setTimeout
// 2.Promise.resolve

主要原因在于 Promise 实现的方式不同,导致执行结果不同。(不确定)

Node.js 的 EventLoop 不同版本可能会有差异,尤其是关于Promise部分,也有不同的解释。整体来说版本越新越与浏览器趋同,个人感觉简单了解即可。其他详细解释可以看这篇文章


参考:

  1. 什么是 EventLoop
  2. 再学浏览器 EventLoop
  3. 浏览器工作原理与实践
  4. Vue源码详解之nextTick:MutationObserver只是浮云,microtask才是核心!
  5. 异步更新队列
  6. Eventloop
  7. 浏览器eventLoop和node eventLoop
  8. 再学 Node EventLoop
  9. Event Loop、计时器、nextTick
  10. 带你彻底弄懂 Event Loop