导致生产环境故障的常见异步/等待误解
解释了九种关于 async/await 的常见误解——从竞态条件到未处理的拒绝情况——这些误解会悄悄破坏现实世界中的 JavaScript 应用程序。
很长一段时间里,许多开发者仅仅因为能够熟练使用 async/await,就认为自己已经完全掌握了它。他们知道 async 函数总会返回一个 promise,也明白在涉及网络请求的调用前需要使用 await,还知道 try/catch 能让异步错误处理变得更简单,这些似乎就足够了。与大量使用回调函数的代码相比,这种写法显得更为整洁,读起来几乎就像普通的同步 JavaScript:获取用户信息、加载其账户数据、刷新界面,同时捕获过程中出现的任何错误。其语法如此直观,以至于人们很容易忽略去深入理解其背后的思维模型。
真正的问题在于,这种表面的流畅度仅涵盖了async/await的表象形式,却未理解异步执行背后的规则。人们常常将await视为会冻结整个程序,误以为调用异步函数就会自动将其执行结果与调用者关联起来,还相信以线性、自上而下的方式编写的代码不可能出现自我竞争的情况。在小型演示中,这些假设很少造成明显问题,因为一次只会有一个操作执行,响应很快返回,模拟数据也会按可预测的顺序生成。但真正的生产环境可没这么宽容。
最终出现的错误并不像是教科书上典型的异步问题。某个函数在某些副作用尚未完成时就返回了;过时的请求覆盖了新请求所设置的状态;try/catch块未能拦截异常的触发;批量任务并发执行的操作数量超出了系统实际处理能力。单独看每个承诺时似乎都毫无问题,但整个工作流程的表现却与最初的设计大相径庭。
要完全理解这一要点需要很长时间:async/await并不能消除 JavaScript 中的并发性。它所做的只是为单个异步函数提供了一种更易理解的暂停与继续执行的方式。除此之外的一切仍然取决于哪些承诺会被返回、哪些会被等待、哪些会被默默忽略、哪些会被取消或重试,以及哪些可以在执行过程中修改共享状态。
我原以为await会暂停多个函数
一个常见的初步误解其实很简单。每当出现await时,人们很容易认为它会暂停其周围的所有操作。JavaScript永远不会阻塞浏览器标签页或 Node.js 进程,但人们仍然容易认为周围的逻辑会以某种方式等待轮到自己执行。
事情并非如此。await仅会暂停它所在的特定异步函数。在等待的承诺尚未解决时,控制权就会返回给运行时,JavaScript会继续处理其他可以运行的任务。事件监听器可能会触发,定时器可能会到期,无关的请求也可能会完成,甚至在此期间还可以再次调用同一个函数。
async function loadProfile() {
console.log("Loading profile");
const profile = await fetchProfile();
console.log("Profile loaded");
return profile;
}
console.log("Before");
loadProfile();
console.log("After");
仅仅因为loadProfile函数中包含了await,并不会让外部代码等待。调用该函数后会立即启动它并返回一个承诺对象。除非调用者选择等待或返回该承诺,否则执行过程会直接继续下去而不会暂停。
这听起来可能只是一个小技术问题,但其影响远不止于控制台日志顺序混乱。请求处理程序可能在审计日志写入仍在执行时就发送响应;命令行工具可能在文件写入尚未完成时就退出;测试可能在嵌套在异步回调中的断言尚未运行时就报告成功。异步函数的行为完全符合设计预期——真正的问题在于调用方从未将自己的完成状态与该异步调用的完成状态关联起来。
因此,值得探讨的问题并非某个函数内部是否使用了await,而是代表整个操作的承诺对象是否真正与需要知晓操作完成情况的代码相连。
我在未确定谁来负责异步函数完成状态的情况下调用了它们
一个经常出现的错误就是直接调用异步函数,而不等待其执行结果或返回值。有时这只是简单的疏忽,而有时候则是因为认为该操作不够重要,可以放在后台运行。
真正的问题并不在于任务会独立执行——而在于没有人明确指定谁来负责处理其结果。
想象一下,先保存订单,然后再发送通知。如果必须等通知成功后整个操作才算完成,那么调用方就需要等待它。如果这确实是可选的,让它在后台自行运行或许没问题,但一旦失败仍需要有个处理方式。仅仅丢弃返回的承诺并不会让这次调用自动变成可靠的后台任务——它只是让调用方无法得知后续发生了什么。
在后端代码中,这种情况尤其危险。即使后续的异步操作出现了失败,函数仍可能返回成功的响应。用户会以为操作已经完成,而系统却没有任何持久记录表明还有未完成的工作存在。如果此时进程恰好重新启动,那些待处理的任务就会直接消失。
应将每一个未被等待的承诺视为一种架构设计选择,而非临时考虑的结果。要么由当前操作负责处理结果并等待其完成,要么由其他层来负责,并需要适当的机制来跟踪任务的完成情况、重试次数以及失败原因。有明确目的的后台任务本身并无问题——但不应仅仅因为有人忘记输入await就让其存在。
一个没人监视的承诺并非按设计属于后台任务,它只是没有负责人的工作而已。
把 forEach 当作理解承诺的存在来使用
最早打破常规思维模式的异步模式之一,就是将 forEach 与异步回调结合使用。其语法看起来完全合理,因此人们很容易忽略为何在真正的工作开始之前,外层函数就已经执行完毕了。
async function saveUsers(users) {
users.forEach(async (user) => {
await saveUser(user);
});
console.log("All users saved");
}
在单条用户记录被保存之前就可能出现完成消息。原因是 forEach 会调用回调函数,但会丢弃其返回的值。异步回调函数总是返回一个承诺对象,因此每次调用都会默默生成一个无人保留引用的承诺对象。由于没有需要等待的内容,外部函数会立即完成执行,而不管其内部调用仍在做什么。
这其实并非 forEach 特有的缺陷。问题出在人们误以为将异步函数传递给任何数组方法就能自动让该方法的接口支持承诺对象。事实并非如此。同步迭代辅助函数无论接收何种类型的函数都会保持同步状态,除非该辅助函数本身就是为协调异步操作而设计的。
map、filter 和 reduce 也存在同样的问题。使用带有异步回调的 map 会返回一个充满待处理承诺的数组,而非最终值的数组。filter 则从不等待异步判断函数的结果,因此它会直接评估承诺对象本身——这些对象始终为真值——而非它们最终产生的结果。虽然可以构建行为正确的异步版 reduce,但由于累加器本身也是一个承诺,在每次迭代时都需要仔细处理其包装结构,因此往往比较难以理解。
一旦这一点明确,编写正确代码就不再是为了记住哪种数组方法是“允许”的,而是要选择任务实际所需的执行模型。当每个步骤确实依赖于前一个步骤完成时,使用包含await的for...of循环可以直接表达这一意图。当各步骤相互独立且可以同时执行时,将它们映射为承诺数组并使用Promise.all一起等待通常是正确的做法。而当需要限制并发性时,单纯的循环或无限制的Promise.all都无法满足需求。
语法应当由实际操作需求决定,而非相反。人们很容易先选择看起来最符合习惯的模式,期望由此得到所需的行为,但这样的操作顺序是反的。
认为Promise.all能无风险地提升效率
在发现forEach从不等待之后,人们很自然地会想到使用Promise.all,因为它通常是合适的工具:将各个元素转换为异步操作,把生成的承诺对象交给Promise.all,然后一起等待所有操作完成。
当各个操作之间互不依赖、批量大小适中,且一次失败就会使整个结果无效时,这种模式表现优异。但一旦超出这些适用范围使用,问题就会出现。
Promise.all本身并不会对任何操作进行节流。大部分底层工作在承诺对象创建的瞬间就会开始,因此遍历数千条记录时可能会几乎同时发起数千次数据库查询或外部请求。这样的代码在处理小型本地数据集时可能表现正常,但一旦面对生产级别的输入量,就会导致连接池耗尽、遇到速率限制或内存溢出。
它的错误处理方式也可能令人意外。当组中的某个承诺对象失败时,其余的并不会自动停止运行。它们会继续执行,这意味着即使在调用代码已经进入异常处理块之后,这些承诺对象仍可能继续写入记录、发送消息或修改外部状态。从技术上讲,说“整个批次处理失败”是准确的,但这并不意味着不会产生其他副作用。
之后重新执行整个批次可以再次处理那些在第一次尝试时已经成功的任务。此时问题已不再是 Promise 的机制,而是操作的幂等性以及如何处理部分完成的情况。
在默认使用 Promise.all 之前,先思考另一组问题会有所帮助:实际上一次安全执行的操作数量是多少?这些操作真的彼此独立吗?某一个操作的失败会对批次中的其他操作产生什么影响?有没有办法停止已经开始的操作?如果重新尝试,那些已经完成的任务会不会被重复处理?调用方真的需要所有的结果吗,还是部分成功的反馈也是可以接受的?
有时 Promise.all 仍是正确的选择。有时 Promise.allSettled 更为合适。还有些情况下,则需要使用顺序循环、并发限制器、队列,或是将整个操作封装在事务中。哪种方法正确取决于该操作需要满足的保证条件,而非代码乍看起来的速度。
假设从上到下执行的代码不会发生竞态条件
或许最严重的误解就是认为 await 能让代码免于竞态条件的影响。在同一个函数内部,await 之后的语句确实会按顺序继续执行,这使得该函数看起来像是一个连续的流程。但在对该函数进行多次并发调用时,多个执行实例很容易出现重叠。
想象有两个请求,它们都读取当前余额、计算新余额并将其保存。这两个请求都会等待读取操作完成,也会等待写入操作完成。每次调用中的每一行代码都会按预期顺序执行。尽管如此,竞态条件依然会出现,因为在这两个请求中的任何一个将更新结果写回之前,它们都可能读取到相同的初始余额。
前端也会出现类似问题的情况。首先进行一次针对旧查询的搜索,随后又进行一次针对新查询的搜索。新请求恰好先处理完成并正确更新了用户界面,而旧请求则在之后处理完毕,用过时的结果覆盖了那个正确的状态。每次调用都按照设计好了的方式等待数据获取完成——所缺失的只是规定哪次调用仍拥有更新界面的权限。
对于所有位于状态读取与对该状态执行操作之间的 await 语句,这一点都值得牢记。这段暂停时间并非毫无影响的空白期,而是一个其他任务可以运行的窗口,在此期间之前函数所做的假设可能会被打破。
在应用层面进行的检查并不会自动在这段时间内保持有效。即便确认用户名可用后再使用该用户名,也无法防止几乎同时有两条请求进行相同的检查。在批准记录之前先验证其仍处于待处理状态,也不能保证在此期间没有其他进程更改了它的状态。
真正的保护通常需要来自更低层级:数据库层面的唯一性约束、条件更新、乐观锁、幂等键、事务处理,或是在用户界面中实施的明确所有权规则。await仅用于管理某个特定承诺的完成情况,它无法锁定共享状态,也无法保证之前读取的值在后续操作时依然有效。
期望try/catch能捕获那些从未被await处理的异常
另一个常见的错误在代码审查时看起来似乎毫无问题:异步调用被放在try/catch块中,人们往往会认为所有的异常都会在那里得到处理。
try {
sendAnalyticsEvent(event);
} catch (error) {
logError(error);
}
当 sendAnalyticsEvent 是一个异步函数且在已返回承诺后拒绝时,周围的 catch 块永远无法捕获到该错误。该调用的同步部分在返回承诺的那一刻就已经成功了,而拒绝操作发生在之后,处于 try 块所监控的堆栈帧之外。
catch 块只有在承诺在其内部被等待时才能起作用:
try {
await sendAnalyticsEvent(event);
} catch (error) {
logError(error);
}
说白了,两者的区别看似很明显,但早期版本之所以看起来安全,只是因为异步调用在视觉上位于错误处理程序内部。这种布局给人一种代码实际上建立了某种关联的错觉,但实际上并不存在这样的关系。
回调函数和事件处理程序中也存在同样的问题。异步回调可以在内部被拒绝,但如果调用它的代码从未等待或检查它返回的 Promise,那么这种拒绝就无处可去。用 try/catch 包裹调用代码也无济于事,因为该拒绝属于一个没有任何作用域内的代码在监听的 Promise。
根本原则是,异步代码中的错误是通过 Promise 传递的,而非通过缩进结构。要捕获拒绝状态,需要直接等待该 Promise,将其返回到已有处理函数的链中,或者添加一个显式的拒绝回调函数。一旦丢弃了 Promise,其错误处理路径也会一同被丢弃。
对于那些处理过度的捕获块来说,这一点同样重要。将每一次错误都转换为默认值 null 会引发另一个问题:调用者将无法区分真正的错误与合法的空结果。仅仅捕获错误并非目标,代码需要为后续使用它的人保留该错误的含义。
将取消误认为是逆转
当团队首次使用 AbortController 时,很容易认为取消请求就意味着底层操作已经真正停止。对于从浏览器发起的请求而言,取消确实能阻止客户端继续等待,而且通常还能避免在客户端进行不必要的后续处理。这对于实时搜索、离开页面或丢弃不再相关的读取内容等场景确实很有用。
它并不能保证已经交给服务器的处理操作会被撤销。
如果写请求在服务器已收到之后被中止,后端仍可能会继续完成数据库更新或外部调用。客户端实际上只知道它已停止等待响应。在没有某种幂等性保护机制的情况下重新发送该请求,可能会导致重复执行在服务器端已经成功的操作。
出现这种区别是因为客户端取消操作与服务器端状态处于网络边界的相对两侧。客户端可以放弃对结果的期待,但它无法跨越该边界去撤销已经产生的影响。
取消操作更应被视为一种关于相关性的信号,而非状态保障。它告知应用程序的其他部分,调用者不再关注某个特定结果,因此该结果不应被允许覆盖当前状态。对于读取操作,中止请求是一种合理的优化方式;而对于写入操作,则仍需要额外的机制来判断该操作是否已完成、仍在处理中,或者能否在不重复产生相同效果的情况下重新尝试。
取消操作解决的是是否仍需要某个结果的问题,而非底层操作是否仍然一致的问题。
在定义工作流之前编写异步语法
所有这些错误的一个共同点在于,人们将异步设计视为纯粹的语法问题,在尚未明确操作实际上需要提供何种保障之前,就决定使用 await、Promise.all 还是异步回调函数。
更有用的问题应该更早提出:调用方是否需要等待该操作完成才能继续执行?多个任务能否安全地同时运行?它们的执行顺序重要吗?当部分操作成功而其他操作失败时,应如何处理?重新尝试该操作安全吗?谁负责处理错误?过时的结果是否仍可能覆盖共享状态?另外,当操作超时时,是意味着操作本身失败了,还是仅表示当前的调用方放弃了等待?
一旦这些问题得到解答,实际的 JavaScript 代码通常就能较为顺利地实现。
那些顺序至关重要的操作流程可以使用循环依次处理每一步;而任务数量已知且有限的独立任务则可以并行执行。对于较大的数据量,可以通过设置并发限制来处理。那些无需阻塞调用方的任务可以交由持久队列来处理。对共享状态的更新可以通过明确的归属检查来进行控制。数据库写入操作可以在存储层以原子方式确保其不变性。那些出现重复会导致严重后果的操作可以使用幂等键,从而避免重试时生成相同效果的第二个副本。
最困难的部分永远是如何正确放置 await 语句。关键在于弄清楚在操作的每一个临界点上,“完成”究竟意味着什么。
知道关键词却不懂其用法
多年来一直错误使用async/await,与其说是忘记了其语法机制,不如说是因为它让异步代码看起来比实际更加顺序化、局部化且可预测。
看到 await 时,人们很容易认为其周围的代码也都暂停了。在未明确谁负责处理异步函数的最终完成情况时调用它,很容易被忽视。使用异步回调来执行同步数组方法,会误以为两者能够互相配合,但实际上并非如此。不考虑负载情况以及仅部分承诺成功时的处理方式就直接使用 Promise.all,虽是常见习惯,却充满风险。即便多个调用实际上可能在时间上重叠,仍相信代码会从上到下依次执行,这会让竞态条件暴露无遗。期望 try/catch 能捕获那些从未被 await 的承诺所抛出的错误,或将请求被取消误认为是服务器上没有发生任何操作,这些误区都源于同样的认知盲点。
所有这些错误都源于同一个问题:代码的呈现形式与它实际所承诺的功能之间的差距。
要正确理解异步代码,就需要跨越函数边界追踪各种承诺,而不仅仅是从上到下阅读代码。这意味着要弄清楚是谁创建了这些承诺、谁在等待它们、谁负责处理拒绝情况,以及当一切结束后还有哪部分状态对结果具有控制权。这也意味着要密切关注每一个使用 await 导致暂停的点,因为正是这些暂停时刻,其他操作才有可能改变该函数所依赖的假设。
这些代码在页面上可能仍看起来是顺序执行的,但这种外观已不能代表系统的实际运行方式。
这一转变使得异步 JavaScript 从一组关键字演变为一种以所有权、时序和错误处理为核心的模型。它也解释了为何那些多年以来看似正确的代码,即便通过了所有测试,在实际运行中仍可能出现异常行为。
学会如何等待 Promise 是比较容易的部分。
要理解在等待过程中程序的其余部分正在执行什么,则需要更长的时间。
相关阅读
- 五种能悄无声息通过代码审查的 Node.js 欺骗性漏洞 —— 通过分析涉及 forEach、悬浮 Promise 和浅拷贝的五种真实 Node.js 漏洞,了解为何运行正常的代码在实际环境中仍可能出错。