事件循环(the Event Loop)、宏任务(macrotask)、微任务(microtask)

288 阅读5分钟

开篇

我们都知道JavaScript是单线程的语言,它不像大多数语言可以开启多线程,当处理一些阻塞并且很慢的操作时,它可以通过多线程使操作变成异步(例如读取文件等IO操作)。其实JavaScript也有同步异步的区分。首先来看一下同步与异步的区别

// 同步
alert('阻塞中...')
console.log('待执行')

// 异步
setTimeout(() => {
	alert('阻塞中...')
}, 1000)
console.log('待执行')

从上面俩张图可以看到区别,alert()同步会阻塞应用,导致下面代码只好等待其完成,而setTimeout异步则不需要等待,log可以先执行。那刚才说JavaScript没有多线程,它又是如何实现异步操作的呢,这里就要说到事件循环啦。

正题

事件循环(the Event Loop)

事件循环会有一个事件的执行栈,当我们调用一个函数,它的地址、参数、局部变量都会压入这个栈中,当运行完之后,他们又会依次的出栈。我们拿一个例子来展示下这个过程。

// 平方
function square(num) {
    return num * num
}
// 平方求和
function sumSqrt(a, b) { 
    return square(a) + square(b)
}
function log(a, b) {
    console.log(sumSqrt(a, b))
}
log(2, 3) // 13

上面的例子是传入俩个参数打印出他们的平方和,当运行log函数时,该函数入栈,发现需要求平方和时,sumSqrt函数入栈,之后,发现需要先计算平方,square函数入栈。当square执行完返回结果后,出栈,以此类推。整个事件循环就是依次入栈,再依次出栈。具体流程图如下:

通过上图应该很容易理解JavaScript的执行栈是如何运作的,那要实现异步该如何做呢,第一步就是不要停留在执行栈中,因为执行栈是入栈出栈操作,停留久了就会造成阻塞。所以此时就要说到第二个概念任务队列

当浏览器遇到需要异步操作时,会把它放入任务队列去执行,执行成功后,会将回调函数返回给主线程。

那是不是任务队列中的异步操作执行完成后,会立即将回调函数入栈从而执行呢,答案当然是否定的,如果是那样,整个程序运行的岂不是很乱套。之所以叫事件循环是因为它是有一个周期的概念,每次循环代表一个周期的。


在这个周期内,主线程执行栈正常工作,当主线程任务清空时,会从任务队列中提取到已完成的异步回调函数入栈,然后执行栈又开始工作,进入下一次事件循环。具体什么意思呢,我们还是用代码配合图来演示整个事件循环周期:

这里要注意的是:每次事件循环只会从任务队列中获取一个回调函数,无论回调队列中有多少个函数,都只会有一个推到主线程。其他的函数需要等到下一次事件循环(主线程任务又清空时)。举个例子:

console.log('script start')
setTimeout(() => {
    console.log('异步操作1')
}, 1000)
setTimeout(() => {
    console.log('异步操作2')
}, 1000)
console.log('script end')
/* 执行结果
 * script start
 * script end
 * 异步操作1
 * 异步操作2
 */

放到chrome控制台执行,虽然结果上看似同时打印异步操作1、异步操作2,但其实打印“异步操作1”的回调函数先被推入主线程,当log执行完后,又进入下一次事件循环,把打印的“异步操作2”的函数推入主线程。所以这里延伸了一个问题:

setTimeout不一定是在规定时间内后立即执行。如上述例子,1000ms只代表多长时间后进入回调队列,但什么时候去执行它,要看主线程的任务什么时候结束。

在这里推荐大家一个事件循环可视化网址 链接 ,在这里可以自己写函数做测试,加深对事件循环机制的印象。弹窗的视频也是非常的赞,推荐大家跟着视频实践一下。

宏任务(macrotask)、微任务(microtask)

其实上面所讲的任务队列存放的任务都叫宏任务,宏任务是指时间耗时比较长的。

宏任务(macrotask): setTimeoutsetIntervalsetImmediate,I/OUI rendering

微任务(microtask):process.nextTickPromisesObject.observeMutationObserver

俩者的区别在于在事件循环中,每次只会执行一个macrotask,而所有microtask都会依次执行直到为空。并且每次主线程任务被清空时,先执行所有microtask,再去执行一个macrotask

console.log('script start');

setTimeout(function() {
  console.log('setTimeout1');
  setTimeout(function() {
	console.log('setTimeout2');
  })
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');
/**
 * script start
 * script end
 * promise1
 * promise2
 * setTimeout1
 * setTimeout2
*/

上面事件循环的整体流程是:


Cycle 1

  1. 打印'script start'
  2. setTimout(fn, 0)被调度到任务队列去执行
  3. Promise.resolve()被列为microtask
  4. 打印'script end'
  5. stack 清空 microtasks 执行
  6. 打印'promise1'
  7. .then()被列为microtask,继续执行microtasks
  8. 打印'promise2'

Cycle 2

  1. microtasks 队列清空 setTimout 的回调可以执行
  2. 打印'setTimeout1'
  3. 回调函数内setTimout(fn, 0)被调度到任务队列去执行

Cycle3

  1. setTimout 的回调可以执行
  2. 打印'setTimeout2'

我们用图片在演示一遍:

总结

  • JavaScript是单线程语言,通过事件循环和任务队列来实现异步操作。
  • 异步操作都会放到任务队列中去执行,执行完毕后,回调函数等待被调度
  • 主线程任务清空时,首先会执行所有的microTask,然后执行一个macroTask
  • setTimeout不会按照给定时间后执行(主线程任务阻塞时,会一直等待)
  • microTask异步也可能造成程序阻塞。(因为每次事件循环会执行所有microTask,死循环)

参考