理解JS并发模型 - Event Loop, Micro Task, Macro Task

单线程

众所周知,Javascript的一大特点是单线程,这种设计是为了最初减少浏览器解析的复杂性,避免多条线程同时操作DOM,造成并发性的问题。

当然,现在已经有了Web Worker,但是,同样是不能在Worker中操作DOM,保持JS主线程的纯洁性。

Event Loop

在我们开发的时候,是不可避免有高运算量或者需要等待的代码块来堵塞我们的主线程的,会造成画面停滞,JS使用了Event Loop的概念来应对这个问题。

浏览器提供一系列的Web API供我们去调用,主要可以分为Macrotask(Task) 和 Microtask (Job)。

  • Macrotask包括:setTimeout, setInterval, setImmediate (仅IE支持), requestAnimationFrame, I/O, UI rendering, XMLHttpRequest, fetch
  • Microtask包括:process.nextTick (node.js支持), Promises, Object.observe(废弃), MutationObserver

接下来看一下Event Loop的整体示意图:
Event loop

可以看到主要的部分就是一个栈和队列,都是基本的数据结构,这里就不展开讲了。但是队列并不止一条,而是Macrotask和Microtask都分别有一条队列。

这里举个例子来了解一下整个循环的运作:

1
2
3
4
5
6
7
8
9
console.log('script start') // 1

// 2
setTimeout(function(){
console.log('set timeout done')
}, 1000)

// 3
console.log('script end')

具体步骤:

  1. 首先是 console.log('script start') 推入执行栈,没有异步操作,所以直接输出script start
  2. 然后是 setTimeout 推入执行栈,执行栈执行的是调用Web API setTimeout的操作,而不是负责执行它本身,因此调用了Web API之后,就可以出栈了。
  3. Web API被调用之后,会执行setTimeout,在确认执行完之后(此处的操作就是倒数1000ms),就会把setTimeout的回调送到 Macrotask 队列中去。
  4. 接下来把 console.log('script end') 推入到执行栈中,并且输出 script end
  5. 此时,如果执行栈为空,就会开始执行macrotask,把3中的回调执行函数推到执行栈中,输出script end

看起来相当的清晰,如果有microtask的情况呢

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
console.log('script start') // 1

// 2
setTimeout(function(){
console.log('set timeout done')
}, 1000)

// 3
Promise.resolve().then(() => {
console.log('microtask 1')
}).then(() => {
console.log('microtask 2')
})

// 4
console.log('script end')

具体步骤:

  1. 首先是 console.log('script start') 推入执行栈,没有异步操作,所以直接输出script start
  2. 然后是 setTimeout 推入执行栈,执行栈执行的是调用Web API setTimeout的操作,而不是负责执行它本身,因此调用了Web API之后,就可以出栈了。
  3. Web API被调用之后,会执行setTimeout,在确认执行完之后(此处的操作就是倒数1000ms),就会把setTimeout的回调送到 Macrotask 队列中去。
  4. 第四步,Promise的两个then部分会顺序推到Microtask 队列中。
  5. 然后 console.log('script end') 推入到执行栈中,并且输出 script end
  6. 此时执行栈为空,按优先级,会先判断Microtask中是否有job,有的话递归执行直到Microtask为空,所以输出microtask 1microtask 2
  7. 此时,如果执行栈为空,就会开始执行macrotask,把3中的回调执行函数推到执行栈中,输出script end

需要注意的面试陷阱

1. 点击按钮和调用.click()函数入栈顺序不一样

假如有两个listener同时监听了按钮,如果直接点击,那么它们会执行完其中一个的回调函数以及回调函数中的Microtask,才会去执行第二个回调函数以及回调函数中的Microtask,最后才会执行Macarotask。
假如是使用.click()来调用,那么两个回调函数中间就会没有了间隔,先执行完同步函数,然后Microtask最后Macrotask。

2. Promise传入函数是同步操作

1
2
3
4
5
6
7
8
9
console.log('1')

new Promise((resolve) => {
console.log('2')
resolve()
console.log('3')
}).then(() => console.log('4'))

console.log('5')

这里会迷惑人,看到Promise自然而然把它归到了Microtask中,实际上,Promise传入的函数是同步操作的,就是说23会顺序输出。
最后结果

1
2
3
4
5
1
2
3
5
4

推荐文章

这篇文章 是我看过讲Macrotask和Microtask之间关系最详细的一篇了,作者好像就是在新加坡JS conf讲Event Loop的那个大神,大家可以看看。