首页 / 文章

Await仅能看到一个承诺:设计能妥善处理任务的异步/Await API

JavaScript 的 async/await 实际上监视了什么,为何分离式的任务、回调函数、流以及 Promise.all 能绕过它的监视,以及如何设计能真正表示任务完成的 Promise。

发布者:REACTAPP.TOP阅读约 13 分钟3003 词

来源: 90% of Developers Don’t Understand What JavaScript `await` Actually Waits For

人们很容易将 await 理解为“等待此函数执行完毕”,但实际上运行时所做出的承诺要有限得多:它只会计算一个表达式,将该结果视为类似 Promise 的值,然后暂停当前的 async 函数,直到该值最终确定。其他所有情况都取决于该值是如何产生的。正是这种差异导致了即使精心使用了 await 关键字,代码仍可能过早返回、运行速度低于必要水平、忽略错误或使用过时数据。在了解以下各种情况后,你就能面对任何 await 语句时提出那个最重要的问题:这个 Promise 真正代表了我所关心的操作吗?

问题:看似已完成但实际上并未完成的 await 代码

该领域的错误都遵循某种模式:开发者等待某个函数执行完毕,以为该函数启动的所有操作都已完成;他们等待数组方法处理完成,以为所有的异步回调都已执行;他们等待计时器设置、事件订阅或流写入完成,以为相关操作已经结束。代码之所以能正常运行,只是因为其中使用了 await。

但实际上 await 并不了解“任务”“请求”或“该函数启动的所有操作”究竟是什么。如果其右侧的表达式无法涵盖工作的整个生命周期,那么这个关键字也就无法覆盖整个过程。牢记这一点会改变你设计 API、循环、后台任务、事件驱动代码以及错误处理的方式。

调用发生在等待之前

以异步 JavaScript 中最普通的代码行为例。

const user = await loadUser();

运行时并不会先看到 await,然后再礼貌地要求 loadUser 异步执行。它会像处理其他任何函数调用一样来评估 loadUser()。该调用会返回一个值,这个值可能是原生 Promise、自定义的 thenable 对象、普通对象、字符串、undefined 或其他任何类型。只有在此之后,await 才会开始处理这个返回值。

其结果是,实际操作通常在调用时就开始了,甚至在开始等待之前就已经启动。如果你调用了两个异步函数,并在等待其中任何一个之前就保留了它们对应的 Promise 对象,那么这两个操作几乎会同时开始执行:

const userPromise = loadUser();
const settingsPromise = loadSettings();

const user = await userPromise;
const settings = await settingsPromise;

在这里,loadUser()开始执行并返回一个 Promise,随后loadSettings()也开始执行。那两行await语句只是用于在结果可用时将其收集起来。

将其与大多数人默认编写的版本对比一下:

const user = await loadUser();
const settings = await loadSettings();

现在,直到第一个 Promise 处理完成且函数恢复执行后,才会调用 loadSettings()。这两个请求是依次执行的。

请注意,await 本身并不决定并行性,真正起决定作用的是函数调用的顺序。一个便于记忆的总结是:调用异步函数会启动相应操作,而 await 则决定当前函数何时继续执行。对于第一种模式需注意一点:如果在您仍在等待 userPromise 的同时 settingsPromise 发生拒绝,这种拒绝会暂时无人处理,某些运行时会将此情况报为错误。Promise.all 通过同时监控两个 Promise 来避免这一问题。如需更深入地比较这些模式,可参阅 Promise.all、Promise.race与顺序式await的选择。

仅一个Promise得到处理,而非所有副作用

设想一个账户创建函数,它先存储一条记录,立即发送欢迎邮件而无需等待回复,之后再返回该账户。

async function createAccount(input) {
  const account = await accountRepository.save(input);

  sendWelcomeEmail(account.email);
  return account;
}

随后有来电者说出了那句显而易见的话:

const account = await createAccount(input);

当 createAccount 返回的 Promise 成为 fulfilled 状态时,这段代码才会继续执行,而这一时刻是在数据保存完成且函数返回之后。它不会提供任何关于电子邮件的信息。sendWelcomeEmail 创建了另一个独立的异步操作,其 Promise 从未与 createAccount 返回的 Promise 相关联,因此从调用者的角度来看,那个操作根本不存在。

这就是误解的根源。await 只会跟踪从其接收到的值可以访问到的 Promise 链,它不会扫描函数启动的所有操作,更不会等待进程进入空闲状态。

实际后果有三点:

  • 如果电子邮件的 Promise 被拒绝,调用者的 try/catch 语句永远无法捕获到这一错误。
  • 如果进程被终止,那封邮件可能根本就不会被发送。
  • 如果 HTTP 处理程序成功响应,这一成功结果并不能为后台任务提供任何可靠的保证。
  • 以上内容并不意味着所有副作用都必须在请求处理期间完成。许多任务确实适合在后台执行。真正的缺陷在于职责划分不明确。重要的任务应当被返回并等待处理,或明确标记为可选任务并加以处理,或者交由持久队列或任务系统来管理。创建一个 Promise 然后置之不理并非后台处理,只是寄希望于某种结果而已。

    异步函数可以在其子任务仍在运行时就结束

    一个相关的假设是,async 关键字会让 JavaScript 跟踪函数内部启动的每一个异步操作。但实际上并非如此。async 函数只会生成一个 Promise,而该 Promise 仅与函数在其自身控制流中明确返回或等待的内容相关。在该 Promise 已解决之后,其他任何操作都可以继续运行。

    经典的示例是使用 forEach:

    async function processUsers(users) {
      users.forEach(async (user) => {
        await updateUser(user);
      });
    }
    

    每次调用异步回调都会返回一个 Promise,但 forEach 会丢弃这些返回值。它既不会收集它们,也不会等待它们或将其呈现出来,而是直接返回 undefined。因此 processUsers 几乎立刻就执行到了结束大括号,而由于在结束点之后运行的异步函数会以 undefined 的形式解析,调用方会在任何更新完成之前就继续执行后续代码。

    await processUsers(users);
    console.log("All users processed");
    

    该假设完全是错误的。调用方等待的是processUsers完成回调函数的调度,而非这些回调函数本身的执行。

    要解决这个问题,需要将子 Promise 与父操作关联起来。根据具体情况,可以采用在for...of循环中使用await来确保严格顺序执行,或使用Promise.all(users.map(updateUser))实现完全并行处理;当数据量过大可能给数据库或 API 带来压力时,则需要加入并发限制机制。这一规则不仅适用于forEach:父 Promise 仅会等待其链式结构中的子任务完成。typescript-eslint 中的no-floating-promises和no-misused-promises规则能够自动检测并纠正许多此类错误。

    回调 API 对 await 来说是不可见的

    JavaScript 生态系统中的很大一部分在 Promises 出现之前就已经存在,它们通过回调函数来报告完成状态。这就导致了如下这样的代码:

    await setTimeout(() => {
      console.log("Timer completed");
    }, 1000);
    

    它不会暂停哪怕一秒。setTimeout会注册回调函数并立即返回一个定时器句柄(在浏览器中为数字,在Node.js中为Timeout对象)。该句柄并非描述定时器何时触发的Promise。await接收的是普通值,会将其视为已可用,并在下一个微任务中继续执行函数,这远早于回调函数实际运行之时。

    事件监听器、旧版的文件系统API、数据库驱动、流处理以及自行编写的SDK也存在同样的问题。如果某个函数返回的是订阅对象、请求句柄、布尔值或事件发射器,等待该返回值并不会让后续的回调函数转化为Promise的完成状态。

    若要等待基于回调的行为完成,就需要一个能在回调函数报告结果时恰好解析或拒绝的Promise:

    function delay(milliseconds) {
      return new Promise((resolve) => {
        setTimeout(resolve, milliseconds);
      });
    }
    
    await delay(1000);
    

    现在,这个等待中的值确实代表了你所关注的事件:在定时器调用 resolve 之前,Promise 会一直处于待处理状态。现代平台通常已经提供了这样的封装机制;例如 Node.js 在 node:timers/promises 中提供了基于 Promise 的 setTimeout,同时还提供了 util.promisify 用于将回调式函数转换为 Promise 形式。

    这引出了每个 await 背后隐藏的设计问题:返回的值代表任务已完成,还是仅仅表示任务已注册、正在启动,或是仍在进行中的操作入口?如果是后者,那么等待它并不会得到你想象中的结果。

    Thenables、普通值与微任务边界

    await并不要求必须是真正的Promise实例。如果该值是可then化的对象,即拥有可调用的.then方法的对象,await就会采用该对象的执行方式。这正是让兼容Promise的库和自定义异步对象能够直接嵌入async函数的原因,同时也意味着某个对象无需通过new Promise即可实现异步行为。

    对于那些不可then化的值,await仍然会插入一个异步步骤:

    async function example() {
      console.log("before");
      await 42;
      console.log("after");
    }
    
    console.log("start");
    example();
    console.log("end");
    

    即使42已经立即可用,它也会先输出start、before、end,之后才输出after。该函数在await处暂停执行,调用之后的同步代码会继续运行,而example的剩余部分则作为微任务在稍后执行。

    因此,等待一个普通值并非同步操作。它可能会改变代码相对于周围同步语句及其他排队微任务的执行顺序;在对顺序敏感或执行频率高的路径中,不必要的await会引入调度边界。从性能角度来看,这种延迟通常可以忽略不计,但核心观点依然成立:await之后的代码永远不会在同一个连续的调用栈上继续执行。

    拒绝处理也是完成流程的一部分

    人们常说 await 是在等待 Promise 解决。更准确的说法是它在等待 Promise 确定状态,而“确定状态”包括被拒绝的情况。当被等待的 Promise 被拒绝时,await 会将在当前 async 函数中的拒绝原因抛出,这正是普通 try/catch 语句能够处理异步错误的原因:

    try {
      const order = await loadOrder(orderId);
      return order;
    } catch (error) {
      logger.error(error);
      throw error;
    }
    

    只有当在 try 块中真正等待了被拒绝的 Promise 时,这种保护才会生效。而那些被创建后未被使用的 Promise 则完全不受此保护:

    try {
      loadOrder(orderId);
    } catch (error) {
      console.error("This does not catch the Promise rejection");
    }
    

    这里的调用本身是成功的:它返回了一个 Promise,且没有抛出异常。拒绝处理会在稍后通过该 Promise 才出现,而此时 try 块早已执行完毕。

    当开发者故意省略 await 以使某操作“非阻塞”,却仍以为周围的 try/catch 语句能保护它时,这种情况尤其危险。实际上并不能。每个异步操作都需要有明确的故障处理责任人:要么是等待该操作的调用者,要么是显式的 .catch 处理器,要么是负责处理的持久化系统。没有责任人的 Promise 并不会带来更快的架构效率,反而会让错误处理路径与能够理解问题根源的代码脱节。文章 探讨了 async/await 真正能保证什么,从错误处理的角度阐述了同样的责任归属理念。

    Promise.all 管理的是结果,而非最终输出

    Promise.all通常被概括为“同时等待多个Promise”。作为初步理解这没有问题,但细节很重要。它会返回一个新的Promise,只有当所有输入的Promise都满足时才会满足,而一旦有任何一个输入的Promise拒绝,它就会立即拒绝。

    const results = await Promise.all(tasks);
    

    即便在这里,await也只有一个目标:即Promise.all所创建的聚合 Promise。

    当某个任务提前失败时,整个聚合 Promise也会随之失败,此时你的函数会进入错误处理流程。其余任务并不会被取消,它们会继续运行,甚至在调用者已经判定整个批次失败之后仍可能产生副作用。想象这样一个批次触发了多项外部更新:其中一项立即失败,调用者报告“批次失败”,而其他更新却悄然成功,导致系统只发生了部分变化。

    Promise.all能帮你协调各任务的结果,但无法实现事务处理或任务取消功能。请根据实际需求选择合适的工具:

    • 如果部分成功也是可以接受的,可以使用Promise.allSettled收集所有结果并逐一检查。
  • 如果需要全有或全无的行为,底层操作就必须具备真正的回滚或补偿机制。
  • 如果在首次失败后就应停止后续操作,那么每个操作都必须支持取消功能,通常是通过共享的 AbortSignal 来实现。
  • 组合后的 Promise 无法撤销已经开始的操作。它的最终结果仅取决于其构建方式所允许的含义。

    流与长时间运行的资源

    流是另一个常令人困惑的概念。流的 write 方法可能会返回一个布尔值,指示内部缓冲区是否还能容纳更多数据。.pipe() 方法会返回目标流。基于事件的解析器在监听器被添加后可能会立即返回结果。等待这些值中的任何一个,并不意味着要等待流完成处理、刷新、关闭或写入所有字节。

    完成通常通过finish、end、close或error等事件来标识,为可靠地等待完成,这些事件必须被转换为Promise。许多库,以及Node.js本身通过node:stream/promises中的pipeline功能,都提供了相应的辅助工具,能够生成一个单一的Promise,其状态变化会涵盖整个处理流程,包括其中的错误。

    这一原则同样适用于任何长期存在的资源:

    • 开始上传并不等同于上传完成。
    • 建立连接并不等同于查询结束。
    • 调度任务并不等同于任务正在运行。
    • 将消息放入队列并不等同于消息正在被处理。

    API返回的值决定了await能够观察到的内容。在信任某个await之前,先弄清楚其对应的Promise实际上代表了哪个生命周期节点。

    完成并不等同于相关性

    想象一个搜索框,用户先输入“JavaScript”,然后迅速改为“TypeScript”。此时有两个请求正在处理中。TypeScript相关的请求先返回,屏幕上显示出正确的结果;随后处理速度较慢的JavaScript请求到达并覆盖了这些结果。

    该流程中的每个函数都正确使用了await。出现错误是因为等待与相关性是两个独立的问题。await会在Promise解决时继续执行,但它不会判断是否还有人需要该结果、是否有新的请求取代了它,或者组件当前是否显示了不同的记录。看似顺序执行的代码仅是在单次调用内部对步骤进行排序,而同一函数的多次调用仍可能相互重叠。

    要解决这个问题,需要明确当前正在执行的操作是哪一个。你可以中止过时的请求,将每个响应与最新的请求标识符进行比对,使用版本检查,或者依赖那种根据生成响应的参数来对响应进行分类的数据获取层。await 无法判断哪个结果是当前有效的。如需针对 React 的详细讲解,请参阅解决竞态条件:防抖无法解决的问题。

    请求已处理并不代表系统状态一致

    在副本尚未同步的情况下,数据库写入操作仍可能成功。消息的发布通常在代理服务器接受它时即告完成,而非在消费者处理它之后。邮件的发送通常在服务提供方接受请求时即告完成,而非在有人收到它之后。人们很容易对await操作赋予超出API实际提供的更多确认意义。

    await publishEvent(event);
    

    此行代码可确保事件已送达代理程序,但并不一定意味着下游的预测模块、通知系统或搜索索引也已同步处理该事件。

    await sendEmail(message);
    

    同样,这可能仅意味着服务提供方已接收该消息。它并不能保证消息已被送达、接收方已阅读,甚至无法确保服务提供方在后续处理后仍会保留该消息。

    “已完成”这一状态的含义由生成 Promise 的 API 订阅协议决定,而 await 无法强化该协议。问题往往出现在后续代码假设已经发生了更广泛的状态变化时,比如某个接口发布事件后立即查询由客户端维护的读取模型,期望新数据已经存在。虽然 Promise 已正确履行,但架构层面仍需时间适应。根据具体产品,合理的设计可能是返回待处理状态、改从写入模型读取数据、等待明确的确认信号,或是在用户界面中采用最终一致性原则。

    设计与其名称相符的 Promise

    一旦养成这样一个思维习惯,大多数关于 await 的困惑就会消失:我所等待的值是否代表我关心的整个操作已经完成?回顾之前的案例,只要出现以下情况,答案显然是否定的:

    • 某个函数启动了子 Promise 但既不返回也不等待它们的结果
    • 某个数组方法丢弃了其异步回调返回的 Promise
    • 某个 API 返回一个处理对象并通过事件来通知操作完成
    • 在消费者完成自身工作之前,发布者就先解决了 Promise

    await 的存在仅能证明当前函数会暂停,直到某个类似 Promise 的值有确定结果。它无法证明相关工作已经完成,无法证明副作用只发生了一次,无法证明被替代的工作已被取消,无法证明错误仍然存在,也无法证明结果仍然是最新的。

    这就是为什么正确实现异步代码在很大程度上是一个 API 设计问题。函数返回的 Promise 应在其名称所体现的功能对应的时刻得到解决。名为 saveDocument 的函数应当明确表明其 Promise 是负责本地存储、远程同步还是两者兼有。名为 scheduleReport 的函数可以在任务被接受时解决,而非在报告完成时解决,但其名称和返回类型应当明确这一点,例如通过返回一个调用者可以轮询或跟踪的任务标识符来体现。

    await 能够和无法承诺什么

    • await 仅会计算一个表达式并等待该单一值得到解决;它对“整个任务”一无所知。
  • 异步函数的调用位置决定了任务是否并行执行;await仅决定何时继续执行。
  • 未被返回或未被await等待的任务会被隔离:其错误、执行时间及完成状态对调用方来说是不可见的。
  • 回调、事件和流API必须被封装在Promise中,并在真正完成后再使用await,这样才能有意义。
  • Promise.all既不会取消任务也不会回滚操作;根据所需语义选择allSettled、补偿机制或AbortSignal。
  • 完成、相关性和一致性是三种不同的保障,只有第一种是由await提供的。
  • 将你返回的每个Promise视为一份契约,使其完成状态完全符合函数名称所暗示的含义。
  • 相关阅读

  • 使用承诺互斥锁在 JavaScript 中序列化异步任务:通过 AsyncLock 承诺链确保并发的客户端请求按先进先出顺序执行,从而在无需使用原生线程锁的情况下保持用户界面状态的有序性。