Abort Controller S1
中文标题:AbortController
提案速览
该提案探索通过扩展 AbortController 和 AbortSignal、而非新增语法,将结构化并发引入 JavaScript 的途径。它讨论了为 signal 添加具有与平台代码同等保证的回调、让 AbortController 可释放或可异步释放以便作用域管理取消与清理,以及让 abort 等待异步清理完成。它还考虑了 controller.wrap 和 controller.all 等辅助设计,用于将取消与 Promise 组合器协同起来。
Note
以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。
为 JavaScript 添加结构化并发
本仓库用于我和其他任何有兴趣的人探索为 JavaScript 添加结构化并发的方向。
什么是结构化并发?
“结构化并发”指的是这样一种编写并发程序的方式:
- 子任务绑定到词法作用域
- 作用域在子任务完成或关闭之前不会退出
- (通常)一个子任务中的错误会取消其他任务
它在其他语言中是什么样子?
Java
Java 有 StructuredTaskScope,设计用于配合其 try-with-resources 语句(这是 Java 进行显式资源管理的方式,就像 JS 的 using 语句)。它看起来像这样:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var userTask = scope.fork(() -> fetchUser(userId));
var ordersTask = scope.fork(() -> fetchOrders(userId));
var recsTask = scope.fork(() -> fetchRecommendations(userId));
scope.join();
scope.throwIfFailed();
// if we get here, all subtasks have completed
return "user=%s, orders=%s, recs=%s".formatted(
userTask.get(), ordersTask.get(), recsTask.get()
);
} catch (Exception e) {
// if we get here, all subtasks have been fully shut down
return "error: failed to load";
}
这些任务运行在轻量级线程中,并且没有显式的 async/await 点,而是有多种阻塞式库方法会检查其线程是否已被中断;如果已中断,则抛出 InterruptedException。
Python
Python 有 asyncio.TaskGroup,设计用于配合其 async with 语句(这是 Python 进行显式资源管理的方式)。它看起来像这样:
try:
async with asyncio.TaskGroup() as tg:
user_task = tg.create_task(fetch_user(userId))
orders_task = tg.create_task(fetch_orders(userId))
recs_task = tg.create_task(fetch_recommendations(userId))
# if we get here, all subtasks have completed
return (f"user={user_task.result()}, "
f"orders={orders_task.result()}, "
f"recs={recs_task.result()}")
except Exception:
# if we get here, all subtasks have been fully shut down
return "error: failed to load"
create_task 返回一个 Task 对象,该对象有一个 .cancel() 方法,会使当前 await 点抛出 CancelledError。
Swift
Swift 的 async/await 从一开始就是为结构化并发而设计的;当作用域退出时(无论是正常退出还是因错误退出),任何未被 await 的 async let 绑定都会被自动取消,然后被 await。它看起来像这样:
do {
async let user = fetchUser(userId)
async let orders = fetchOrders(userId)
async let recs = fetchRecommendations(userId)
let result = try await "user=\(user), orders=\(orders), recs=\(recs)"
// if we get here, all subtasks have completed
return result
} catch {
// if we get here, all subtasks have been fully shut down
return "error: failed to load"
}
在 Swift 中有一个环境 Task,异步函数必须在所有希望可取消的点上显式检查 Task.isCancelled(尽管许多标准库函数会在内部这样做)。
我们在 JavaScript 中能做什么?
这里已有先例:例如,Effection 库无需新增 JS 特性即可实现结构化并发,它要求你使用_生成器_而不是异步函数来编写异步代码。这使其执行器可以在任何 await 点(或者更准确地说,由于这些是生成器,是 yield 点)中断执行,并利用这一点进行取消(甚至因为生成器的工作方式而触发 finally 块)。这为你提供了与 Python 非常相似的结构化并发。
遗憾的是,JavaScript1 已经有一个取消原语 AbortController,而浏览器已表示无意添加新的取消原语。此外,我们也不太可能说服用户改变他们编写每个函数的方式。
因此,我的观点是,前进的最佳路线是增强 AbortController,使其可用于结构化并发。这正是本仓库探索的内容。不过,请在 issues 中提出替代方案!
当前状况
以下是你如何使用 AbortController 编写并发代码:
const controller = new AbortController();
const { signal } = controller;
try {
const userP = fetchUser(userId, { signal });
const ordersP = fetchOrders(userId, { signal });
const recsP = fetchRecommendations(userId, { signal });
const [ user, orders, recs ] = await Promise.all([ userP, ordersP, recsP ]);
return `user=${user}, orders=${orders}, recs=${recs}`;
} catch (e) {
return 'error: failed to load';
} finally {
controller.abort();
}
async function fetchUser(userId, { signal }) {
signal.addEventListener('abort', () => {
cleanup();
});
// or:
try {
// ...
signal.throwIfAborted();
} finally {
cleanup();
}
}
这有几个缺点,包括
signal 必须手动传递到每个子任务,以及它们的子任务
- 平台代码从 signal 获得的保证 不适用于你自己编写的代码 (whatwg/dom#1425)
- catch 块在其他任务被取消之前运行
- 实际上没有人会写那个
finally
- 这不会等待任务的异步清理完成(因此它_不是_结构化并发)
我不提议对第一点做任何处理,但其余几点都可以通过扩展 AbortController 和 AbortSignal 来解决,而无需新增语法。
addAbortCallback
有一个开放的 PR 要为 signal 添加 .addAbortCallback(cb) 方法,这将允许编写 signal.addAbortCallback(cleanup),并获得与平台代码相同的保证:
- 你的回调只能由调用
controller.abort 的人触发,而不会被其他摆弄该 signal 的代码触发
- 另一个回调调用
stopImmediatePropagation 不会阻止你的回调被触发
- 一旦 signal 被中止,该回调就会被释放,而不是一直无法被垃圾回收,直到 signal 本身被回收
- 在 signal 已经中止后添加回调不会做任何事(尤其是不会持有对你回调的引用)
这将解决上面的第 2 点。
在链接的讨论串中,有一些关于它是否应返回“unregister”能力的讨论,从而让你可以移除回调。我强烈认为它应该如此,而且这个 token 还应有一个 Symbol.dispose 方法,用于移除回调。目前,未能清理回调 是 AbortSignal 泄漏的一个常见来源。这将让你可以这样写
async function doTasks({ signal }) {
using _ = signal.addAbortCallback(() => {
console.log('aborted');
});
await task1({ signal });
await task2({ signal });
// cleanup handler is automatically removed when we get here, or if either task throws
}
可释放的 AbortController
Java 和 Python 利用其显式资源管理机制将任务绑定到作用域。如果我们做同样的事呢?如果 AbortController 有一个 Symbol.dispose 方法,作为其 .abort() 的别名,我们就可以这样写:
try {
using disposer = new AbortController.Disposable();
const { signal } = disposer;
const userP = fetchUser(userId, { signal });
const ordersP = fetchOrders(userId, { signal });
const recsP = fetchRecommendations(userId, { signal });
const [ user, orders, recs ] = await Promise.all([ userP, ordersP, recsP ]);
return `user=${user}, orders=${orders}, recs=${recs}`;
} catch (e) {
return 'error: failed to load';
}
(你可以用 DisposableStack 做到这一点,但会有很多样板代码。)
这将解决上面的第 3 点,并且(希望)也能解决第 4 点。由于第 5 点,它仍然不是结构化并发,但正在朝那个方向发展。
我已经为此倡导有一段时间了。我认为它独立地就很有用,即使没有这里的其他内容。请注意,上面我建议使用 AbortController 的子类,这是出于对我的同事的尊重,但就我个人而言,让基类 AbortController 这样工作也没问题。
异步清理
与上述内容无关,人们已经提出希望能够等待异步清理任务完成。我们可以想象一个 AbortController 版本,其中 .abort() 返回一个 Promise,该 Promise 仅当清理回调返回的所有 Promise 都已敲定时才敲定:
const controller = new AsyncAbortController();
const { signal } = controller;
try {
const userP = fetchUser(userId, { signal });
const ordersP = fetchOrders(userId, { signal });
const recsP = fetchRecommendations(userId, { signal });
const [ user, orders, recs ] = await Promise.all([ userP, ordersP, recsP ]);
return `user=${user}, orders=${orders}, recs=${recs}`;
} catch (e) {
return 'error: failed to load';
} finally {
// tasks add Promise-returning cleanup callbacks to the signal
// the "await" blocks until those have all finished
await controller.abort();
}
我们甚至可以给这个类添加一个 Symbol.asyncDispose 方法,使其可以与 await using 一起使用:
try {
await using controller = new AbortController.AsyncDisposable();
const { signal } = controller;
const userP = fetchUser(userId, { signal });
const ordersP = fetchOrders(userId, { signal });
const recsP = fetchRecommendations(userId, { signal });
const [ user, orders, recs ] = await Promise.all([ userP, ordersP, recsP ]);
return `user=${user}, orders=${orders}, recs=${recs}`;
} catch (e) {
return 'error: failed to load';
}
这解决了上面的第 5 点,前提是任务在处理器内部完成其所有清理(关于这一点,见下文)。至少从调用方的角度来看,这将为我们提供结构化并发。
使用 signal
让我们假设我们得到了上一节中那种可异步释放的 AbortController。你实际如何使用它?记住,我们只有 addEventListener(或者,希望还有 addAbortCallback)和 throwIfAborted。throwIfAborted 的好处在于它会触发 finally 块和 using 清理方法,但为了让异步 abort 生效,我们需要在回调内部进行清理,而该回调无法运行 finally 块或 using 清理方法。我们可以用一种有点笨拙的方式绕过这一点:
async function fetchUser(userId, { signal }) {
const { promise, resolve } = Promise.withResolvers();
using _ = signal.addAbortCallback(() => promise);
try {
await using db = await connectToDatabase();
signal.throwIfAborted();
const page = await db.fetchForUser(userId);
signal.throwIfAborted();
return await parse(page);
} finally {
resolve();
}
}
但如果有辅助函数会更好:
AbortSignal.prototype.mustComplete = function() {
const { promise, resolve } = Promise.withResolvers();
const token = this.addAbortCallback(() => promise);
return {
[Symbol.dispose]: {
token.unregister();
resolve();
},
};
};
async function fetchUser(userId, { signal }) {
using _ = signal.mustComplete();
await using db = await connectToDatabase();
signal.throwIfAborted();
const page = await db.fetchForUser(userId);
signal.throwIfAborted();
return await parse(page);
}
这样函数中就只有一行样板代码。
替代方案:controller.wrap(promise)
与其要求函数通过 signal.mustComplete() 自行注册,我们可以提供一个 controller.wrap(promise),它除了将传入的 Promise 添加到必须在 .abort() 返回的 Promise 敲定之前敲定的值列表之外,其余行为等同于恒等操作:
try {
await using controller = new AbortController.AsyncDisposable();
const { signal } = controller;
const userP = controller.wrap(fetchUser(userId, { signal }));
const ordersP = controller.wrap(fetchOrders(userId, { signal }));
const recsP = controller.wrap(fetchRecommendations(userId, { signal }));
const [ user, orders, recs ] = await Promise.all([ userP, ordersP, recsP ]);
return `user=${user}, orders=${orders}, recs=${recs}`;
} catch (e) {
return 'error: failed to load';
}
这会在调用方增加更多样板代码,但确实避免了被调用方中的任何额外样板代码。
替代方案:controller.all(promises)
我不再认为这个想法很好,因此已将其隐藏。我收到反馈(我也同意)称,人们本来就已经难以内化 Promise 组合器的用法,而为它们全都添加略微不同的变体会让情况更糟。
点击显示
上面的设计让 controller 只负责取消,而任务合并仍然使用通常的 Promise 组合器(如 Promise.all)来完成。这可行,而且我认为这是我偏好的路线;它是最简单的设计。但它确实要求在被调用方中使用 signal.mustComplete() 样板代码,这很不幸。另一种选择是引入一个 AbortController 版本的 Promise.all,它不会在第一个异常时立即返回,而是执行取消并继续等待尚未完成的 Promise,然后才抛出该异常。像这样:
AbortController.prototype.all = function (promises) {
const controller = this;
let firstError;
let hasError = false;
const wrapped = Array.from(promises, (p) =>
Promise.resolve(p).then(
(value) => value,
(reason) => {
if (!hasError) {
hasError = true;
firstError = reason;
controller.abort();
}
}
)
);
return Promise.all(wrapped).then((results) => {
if (hasError) throw firstError;
return results;
});
};
(其他 Promise 组合器也类似。)
然后你可以这样做
try {
await using controller = new AbortController.AsyncDisposable();
const { signal } = controller;
const userP = fetchUser(userId, { signal });
const ordersP = fetchOrders(userId, { signal });
const recsP = fetchRecommendations(userId, { signal });
const [ user, orders, recs ] = await controller.all([ userP, ordersP, recsP ]);
return `user=${user}, orders=${orders}, recs=${recs}`;
} catch (e) {
return 'error: failed to load';
}
而无需 mustComplete 辅助函数(因为该辅助函数会自动等待所有原始 Promise 敲定)。
如果块中还有其他可能的提前退出,这就不够充分,因为 controller 在任务被传给 controller.all 之前并不知道它们。
这确实有一个优点:如果证明添加等待异步 abort 回调的能力不可行,那么它并不严格要求该能力。
缺点
我们还没有实现什么?
最明显的一点是,signal 需要贯穿所有内容。能避免这一点会很好,但任何这类机制都会对语言造成大得多的改动,所以我将其搁置。
另外,我们处于一种尴尬的状态:.throwIfAborted() 会触发 catch 处理器,而这些处理器通常必须重新抛出异常。在过去,有很多关于建立一种机制来绕过 catch 处理器并直接触发 finally 的讨论,就像生成器上的 .return() 方法那样(Effection 就使用了这一点),但我们未能就此达成共识,而且我认为重新讨论这件事不会有成效。因此,我们将需要告诉人们在他们的 catch 处理器顶部调用 signal.throwIfAborted()。Swift 主要通过模式匹配的 catch 块来应对这一点,而我们或许有朝一日也能获得这种能力。
最后,取消使用了一种你可以称之为“带内”信号机制,这意味着库(包括标准库)没有明显的方式来处理通用取消,即使是它们发起的任务,除非它们约束所调用函数的签名。例如,一个并发的 AsyncIterator.prototype.map 正在调用一个通用异步映射函数;如果提前关闭迭代器(使用 .return)能够取消因调用该函数而创建的任务,那会很好,但除非迭代器内部创建一个 AbortController 并将其 signal 传递给映射函数,否则没有明显的方法做到这一点。
与我交流
这仍然非常处于探索阶段。上述大多数事项都有各自独立的动机,所以我希望它们能够被单独推进,而不必成为某个紧密整体的一部分,但我认为有一个可以努力追求的紧密整体会是件好事。
其中许多内容是在与 @rixtox 的一次讨论中对我而言变得清晰的,为此我要感谢他们。
请打开 issue 提出建议!