事件循环、宏任务和微任务

2,380 阅读5分钟

事件循环

JavaScript是单线程的语言,也就是说,同一个时间只能做一件事。如下事例一:

console.log('同步1');
console.log('同步2');
console.log('同步3');
...

以上是一个简单的按顺序执行的任务,一眼就能看出按照任务1,2,3…依次一个个执行。如果任务再复杂一点,如下事例二:

    console.log('1')
    setTimeout(() => {
        console.log('2')
    })
    new Promise((resolve, rejects) => {
        console.log('3')
        resolve()
    }).then(() => {
         console.log('4')
    })
    console.log(5)

此时该怎么执行?会先打印1,再依次打印2,3,4,5这样执行?如若在setTimeout部分花费的时间较长,后面的Promise岂不是要一直挂起等待。这只是稍稍多一点任务,如若再涉及其他事件、用户交互、脚本、UI 渲染和网络处理等行为,那就会相当的卡顿了。

事实上不会这样。JavaScript是分为同步任务和异步任务来解决上面的问题的。如下图: JS执行任务图

  • 同步任务都在主线程上执行,形成一个执行栈。主线程之外,异步进入Event Table并注册函数。

  • 只要异步任务有了运行结果时,Event Table会将这个函数移入Event Queue。

  • 主线程内的任务执行完毕为空,会去Event Queue按先进先出的原则读取对应的函数,进入主线程执行。

    那什么时候主线程执行栈为空呢? js引擎有一个monitoring process进程,会持续不断的检查主线程执行栈是否为空, 一旦执行栈中的所有同步任务执行完毕(此时引擎空闲), 就会去Event Queue那里检查是否有等待被调用的函数。

  • 上述过程会不断重复,也就是常说的Event Loop(事件循环)。

事件循环是通过任务队列的机制来进行协调的。一个 Event Loop 中,可以有一个或者多个任务队列(Task queue),一个任务队列便是一系列有序任务(Task)的集合;每个任务都有一个任务源(Task Source),源自同一个任务源的 Task 必须放到同一个任务队列,从不同源来的则被添加到不同队列。setTimeout/Promise等API便是任务源,而进入任务队列的是他们指定的具体执行任务。

在事件循环中,每进行一次循环操作称为 Tick,每一次 Tick 的任务处理模型是比较复杂的,但关键步骤如下:

  • 在此次 Tick 中选择最先进入队列的任务(Oldest Task),如果有则执行(一次)。
  • 检查是否存在 micro-task,如果存在则不停地执行,直至清空 micro-task Queue。
  • 更新 render
  • 主线程重复执行上述步骤

上面事例二中的setTimeout和Promise部分都为异步任务,但它们在异步任务中不属于同一队列,一个属于macro-task Queue(宏任务队列),一个属于micro-task Queue(微任务队列)。

宏任务(macro-task)

宏任务主要有:

  • script(整体代码)
  • setTimeout
  • setInterval
  • I/O
  • UI交互事件
  • postMessage
  • MessageChannel
  • setImmediate(Node.js 环境)

可以把每次执行栈执行的代码理解为一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)。

浏览器为了能够使得JS内部macro-task与DOM任务能够有序的执行,会在一个macro-task执行结束后,在下一个macro-task 执行开始前,对页面进行重新渲染,流程如下:

macro-task->渲染->macro-task->...

微任务(micro-task)

微任务主要有:

  • Promise.then
  • Object.observe
  • MutaionObserver
  • process.nextTick(Node.js 环境)

micro-task,可以理解是在当前 Task 执行结束后立即执行的任务。也就是说,在当前Task任务后,下一个Task之前,在渲染之前。

宏任务和微任务运行机制如下:

  • 执行一个宏任务(栈中没有就从事件队列中获取)
  • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
  • 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
  • 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
  • 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)

上面事例二中的script代码整体作为一个宏任务进入当前执行栈,先打印出 1;setTimeout为一个宏任务,加入宏任务队列;创建一个Promise的实例,立即执行传给Promise的回调,打印出 3,它的then为一个微任务,加入微任务队列;打印出 5;主线程执行完后开始检查微任务队列,打印出 4,微任务队列执行完;在检查宏任务队列,打印出 2。

async/await 如下事例三:

async function async1() {
    console.log(1); 
    const result = await async2(); 
    console.log(3); 
} 

async function async2() { 
    console.log(2); 
} 

Promise.resolve().then(() => { 
    console.log(4); 
}); 

setTimeout(() => { 
    console.log(5); 
}); 

async1(); 
console.log(6);
// 1 2 6 4 3 5

async/await本质上还是基于Promise的一些封装,而Promise是属于微任务的一种。所以在使用await关键字与Promise.then效果类似。async函数在await之前的代码都是同步执行的,await之后的所有代码类似于在Promise.then中的回调。先打印 1,再调用async2函数,打印 2,再打印出 6,主线程执行完毕。执行微任务打印出 4,再打印出异步 3,最后执行宏任务,打印出 5。

process.nextTick()

console.log('1');
setTimeout(function() {
    console.log('2');
    process.nextTick(function() {
        console.log('3');
    })
    new Promise(function(resolve) {
        console.log('4');
        resolve();
    }).then(function() {
        console.log('5')
    })
})
process.nextTick(function() {
    console.log('6');
})
new Promise(function(resolve) {
    console.log('7');
    resolve();
}).then(function() {
    console.log('8')
})

setTimeout(function() {
    console.log('9');
    process.nextTick(function() {
        console.log('10');
    })
    new Promise(function(resolve) {
        console.log('11');
        resolve();
    }).then(function() {
        console.log('12')
    })
})
// 1 7 6 8 2 4 3 5 9 11 10 12 

此事例中注意,遇到process.nextTick(),其回调函数被分发到微任务Event Queue中。

process.nextTick()可参考理解 Node.js 里的 process.nextTick()

参考: js中的宏任务与微任务