浅聊浏览器中的事件循环机制

1,126 阅读4分钟

灵魂三问

  1. JavaScript为什么是单线程的?
  2. JavaScript 为什么需要异步?
  3. JavaScript 单线程又是如何实现异步的?

首先:JavaScript 为什么是单线程的?

假设现在有 2 个线程 process1 和 process2,并且 JavaScript 是多线程的,此时他们可以对同一个 dom 同时进行操作。如果process1 删除了该 dom,而 process2 编辑了该 dom,此时线程间发生冲突,浏览器究竟该如何执行呢?这样想,JavaScript 为什么被设计成单线程应该就容易理解了吧。

其次:JavaScript 为什么需要异步?

假设 JavaScript 中不存在异步,只能自上而下执行,如果上一行解析时间很长,那么下面的代码就会被阻塞。对于用户而言,阻塞就意味着"卡死",这样就导致了很差的用户体验,所以 JavaScript 中存在异步执行。

3.JavaScript 单线程又是如何实现异步的呢?

是通过事件循环来实现的

为了利用多核 CPU 的计算能力,HTML5 提出 Web Worker 标准,允许 JavaScript 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM。所以,这个新标准并没有改变 JavaScript 单线程的本质。

任务队列

当遇到一个异步事件后,JavaScript 引擎并不会一直等待异步事件返回结果,而是会将这个事件挂在与执行栈不同的队列中,我们称之为任务队列。 这些任务又被细分为 宏任务微任务

宏任务(macrotask):script(全局任务),setTimeout ,setInterval ,setImmediate (node.js 独有),I/O(磁盘读写或网络通信) ,UI rendering(UI交互事件)

微任务(microtask):process.nextTick (node.js 独有), Promise.then, MutationObserver

事件循环(event loop)

这里首先要明确一点:浏览器是一个进程,其有多个线程

一般情况下, 浏览器有如下五个线程:

  1. GUI 渲染线程
  2. JavaScript 引擎线程
  3. 浏览器事件触发线程
  4. 定时器触发线程
  5. 异步 HTTP 请求线程 GUI 渲染线程和 JavaScript 引擎线程是互斥的,其他线程相互之间都是可以并行执行的。

浏览器中,JavaScript 引擎循环地从任务队列中读取任务并执行,这种运行机制就叫做事件循环。

宏任务.png

事件循环是按下面几个步骤执行的:

  1. 执行同步代码,这属于宏任务(初始时的同步代码就是 script 整体代码)
  2. 执行栈为空,查询是否有微任务 (microtask) 需要执行
  3. 执行所有微任务
  4. 必要的话渲染 UI
  5. 然后开始下一轮 Event loop,执行宏任务中的异步代码

例题:

console.log('script start');

async function async1() {
  await async2()
  console.log('async1 end');
}

async function async2() {
  await async3()
  console.log('async2 end');
}

async function async3() {
  console.log('async3 end');
}

async function Myasync1() {
  await Myasync2()
  console.log('Myasync1 end');
}

async function Myasync2() {
  await Myasync3()
  console.log('Myasync2 end');
}

async function Myasync3() {
  console.log('Myasync3 end');
}

async1()
Myasync1()

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

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

console.log('script end');

正确答案是:script start->async3 end->Myasync3 end->Promise->script end->async2 end->Myasync2 end->promise1->async1 end->Myasync1 end->promise2->setTimeout

解释如下:

  1. 执行全局任务: script 输出 script start

  2. 遇到同步代码 async1() 形成微任务队列,微任务排序如下:async3() -> async2() -> async1(),此时会执行async()3, 并输出 async()3 end,然后该微任务队列交出控制权,继续往下执行代码

  3. 遇到同步代码 Myasync1() 形成微任务队列,微任务排序如下:Myasync3() -> Myasync2() -> Myasync1(),此时会执行Myasync()3, 并输出 Myasync()3 end,然后该微任务队列交出控制权,继续往下执行代码 由于定时函数是宏任务,会在此次微任务执行完成之后再考虑是否调用,所以setTimeout不会因为时间为0而立即执行

  4. 遇到同步代码new Promise,形成微任务队列,微任务排序如下:Promise() -> Promise.then() -> Promise.then(),此时会执行Promise, 并输出 Promise 1,然后该微任务队列交出控制权,继续往下执行代码

  5. 遇到同步代码console.log('script end'); 输出 script end 此时第一次script宏任务结束,由于该宏任务产生了微任务,会执行完所有的微任务后再开始下一个宏任务

此时产生的三个微任务队列排序如下: async -> myasync -> Promise.then()

IMG_0008 (1).png

循环执行微任务队列,并且当前微任务队列执行一次就会把控制权交出给到下一个微任务队列 依次输出

  1. async2 end
  2. Myasync2 end
  3. promise1
  4. async1 end
  5. Myasync1 end
  6. promise2 当promise2被输出,意味着第一次宏任务产生的所有微任务队列被执行完毕,然后继续执行宏任务setTimeout并输出setTimeout,到此,该段代码执行完毕

参考链接