JavaScript 执行模型
本页介绍了 JavaScript 运行时环境的基本基础设施。该模型在很大程度上是理论性和抽象的,不包含任何特定于平台或实现的细节。现代 JavaScript 引擎对所描述的语义进行了大量优化。
本页是参考资料。它假定你已经熟悉其他编程语言(如 C 和 Java)的执行模型。它大量引用了操作系统和编程语言中的现有概念。
引擎和主机
JavaScript 执行需要两种软件的协作:JavaScript 引擎和宿主环境。
JavaScript 引擎实现 ECMAScript (JavaScript) 语言,提供核心功能。它接收源代码,解析并执行它。然而,为了与外部世界交互,例如产生任何有意义的输出,与外部资源接口,或实现安全或性能相关的机制,我们需要宿主环境提供的额外环境特定机制。例如,当 JavaScript 在 Web 浏览器中执行时,HTML DOM 就是宿主环境。Node.js 是另一个宿主环境,允许 JavaScript 在服务器端运行。
虽然本参考资料主要关注 ECMAScript 中定义的机制,但我们偶尔也会讨论 HTML 规范中定义的机制,这些机制通常被其他宿主环境(如 Node.js 或 Deno)模仿。通过这种方式,我们可以对 Web 及其他地方使用的 JavaScript 执行模型给出一个连贯的描述。
代理执行模型
在 JavaScript 规范中,每个自主执行 JavaScript 的执行者都被称为代理(agent),它维护其代码执行设施。
- 堆(对象堆):这只是一个名称,表示一大片(大部分是非结构化的)内存区域。它会随着程序中对象的创建而填充。请注意,在共享内存的情况下,每个代理都有自己的堆,其中包含自己的
SharedArrayBuffer
对象的版本,但缓冲区表示的底层内存是共享的。 - 队列(任务队列):这在 HTML(以及通常)中被称为事件循环,它使 JavaScript 能够进行异步编程,同时保持单线程。它被称为队列是因为它通常是先进先出的:较早的任务在较晚的任务之前执行。
- 栈(执行上下文栈):这就是所谓的调用栈,它允许通过进入和退出函数等执行上下文来转移控制流。它被称为栈是因为它是后进先出的。每个任务通过将一个新帧推入(空)栈来进入,并通过清空栈来退出。
这些是三种不同的数据结构,用于跟踪不同的数据。我们将在以下章节中更详细地介绍队列和栈。要了解有关堆内存如何分配和释放的更多信息,请参阅内存管理。
每个代理都类似于一个线程(请注意,底层实现可能是一个实际的操作系统线程,也可能不是)。每个代理可以拥有多个 域(realm)(它们与全局对象一对一关联),这些域可以同步地相互访问,因此需要在一个执行线程中运行。代理还具有单一的内存模型,指示它是小端序还是大端序,它是否可以同步阻塞,原子操作是否无锁等。
Web 上的代理可以是以下之一:
- 一个同源窗口代理,它包含各种可能相互访问的
Window
对象,无论是直接访问还是通过使用document.domain
。如果窗口是源键控(origin-keyed)的,则只有同源窗口才能相互访问。 - 一个专用 worker 代理,包含一个
DedicatedWorkerGlobalScope
。 - 一个共享 worker 代理,包含一个
SharedWorkerGlobalScope
。 - 一个服务 worker 代理,包含一个
ServiceWorkerGlobalScope
。 - 一个Worklet 代理,包含一个
WorkletGlobalScope
。
换句话说,每个 worker 都会创建自己的代理,而一个或多个窗口可能在同一个代理中——通常是一个主文档及其同源 iframe。在 Node.js 中,有一个类似的概念,称为worker threads。
下图说明了代理的执行模型:
域
每个代理拥有一或多个域(realm)。每段 JavaScript 代码在加载时都会与一个域关联,即使从另一个域调用,该关联也保持不变。一个域包含以下信息:
- 内置对象的列表,如
Array
、Array.prototype
等。 - 全局声明的变量、
globalThis
的值和全局对象。 - 模板字面量数组的缓存,因为对同一标记模板字面量表达式的求值总是会导致标签接收到相同的数组对象。
在 Web 上,域和全局对象是一一对应的。全局对象可以是 Window
、WorkerGlobalScope
或 WorkletGlobalScope
。因此,例如,每个 iframe
都在不同的域中执行,尽管它可能与父窗口位于同一个代理中。
在谈论全局对象的身份时,通常会提到域。例如,我们需要 Array.isArray()
或 Error.isError()
等方法,因为在另一个域中构造的数组将拥有一个与当前域中的 Array.prototype
对象不同的原型对象,因此 instanceof Array
将错误地返回 false
。
栈和执行上下文
我们首先考虑同步代码执行。每个 任务 都通过调用其关联的回调函数来进入。此回调函数中的代码可以创建变量、调用函数或退出。每个函数都需要跟踪自己的变量环境和返回位置。为了处理这个问题,代理需要一个栈来跟踪执行上下文。执行上下文,通常也称为栈帧(stack frame),是最小的执行单元。它跟踪以下信息:
- 代码评估状态
- 包含此代码的模块或脚本、函数(如果适用)以及当前正在执行的生成器
- 当前域
- 绑定,包括
- 用
var
、let
、const
、function
、class
等定义的变量。 - 仅在当前上下文中有效的私有标识符,如
#foo
。 this
引用
- 用
设想一个由以下代码定义的单任务程序:
function foo(b) {
const a = 10;
return a + b + 11;
}
function bar(x) {
const y = 3;
return foo(x * y);
}
const baz = bar(7); // assigns 42 to baz
- 当任务开始时,第一个帧被创建,其中定义了变量
foo
、bar
和baz
。它用参数7
调用bar
。 - 为
bar
调用创建了第二个帧,其中包含参数x
和局部变量y
的绑定。它首先执行乘法x * y
,然后用结果调用foo
。 - 为
foo
调用创建了第三个帧,其中包含参数b
和局部变量a
的绑定。它首先执行加法a + b + 11
,然后返回结果。 - 当
foo
返回时,栈顶帧元素被弹出,调用表达式foo(x * y)
解析为返回值。然后它继续执行,也就是返回这个结果。 - 当
bar
返回时,栈顶帧元素被弹出,调用表达式bar(7)
解析为返回值。这将用返回值初始化baz
。 - 我们到达了任务源代码的末尾,因此入口点的栈帧从栈中弹出。栈为空,因此任务被认为已完成。
生成器与重入
当一个帧被弹出时,它不一定永远消失,因为有时我们需要回到它。例如,考虑一个生成器函数:
function* gen() {
console.log(1);
yield;
console.log(2);
}
const g = gen();
g.next(); // logs 1
g.next(); // logs 2
在这种情况下,调用 gen()
首先会创建一个被暂停的执行上下文——gen
内部的代码尚未执行。生成器 g
在内部保存这个执行上下文。当前运行的执行上下文仍然是入口点。当调用 g.next()
时,gen
的执行上下文被推到栈上,并且 gen
内部的代码执行直到 yield
表达式。然后,生成器执行上下文被暂停并从栈中移除,这将控制权返回给入口点。当再次调用 g.next()
时,生成器执行上下文被重新推到栈上,并且 gen
内部的代码从上次离开的地方恢复执行。
尾调用
规范中定义的一种机制是尾调用优化(PTC)。如果调用者在调用后除了返回值之外不执行任何操作,则函数调用是尾调用。
function f() {
return g();
}
在这种情况下,对 g
的调用是一个尾调用。如果函数调用处于尾位置,引擎需要丢弃当前的执行上下文,并将其替换为尾调用的上下文,而不是为 g()
调用推送一个新的帧。这意味着尾递归不受栈大小限制的约束。
function factorial(n, acc = 1) {
if (n <= 1) return acc;
return factorial(n - 1, n * acc);
}
实际上,丢弃当前帧会导致调试问题,因为如果 g()
抛出错误,f
将不再在栈上,并且不会出现在栈跟踪中。目前,只有 Safari (JavaScriptCore) 实现了 PTC,并且他们发明了一些特定的基础设施来解决可调试性问题。
闭包
另一个与变量作用域和函数调用相关的有趣现象是闭包。每当创建一个函数时,它也会在内部记住当前运行执行上下文的变量绑定。然后,这些变量绑定可以比执行上下文的生命周期更长。
let f;
{
let x = 10;
f = () => x;
}
console.log(f()); // logs 10
任务队列和事件循环
代理是一个线程,这意味着解释器一次只能处理一条语句。当代码全部是同步的时候,这没问题,因为我们总能取得进展。但如果代码需要执行异步操作,那么除非该操作完成,否则我们无法取得进展。然而,如果这会暂停整个程序,那将对用户体验造成损害——JavaScript 作为一种 Web 脚本语言的性质要求它永不阻塞。因此,处理异步操作完成的代码被定义为回调。一旦操作完成,此回调会定义一个任务,该任务被放置到任务队列中——或者,用 HTML 术语来说,是事件循环。
每次,代理都会从队列中拉取一个任务并执行它。当任务执行时,它可能会创建更多任务,这些任务会添加到队列的末尾。任务也可以通过异步平台机制的完成来添加,例如计时器、I/O 和事件。当栈为空时,任务被认为是完成的;然后,从队列中拉取下一个任务。任务可能不会以统一的优先级拉取——例如,HTML 事件循环将任务分为两类:宏任务(tasks)和微任务(microtasks)。微任务具有更高的优先级,微任务队列在拉取任务队列之前被清空。有关更多信息,请查阅 HTML 微任务指南。如果任务队列为空,代理会等待更多任务被添加。
“运行到完成”
每个任务在处理其他任务之前都完全处理。这在推断程序时提供了一些很好的特性,包括无论何时函数运行,它都不能被抢占,并且会在任何其他代码运行(并可能修改函数操作的数据)之前完全运行。这与 C 不同,例如,在 C 中,如果一个函数在一个线程中运行,它可能会在任何时候被运行时系统停止,以在另一个线程中运行其他代码。
例如,考虑以下示例:
const promise = Promise.resolve();
let i = 0;
promise.then(() => {
i += 1;
console.log(i);
});
promise.then(() => {
i += 1;
console.log(i);
});
在此示例中,我们创建了一个已经解析的 promise,这意味着附加到它的任何回调都将立即作为任务进行调度。这两个回调似乎会导致竞态条件,但实际上,输出是完全可预测的:1
和 2
将按顺序记录。这是因为每个任务在下一个任务执行之前都会运行到完成,所以总的顺序始终是 i += 1; console.log(i); i += 1; console.log(i);
,而不是 i += 1; i += 1; console.log(i); console.log(i);
。
这种模式的一个缺点是,如果一个任务完成时间过长,Web 应用程序将无法处理用户交互,如点击或滚动。浏览器通过“脚本运行时间过长”对话框来缓解这个问题。一个好的做法是使任务处理时间短,如果可能,将一个任务分解成多个任务。
永不阻塞
事件循环模型提供的另一个重要保证是 JavaScript 执行永不阻塞。I/O 处理通常通过事件和回调执行,因此当应用程序等待 IndexedDB 查询返回或 fetch()
请求返回时,它仍然可以处理其他事情,例如用户输入。异步操作完成后执行的代码总是作为回调函数提供(例如,promise 的 then()
处理程序、setTimeout()
中的回调函数或事件处理程序),它定义了一个在操作完成后添加到任务队列的任务。
当然,“永不阻塞”的保证要求平台 API 本质上是异步的,但存在一些遗留的例外,如 alert()
或同步 XHR。为了确保应用程序的响应能力,通常认为避免使用它们是最佳实践。
代理集群和内存共享
多个代理可以通过内存共享进行通信,形成一个代理集群。当且仅当代理可以共享内存时,它们才属于同一个集群。两个代理集群之间没有内置机制可以交换任何信息,因此它们可以被视为完全隔离的执行模型。
在创建代理(例如通过派生 worker)时,有一些标准可以判断它是否与当前代理在同一集群中,或者是否创建了一个新集群。例如,以下全局对象对都在同一个代理集群中,因此可以相互共享内存:
- 一个
Window
对象和它创建的一个专用 worker。 - 一个 worker(任何类型)和它创建的一个专用 worker。
- 一个
Window
对象 A 和 A 创建的同源iframe
元素的Window
对象。 - 一个
Window
对象和打开它的同源Window
对象。 - 一个
Window
对象和它创建的一个 Worklet。
以下全局对象对不在同一个代理集群中,因此无法共享内存:
- 一个
Window
对象和它创建的一个共享 worker。 - 一个 worker(任何类型)和它创建的一个共享 worker。
- 一个
Window
对象和它创建的一个服务 worker。 - 一个
Window
对象 A 和 A 创建的iframe
元素的Window
对象,该iframe
与 A 不能同源。 - 任何两个没有 opener 或祖先关系的
Window
对象。即使这两个Window
对象同源,也成立。
有关精确的算法,请查阅 HTML 规范。
跨代理通信和内存模型
如前所述,代理通过内存共享进行通信。在 Web 上,内存通过 postMessage()
方法共享。使用 Web Workers 指南提供了相关概述。通常,数据仅按值传递(通过结构化克隆),因此不涉及任何并发复杂性。要共享内存,必须发送一个 SharedArrayBuffer
对象,该对象可以由多个代理同时访问。一旦两个代理通过 SharedArrayBuffer
共享访问同一内存,它们就可以通过 Atomics
对象同步执行。
有两种访问共享内存的方式:通过普通内存访问(非原子)和通过原子内存访问。后者是顺序一致的(这意味着集群中所有代理都同意事件存在严格的总序),而前者是无序的(这意味着不存在排序);JavaScript 不提供其他排序保证的操作。
规范为处理共享内存的程序员提供了以下指导:
我们建议程序保持无数据竞争,即确保不可能在同一内存位置上同时进行非原子操作。无数据竞争的程序具有交错语义,其中每个代理的评估语义中的每一步都相互交错。对于无数据竞争的程序,无需理解内存模型的细节。这些细节不太可能建立有助于更好地编写 ECMAScript 的直觉。
更一般地,即使程序存在数据竞争,它也可能具有可预测的行为,只要原子操作不涉及任何数据竞争,并且所有竞争操作都具有相同的访问大小。避免原子操作涉及竞争的最简单方法是确保原子操作和非原子操作使用不同的内存单元,并且不同大小的原子访问不会同时访问相同的内存单元。实际上,程序应该尽可能将共享内存视为强类型。你仍然不能依赖竞争的非原子访问的排序和时间,但如果内存被视为强类型,则竞争的访问不会“撕裂”(它们的值的位不会混淆)。
并发与确保向前进展
当多个代理协作时,永不阻塞的保证并非总是成立。代理可能会被阻塞或暂停,等待另一个代理执行某些操作。这与在同一代理中等待 promise 不同,因为它会停止整个代理,并且在此期间不允许任何其他代码运行——换句话说,它无法实现向前进展。
为了防止死锁,对何时以及哪些代理可能被阻塞有一些严格的限制。
- 每个具有专用执行线程的未阻塞代理最终都会向前进展。
- 在一组共享执行线程的代理中,一个代理最终会向前进展。
- 代理不会导致另一个代理被阻塞,除非通过提供阻塞的显式 API。
- 只有某些代理可以被阻塞。在 Web 上,这包括专用 worker 和共享 worker,但不包括同源窗口或 service worker。
在外部暂停或终止的情况下,代理集群确保其代理的活跃度保持一定程度的完整性。
- 代理可能会在不知情或不配合的情况下暂停或恢复。例如,离开窗口可能会暂停代码执行但保留其状态。然而,不允许代理集群部分停用,以避免代理因另一个代理停用而饿死。例如,共享 worker 从来不会与创建者窗口或其他专用 worker 处于同一个代理集群中。这是因为共享 worker 的生命周期独立于文档:如果一个文档在其专用 worker 持有锁时停用,那么共享 worker 将被阻止获取锁,直到专用 worker 被重新激活(如果会的话)。与此同时,其他试图从其他窗口访问共享 worker 的 worker 将会饿死。
- 同样,代理可能会因集群外部因素而终止。例如,操作系统或用户关闭浏览器进程,或者浏览器强制终止某个代理因为它使用了太多资源。在这种情况下,集群中的所有代理都会被终止。(规范还允许第二种策略,即一个 API 允许集群中至少一个剩余成员识别终止和被终止的代理,但这在 Web 上尚未实现。)
规范
规范 |
---|
ECMAScript® 2026 语言规范 |
ECMAScript® 2026 语言规范 |
HTML |