Async Context S2
中文标题:异步上下文
提案速览
该提案引入了 AsyncContext API,用于在异步代码中隐式传播值,解决了 Promise 延续、async/await 和平台任务中隐式上下文的丢失问题。它定义了 AsyncContext.Variable 用于值存储,以及 AsyncContext.Snapshot 用于捕获和恢复上下文,并约定内置调度器在注册时进行快照。
Note
以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。
JavaScript 的异步上下文
状态:第 2 阶段
推动者:
通过 #tc39-async-context matrix 房间(Matrix 指南)与小组讨论并加入双周会议。
目录
引言
本提案引入了 API,用于在异步代码(如 Promise 延续或异步回调)中隐式传播值。
与其他语言中的类似 API 相比,本提案将以下功能视为非目标:
- 异步任务的调度和拦截。
- 通过异步堆栈的错误处理和冒泡。
动机
在编写同步 JavaScript 代码时,开发者的合理期望是值在同步执行的整个生命周期内始终可用。这些值可以显式传递(即作为函数或嵌套函数的参数,或作为闭包变量),也可以隐式传递(从调用栈中提取,例如作为函数或嵌套函数可以访问的外部对象)。
function program() {
const value = { key: 123 };
// 通过参数显式传递值给函数。
// 该值在函数执行的整个过程中可用。
explicit(value);
// 通过闭包显式捕获。
// 只要闭包存在,该值就可用。
const closure = () => {
assert.equal(value.key, 123);
};
// 通过共享引用隐式传播到外部变量。
// 只要共享引用被设置,该值就可用。
// 在这种情况下,只要 try-finally 代码的同步执行。
try {
shared = value;
implicit();
} finally {
shared = undefined;
}
}
function explicit(value) {
assert.equal(value.key, 123);
}
let shared;
function implicit() {
assert.equal(shared.key, 123);
}
program();
async/await 语法改善了编写异步 JS 的人体工程学。它允许开发者以同步代码的方式思考异步代码。事件循环执行代码的行为与 Promise 链中相同。然而,通过事件循环传递代码时,会丢失调用点的_隐式_信息,因为我们最终替换了调用栈。在 async/await 语法的情况下,由于与同步代码的视觉相似性,隐式调用点信息的丢失变得不可见——唯一的障碍指示符是 await 关键字。因此,在同步 JS 中“正常工作”的代码在异步 JS 中会出现意外行为,同时看起来几乎完全相同。
function program() {
const value = { key: 123 };
// 通过共享引用隐式传播到外部变量。
// 该值仅对 try-finally 代码的_同步执行_可用。
try {
shared = value;
implicit();
} finally {
shared = undefined;
}
}
let shared;
async function implicit() {
// 共享引用仍设置为正确的值。
assert.equal(shared.key, 123);
await 1;
// 在 await 之后,共享引用已重置为 `undefined`。
// 我们失去了对原始值的访问。
assert.throws(() => {
assert.equal(shared.key, 123);
});
}
program();
上述问题在 Promise 回调式代码中已经存在,但 async/await 语法的引入使其更加严重,因为栈替换几乎不可检测。这个问题通常无法仅靠用户代码解决。例如,如果函数被调用时调用栈已被替换,该函数将永远没有机会捕获共享引用。
function program() {
const value = { key: 123 };
// 通过共享引用隐式传播到外部变量。
// 该值仅对 try-finally 代码的_同步执行_可用。
try {
shared = value;
setTimeout(implicit, 0);
} finally {
shared = undefined;
}
}
let shared;
function implicit() {
// 当此代码执行时,共享引用已被重置。
// `implicit` 无法解决此问题,因为该错误是由 `program` 函数(意外地)造成的。
assert.throws(() => {
assert.equal(shared.key, 123);
});
}
program();
此外,async/await 语法绕过了用户态 Promise,使得现有的工具(如 Zone.js)无法在不进行转译的情况下与之配合使用,该工具对 Promise 进行了插桩。
本提案引入了一种通用机制,通过该机制可以捕获丢失的隐式调用点信息,并在事件循环的转换中使用,同时允许开发者像在没有隐式信息的情况下一样编写异步代码。目标是减少在这种情况下处理异步代码所需的当前心智负担。
本提案旨在降低开发者对生产应用进行插桩的障碍,以便调试真实用户问题,从而使用追踪工具收集额外的性能指标和代码流程信息。这是其他编程语言中广泛采用的方法,但由于许多 Web API 的异步特性,Web 上至今无法实现。前端框架将使用本提案来减少和自动化将异步代码归因到特定组件的样板代码,这目前是难以调试问题的来源。
使用场景
提议的解决方案
本提案由两个主要部分组成:
- 用户交互的 API 接口,即
AsyncContext 对象。这将成为 ECMAScript 核心规范的一部分。
- 跨异步 API 的传播。虽然 ECMA-262 定义了一些异步 API(
Promise 和 FinalizationRegistry),但大多数由 Web 规范定义。这种传播应如何工作,在 ./WEB-INTEGRATION.md 中描述。
AsyncContext
AsyncContext 被设计为用于在逻辑连接的同步/异步代码执行之间传播上下文的值存储。
namespace AsyncContext {
class Variable<T> {
constructor(options?: AsyncVariableOptions<T>);
get name(): string;
get(): T | undefined;
run<R>(value: T, fn: (...args: any[])=> R, ...args: any[]): R;
}
interface AsyncVariableOptions<T> {
name?: string;
defaultValue?: T;
}
class Snapshot {
constructor();
run<R>(fn: (...args: any[]) => R, ...args: any[]): R;
static wrap<T, R>(fn: (this: T, ...args: any[]) => R): (this: T, ...args: any[]) => R;
}
}
AsyncContext.Variable
Variable 是一个容器,用于存储与当前执行流关联的值。该值通过异步执行流传播,并且可以使用 Snapshot 进行快照和恢复。
Variable.prototype.run() 和 Variable.prototype.get() 设置和获取异步执行流的当前值。
const asyncVar = new AsyncContext.Variable();
// 将当前值设置为 'top',并执行 `main` 函数。
asyncVar.run("top", main);
async function main() {
// AsyncContext.Variable 在 async/await 上传播。
await Promise.resolve();
console.log(asyncVar.get()); // => 'top'
// AsyncContext.Variable 通过平台任务传播。
setTimeout(() => {
console.log(asyncVar.get()); // => 'top'
asyncVar.run("A", () => {
console.log(asyncVar.get()); // => 'A'
setTimeout(() => {
console.log(asyncVar.get()); // => 'A'
}, randomTimeout());
});
}, randomTimeout());
// AsyncContext.Variable 运行可以嵌套。
asyncVar.run("B", () => {
console.log(asyncVar.get()); // => 'B'
setTimeout(() => {
console.log(asyncVar.get()); // => 'B'
}, randomTimeout());
});
// 上次运行后,AsyncContext.Variable 已恢复。
console.log(asyncVar.get()); // => 'top'
}
function randomTimeout() {
return Math.random() * 1000;
}
TIP
关于 AsyncContext.Variable 动态作用域已有长时间详细讨论。请查看 SCOPING.md 获取更多细节。
宿主预计会使用本提案中的基础设施,不仅跟踪异步调用栈,还可以跟踪在事件循环上调度任务的其他方式(如 setTimeout),以最大化这些使用场景的价值。我们在 web 集成文档 中描述了与 Web 平台 API 所需集成。
详细的使用场景示例可在 使用场景 和 框架 文档中找到。
AsyncContext.Snapshot
AsyncContext.Snapshot 是一个高级 API,允许不透明地捕获所有 Variable 的当前值,并在稍后执行函数,就好像这些值仍然是当前值一样。
Snapshot 对于实现逻辑上“调度”回调的 API 很有用,因此回调将使用其逻辑上所属的上下文调用,而不管其实际运行时的上下文如何:
let queue = [];
export function enqueueCallback(cb: () => void) {
// 每个回调与其入队时的上下文一起存储。
const snapshot = new AsyncContext.Snapshot();
queue.push(() => snapshot.run(cb));
}
runWhenIdle(() => {
// 队列中的所有回调如果未被包装,将以当前上下文运行。
for (const cb of queue) {
cb();
}
queue = [];
});
大多数 Web 开发者,即使是直接与 AsyncContext.Variable 交互的开发者,预计也永远不需要使用 AsyncContext.Snapshot。
TIP
关于为什么 AsyncContext.Snapshot 是必需品的详细解释,请参阅 SNAPSHOT.md。
请注意,即使使用 AsyncContext.Snapshot,您也只能访问您有权访问的 AsyncContext.Variable 实例关联的值。无法遍历快照中的条目或 AsyncContext.Variable。
const asyncVar = new AsyncContext.Variable();
let snapshot
asyncVar.run("A", () => {
// 捕获此时所有 AsyncContext.Variable 的状态。
snapshot = new AsyncContext.Snapshot();
});
asyncVar.run("B", () => {
console.log(asyncVar.get()); // => 'B'
// 快照将恢复所有 AsyncContext.Variable 到其快照状态,并调用包装函数。我们传递一个函数供其调用。
snapshot.run(() => {
// 尽管在词法上嵌套在 'B' 中,快照将我们恢复到快照 'A' 状态。
console.log(asyncVar.get()); // => 'A'
});
});
AsyncContext.Snapshot.wrap
AsyncContext.Snapshot.wrap 是一个辅助方法,它捕获所有 Variable 的当前值并返回一个包装函数。调用时,此包装函数恢复所有 Variable 的状态并执行内部函数。
const asyncVar = new AsyncContext.Variable();
function fn() {
return asyncVar.get();
}
let wrappedFn;
asyncVar.run("A", () => {
// 捕获此时所有 AsyncContext.Variable 的状态,返回恢复该状态的包装闭包。
wrappedFn = AsyncContext.Snapshot.wrap(fn)
});
console.log(fn()); // => undefined
console.log(wrappedFn()); // => 'A'
您可以将其视为 Snapshot 的更便捷版本,只需包装单个函数。它也适用于不支持 AsyncContext 的库的消费者,以确保函数在正确的执行上下文中执行。
// 使用遗留库的用户代码
const asyncVar = new AsyncContext.Variable();
function fn() {
return asyncVar.get();
}
asyncVar.run("A", () => {
defer(fn); // setTimeout 在 "A" 上下文期间调度。
})
asyncVar.run("B", () => {
defer(fn); // 不调用 setTimeout,fn 仍将看到 "A" 上下文。
})
asyncVar.run("C", () => {
const wrapped = AsyncContext.Snapshot.wrap(fn);
defer(wrapped); // 包装回调捕获 "C" 上下文。
})
// 某个遗留库,每个宏任务队列多个回调
// 因为 setTimeout 每个队列批次只调用一次,
// 所有回调将以_该_上下文调用,而不管调用 `defer` 时处于什么上下文。
const queue = [];
function defer(callback) {
if (queue.length === 0) setTimeout(processQueue, 1);
queue.push(callback);
}
function processQueue() {
for (const cb of queue) {
cb();
}
queue.length = 0;
}
示例
确定任务的发起者
像 OpenTelemetry 这样的应用监控工具将其追踪跨度保存在 AsyncContext.Variable 中,并在需要确定是什么开始了这个交互链时检索跨度。
这些库不能侵入开发者 API 以实现无缝监控。追踪跨度不需要用户代码手动传递。
// tracer.js
const asyncVar = new AsyncContext.Variable();
export function run(cb) {
// (a)
const span = {
startTime: Date.now(),
traceId: randomUUID(),
spanId: randomUUID(),
};
asyncVar.run(span, cb);
}
export function end() {
// (b)
const span = asyncVar.get();
span?.endTime = Date.now();
}
// my-app.js
import * as tracer from "./tracer.js";
button.onclick = (e) => {
// (1)
tracer.run(() => {
fetch("https://example.com").then((res) => {
// (2)
return processBody(res.body).then((data) => {
// (3)
const dialog = html`<dialog>
这是很酷的数据:${data} <button>好的,酷</button>
</dialog>`;
dialog.show();
tracer.end();
});
});
});
};
在上面的示例中,run 和 end 与实际代码函数不共享相同的词法作用域,它们能够异步重入,因此能够并发多跟踪。
传递性任务归属
用户任务可以带有归属进行调度。通过 AsyncContext.Variable,任务归属在异步任务流中传播,子任务可以以相同的优先级调度。
const scheduler = {
asyncVar: new AsyncContext.Variable(),
postTask(task, options) {
// 实践中,任务执行可能会延迟。
// 这里我们简单地立即运行任务。
return this.asyncVar.run({ priority: options.priority }, task);
},
currentTask() {
return this.asyncVar.get() ?? { priority: "default" };
},
};
const res = await scheduler.postTask(task, { priority: "background" });
console.log(res);
async function task() {
// 通过引用 scheduler.currentTask(),fetch 保持后台优先级。
const resp = await fetch("/hello");
const text = await resp.text();
scheduler.currentTask(); // => { priority: 'background' }
return doStuffs(text);
}
async function doStuffs(text) {
// 一些异步计算...
return text;
}
用户态队列
用户态队列可以使用 AsyncContext.Snapshot 实现,以传播所有 AsyncContext.Variable 的值,而无需访问其中任何一个。这使得用户态队列的实现可以与 AsyncContext.Variable 的消费者解耦。
// 调度器不访问任何 AsyncContext.Variable。
const scheduler = {
queue: [],
postTask(task) {
// 每个回调与其入队时的上下文一起存储。
const snapshot = new AsyncContext.Snapshot();
queue.push(() => snapshot.run(task));
},
runWhenIdle() {
// 队列中的所有回调如果未被包装,将以当前上下文运行。
for (const cb of this.queue) {
cb();
}
this.queue = [];
}
};
function userAction() {
scheduler.postTask(function userTask() {
console.log(traceContext.get());
});
}
// 追踪库可以使用 AsyncContext.Variable 存储追踪上下文。
const traceContext = new AsyncContext.Variable();
traceContext.run("trace-id-a", userAction);
traceContext.run("trace-id-b", userAction);
scheduler.runWhenIdle();
// userTask 将使用其入队时的追踪上下文运行。
// => 'trace-id-a'
// => 'trace-id-b'
FAQ
有先例吗?
请查看 prior-arts.md 获取更多细节。
为什么 run 接受一个函数?
Variable.prototype.run 和 Snapshot.prototype.run 方法接受一个要执行的函数,因为它确保异步上下文变量在给定的执行流中始终包含一致的值。任何修改必须发生在异步执行流的子图中,并且不能影响其父级或兄弟作用域。
const asyncVar = new AsyncContext.Variable();
asyncVar.run("A", async () => {
asyncVar.get(); // => 'A'
// ...任意同步代码。
// ...或 await 的异步调用。
// 此时无法修改该值。
asyncVar.get(); // => 'A'
});
这增加了异步上下文变量的完整性,使人们更容易理解异步变量值的来源。
AsyncContext 如何与内置调度器交互?
每当调度器(如 setTimeout、addEventListener 或 Promise.prototype.then)运行用户提供的回调时,它必须选择在哪个快照中运行。虽然用户态调度器可以在此自由选择,本提案采用一种约定,即内置调度器始终在回调被传递给内置函数时(即“注册时”)活动的快照中运行回调。这等同于用户在所有回调传递之前显式调用 AsyncContext.Snapshot.wrap。
这一选择与 run 接受函数所产生的函数作用域结构最为一致,也是替代方案中最明确定义的选项。例如,许多事件监听器可以通过编程方式或用户交互启动;在前一种情况下,可能有一个更近期的相关快照可用,但在不同类型的事件甚至同一类型事件的不同实例之间,这是不一致的。另一方面,将回调传递给内置函数发生在非常明确的时间。
注册时快照的另一个优点是,预计会减少跳出默认快照所需的干预量。由于 AsyncContext 是一个微妙的功能,期望每个 Web 开发者都对其细微差别有完整理解是不合理的。此外,重要的是库用户不需要知道库实现隐式传递的变量的性质。如果出现开发者觉得需要在将回调传递到任何地方之前包装它们的常见做法,那将是有害的。让函数在不同快照中运行的主要方法是通过 Snapshot.wrap,但在将回调传递给内置函数时,这将是幂等的,这使得这种常见做法首先不太可能开始,并且在确实不必要时也不太有害。
如果我需要从更近的原因访问快照怎么办?
注册时快照的缺点是,无法选择_不_进行快照恢复,以访问快照恢复_之前_的快照。这种快照更相关的使用场景包括:
- 程序化分发的事件,其处理程序在应用程序初始化时安装
- 未处理拒绝处理程序是上述的一个具体示例
- 追踪执行流,其中一个任务“继承自”同级任务
如上所述,替代的快照选择更特定于个别使用场景,但它们可以通过侧通道提供。例如,Web 规范可以包括某些事件类型在事件对象上暴露一个 originSnapshot 属性(实际名称待定),该属性包含从发起事件的特定时间点活动的 AsyncContext.Snapshot。
通过侧通道提供这些附加快照有几个好处,而不是默认切换到它们,或通过通用的“先前快照”机制:
- 不同类型的调度器可能有各种潜在的起源点,其范围可以通过明确定义的侧通道精确匹配
- 通过已知侧通道访问避免了回调被多次包装时幂等性的丧失(而“先前快照”会变得不太清楚)
- 没有单一的包装方法让开发者养成坏习惯