For AI agents: the complete documentation index is available at /tc39-atlas/llms.txt, the full documentation bundle is available at /tc39-atlas/llms-full.txt, and this page is available as Markdown at /tc39-atlas/proposals/year/pending/abort-controller.md.
  • 简体中文
  • 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();
      }
    }

    这有几个缺点,包括

    1. signal 必须手动传递到每个子任务,以及它们的子任务
    2. 平台代码从 signal 获得的保证 不适用于你自己编写的代码 (whatwg/dom#1425)
    3. catch 块在其他任务被取消之前运行
    4. 实际上没有人会写那个 finally
    5. 这不会等待任务的异步清理完成(因此它_不是_结构化并发)

    我不提议对第一点做任何处理,但其余几点都可以通过扩展 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 提出建议!

    Footnotes

    1. 严格来说,这不在 JavaScript 中,但它在 WinterTC 最小公共 API 中,而该 API 受到多种 JavaScript 运行时的支持。 ↩