ECMAScript Async Context 提案介绍
背景
由阿里巴巴 TC39 代表主导的Async Context 提案[1] 刚在 2023年 2 月初的 TC39 会议中成为了 TC39 Stage 1 提案。提案的目标是定义在 JavaScript 的异步任务中传递数据的方案。
我们先以一个同步调用中访问全局变量为例,来讲讲什么我们为什么需要定义异步上下文。设想一下,我们是一个 npm 库作者。在这个库中,我们提供了一个简单的 log
函数和 run
函数。开发者可以将他们的回调函数和一个 id 传给我们的 run
函数。run
会调用用户的回调函数,并且,开发者可以在这个回调函数中调用我们的 log
函数来生成自动被调用 run
函数时传入的 id 标注了的日志。如我们的库实现如下:
// my-awesome-library
let currentId = undefined;
export function log() {
if (currentId === undefined) throw new Error('must be inside a run call stack');
console.log(`[${currentId}]`, ...arguments);
}
export function run<T>(id: string, cb: () => T) {
let prevId = currentId;
try {
currentId = id;
return cb();
} finally {
currentId = prevId;
}
}
开发者可以这样调用我们的库:
import { run, log } from 'my-awesome-library';
import { helper } from 'some-random-npm-library';
document.body.addEventListener('click', () => {
const id = nextId();
run(id, () => {
log('starting');
// 假设这个 helper 会调用 doSomething.
helper(doSomething);
log('done');
});
});
function doSomething() {
log("did something");
}
在这个例子中,无论用户点击多少次,对于每一个 id,我们都可以看到如下的完整日志序列:
[id1] starting
[id1] did something
[id1] done
由此,我们实现了一个基于同步调用栈传递的 id
的机制,开发者不需要手动在他们的代码中传递、保存 id。这个模式非常实用,因为不是每一个函数我们都能增加调用参数用来传递额外的信息,比如我们通过 React Context[2] 在 React 中将参数透过数个中间组件传递给内嵌的目标组件中。
但是,一旦我们开始引入异步操作,这个模式就开始出现问题了:
document.body.addEventListener('click', () => {
const id = new Uuid();
run(id, async () => {
log('starting');
await helper(doSomething);
// 这条日志已经无法打印期望的 id 了
log('done');
});
});
function doSomething() {
// 这条日志能够打印期望的 id 取决于 helper 是否在调用 doSomething 之前 await 过
log("did something");
}
而我们提案的 AsyncContext
就是为了解决这里的问题。它允许我们将 id 即通过同步调用栈传递,也可以通过异步任务链传递。
// my-awesome-library
const context = new AsyncContext();
export function log() {
const currentId = context.get();
if (currentId === undefined) throw new Error('must be inside a run call stack');
console.log(`[${currentId}]`, ...arguments);
}
export function run<T>(id: string, cb: () => T) {
context.run(id, cb);
}
AsyncContext
AsyncContext
是一个能够将任意 JavaScript 值通过逻辑连接的同步、异步操作,传播到逻辑连接的异步操作的执行上下文的存储。它提供如下操作:
class AsyncContext<T> {
// 快照当前执行上下文中所有 AsyncContext 实例的值,并返回一个函数。
// 当这个函数执行时,会将 AsyncContext 状态快照恢复为执行上下文的全局状态。
static wrap<R>(fn: (...args: any[]) => R): (...args: any[]) => R;
// 立刻执行 fn,并在 fn 执行期间将 value 设置为当前
// AsyncContext 实例的值。这个值会在 fn 过程中发起的异步操作中被
// 快照(相当于 wrap)。
run<R>(value: T, fn: () => R): R;
// 获取当前 AsyncContext 实例的值。
get(): T;
}
AsyncContext.prototype.run()
与 AsyncContext.prototype.get()
分别向当前执行上下文中写入、读取 AsyncContext
实例值。而 AsyncContext.wrap()
允许我们对所有的 AsyncContext
实例在当前执行上下文保存的值进行快照,并通过返回的函数来将状态快照在后续任意时间恢复为执行上下文的全局 AsyncContext
状态。
这三个操作定义了在异步任务间传播任意 JavaScript 值的最小操作接口。开发者可以通过 AsyncContext.prototype.run()
与 AsyncContext.prototype.get()
来写入、读取保存在异步上下文中的变量,而 JavaScript 运行时、任务队列实现者、框架作者可以通过 AsyncContext.wrap
来传播异步上下文变量。
// 简单实现一个任务队列
const loop = {
queue: [],
addTask: (fn) => {
queue.push(AsyncContext.wrap(fn));
},
run: () => {
while (queue.length > 0) {
const fn = queue.shift();
fn();
}
},
};
const ctx = new AsyncContext();
ctx.run('1', () => {
// loop 通过 AsyncContext.wrap 对当前上下文状态进行了快照。
loop.addTask(() => {
console.log('task:', ctx.get());
});
// AsyncContext 值会通过异步任务自动传播。即使这个 timeout callback
// 在 `ctx.run` 的同步调用栈之外执行,也能获取到传播的值。
// 而且这个 timeout 比下面第二次更迟执行,ctx 的值任然是 1。
setTimeout(() => {
console.log(ctx.get()); // => 1
}, 1000);
});
ctx.run('2', () => {
// 设置一个更快执行的 timeout。
setTimeout(() => {
console.log(ctx.get()); // => 2
}, 500);
});
console.log(ctx.get()); // => undefined
// 清空任务队列。
// AsyncContext.wrap 返回的函数在执行期间恢复了快照的状态。
loop.run(); // => task: 1
使用场景
异步链路追踪
OpenTelemetry[3] 这种应用性能监测工具(APM 工具)为了实现无感知监测(即不需要开发者修改任何业务代码),通常不能修改用户、第三方库、运行时 API。所以对于 APM 工具来说,他们不能让开发者来手动传播链路追踪数据。
而现在,他们可以将链路追踪数据保存在 AsyncContext
中,并在需要判断当前异步调用链路时,从 AsyncContext
中获取当前的链路数据。这就不再需要开发者修改业务代码。如下我们看一个简单的链路追踪例子:
// tracer.js
const context = new AsyncContext();
export function run(cb) {
// (a)
const span = {
// 建立异步调用链路
parent: context.get(),
// 设置当前异步调用属性
startTime: Date.now(),
traceId: randomUUID(),
spanId: randomUUID(),
};
context.run(span, cb);
}
export function end() {
// (b) 标记当前异步调用结束
const span = context.get();
span?.endTime = Date.now();
}
// 自动插桩 fetch API,注入链路追踪代码
const originalFetch = globalThis.fetch;
globalThis.fetch = (...args) => {
return run(() => {
return originalFetch(...args)
.finally(() => end());
});
};
对于用户代码来说,不管需不需要支持链路追踪,clickHandler
即其内部调用的依赖函数都不需要修改:
// my-app.js
import * as tracer from './tracer.js'
// 通过框架或者自动插桩,包装用户的 clickHandler
button.onclick = e => {
// (1)
tracer.run(async () => {
await clickHandler();
tracer.end();
});
};
// 用户代码
const clickHandler = () => {
return fetch("https://example.com").then(res => {
// (2)
return processBody(res.body).then(data => {
// (3)
const dialog = html`<dialog>Here's some cool data: ${data}
<button>OK, cool</button></dialog>`;
dialog.show();
});
});
}
我们作为 OpenTelemetry 的维护者,这个提案是我们将 OpenTelemetry 的开发者无感知的链路追踪能力带给 Web 应用的必要特性之一。
异步任务属性传递
许多 JavaScript 运行时 API 如 Web API 都会提供任务调度相关的特性。这些任务通常可以设置如优先级等属性,让运行时发挥其启发式的调度逻辑,提供更好用户体验、低延迟的用户交互。
通过 AsyncContext
,开发者就不需要再手动传递如任务优先级这些任务属性。JavaScript 运行时可以通过 AsyncContext
获取通过异步调用传播的任务属性,默认配置子任务优先级:
// 假设我们有一个简单的任务调度器
const scheduler = {
context: new AsyncContext(),
postTask(task, options) {
// 实际上,这个 task 需要被延迟到更空闲的时间执行。
// 但是我们这里管不了这么多了,立刻通过 AsyncContext.run 执行。
this.context.run({ priority: options.priority }, task);
},
currentTask() {
return this.context.get() ?? { priority: 'default' };
},
};
// 用户通过调度器 API 设置一个低优先级任务,可以在渲染空闲期执行。
const res = await scheduler.postTask(task, { priority: 'background' });
console.log(res);
async function task() {
// 通过 scheduler.currentPriority(),这个 fetch 任务和回复内容解析都可以被
// 自动设置为 'background' 优先级。
const resp = await fetch('/hello');
const text = await resp.text();
// 即使我们上面已经 await 了多个 Promise,当前任务还是我们期望的 'background' 优先级。
scheduler.currentTask(); // => { priority: 'background' }
// doStuffs 运行在 'background' 优先级,不需要我们重新通过调度器
// scheduler.postTask(doStuffs, { priority: 'background' });
// 来设置期望的优先级。
return doStuffs(text);
}
async function doStuffs(text) {
// 一些异步操作...
return text;
}
以上例子是当前 WICG Scheduling APIs[4] 待解决的一个难点[5]。我们目前也正在与 Chrome Web Performance 团队讨论基于 AsyncContext
的 Web API 拓展的设计。
Prior Arts
线程局部变量
线程作为一个程序执行单元,它们有自己的 Program Counter 等等,但是线程之间可以共享整个进程的内存空间访问。但正是因为内存的共享,内存安全是每一个使用线程的程序都需要考虑的问题。而采用线程局部变量 (thread_local)[6]可以以更低的兼容成本来为已有的函数提供可重入的能力。可见线程局部变量设计初衷是为了解决传统 API 的可重入问题的,如 glibc 中许多函数都需要使用到一个变量 errno[7] 用于存储系统调用的错误信息,如果 errno
只是一个普通的全局变量,那么当多个线程同时调用了依赖 errno
的函数时,errno
中的值就可能会在被用户代码使用前被其他系统调用覆盖。而通过将 error
声明为线程局部变量,那么依赖 errno
的 API 无需做任何改动即可获得可重入能力,即线程安全。
举个 C++ 的例子,我们在不同的线程访问同一个 thread_local
变量并修改、赋值,对于这个变量的修改不会影响到其他的线程:
#include <iostream>
#include <string>
#include <thread>
#include <mutex>
thread_local unsigned int rage = 1;
std::mutex cout_mutex;
void increase_rage(const std::string& thread_name) {
++rage; // modifying outside a lock is okay; this is a thread-local variable
std::lock_guard<std::mutex> lock(cout_mutex);
std::cout << "Rage counter for " << thread_name << ": " << rage << '\n';
}
int main() {
std::thread a(increase_rage, "a"), b(increase_rage, "b");
a.join();
b.join();
{
std::lock_guard<std::mutex> lock(cout_mutex);
std::cout << "Rage counter for main: " << rage << '\n';
}
return 0;
}
这些例子会输出:
Rage counter for b: 2
Rage counter for a: 2
Rage counter for main: 1
除了在解决函数可重入问题中可以使用线程局部变量之外,另一个常见的使用场景就是在使用线程作为服务端请求处理单元的模型中,我们也可以使用 thread_local
来存储请求的链路信息:因为每一个线程在同一时间只会处理一个请求,那么此时线程局部变量只需要对应当前这个请求链路,并且对这些数据的访问是线程安全的。
使用 thread_local
存储类似的信息有几个好处:
用户使用起来没有感知,不需要用户主动给框架传递请求链路信息参数;
多个模块不会互相干扰,如果我们简单地将这些信息寄存在请求对象上,功能当然可以完成,但是如果多个模块都使用了类似的方法进行存储,这样十分容易出现冲突。
AsyncLocalStorage
与线程局部变量类似,Node.js 的 AsyncLocalStorage
提供了基于单线程的事件循环模型上的"异步局部变量"。AsyncContext
的 API 即是从 AsyncLocalStorage
之上发展而来的:
class AsyncLocalStorage<T> {
constructor();
// 立刻执行 callback,并在 callback 执行期间设置异步局部变量值。
run<R>(store: T, callback: (...args: any[]) => R, ...args: any[]): R;
// 获取异步局部变量当前值
getStore(): T;
}
class AsyncResource {
// 快照当前的执行上下文异步局部变量全局状态。
constructor();
// 立刻执行 fn,并在 fn 执行期间将快照恢复为当前执行上下文异步局部变量全局状态。
runInAsyncScope<R>(fn: (...args: any[]) => R, thisArg, ...args: any[]): R;
}
这些方法都可以与 AsyncContext
对应。目前 Async Context 提案还在 Stage 1 讨论阶段,后续提案的 API 可能会有所变更,不过可以预期的是整体操作不会有较大的变化。
Noslate & WinterCG
Noslate Aworker[8] 作为 Web 兼容运行时工作组(Web-Interoperable Runtimes CG[9] ) 的实现者之一。WinterCG 包含了如 Cloudflare、Deno 等 JavaScript 运行时产商。当前各个 JavaScript 运行时对于 AsyncContext 的需求是非常迫切的,许多客户都在催促着 Cloudflare、Deno 去实现类似的方案。因此,我们也在 WinterCG 中与 Cloudflare workerd、Deno 等提议了在 AsyncContext 提案进入 Stage 3 之前的实现路径。
为了避免这些运行时在 AsyncContext 提案早期 API 未稳定的阶段就在生产环境中使用 AsyncContext API,我们通过 WinterCG 商议了指导性建议:AsyncLocalStorage 子集[10]。这个子集只包含了保证在未来几年中是能够符合 AsyncContext 提案演进路线、不会限制 AsyncContext 提案发展的 AsyncLocalStorage API 子集。Noslate Aworker 也会实现这个 API 子集:https://noslate-project.github.io/aworker/classes/aworker.AsyncLocalStorage.html。
更多 ECMAScript 语言提案
由贺师俊牵头,阿里巴巴前端标准化小组等多方参与组建的 JavaScript 中文兴趣小组(JSCIG,JavaScript Chinese Interest Group)在 GitHub 上开放讨论各种 ECMAScript 的问题,非常欢迎有兴趣的同学参与讨论:https://github.com/JSCIG/es-discuss/discussions。
参考资料
Async Context 提案: https://github.com/tc39/proposal-async-context
[2]React Context: https://reactjs.org/docs/context.html
[3]OpenTelemetry: https://opentelemetry.io/
[4]Scheduling APIs: https://github.com/WICG/scheduling-apis
[5]难点: https://github.com/WICG/scheduling-apis/blob/main/misc/userspace-task-models.md#challenges-in-creating-a-unified-task-model
[6]线程局部变量 (thread_local): https://zh.wikipedia.org/wiki/%E7%BA%BF%E7%A8%8B%E5%B1%80%E9%83%A8%E5%AD%98%E5%82%A8
[7]errno: http://man7.org/linux/man-pages/man3/errno.3.html
[8]Noslate Aworker: https://noslate.midwayjs.org/docs/noslate_workers/intro
[9]Web-Interoperable Runtimes CG: https://wintercg.org/
[10]AsyncLocalStorage 子集: https://github.com/wintercg/proposal-common-minimum-api/blob/main/asynclocalstorage.md
微信扫码关注该文公众号作者