async/await真正能保证什么,以及它将什么留给开发者
了解 actually suspends 具体指什么,如何避免串行请求,以及为何错误处理、取消操作、顺序控制与重试机制需要超越 async/await 的设计方案。
一个充满 await 表达式的函数看起来就像逐行执行的脚本,而正是这种印象让许多异步错误由此产生。缓慢的界面响应、被隐藏的错误信息、过时的搜索结果以及重复扣款,往往都源于人们期望 async/await 能提供它实际上并不具备的保障。一旦你了解了该语法背后那些相对简单的规则,就能立刻判断哪些行为是语言本身决定的,哪些行为还需要你自己设计。
下面是一个明显呈顺序执行的函数示例:
async function loadDashboard(userId) {
const user = await fetchUser(userId);
const projects = await fetchProjects(userId);
const notifications = await fetchNotifications(userId);
return {
user,
projects,
notifications,
};
}
其自然的理解方式是:先获取用户信息并等待,再获取项目信息并等待,接着获取通知信息,最后返回结果。这种理解并非完全错误,但当代码需要具备快速处理、并发执行、可取消或容错能力时,它就会掩盖那些关键的部分。
await 并未规定底层操作的具体执行方式,它仅用于标记所在异步函数必须暂停等待承诺状态确定的点。在该函数暂停期间,运行时仍会继续处理其他任务。许多误解源于将本应属于承诺、宿主环境、如 fetch 这样的 API、并发策略或应用程序本身的行为归咎于 async/await。
await 只暂停单个函数,而非整个运行时
请看这个在网络请求前后进行日志记录的简单程序:
async function loadUser() {
console.log("A");
const user = await fetch("/api/user");
console.log("B");
return user;
}
console.log("1");
loadUser();
console.log("2");
调用 loadUser() 会立即开始执行其代码体,因此 "1" 之后首先输出 "A"。当执行到 await 时,函数不会在请求处理期间阻塞线程,而是暂停自身并将控制权交还给调用者,这就是为什么 "2" 会出现在 "B" 之前的原因。一旦 fetch 的承诺状态确定,loadUser() 的剩余部分会被排队继续执行,此时才会输出 "B"。最终的顺序为 1、A、2、B。
因此,await 的正确译法并非“在此处停止 JavaScript”,而更接近于“此行之后的所有操作都依赖于该值,稍后再继续执行此函数”。
即使等待的值已经可用,情况也是如此。正如MDN文档所述,无论是等待已满足的promise还是普通的非promise值,都会将函数的其余部分推迟到后续的微任务中执行,而不会继续在当前线程中运行。正因如此,在代码中随意添加看似无害的额外await表达式就会改变执行调度:每一个await都会增加一个微任务边界。
实际后果是,不能将每个await视为同步函数中的阻塞调用来理解整个程序。你必须追踪哪些操作已经启动、哪些函数当前处于挂起状态,以及在某个函数恢复执行之前还允许运行什么操作。
async并不会将任务从主线程中移开
async关键字还会引发另一个误解。看看这个依赖CPU处理的循环:
async function calculateTotal(items) {
let total = 0;
for (const item of items) {
total += expensiveCalculation(item);
}
return total;
}
这段代码完全没有并行处理的特性。添加 async 并不会将 expensiveCalculation() 放到另一个线程上执行,也不会让循环并行化,更无法防止繁重的同步操作阻塞同一线程上的其他任务。
async 所改变的是函数的返回机制——调用该函数总会返回一个 promise,而直接返回普通值则相当于用该值来满足这个 promise。ECMAScript 规范将异步函数的执行描述为通过 promise 机制来实现,该 promise 最终会成为函数的执行结果。
async function getNumber() {
return 42;
}
const result = getNumber();
console.log(result);
// Promise
打印 result 会显示为待处理或已满足的 Promise,而非 42。只有通过等待该 promise 或调用其 .then() 方法,才能得到对应的数值。
不过,返回一个 promise 并不会使函数体变为异步的。在第一次 await 之前的所有代码都会在调用时同步执行。如果你在异步函数中放入耗时的计算任务,并期望界面始终保持响应,那将会失望:该函数最终会返回一个 promise,但繁重的任务仍会先占用线程。对于真正需要大量 CPU 资源的任务,浏览器中可以使用 Web Workers,Node.js 中可使用 worker_threads,或者将任务拆分成多个部分来处理。
简而言之,async/await 是一种自上而下读取的 promise 链编写方式。它只是安排任务的继续执行,并不会让阻塞性代码同时运行。
放置 await 的位置可以串行化独立任务
回到仪表板加载器的话题:
async function loadDashboard(userId) {
const user = await fetchUser(userId);
const projects = await fetchProjects(userId);
const notifications = await fetchNotifications(userId);
return {
user,
projects,
notifications,
};
}
假设fetchProjects()不需要user,而fetchNotifications()也不需要前一个函数的执行结果。但该函数仍然会依次发起三个独立的请求,因为第二次调用要等到第一次的Promise完成之后才会执行,第三次则要等待第二次的结果。总延迟就是这三个请求延迟之和:
fetch user
████████
fetch projects
███████████
fetch notifications
███████
这种解决方案通常被描述为“使用Promise.all()让它们并行执行”。更准确的表述是,所有独立操作都必须在函数开始等待结果之前启动。先调用这三个函数会生成三个正在处理的Promise,只有之后代码才会等待它们的综合结果:
async function loadDashboard(userId) {
const userPromise = fetchUser(userId);
const projectsPromise = fetchProjects(userId);
const notificationsPromise =
fetchNotifications(userId);
const [user, projects, notifications] =
await Promise.all([
userPromise,
projectsPromise,
notificationsPromise,
]);
return {
user,
projects,
notifications,
};
}
现在这些请求会同时进行,总延迟大致等于其中最慢的那个请求的延迟:
fetch user
████████
fetch projects
███████████
fetch notifications
███████
all required results available
Promise.all() 接收一组承诺对象,返回一个新的承诺,在所有输入的承诺都得到满足时该新承诺才会满足。结果数组会保持输入对象的顺序,无论哪个操作先完成,因此将结果解构为 user、projects 和 notifications 时顺序依然正确。
需要注意的是,区分顺序执行代码与并发执行代码的关键并非是否存在 await,而是依赖关系。当某一步确实需要另一步的输出时,按顺序等待才是正确的选择:
const user = await fetchUser(userId);
const permissions = await fetchPermissions(user.role);
当不存在此类依赖关系时,每次操作完成后才启动下一个操作只会增加延迟,并不能提升正确性。因此,有意义的代码审查问题不是“是否应该使用 Promise.all()?”,而是“哪些操作依赖于之前的结果,哪些操作可以立即开始执行?”
Promise.all() 仅用于协调结果,无法取消任务
在了解到 Promise.all() 之后,许多开发者对出错时的处理方式产生了新的误解。以以下版本为例:
const [user, projects, notifications] =
await Promise.all([
fetchUser(userId),
fetchProjects(userId),
fetchNotifications(userId),
]);
如果 fetchProjects() 提前被拒绝,整个承诺会立即因该原因而被拒绝,而不会等待其余操作完成。人们很容易误以为另外两个请求也会随之停止,但实际上并非如此。MDN 明确指出,整体承诺的拒绝并不会取消任何已经启动的操作。其他操作会继续执行,只是它们的最终结果会被忽略。
在读取操作中,这主要会导致资源浪费;而在写入操作中,则可能带来严重后果:
await Promise.all([
updateProfile(),
writeAuditLog(),
sendWebhook(),
]);
如果 writeAuditLog() 被拒绝,updateProfile() 可能已经完成数据提交,而 sendWebhook() 也可能已经在传输途中。即便捕获到了拒绝错误,也无法撤销这些已发生的操作。
那是系统设计问题,而非语法问题。如果这些步骤必须要么全部成功,要么全部失败,就需要真正的原子性机制:对于单个数据库的更改可使用数据库事务,而对于远程系统,则需要结合补偿操作、幂等操作以及持久化的工作流状态。Promise.all()只能告知你一组承诺何时已解决,但它无法保证回滚、取消,也无法确保副作用作为一个整体执行。这些保障必须来自其他地方。
另一个相关工具是Promise.allSettled(),它会等待所有输入并分别报告每个结果。当你需要确切知道哪些步骤成功时它很有用,但同样也不提供回滚功能。
try/catch仅能捕获传入它的拒绝情况
async/await得以普及的一个原因在于,可以通过熟悉的try/catch结构来处理承诺对象中的错误:
async function loadUser(userId) {
try {
const user = await fetchUser(userId);
return user;
} catch (error) {
console.error("Failed to load user", error);
throw error;
}
}
当被等待的承诺对象拒绝时,await表达式会将拒绝原因抛出到函数内部,而周围的catch块可以像处理同步异常一样捕获它。
但这并不意味着try块会监控其大括号内启动的每一个异步操作。以账户创建为例:
async function createAccount(input) {
try {
await saveUser(input);
sendWelcomeEmail(input.email);
return { success: true };
} catch (error) {
console.error(error);
return { success: false };
}
}
saveUser()被异步等待,因此其失败会进入catch块。而sendWelcomeEmail()则不是这样。如果它返回的承诺后来被拒绝,由于没有代码在等待或接收该承诺,这种拒绝永远不会进入当前的执行流程。createAccount()可能在发送邮件失败之前就已经返回了{ success: true },此时失败会单独显现,通常表现为未处理的拒绝异常。在当前版本的Node.js中,未处理的拒绝异常会默认终止进程,因此这并非仅仅是表面问题。
跳过 await 并不一定是错误。有时邮件会被刻意排除在请求路径之外。但在实际生产环境中,这类独立任务通常会放入持久队列中处理,而非以无人监控的承诺形式启动。真正的错误在于,仅仅因为代码调用位于 try 块内,就误以为该块能为后续的异步操作创建错误处理边界。
同样的问题也出现在调用处。这段代码看似有防护机制,但实际上只是没有使用 await 的普通 JavaScript 代码:
try {
loadDashboard(userId);
} catch (error) {
console.error("Dashboard failed");
}
loadDashboard() 会立即返回一个承诺。任何异步错误都会在 try 块执行完毕之后才导致该承诺被拒绝,因此 catch 块永远无法运行。调用方必须参与到承诺链中才能处理错误:
try {
await loadDashboard(userId);
} catch (error) {
console.error("Dashboard failed");
}
或者,可以使用 .catch() 添加拒绝处理程序。一旦不再将异步函数视为仅包含 await 的普通函数,其底层原理就变得很简单了:调用者会收到承诺对象,而错误则会通过该承诺契约传递。
取消操作是独立的机制
想象一个搜索框,用户需要逐个输入字符:
r
re
rea
reac
react
一种简单的实现方式是每次按键都会发起请求,即便之前的请求仍在处理中:
async function search(query) {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`
);
return response.json();
}
await 中并没有规定因为现在需要处理 "react" 的请求,就应当停止对 "r" 的请求。即使界面不再需要之前的请求,它仍会执行完毕。
对于 fetch,可通过 AbortController 及其 AbortSignal 来实现取消操作。此方式会在启动新请求之前终止之前的请求:
let controller;
async function search(query) {
controller?.abort();
controller = new AbortController();
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{
signal: controller.signal,
}
);
return response.json();
}
该控制器会提供一个信号,兼容的 API 会监听这个信号。一旦中止该信号,fetch 就会停止执行,包括请求本身以及响应体的读取。需要注意的一点是:被中止的请求会抛出 AbortError 错误,因此调用 search() 的代码应当识别该错误并忽略它,而不应将其展示给用户。
“兼容的 API”这一表述非常重要。Promise 并没有通用的取消机制,await 也无法停止它所等待的操作。只有当所调用的操作支持取消功能并将信号传递给执行实际工作的部分时,取消操作才能生效。
您自己的函数也可以采用相同的契约,通过接收信号并在各步骤之间对其进行检查:
async function processFile(file, { signal }) {
for (const chunk of file.chunks) {
signal.throwIfAborted();
await processChunk(chunk);
}
}
signal.throwIfAborted()会在请求取消时抛出取消原因,从而使循环在处理下一块数据之前停止。现在,取消操作已成为函数接口的明确组成部分,而非调用者期望通过await来实现的功能。为了更精细的控制,您还可以将信号传递给processChunk(),这样较长的数据块就可以在处理到中途时停止。
超时处理也遵循相同的逻辑。使用Promise.race()让操作与计时器竞争,可以让调用者提前停止等待,但底层操作仍会继续执行,除非也被要求停止。停止观察与停止工作是两回事。
局部顺序不等于全局顺序
顺序的 await 确实能保证同一个函数内的执行顺序:
await saveOrder(order);
await sendConfirmation(order);
直到 saveOrder() 执行完成才会调用 sendConfirmation()。不过,这种局部保证对整个系统的其他部分几乎没有任何说明作用。想象有两个请求在更新同一个用户资料,其中一个发送请求:
await updateProfile({
name: "Umar",
});
几毫秒后,另一个请求也发送了:
await updateProfile({
name: "Umar Dev",
});
每个调用方都会正确地等待自己的更新结果。但这些因素都无法决定哪条写入操作会最后到达数据库,也无法确定双方的重试过程是如何交织的,更无法判断这两个请求是由不同的服务器处理的,或是旧数据是否会覆盖新数据。
在浏览器环境中,这个问题表现为典型的竞态条件:请求A先开始执行但比请求B晚完成,而负责渲染返回结果的代码会将过时的数据展示在屏幕上:
const results = await search(query);
render(results);
额外的 await 无法解决此问题。应用程序需要相关性的规则或顺序控制:取消较旧的搜索请求,为请求添加版本标签并丢弃过时的响应,在渲染前比较标识符,或在数据存储处实施版本检查。需要记住的要点是:await 只能控制单个函数内的执行顺序,无法为独立的异步操作创建全局顺序。
重试会暴露出 await 从未承诺过的行为
当思维模型不完整时,重试机制会带来高昂代价。以支付请求为例:
async function submitPayment(payment) {
const response = await fetch("/api/payments", {
method: "POST",
body: JSON.stringify(payment),
});
return response.json();
}
假设请求超时了,某开发者简单地添加了重试逻辑:
try {
return await submitPayment(payment);
} catch {
return await submitPayment(payment);
}
这种做法假设失败的尝试不会产生任何影响。超时仅表示客户端未能及时收到响应。服务器可能已经扣款但未发送响应,或者在副作用已发生之后连接就已断开。盲目重试可能会导致客户被重复扣款。
await 无法告知你是否可以安全地重试。Promise 只会报告此次尝试是成功完成了操作还是被拒绝,且这些结果是可以被调用方观察到的。它不会说明在得到该结果之前远程系统是否已经执行了不可逆的操作。
对于会引发副作用的操作,必须在其设计阶段就考虑重试的安全性,通常是通过实现幂等性来实现。客户端会在每次逻辑支付尝试时生成一个唯一的稳定密钥,并在每次重试时一同发送该密钥:
await fetch("/api/payments", {
method: "POST",
headers: {
"Idempotency-Key": paymentAttemptId,
},
body: JSON.stringify(payment),
});
只有当服务器认可该键时,它才有作用:服务器必须将键与结果一起记录下来,当相同的键再次出现时,应返回之前的处理结果而非再次收费。此外,在同一操作的多次重试中,该键必须保持不变;每次请求都生成新的键会违背其设计初衷。不同系统的实现方式可能有所差异,但原则是相同的:重试机制属于操作本身及其副作用的处理逻辑,而非 async/await。关于服务器端的处理方式,请参阅Node.js POST 接口中幂等键的原理。
由此可得出在编写生产级 JavaScript 时一个宝贵的习惯:每当异步调用失败时,要将两句话分开书写——“我没有收到成功响应”与“该操作确实没有发生”。这两者并非相同的表述。
更简洁、更精确的思维模型
要正确理解 async/await,无需记住 ECMAScript 规范。你只需掌握一个简明的规则:异步函数会返回一个承诺对象;其代码体会正常执行,直到遇到 await 语句;此时该函数会被暂停,直到所等待的值确定下来,之后才会继续执行后续代码。
其他所有问题都是独立的,各有其答案:
- 多个操作:每个操作何时开始,它们之间是否存在依赖关系?这能帮助你判断操作的顺序是刻意安排的还是偶然形成的。
try/catch是否真的保护了你认为需要保护的内容。await调用无法实现,那么究竟是哪种真正的原子性或协调机制在起作用?核心要点
async/await的设计初衷十分简洁。它让异步控制流更易于理解,这一优势极为重要,但正是这种可读性使得代码看起来比实际运行的系统更像同步代码。遇到await时,不要将其理解为“程序在此处等待”,而应将其视为一种提示:该函数会暂停执行直到有值返回,那么此时有哪些操作已经在运行,其间可能发生什么,以及你的设计需要提供哪些保障?这个问题恰恰反映了该语法的实际作用,它能引导你做出那些能够避免生产环境出现故障的设计决策。
相关阅读
- Async/Await 与 Promises:底层究竟有何不同 — 阐述了 async/await 与 Promises 在执行流程、内存使用及堆栈跟踪方面的真实差异,以及何时仍需使用原始的 Promise API。
- 导致生产环境故障的常见 async/await 误区 — 解释了九种容易被忽视的 async/await 错误理解,从竞态条件到未处理的拒绝事件,这些都会悄无声息地破坏实际的 JavaScript 应用程序。