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 线程中产生的新任务添加进 消息队列尾部;
- 渲染主线程会循环地 从消息队列头部中读取任务,执行任务。
如下图所示:
处理其他进程发送过来的任务
线程之间的消息通信可以通过消息队列来实现,那么进程之间呢?在 Chrome 中,跨进程之间的任务也是频繁发生的,那么渲染进程如何处理其他进程发送过来的任务呢?
可以参考下图:
渲染进程专门有一个 IO 线程 用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程。
JavaScript 使用单线程带来的问题
页面线程所有执行的任务都来自于消息队列。消息队列是先进先出的特性,即放入队列中的任务,需要等待前面的任务被执行完,才会被执行。鉴于这个属性,就有如下两个问题需要解决:
当出现高优先级的任务时应该怎么办?
比如一个典型的场景是 监控 DOM 节点的变化情况(节点的插入、修改、删除等动态变化),然后根据这些变化来处理相应的业务逻辑。一个通用的设计的是,利用 JavaScript 设计一套监听接口,当变化发生时,渲染引擎同步调用这些接口,这是一个典型的观察者模式。
不过这个模式有个问题,因为 DOM 变化非常频繁,如果每次发生变化的时候,都直接调用相应的 JavaScript 接口,那么这个当前的任务执行时间会被拉长,从而导致执行效率的下降。
如果将这些 DOM 变化做成异步的消息事件,添加到消息队列的尾部,那么又会影响到监控的实时性,因为在添加到消息队列的过程中,可能前面就有很多任务在排队了。
这也就是说,如果 DOM 发生变化,采用同步通知的方式,会影响当前任务的执行效率;如果采用异步方式,又会影响到监控的实时性。
那该如何权衡效率和实时性呢?针对这种情况,微任务就应用而生了。
- 通常把 消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果 DOM 有变化,那么就会将该变化添加到微任务列表中,这样就不会影响到宏任务的继续执行,因此也就解决了 执行效率 的问题。
- 等宏任务中的主要功能都直接完成之后,这时候,渲染引擎并不着急去执行下一个宏任务,而是 执行当前宏任务中的微任务,因为 DOM 变化的事件都保存在这些微任务队列中,这样也就解决了 实时性问题。
单个任务执行时间过长时应该怎么办?
因为所有的任务都是在单线程中执行的,所以 每次只能执行一个任务,而其他任务就都处于等待状态。如果其中一个任务执行时间过久,那么下一个任务就要等待很长时间。
针对这种情况,JavaScript 是通过 回调功能 来规避这种问题的,也就是让要执行的 JavaScript 任务滞后执行。
浏览器中的 Event Loop
主线程从消息队列中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为 Event Loop (事件循环)。
而不同的任务源会分到不同的消息队列中,任务源可以分为 微任务(microtask) 和 宏任务(macrotask)。在 ES6 规范中,microtake
称为 jobs
,macrotask
称为 task
。
⻚面渲染事件,各种 IO 的完成事件等随时被添加到消息队列中,一直会保持 先进先出 的原则执行,我们不能准确地控制这些事件被添加到消息队列中的位置。但是这个时候突然有 高优先级 的任务需要尽快执行,那么一种类型的任务就不合适了,所以引入了 微任务队列。
- 微任务包括
process.nextTick
,promise
,MutationObserver
,其中process.nextTick
为 Node.js 独有。 - 宏任务包括
<script>
,setTimeout
,setInterval
,setImmediate
,I/O ,UI rendering
。
Event Loop 的执行顺序如下所示:
- 首先执行执行栈中的 同步代码,这属于宏任务
- 当执行完所有同步代码后,执行栈为空,查询是否有 异步代码 需要执行
- 之后执行所有 微任务
- 当执行完所有微任务后,如有必要会渲染页面
- 然后开始下一轮 Event Loop ,执行宏任务中的异步代码,也就是
setTimeout
中的回调函数
这里很多人会有个误区,认为微任务快于宏任务,其实是错误的。因为宏任务中包括了 script
,浏览器会先执行一个宏任务,接下来有异步代码的话才会先执行微任务。
总结
- JavaScript 是单线程的语言,Chrome 通过引入事件循环机制来处理在线程运行过程中产生的新任务,通过消息队列来处理其它线程发过来的任务,渲染进程通过 IO 线程来接收整理其它进程传进来的消息。
- 浏览器通过在宏任务中维护一个微任务队列来执行高优先级的任务,通过回调功能来解决单个任务执行时间过长导致阻塞的问题。
- Event Loop 就是主线程从消息队列中循环不断的读取事件去执行的过程。