Disposable AsyncContext.Variable S1
中文标题:可释放的 AsyncContext.Variable
提案速览
该提案旨在解决使用 AsyncContext.Variable.prototype.run 限定变量变更范围所带来的不便,这需要创建新的函数作用域,并使得重构包含 break、continue、return 或 yield 的代码变得复杂。它提出了一种基于 using 的 API,具体是在 AsyncContext.Variable 上增加一个 withValue 方法,以将值绑定到词法作用域,并探索了三种设计方案(A、B、C),它们在强制执行绑定和防止手动退出作用域等方面各有权衡。该提案仍处于探索阶段;尚未确定最终解决方案,并正在积极寻求实现者的反馈和使用案例。
Note
以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。
可释放的 AsyncContext.Variable
提案发起人:
动机
AsyncContext 通过函数作用域 API AsyncContext.Variable.prototype.run 强制执行 AsyncContext.Variable 的变更。这要求任何 Variable 值的变更都必须在新的函数作用域内进行。
对 Variable 值的修改会传播到其子任务。这种 .run 作用域强制机制防止任何修改对其调用方函数作用域可见,从而避免其传播到兄弟函数调用中创建的任务。
例如,这可以防止事件监听器中的修改意外泄漏到其分发器:
const asyncVar = new AsyncContext.Variable();
eventTarget.addEventListener("click", function firstListener() {
asyncVar.run("first", () => {
//...
});
// 'first' 在 firstListener 外部不可见
});
eventTarget.addEventListener("click", function secondListener() {
asyncVar.run("second", () => {
//...
});
});
eventTarget.dispatch(new Event("click"));
// 'first' 对 secondListener 不可见。
run 的用法
run 模式已经可以很好地处理许多涉及函数调用的现有使用模式,例如:
例如,事件处理器可以通过包装轻松地重构为使用 .run(value, fn):
function handler(event) {
...
}
button.addEventListener("click", handler);
// ... 将其替换为 ...
button.addEventListener("click", event => {
asyncVar.run(createSpan(), handler, event);
});
或者,在 Node.js 服务器应用程序中,中间件很常见:
const middlewares = [];
function use(fn) {
middlewares.push(fn);
}
async function runMiddlewares(req, res) {
function next(i) {
if (i === middlewares.length) {
return;
}
return middlewares[i](req, res, next.bind(i++));
}
return next(0);
}
像 OpenTelemetry 这样的跟踪库可以使用中间件包装器对其进行插桩,如下所示:
async function otelMiddleware(req, res, next) {
const w3cTraceContext = extractW3CHeaders(req);
const span = createSpan(w3cTraceContext);
try {
await asyncVar.run(span, next);
} catch (e) {
span.setError(e);
} finally {
span.end();
}
}
run 的限制
强制执行变更作用域可以减少变更以意外方式暴露给父作用域的机会,但也提高了使用该功能或将现有代码迁移到采用该功能的门槛。
例如,给定一段代码:
function* gen() {
yield computeResult();
yield computeResult2();
}
如果我们想用一个新的 AsyncContext 值来限定 computeResult 和 computeResult2 调用的作用域,则需要进行不平凡的重构:
const asyncVar = new AsyncContext.Context();
function* gen() {
const span = createSpan();
yield asyncVar.run(span, () => computeResult());
yield asyncVar.run(span, () => computeResult2());
// ...或者
yield* asyncVar.run(span, function* () {
yield computeResult();
yield computeResult2();
});
}
.run(val, fn) 创建了一个新的函数体。新的函数环境与外部环境不等价,不能轻易地在它们之间共享代码片段。此外,break/continue/return 语句不能被天真地重构。
如果能够插入一行代码来使用新的 AsyncContext 值限定 computeResult 和 computeResult2 调用的作用域,而无需重构现有代码,那将会更直观。
const asyncVar = new AsyncContext.Variable();
function *gen() {
+ using _ = asyncVar.withValue(createSpan(i));
yield computeResult(i);
yield computeResult2(i);
}
目标
我们正在寻找一种方法,将 Variable 值绑定到词法作用域,而无需以函数的形式创建新的词法作用域。
我们还希望使内部包含 AsyncContext.Variable 实例的非 Variable 对象能够对其内部的 AsyncContext.Variable 实例使用相同的词法绑定,而无需向用户暴露 AsyncContext.Variable 实例。
我们不一定寻求为异步上下文创建一个通用的 enter/exit API,该 API 可以任意交错变量作用域。我们已从实现者那里听说,这样做会非常难以实现高性能(参见 #3)。在审查了 Node 中 AsyncLocalStorage 的许多当前生态系统用法后,我们相对确信,在 Node 中使用 als.enterWith() 的大多数用例都可以切换到本文提出的基于 using 的 API,或 AsyncContext.Variable#run() API。
如果您认为有需要“不安全”的通用
enter/exit API 的用例,请提交 issue 进行讨论。我们有兴趣了解这些用例并看看我们如何满足它们。
提案
我们尚未确定任何解决方案,但目前正在探索以下三个选项:
- 使用
using 进行词法绑定,通过向 AsyncContext.Variable 添加 @@dispose 和 @@enter
- 使用
using 进行词法绑定,通过自动跟踪在手动进入 Symbol.enter 后进行清理,确保变更仅存在于 using 所在的词法作用域内
- 使用
using 进行词法绑定,通过一个特殊的可子类化类直接与 using 集成以自动清理
这些选项各有其权衡,但它们都具有在词法作用域内变更 AsyncContext.Variable 值的相同语义。其中一些具有副作用,允许手动进入和退出与词法作用域不同步的作用域。这些选项将在下面更详细地讨论。
提案 A:可手动调用的 @@dispose 和 @@enter
AsyncContext.Variable 将获得一个 withValue 方法,该方法返回一个实现知名符号接口 @@dispose 和 @@enter 的对象。这将允许使用 using 声明将 AsyncContext.Variable 的值绑定到词法作用域。当 using 声明超出作用域时,将调用 @@dispose 方法。
@@enter 方法将使用传递给 withValue 的值进入一个新的异步上下文作用域,并设置 AsyncContext.Variable 的值。@@dispose 方法将退出异步上下文作用域并恢复 AsyncContext.Variable 的先前值。
AsyncContext.Snapshot 被有意排除在此功能之外,因为它会影响 AsyncContext 映射,包括其他 AsyncContext.Variable 实例。
const asyncVar = new AsyncContext.Variable();
{
using _ = asyncVar.withValue("main");
new AsyncContext.Snapshot(); // 快照 0
console.log(asyncVar.get()); // => "main"
}
{
using _ = asyncVar.withValue("value-1");
new AsyncContext.Snapshot(); // 快照 1
Promise.resolve()
.then(() => { // 续体 1
console.log(asyncVar.get()); // => 'value-1'
});
}
{
using _ = asyncVar.withValue("value-2");
new AsyncContext.Snapshot(); // 快照 2
Promise.resolve()
.then(() => { // 续体 2
console.log(asyncVar.get()); // => 'value-2'
});
}
值得注意的是,withValue 不会就地变更 AsyncContext 映射,就像 .run 一样。它为后续作用域创建了一个新的映射。值映射等价于:
⌌-----------⌍ 快照 0
| 'main' |
⌎-----------⌏
|
⌌-----------⌍ 快照 1
| 'value-1' | <---- 续体 1
⌎-----------⌏
|
⌌-----------⌍ 快照 2
| 'value-2' | <---- 续体 2
⌎-----------⌏
每个 @@enter 操作都会使用新值创建一个 AsyncContext 映射,避免对捕获了当前 AsyncContext.Variable 值的现有 AsyncContext 映射进行任何变更。
这个特性对于 run 和 withValue 都很重要,因为对 AsyncContext.Variable 的变更不得改变先前的 AsyncContext.Snapshot。
但这确实有一个缺点:知名符号 @@dispose 和 @@enter 未绑定到 using 声明语法,因此它们可以手动调用。这可能导致用户手动进入和退出与词法作用域不同步的作用域(参见 #2)。除非为此添加缓解措施,否则这可能导致一个无法在不产生性能开销的情况下实现的功能(参见 #3)。
必须添加额外的缓解措施,以确保 @@dispose 和 @@enter 方法不与 DisposableStack 一起使用,因为 DisposableStack 不强制执行与词法作用域的绑定。
提案 B:通过自动进入跟踪强制执行清理
作为上述手动进入和退出与词法作用域不同步问题的缓解措施,另一个提案是专门化 using 声明中异步上下文变量的处理,如下面的伪代码 diff 所示,该伪代码表示 using 的简化转译输出:
const v = new AsyncContext.Variable();
{
const _ = v.withValue("some value");
const __enter__ = span[Symbol.enter];
const __dispose__ = span[Symbol.dispose];
+ const __start__ = AsyncContextSnapshot();
+ globalThis.SNAPSHOTS.push(__start__);
__enter__.call(span);
+ AsyncContextEnter(globalThis.SNAPSHOTS.pop());
try {
console.log("do some work");
} finally {
__dispose__.call(span);
+ AsyncContextEnter(__start__);
}
}
+AsyncContext.Variable.prototype.withValue = function (value) {
+ const key = this;
+ return {
+ [Symbol.enter]() {
+ const snapshot = globalThis.SNAPSHOTS.pop();
+ if (!snapshot) {
+ throw new Error(
+ "Can not call [Symbol.enter] outside of a using declaration.",
+ );
+ }
+ const newSnapshot = AsyncContextAdd(snapshot, key, value);
+ globalThis.SNAPSHOTS.push(newSnapshot);
+ },
+ [Symbol.dispose]() {},
+ };
+};
-
在 using 机制中,在调用 @@enter 方法之前,捕获当前快照并将其推送到全局栈变量中。
-
@@enter 方法的实现将检查全局栈是否包含快照。如果不包含,则抛出错误。如果包含,则 @@enter 方法将通过添加变量从栈顶快照创建一个新快照,然后将其设置回栈中的同一位置。这随后被 using 机制读取。
-
在 using 机制中,在 @@enter 方法返回后,从全局栈中移除该快照并进入。保存原始捕获的当前快照。
-
在 using 机制中,一旦 using 声明词法作用域关闭,照常调用 @@dispose 方法。一旦 @@dispose 方法返回,在 using 机制内部恢复在 enter 期间捕获的快照。
此提案不允许手动进入和退出异步上下文作用域,并且要求 @@enter 方法从 using 声明中调用。强制绑定到词法作用域,就像 run 一样。
该提案确实为 using 机制增加了一些额外的复杂性。
根据此提案,AsyncContext.Variable#withValue 对象上的 Symbol.enter 永远不会直接进入作用域,而是会在调用 Symbol.enter 回调时安排进入。这对于确保具有输入值的作用域不会泄漏是必要的。
提案 C:通过专门化类强制执行清理
作为上述手动进入和退出与词法作用域不同步问题的替代缓解措施,另一个提案是专门化 using 声明中异步上下文变量的处理,如下所示:
class AsyncVariableScope {
#asyncVar;
#value;
#previousContextMapping;
constructor(asyncVar, value) {
this.#asyncVar = asyncVar;
this.#value = value;
}
// 如果存在,由 `using` 代替 @@enter 调用的槽位
[[UsingEnter]]() {
const asyncContextMapping = snapshot + { [[AsyncContextKey]]: this.[[AsyncVariable]], [[AsyncContextValue]]: this.[[Value]] };
this.#previousContextMapping = AsyncContextSwap(asyncContextMapping);
}
// 如果存在,由 `using` 代替 @@dispose 调用的槽位
[[UsingDispose]]() {
AsyncContextSwap(this.#previousContextMapping);
}
}
这可以在用户代码中通过子类化来使用:
class SpanRef extends AsyncVariableScopable {
#span;
constructor(tracer, span) {
super(tracer.asyncContext, span);
this.#span = span;
}
// 这里包含 span API 等...
setAttribute(name, value) {
this.#span.setAttribute(name, value);
}
}
class Tracer {
startSpan() {
const span = this.createSpan();
return new SpanRef(this, span);
}
}
using span = tracer.startSpan();
使用案例
跟踪
在像 OpenTelemetry 这样的跟踪系统中,AsyncContext.Variable 用于跟踪当前活动的 span。这用于创建子 span,并管理上下文传播,而无需在整个应用程序及其依赖项中手动传递 span 信息。
由于 AsyncContext.Variable 用于跟踪当前活动的 span,因此必须使用 .run 为活动 span 创建新的异步上下文作用域。这很不方便,因为每次创建新的 span 时,都必须创建一个新的函数作用域。这对于循环或生成器内部的 span 尤其不方便,因为 break/continue/return/yield 语句在包装在新的函数作用域中时不再有效。
目前,这意味着需要大量样板代码来创建 span 和管理异步上下文:
如果与 using 集成,为现有代码添加跟踪将涉及显着更少的重构,并且看起来会更加直观:
async function doAnotherWork() {
// 将工作推迟到下一个 promise 周期。
await 0;
using span = tracer.startActiveSpan("anotherWork");
console.log("doing another work");
// 当 span 超出作用域时它将被关闭
}
async function* doGeneratedWork() {
using span = tracer.startActiveSpan("generatedWork");
console.log("doing some work...");
yield 1;
yield 2;
yield 3;
// 当 span 超出作用域时它将被关闭
}
async function doWork() {
using parent = tracer.startActiveSpan("doWork");
// 做一些由 'parent' 跟踪的工作
console.log("doing some work...");
await doAnotherWork();
// 创建一个嵌套的 span 来跟踪嵌套工作
{
using child = tracer.startActiveSpan("child");
// 做一些由 'child' 跟踪的工作
console.log("doing some nested child work...");
}
// 调用一个生成器函数
for await (const work of doGeneratedWork()) {
console.log("did work ", work);
}
// 这个父 span 在超出作用域时也会被关闭
}
此示例改编自 OpenTelemetry Python 示例。
https://opentelemetry.io/docs/languages/python/instrumentation/#creating-spans
借助提案 A,tracer.startActiveSpan 的实现可能如下所示。它从其自己的 AsyncContext.Variable 实例中检索父 span,并创建 span 作为子项,然后将子 span 设置为 AsyncContext.Variable 实例的当前值:
class Tracer {
#var = new AsyncContext.Variable();
startActiveSpan(name) {
let scope;
const span = {
name,
parent: this.#var.get(),
[Symbol.enter]: () => {
scope = this.#var.withValue(span)[Symbol.enter]();
return span;
},
[Symbol.dispose]: () => {
scope[Symbol.dispose]();
},
};
return span;
}
}
不改变现有 AsyncContext 映射的语义对于此处的 startAsCurrentSpan 示例至关重要,因为它允许 doAnotherWork 成为 "parent" 的子 span,而不是 "child",如下面的图所示:
⌌----------⌍
| 'parent' |
⌎----------⌏
| ⌌-----------------⌍
|---| 'doSomeWork' |
| ⌎-----------------⌏
| ⌌---------⌍
|---| 'child' |
| ⌎---------⌏
| ⌌-----------------⌍
|---| 'doAnotherWork' |
| ⌎-----------------⌏