JavaScript 事件循环模型

Author: Guang

JavaScript程序的执行离不开JavaScript引擎对程序代码的解释和执行,同时,也离不开完备的宿主环境承载。任何JavaScript程序额输出、资源访问、用户交互以及其它副作用的产生,都需要宿主环境提供的机制来保证。在客户端,JavaScript程序寄宿在浏览器环境中,它提供了内存存储、任务调度、网络资源请求API(fetch等)、HTML DOM等基础设施。在服务端,Node.js是另一种JavaScript程序的宿主环境。JavaScript程序被设计为单线程应用程序,同时支持强大的异步编程,主要原因就在于宿主环境按照事件循环进行的任务调度机制在起作用。我们会对JavaScript程序的执行模型做简单介绍,以及深入探讨浏览器中的事件循环机制。

执行模型

JavaScript程序的正常运行,离不开JavaScript引擎和宿主环境的相互配合。JavaScript引擎实现了ECMAScript描述的语言语法和基本对象、提供了核心功能。它获取源代码,进行解析并执行。程序执行的效果和各种资源访问和交互,则由宿主环境承载。

JavaScript执行代理

在JavaScript中,每个JavaScript的自主执行器被称为一个代理。每个JavaScript执行代理类似于一个线程(请注意,底层实现可能是也可能不是实际的操作系统线程)。宿主环境会为每个JavaScript执行代理提供以下基础资源:

  • 堆内存。宿主环境为每个JavaScript执行代理分配一块独属的堆内存区域,用于存储JavaScript执行代理运行期间产生的对象。同时,宿主环境提供不同代理之间共享内存的机制(SharedArrayBuffer)。
  • 执行上下文栈。执行上下文栈就是调用栈,它通过将执行上文(例如函数执行上下文)入栈和出栈来转移代码执行控制流。执行上下文栈遵循后进先出的原则,每个任务都会被作为一个新的“栈帧”推送到栈的顶端,随着一个任务执行完毕,它所对应的栈帧也会被推出栈。
  • 任务队列。任务队列在HTML规范中被称为事件循环,它是单线程运行的JavaScript支持异步编程的重要机制。之所以称之为队列,是因为它遵循先进先出的原则:先执行的任务会先于后执行。

对于(堆)内存的管理,我们在JavaScript 内存管理中有详细讨论,文中论述了JavaScript的内存分配和回收的机制。对于执行上下文栈,我们在JavaScript 执行上下文和执行栈,文中解析了JavaScript执行上下文模型及介绍了它给声明提升的影响。关于任务队列(事件循环),我们在本文的后半部分会详细解析。

一个web环境的JavaScript执行代理可以是以下之一:

  1. 同源window代理。同源window代理包括数个window可相互访问的对象,它们可以直接相互访问或者通过document.domain访问。如果window是源键(origin-keyed)标识的,则只有同源window才能相互访问。
  2. Dedicated worker代理。它包含一个DedicatedWorkerGlobalScope
  3. Shared worker代理。它包含一个SharedWorkerGlobalScope
  4. Service worker代理。它包含一个ServiceWorkerGlobalScope
  5. Worklet worker代理。它包含一个WorkletGlobalScope

换句话说,每个Web Worker都会创建自己的代理,而一个或多个window对象可能位于同一个代理中——一般情况下是HTML文档中包含同源的iframe。

JavaScript执行代理的执行模型图: JavaScript 执行代理模型

领域(Realms)

每个JavaScript执行代理拥有多个领域(realms),这些领域可以同步的访问彼此。每个执行代理还拥有单独的内存模型,用于指示它是否是小端字节序存储、是否可以被同步阻塞、以及原子操作是否无锁。每段JavaScript代码在加载时都会与一个领域 (realm) 关联,即使从其他领域调用,该领域 (realm) 也保持不变。领域 (realm) 包含以下信息:

  1. 内置对象列表,列表中包括ArrayFunctionRegExp等。
  2. 全局声明的变量、globalThis对象、其它全局对象。
  3. 模板字符数组缓存。

需要注意的是,领域和全局对象(WindowWorkerGlobalScopeWorkletGlobalScope)是一一对应的。譬如说,几个同源的iframe嵌入在同一个同源的HTML文档中,它们的JavaScript可能运行在同一个JavaScript执行代理下,但是它们在各自不同的域中执行,各自拥有自己的全局对象和领域 (realm) 。在讨论全局对象的身份时,通常会提到领域 (Realm)。在另一个领域中构造的数组arr将具有与当前领域中的Array.prototype对象不同的原型对象,因此,arr instanceof Array会错误地返回falseArray.isArray()Error.isError()之类的方法才能正常工作。

事件循环(任务队列)

代理是一个线程,这意味着解释器一次只能处理一个语句。当代码全部同步时,这没问题,因为我们总是可以取得进展。当程序遇到一个耗时操作的时候,很容易造车页面“假死”,作为界面程序的脚本语言,我们希望JavaScript程序运行时永远不会阻塞的。JavaScript执行代理将JavaScript的逻辑调用(事件)组织成一个个任务,并维护任务组成的队列对任务逐个按顺序执行,在HTML术语中,它被称为事件循环。

根据事件循环驱动的执行代理的类型,事件循环分为三种:

  1. Window事件循环
  2. Worker事件循环
  3. Worklet事件循环

同一个代理中的不同领域(realm)共享同一个事件循环。这意味着,多个同源窗口可能运行在相同的事件循环中,每个队列任务进入到事件循环中以便处理器能够轮流对它们进行处理。网页或者app的代码和浏览器本身的用户界面程序运行在相同的线程中,共享相同的事件循环。该线程就是主线程,它除了运行网页本身的代码之外,还负责收集和派发用户和其他事件,以及渲染和绘制网页内容等。

当代理执行一个任务的时候,可能会创建新的任务,新创建的任务会放入任务队列的尾端。所谓异步编程,就是创建的任务并非直接放入待执行任务队列,而是被挂起,等待恰当的时机放入执行队列排队、等待执行。当执行一个任务的时候,JavaScript执行代理会将任务压入执行栈进行执行。JavaScript执行代理会周期性的检查任务队列,一旦队列不为空就会开始执行,直到任务队列为空,然后等待下一次循环。

JavaScript事件循环的示意图如下: JavaScript 事件循环

在执行某个任务时,执行的任务都会在处理其他任务之前完全处理完毕。这在推理程序时提供了一些很好的特性,例如,每当一个函数运行时,它都不会被抢占,并且会在任何其他代码运行之前完全运行(并且可以修改该函数操作的数据)。这样,JavaScript执行代理既保证了单线程开发的简洁,又实现了异步模式。相较于其它多线程实现异步的开发语言,它们总是需要处理复杂的线程间资源竞争和状态同步问题。

任务与微任务

为了更好的实现异步编程,HTML规范引入了微任务的概念。由此,HTML事件循环将任务分为两类:任务和微任务。队列也分为任务队列和微任务队列。起初,微任务队列主要用来实现Promise对象的异步机制。目前,以下异步机制都基于微任务实现:

  1. Promiseasync/await)异步管理;
  2. HTML5新特性MutaionObserver
  3. 使用全局的queueMicrotask()方法创建微任务;
  4. Node.js中的process.nextTick()方法创建微任务。

而以下代码调用产生是任务:

  1. <script>标签代码;
  2. setTimeout()setInterval()注册回调;
  3. 触发了一个事件,将其回调函数添加到任务队列时,包括I/O事件和UI事件;
  4. UI render;
  5. setImmediate()注册回调(Node.js中)。

很多文档中会采用“宏任务(micro task)”来特指任务,“宏任务”这一术语并非来自ECMAScript标准或HTML规范,HTML规范中使用“Generic Task”一词表示普通任务。宏任务(micro task)一词来源于JavaScript社区约定。MDN文档没有采用“宏任务”一词,本文和MDN文档保持一致。

微任务具有更高优先级,当JavaScript执行代理完成(清空)当前的任务队列,总是优先拉取微任务队列中的任务作为新任务,只有清空了微任务队列,JavaScript执行代理才会拉取新的任务队列。它们的区别很重要,JavaScript执行代理对它们的处理规则如下:

  • 将每次JavaScript执行的每次循环称为一次迭代。当一次循环迭代开始时,会检查任务队列,将任务队列中的所有任务加入到当前迭代中,迭代开始后,后续加入到任务队列的任务不再加入到当前迭代,而是等待下一次迭代进行执行。
  • JavaScript执行代理执行完加入当前迭代的所有任务并且执行上下文为空后,执行代理开始按顺序执行微任务队列中的微任务,直到微任务队列为空。与任务队列不同的是,新加入的微任务也会在当前迭代执行,没有等待,一直到微任务队列为空,换句话说,微任务可以添加新的微任务到队列中,这些新的微任务将在下一个任务开始运行之前,在当前事件循环迭代结束之前执行。

加入微任务后的事件循环模型运行图示如下: JavaScript 微任务事件循环

setTimeOut()设置延迟为零并不是真的会立即执行:

setTimeout(() => {
    console.log('run setTimeout task.'); // 异步任务
}, 0);

new Promise((resolve) => {
    console.log('promise created.'); // 同步任务
    resolve();
}).then(() => {
    console.log('promise resolved.'); // 异步微任务
});

运行代码,得到按顺序输出的以下结果:

promise created.
promise resolved.
run setTimeout task.

微任务和任务在执行等级上的区别,使得开发人员更灵活地安排代码地执行逻辑。它保证了使得给定的函数在没有其他脚本执行干扰的情况下运行(没有鼠标坐标更改,没有新的网络数据等)。也保证了微任务能在用户代理有机会对该微任务带来的行为做出反应之前运行。Web Worker和Worklet分别有自己的任务队列和微任务队列。

使用queueMicrotask()

有时,为了确保任务顺序的一致性,即便当结果或数据是同步可用的,也要同时减少操作中用户可感知到的延迟而带来的风险,使用微任务是非常有效的。通常,这些场景关乎捕捉或检查结果、执行清理等;其时机晚于一段JavaScript执行上下文主体的退出,但早于任何事件处理函数、timeouts或intervals及其他回调被执行。

queueMicrotask()出现之前,人们晦涩地使用Promise去创建微任务,这么做有一定风险。首先,Promise中由回调抛出的异常被报告为rejected promises而不是标准异常;其次,创建和销毁 promise 带来了事件和内存方面的额外开销。为了允许第三方库、框架、polyfill能更好的使用微任务,JavaScript在WindowWorkerGlobalScope接口上暴露了queueMicrotask()方法。

警告:因为微任务自身可以入列更多的微任务,且事件循环会持续处理微任务直至队列为空,那么就存在一种使得事件循环无尽处理微任务的真实风险。如何处理递归增加微任务是要谨慎而行的。

参考内容

发表留言

历史留言

--空--