浏览器知识点整理(十二)事件循环机制(Event Loop)

1,542 阅读8分钟

Event Loop 是一个很重要的概念,指的是计算机系统的一种运行机制,JavaScript 语言就采用这种机制,来解决单线程运行带来的一些问题。

JavaScript 为什么是单线程?

作为浏览器脚本语言, JavaScript 从诞生开始就是单线程。原因大概是不想让浏览器变得太复杂,因为多线程需要共享资源、且有可能修改彼此的运行结果,对于一种网页脚本语言来说,这就太复杂了。后来就约定俗成,JavaScript 为一种单线程语言

JavaScript 是单线程语言大概还和它的用途有关。JavaScript 的主要用途是 和用户互动,以及操作 DOM这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假如 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准呢?

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

为什么需要事件循环机制?

这得从一个问题开始讲起:JavaScript 这个单线程是怎样处理任务的?

处理安排好的任务

这个场景比较简单,将这些任务代码按照顺序写进主线程里,等线程执行时,这些任务就是 按照顺序在线程中依次被执行;等所有任务执行完成之后,线程会自动退出。

处理线程运行过程中产生的新任务

并不是所有的任务都是在执行之前就可以安排好的,大部分情况下,新的任务会在线程运行过程中不断产生。要想在线程运行过程中,能接收并执行新的任务,就需要用到 事件循环机制 了。

相较于处理安排好的线程任务,如果要处理新的任务,这个单线程需要做以下改进:

  • 第一点引入了 循环机制,具体实现方式是在线程语句后面添加了一个 for 循环语句,让线程会一直循环执行。
  • 第二点是引入了 事件,在线程运行过程中,等待用户的操作事件,等待过程中线程处于暂停状态,等接收到用户的操作之后再激活线程,然后继续执行。

消息队列又是什么?

上面两个场景都是发生在 JS 线程上面的场景,那么如果其它线程或进程发来一些任务呢?

处理其他线程发送过来的任务

渲染主线程会频繁接收到来自于 IO 线程的一些任务,接收到这些任务之后,渲染进程就需要着手处理,比如接收到资源加载完成的消息后,渲染进程就要着手进行 DOM 解析了;接收到鼠标点击的消息后,渲染主线程就要开始执行相应的 JavaScript 脚本来处理该点击事件。

那浏览器是怎么让线程模型接收其它线程发送的消息的呢?

是的,没错,让线程模型接收其它线程发送的消息就是通过 消息队列 实现的。

消息队列是一种数据结构,用来存放要执行的任务。它符合 队列先进先出 的特点,也就是说要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取。

这个消息队列是这样的过程:

  • 在线程之间添加一个 消息队列
  • IO 线程中产生的新任务添加进 消息队列尾部
  • 渲染主线程会循环地 从消息队列头部中读取任务,执行任务。

如下图所示:

image.png

处理其他进程发送过来的任务

线程之间的消息通信可以通过消息队列来实现,那么进程之间呢?在 Chrome 中,跨进程之间的任务也是频繁发生的,那么渲染进程如何处理其他进程发送过来的任务呢?

可以参考下图:

image.png

渲染进程专门有一个 IO 线程 用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程。

JavaScript 使用单线程带来的问题

页面线程所有执行的任务都来自于消息队列。消息队列是先进先出的特性,即放入队列中的任务,需要等待前面的任务被执行完,才会被执行。鉴于这个属性,就有如下两个问题需要解决:

当出现高优先级的任务时应该怎么办?

比如一个典型的场景是 监控 DOM 节点的变化情况(节点的插入、修改、删除等动态变化),然后根据这些变化来处理相应的业务逻辑。一个通用的设计的是,利用 JavaScript 设计一套监听接口,当变化发生时,渲染引擎同步调用这些接口,这是一个典型的观察者模式。

不过这个模式有个问题,因为 DOM 变化非常频繁,如果每次发生变化的时候,都直接调用相应的 JavaScript 接口,那么这个当前的任务执行时间会被拉长,从而导致执行效率的下降。

如果将这些 DOM 变化做成异步的消息事件,添加到消息队列的尾部,那么又会影响到监控的实时性,因为在添加到消息队列的过程中,可能前面就有很多任务在排队了。

这也就是说,如果 DOM 发生变化,采用同步通知的方式,会影响当前任务的执行效率;如果采用异步方式,又会影响到监控的实时性

那该如何权衡效率和实时性呢?针对这种情况,微任务就应用而生了。

  • 通常把 消息队列中的任务称为宏任务每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果 DOM 有变化,那么就会将该变化添加到微任务列表中,这样就不会影响到宏任务的继续执行,因此也就解决了 执行效率 的问题。
  • 等宏任务中的主要功能都直接完成之后,这时候,渲染引擎并不着急去执行下一个宏任务,而是 执行当前宏任务中的微任务,因为 DOM 变化的事件都保存在这些微任务队列中,这样也就解决了 实时性问题

单个任务执行时间过长时应该怎么办?

因为所有的任务都是在单线程中执行的,所以 每次只能执行一个任务,而其他任务就都处于等待状态。如果其中一个任务执行时间过久,那么下一个任务就要等待很长时间。

针对这种情况,JavaScript 是通过 回调功能 来规避这种问题的,也就是让要执行的 JavaScript 任务滞后执行。

浏览器中的 Event Loop

主线程从消息队列中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为 Event Loop (事件循环)

而不同的任务源会分到不同的消息队列中,任务源可以分为 微任务(microtask)宏任务(macrotask)。在 ES6 规范中,microtake 称为 jobsmacrotask 称为 task

⻚面渲染事件,各种 IO 的完成事件等随时被添加到消息队列中,一直会保持 先进先出 的原则执行,我们不能准确地控制这些事件被添加到消息队列中的位置。但是这个时候突然有 高优先级 的任务需要尽快执行,那么一种类型的任务就不合适了,所以引入了 微任务队列

  • 微任务包括 process.nextTickpromiseMutationObserver,其中 process.nextTick 为 Node.js 独有。
  • 宏任务包括 <script>setTimeoutsetIntervalsetImmediate,I/O ,UI rendering

image.png

Event Loop 的执行顺序如下所示:

  • 首先执行执行栈中的 同步代码,这属于宏任务
  • 当执行完所有同步代码后,执行栈为空,查询是否有 异步代码 需要执行
  • 之后执行所有 微任务
  • 当执行完所有微任务后,如有必要会渲染页面
  • 然后开始下一轮 Event Loop ,执行宏任务中的异步代码,也就是 setTimeout 中的回调函数

这里很多人会有个误区,认为微任务快于宏任务,其实是错误的。因为宏任务中包括了 script,浏览器会先执行一个宏任务,接下来有异步代码的话才会先执行微任务。

总结

  • JavaScript 是单线程的语言,Chrome 通过引入事件循环机制来处理在线程运行过程中产生的新任务,通过消息队列来处理其它线程发过来的任务,渲染进程通过 IO 线程来接收整理其它进程传进来的消息
  • 浏览器通过在宏任务中维护一个微任务队列来执行高优先级的任务,通过回调功能来解决单个任务执行时间过长导致阻塞的问题
  • Event Loop 就是主线程从消息队列中循环不断的读取事件去执行的过程