JavaScript事件循环(Event Loop)

529 阅读9分钟

这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战

前言

想要了解事件循环,首先需要知道一个概念,JS是一门单线程语言。为什么?因为JS在设计之初就是一个专门用于编写页面交互语言,而与页面交互则会频繁的进行DOM操作,试想,如果JS是多线程的,一个线程在操作这个DOM节点,而另一个要删除这个节点,此时浏览器应该听谁的?为了避免复杂性,JS从一诞生开始,就是一门单线程语言。

基于这个原因,JS被设计成了一门单线程语言。但单线程也有它的缺点,那就是同一时间只能做一件事情,而我们都知道,发http请求或者各种IO,都可能非常耗时,这带来的问题就是,如果这个任务被卡住了,那后面的代码都得等这个任务完成后才能继续执行,此时页面看起来就像完全停滞了一样,但此时CPU却是空闲的状态,这是何等的浪费性能。并且,互联网的世界里,没有用户会花超过5秒耐心等你,所以我们必须引入一种机制,可以充分的利用资源,优化执行逻辑。

事件循环,就是这么诞生的。

模型

image.png 这是一张经典的事件循环模型,我们可以看到该模型由几个核心部分组成:

  • 执行栈(execution context stack)
    • JS的内存空间分为栈(stack)、堆(heap)
    • 其中栈存放变量,堆存放复杂对象
    • LIFO(先进后出)
  • webApis(如DOM操作,setTimeout、setInterval、ajax)
  • 任务队列(callback queue)
    • FIFO(先进先出)

执行栈、调用栈

着两个东西在英文里分别叫做execution context stack和call stack,但我认为在该模型中可以表达同一个意思。我们通过一段简单的代码来熟悉一下。

function c(a, b) {
  return a * b;
}

function b(x) {
  return c(x);
}

function a(n) {
  let res = b(n);
  console.log('aaa');
}

a(2);

如果我们打断点执行一下,会发现,此处的调用栈是右下角这个样子的,即a在栈底(忽略anonymout),b在a上,c在b上

image.png

这个也很好理解,因为函数的执行肯定都要将内部全部执行完才能算结束,所以会一层一层往里走,如果遇到了一个新的函数,就将它压入栈中。

能解释这个原因的另个现象,就是我们的报错信息,一般我们的报错信息都是,从栈顶到栈底,一排排信息自上而下排列。

让我们把刚才的代码改一下,在c函数中抛出一个错误试试

function c(a, b) {
  // return a * b;
  throw new Error('xixi');
}

function b(x) {
  return c(x);
}

function a(n) {
  let res = b(n);
  console.log('aaa');
}

a(2);

image.png 此时你就会发现,为什么平时报错信息长着样子了,因为报错是从内而外,从栈顶到栈底,不断传递的。

好的,了解了基本的call stack的概念,我们就可以继续往下了

任务队列

任务队列也可以称为消息队列、事件队列,队列中的事件包含:IO操作、Ajax请求、用户事件(点击、按键、滚动等)等,只要指定了回调函数,这些事件就会进入到任务队列中,当执行栈运行完成后,便会执行任务队列中的任务。

同步任务与异步任务

在该模型中,所有任务被分为两种,一种是同步任务,一种是异步任务。

同步任务(synchronous)

在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行下一个任务。

异步任务(asynchronous)

不进入主线程、而进入"任务队列"(callback queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

每当运行开始时,引擎会将所有同步任务放入到执行栈(execution context stack)中,把所有异步任务放入任务队列中(callback queue),JS的引擎会不断检查主线程的执行栈是否为空,一旦执行栈将所有同步任务执行完成,系统就会查看任务队列中是否有异步任务,如果有,则将它放入执行栈中执行。同步任务总是会在异步任务之前执行。

上述过程会不断重复,于是就形成了事件循环。

宏任务与微任务

任务队列中的,异步任务又被分为宏任务(macro-task)和微任务(micro-task),在最新标准中,它们被称为task与jobs,分别位于task queue和job queue中。

宏任务(task)

  • script
  • setTimeout
  • setInterval
  • requestAnimationFrame
  • IO
  • UI rendering

微任务(job)

  • promise.then catch finally
  • async await
  • MutationObserver
  • process.nextTick

在每一次循环中,他们的执行顺序是这样的:

  • 从task queue中拿出队列第一个task执行
  • 执行完这一个task后,查看job queue
  • 执行job queue中所有可执行的任务
  • 完成后,本轮循环结束

值得注意的是,会有以下的情况发生:

  • 在执行task的时候,有可能会有新的任务产生,有可能是task,也有可能是job
  • 如果是job,直接追加到job queue中
  • 如果是task,直接追加到task queue中
  • 当执行完毕后,如果job queue有内容,则在本轮中执行完job queue中所有job,而新追加的task,则全都放到下一轮执行
  • 需要记得,script也是一个宏任务
  • await下面的代码,都会被当成job

示例

console.log(1);
setTimeout(() => {
    console.log(2)
}, 100);
console.log(3);

我们尝试一下将代码套入我们的模版

  • 执行script
  • 遇到console.log(1),为一个同步任务,直接放入执行栈运行,输出1
  • 遇到setTimeout,为一个异步的宏任务,开始计时并等待放入task queue中运行
  • 遇到console.log(3),同步,直接放入执行栈执行,输出3
  • 同步任务执行完毕,task queue中的任务放入执行栈中并执行,输出2

最后,代码的输出顺序为 1、3、2。

刚刚那个太简单,没啥难度,再来个稍微复杂点的

console.log(1);

new Promise((resolve) => {
    console.log(2);
    resolve(3);
    
    setTimeout(() => {
        console.log(4);
    }, 0);
}).then(data => {
    console.log(data);
})

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

console.log(6);
  • 执行script
  • 遇到console.log(1),放入执行栈,执行,输出1
  • 遇到promise,此时要注意,new Promise的时候,产生的代码为同步的,因为此处内部的代码是会被立即执行的,所以直接输出2
  • 随后遇到第一个setTimeout,虽然是在new Promise的内部,但是它是一个宏任务(task),因此开始计时,并等待放入task queue中执行
  • 遇到then函数,这也是一个微任务(job),放入job queue中,等待执行
  • 遇到第二个setTimeout,开始计时并等待放入task queue中执行
  • 遇到console.log(6),直接输出
  • 本轮task执行完毕,开始执行job queue中的所有job
  • 先执行then函数,输出3
  • job全部执行完毕,开启下一轮
  • 开始执行task,执行第一个setTimeout,输出4
  • 检查一下job queue,无,开启下一轮
  • 执行第二个setTimeout(此时已经在队列的头部),输出5 所以最后的输出顺序为,1、2、6、3、4、5

来一个更有挑战的

async function async1(){
    console.log('async1 start') // 2
    await async2()
    console.log('async1 end') // 6
}
async function async2(){
    console.log('async2') // 3
}
console.log('script start') // 1
setTimeout(function(){
    console.log('setTimeout') // 8
},0)
async1();
new Promise(function(resolve){
    console.log('promise1') // 4
    resolve();
}).then(function(){
    console.log('promise2') // 7
})
console.log('script end') // 5
  • 执行script
  • 遇到同步的script start,输出
  • 遇到setTimeout,放入task queue
  • 遇到async1(),执行后,输出async1 start
  • 遇到async2(),输出async2
  • await之后的代码都当成job,放入job queue中
  • 遇到new Promise,执行,输出同步的promise1
  • then放入job queue中
  • 输出script end
  • 到目前为止,我们已经输出了script start、async1 start、async2、promise1、script end
  • 本轮宏任务执行完毕,开始执行job queue中的所有job
  • 输出async1 end
  • 输出promise2
  • 本轮结束
  • 下一轮开始,执行第一个task,输出setTimeout

所以最后的输出顺序为:

  • script start
  • async1 start
  • async2
  • promise1
  • script end
  • async1 end
  • promise2
  • setTimeout

最后我们来看一个比较抽象的

Promise.resolve().then(()=>{
  console.log('Promise1')  
  setTimeout(()=>{
    console.log('setTimeout2')
  },0)
})

setTimeout(()=>{
  console.log('setTimeout1')
  Promise.resolve().then(()=>{
    console.log('Promise2')    
  })
},0)
  • 执行宏任务script,没发现任何同步代码
  • 遇到第一个then,把它放入job queue
  • 遇到一个setTimeout,把它放入task queue
  • 本轮结束,第二轮开始,先 job queue 再 task queue
  • 输出promise1,遇到一个setTimeout,加入task queue
  • 本轮job queue全部清空,执行task queue
  • 执行task queue中第一个task,输出setTimeout1
  • 遇到一个promise,加入job queue中
  • 本轮结束,第三轮开始
  • 执行 job queue,输出promise2
  • 本轮job queue全部清空,执行task queue
  • 输出setTimeout2
  • 执行完毕

所以最后的输出顺序是,promise1、setTimeout1、promise2、setTimeout2

Q&A

  • setTimeout能保证时间严格执行吗?如果不,为什么? 不能,setTimeout中的事件只是它的最短执行时间(0除外)。因为setTimeout是一个异步任务,在每个循环中,异步任务是在同步任务之后执行的,如果同步任务需要执行很久很久,那setTimeout也必须一直等待同步任务执行完毕,才能将它放入执行栈中执行。

  • setTimeout设置为0,能解决问题吗? 不能,还是得按照模型来跑,并且根据官方的规定,最小执行事件为4ms。

setTimeout(fn,0)的含义是,指定某个任务在主线程最早可得的空闲时间执行,也就是说,尽可能早得执行。它在”任务队列”的尾部添加一个事件,因此要等到同步任务和”任务队列”现有的事件都处理完,才会得到执行。

小结

事件循环非常绕,很有可能尝试了很多遍还是不理解,没关系,只要坚持总结、思考,总会研究出来的。这篇学习笔记是自己看了许多博客、文档的总结,主要是个人的理解,如果有不正确的地方请及时指出,相互交流

参考文献

并发模型与事件循环(MDN Web Docs)

JS事件循环机制(event loop)之宏任务/微任务

进程与线程的一个简单解释

JavaScript 运行机制详解:再谈Event Loop

What the hack is the event loop anyway?

Difference between microtask and macrotask within an event loop context