释放 JavaScript 并行处理的强大能力。学习使用 Promise.all, allSettled, race 和 any 管理并发 Promise,构建更快、更稳健的应用程序。
精通 JavaScript 并发:深入解析 Promise 并行处理
在现代 Web 开发领域,性能不是一个功能,而是一项基本要求。全球用户都期望应用程序快速、响应灵敏且无缝。这一性能挑战的核心,尤其是在 JavaScript 中,在于如何高效地处理异步操作。从 API 获取数据到读取文件或查询数据库,许多任务都不是即时完成的。我们如何管理这些等待时间,将决定一个应用程序是迟缓还是流畅愉悦的用户体验。
JavaScript 本质上是一种单线程语言。这意味着它一次只能执行一段代码。这听起来可能是一个限制,但 JavaScript 的事件循环和非阻塞 I/O 模型使其能够以惊人的效率处理异步任务。这个模型的现代基石是 Promise——一个表示异步操作最终完成(或失败)的对象。
然而,仅仅使用 Promise 或其优雅的 `async/await` 语法并不能自动保证最佳性能。开发者常见的一个陷阱是按顺序处理多个独立的异步任务,从而造成不必要的瓶颈。这就是并发 Promise 处理发挥作用的地方。通过并行启动多个异步操作并集体等待它们,我们可以极大地减少总执行时间,并构建出效率高得多的应用程序。
这份全面的指南将带您深入了解 JavaScript 并发的世界。我们将探讨语言内置的工具——`Promise.all()`、`Promise.allSettled()`、`Promise.race()` 和 `Promise.any()`——帮助您像专业人士一样编排并行任务。无论您是刚开始接触异步性的初级开发者,还是希望优化模式的资深工程师,本文都将为您提供编写更快、更具弹性、更复杂的 JavaScript 代码所需的知识。
首先,快速澄清:并发与并行
在继续之前,有必要澄清两个在计算机科学中经常互换使用但含义不同的术语:并发(concurrency)和并行(parallelism)。
- 并发(Concurrency) 是指在一段时间内管理多个任务的概念。它是关于同时处理很多事情。一个系统如果能够在不等待前一个任务完成的情况下,开始、运行和完成多个任务,那么它就是并发的。在 JavaScript 的单线程环境中,并发是通过事件循环实现的,它允许引擎在任务之间切换。当一个长时间运行的任务(如网络请求)在等待时,引擎可以处理其他事情。
- 并行(Parallelism) 是指同时执行多个任务的概念。它是关于同时做很多事情。真正的并行需要一个多核处理器,不同的线程可以在不同的核心上在完全相同的时间运行。虽然 Web Worker 允许在浏览器端的 JavaScript 中实现真正的并行,但我们在此讨论的核心并发模型与单个主线程有关。
对于 I/O 密集型操作(如网络请求),JavaScript 的并发模型提供了并行的*效果*。我们可以一次性发起多个请求。当 JavaScript 引擎等待响应时,它可以自由地做其他工作。从外部资源(服务器、文件系统)的角度来看,这些操作是“并行”发生的。这就是我们将要利用的强大模型。
顺序执行的陷阱:一个常见的反模式
让我们从识别一个常见的错误开始。当开发者初次学习 `async/await` 时,其语法非常简洁,很容易写出看起来是同步的、但无意中却是顺序执行且效率低下的代码。想象一下,您需要获取用户的个人资料、他们最近的帖子以及他们的通知来构建一个仪表盘。
一个幼稚的方法可能看起来像这样:
示例:低效的顺序获取
async function fetchDashboardDataSequentially(userId) {
console.time('sequentialFetch');
console.log('Fetching user profile...');
const userProfile = await fetchUserProfile(userId); // Waits here
console.log('Fetching user posts...');
const userPosts = await fetchUserPosts(userId); // Waits here
console.log('Fetching user notifications...');
const userNotifications = await fetchUserNotifications(userId); // Waits here
console.timeEnd('sequentialFetch');
return { userProfile, userPosts, userNotifications };
}
// Imagine these functions take time to resolve
// fetchUserProfile -> 500ms
// fetchUserPosts -> 800ms
// fetchUserNotifications -> 1000ms
这张图有什么问题?每个 `await` 关键字都会暂停 `fetchDashboardDataSequentially` 函数的执行,直到 Promise 解析。获取 `userPosts` 的请求甚至在 `userProfile` 请求完全完成之前都不会开始。获取 `userNotifications` 的请求在 `userPosts` 返回之前也不会开始。这三个网络请求是相互独立的;没有理由去等待!总耗时将是所有单个时间的总和:
总时间 ≈ 500ms + 800ms + 1000ms = 2300ms
这是一个巨大的性能瓶颈。我们可以做得更好。
释放性能:并发执行的力量
解决方案是立即启动所有异步操作,而不是立即等待它们。这使得它们可以并发运行。我们可以将待处理的 Promise 对象存储在变量中,然后使用 Promise 组合器 来等待它们全部完成。
示例:高效的并发获取
async function fetchDashboardDataConcurrently(userId) {
console.time('concurrentFetch');
console.log('Initiating all fetches at once...');
const profilePromise = fetchUserProfile(userId);
const postsPromise = fetchUserPosts(userId);
const notificationsPromise = fetchUserNotifications(userId);
// Now we wait for all of them to complete
const [userProfile, userPosts, userNotifications] = await Promise.all([
profilePromise,
postsPromise,
notificationsPromise
]);
console.timeEnd('concurrentFetch');
return { userProfile, userPosts, userNotifications };
}
在这个版本中,我们调用了三个 fetch 函数而没有使用 `await`。这会立即启动所有三个网络请求。JavaScript 引擎将它们交给底层环境(浏览器或 Node.js)并接收回三个待处理的 Promise。然后,使用 `Promise.all()` 等待这三个 Promise 全部解析。总耗时现在由*运行时间最长*的操作决定,而不是总和。
总时间 ≈ max(500ms, 800ms, 1000ms) = 1000ms
我们刚刚将数据获取时间减少了一半以上!这就是并行 Promise 处理的基本原则。现在,让我们来探索 JavaScript 为编排这些并发任务提供的强大工具。
Promise 组合器工具箱:`all`, `allSettled`, `race`, 与 `any`
JavaScript 在 `Promise` 对象上提供了四个静态方法,称为 Promise 组合器。每个方法都接受一个可迭代对象(如数组)的 Promise,并返回一个新的单一 Promise。这个新 Promise 的行为取决于您使用哪个组合器。
1. `Promise.all()`:全有或全无的方法
`Promise.all()` 是当您有一组对下一步至关重要的任务时的完美工具。它代表了逻辑“与”条件:任务1、任务2 和任务3 必须全部成功。
- 输入: 一个 Promise 的可迭代对象。
- 行为: 它返回一个单一的 Promise,该 Promise 在所有输入的 Promise 都 fulfilled(成功)时才会 fulfilled。fulfilled 的值是一个包含所有输入 Promise 结果的数组,顺序与输入时相同。
- 失败模式: 只要输入的 Promise 中有任何一个 rejected(失败),它就会*立即* reject。拒绝的原因是第一个 reject 的 Promise 的原因。这通常被称为“快速失败”(fail-fast)行为。
用例:关键数据聚合
我们的仪表盘示例是一个完美的用例。如果您无法加载用户的个人资料,那么显示他们的帖子和通知可能就没有意义了。整个组件依赖于所有三个数据点的可用性。
// Helper to simulate API calls
const mockApiCall = (value, delay, shouldFail = false) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldFail) {
reject(new Error(`API call failed for: ${value}`));
} else {
console.log(`Resolved: ${value}`);
resolve({ data: value });
}
}, delay);
});
};
async function loadCriticalData() {
console.log('Using Promise.all for critical data...');
try {
const [profile, settings, permissions] = await Promise.all([
mockApiCall('userProfile', 400),
mockApiCall('userSettings', 700),
mockApiCall('userPermissions', 500)
]);
console.log('All critical data loaded successfully!');
// Now render the UI with profile, settings, and permissions
} catch (error) {
console.error('Failed to load critical data:', error.message);
// Show an error message to the user
}
}
// What happens if one fails?
async function loadCriticalDataWithFailure() {
console.log('\nDemonstrating Promise.all failure...');
try {
const results = await Promise.all([
mockApiCall('userProfile', 400),
mockApiCall('userSettings', 700, true), // This one will fail
mockApiCall('userPermissions', 500)
]);
} catch (error) {
console.error('Promise.all rejected:', error.message);
// Note: The 'userProfile' and 'userPermissions' calls may have completed,
// but their results are lost because the whole operation failed.
}
}
loadCriticalData();
// After a delay, call the failure example
setTimeout(loadCriticalDataWithFailure, 2000);
`Promise.all()` 的陷阱
其主要陷阱是快速失败的特性。如果您正在为一个页面上的十个不同的、独立的组件获取数据,而其中一个 API 失败了,`Promise.all()` 将会 reject,您将丢失其他九个成功调用的结果。这时,我们的下一个组合器就派上用场了。
2. `Promise.allSettled()`:稳健的结果收集器
在 ES2020 中引入的 `Promise.allSettled()` 对弹性编程来说是一个游戏规则的改变者。它专为当您想知道每一个 Promise 的最终结果(无论是成功还是失败)而设计。它从不 reject。
- 输入: 一个 Promise 的可迭代对象。
- 行为: 它返回一个*总是* fulfilled 的单一 Promise。一旦所有输入的 Promise 都 settled(无论是 fulfilled 还是 rejected),它就会 fulfilled。fulfilled 的值是一个对象数组,每个对象描述一个 Promise 的结果。
- 结果格式: 每个结果对象都有一个 `status` 属性。
- 如果 fulfilled: `{ status: 'fulfilled', value: theResult }`
- 如果 rejected: `{ status: 'rejected', reason: theError }`
用例:非关键的独立操作
想象一个显示多个独立组件的页面:一个天气小部件、一个新闻源和一个股票行情器。如果新闻源 API 失败,您仍然希望显示天气和股票信息。`Promise.allSettled()` 对此非常适用。
async function loadDashboardWidgets() {
console.log('\nUsing Promise.allSettled for independent widgets...');
const results = await Promise.allSettled([
mockApiCall('Weather Data', 600),
mockApiCall('News Feed', 1200, true), // This API is down
mockApiCall('Stock Ticker', 800)
]);
console.log('All promises have settled. Processing results...');
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`Widget ${index} loaded successfully with data:`, result.value.data);
// Render this widget to the UI
} else {
console.error(`Widget ${index} failed to load:`, result.reason.message);
// Show a specific error state for this widget
}
});
}
loadDashboardWidgets();
通过使用 `Promise.allSettled()`,您的应用程序变得更加健壮。单个故障点不会导致级联失败,从而拖垮整个用户界面。您可以优雅地处理每一种结果。
3. `Promise.race()`:第一个冲过终点线
`Promise.race()` 的作用正如其名。它让一组 Promise 相互竞争,一旦第一个冲过终点线,就宣布获胜者,无论它是成功还是失败。
- 输入: 一个 Promise 的可迭代对象。
- 行为: 它返回一个单一的 Promise,该 Promise 在输入的 Promise 中*第一个* settle(fulfilled 或 rejected)时就会立即 settle。返回的 Promise 的 fulfilled 值或 rejection 原因将是那个“获胜”的 Promise 的值或原因。
- 重要提示: 其他的 Promise 不会被取消。它们将继续在后台运行,只是它们的结果会被 `Promise.race()` 上下文忽略。
用例:实现超时
`Promise.race()` 最常见和实用的用例是为一个异步操作强制设置超时。您可以让您的主要操作与一个 `setTimeout` Promise “竞赛”。如果您的操作耗时过长,超时 Promise 将首先 settle,您可以将其作为错误来处理。
function createTimeout(delay) {
return new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`Operation timed out after ${delay}ms`));
}, delay);
});
}
async function fetchDataWithTimeout() {
console.log('\nUsing Promise.race for a timeout...');
try {
const result = await Promise.race([
mockApiCall('some critical data', 2000), // This will take too long
createTimeout(1500) // This will win the race
]);
console.log('Data fetched successfully:', result.data);
} catch (error) {
console.error(error.message);
}
}
fetchDataWithTimeout();
另一个用例:冗余端点
您也可以使用 `Promise.race()` 来查询多个冗余服务器以获取相同的资源,并采用最快服务器的响应。然而,这样做有风险,因为如果最快的服务器返回错误(例如,500 状态码),`Promise.race()` 将立即 reject,即使一个稍慢的服务器本可以返回成功的响应。这就引出了我们最后一个、更适合此场景的组合器。
4. `Promise.any()`:第一个成功者
在 ES2021 中引入的 `Promise.any()` 像是 `Promise.race()` 的一个更乐观的版本。它也等待第一个 Promise settle,但它特别寻找第一个 *fulfilled* 的 Promise。
- 输入: 一个 Promise 的可迭代对象。
- 行为: 它返回一个单一的 Promise,该 Promise 在任何一个输入的 Promise fulfilled 时就会立即 fulfill。fulfillment 的值是第一个 fulfilled 的 Promise 的值。
- 失败模式: 只有在*所有*输入的 Promise 都 reject 时,它才会 reject。拒绝的原因是一个特殊的 `AggregateError` 对象,它包含一个 `errors` 属性——一个包含所有单个拒绝原因的数组。
用例:从冗余源获取
这是从多个源(如主服务器和备份服务器,或多个内容分发网络 CDN)获取资源的完美工具。您只关心尽快获得一个成功的响应。
async function fetchResourceFromMirrors() {
console.log('\nUsing Promise.any to find the fastest successful source...');
try {
const resource = await Promise.any([
mockApiCall('Primary CDN', 800, true), // Fails quickly
mockApiCall('European Mirror', 1200), // Slower but will succeed
mockApiCall('Asian Mirror', 1100) // Also succeeds, but is slower than the European one
]);
console.log('Resource fetched successfully from a mirror:', resource.data);
} catch (error) {
if (error instanceof AggregateError) {
console.error('All mirrors failed to provide the resource.');
// You can inspect individual errors:
error.errors.forEach(err => console.log('- ' + err.message));
}
}
}
fetchResourceFromMirrors();
在此示例中,`Promise.any()` 将忽略主 CDN 的快速失败,并等待欧洲镜像 fulfill,此时它将用该数据解析,并有效忽略亚洲镜像的结果。
为工作选择合适的工具:快速指南
有四个强大的选项,您如何决定使用哪一个?这里有一个简单的决策框架:
- 我是否需要所有 Promise 的结果,并且其中任何一个失败都是灾难性的?
使用Promise.all()。这适用于紧密耦合、全有或全无的场景。 - 我是否需要知道所有 Promise 的结果,无论它们成功还是失败?
使用Promise.allSettled()。这适用于处理多个独立任务,当您想要处理每一种结果并保持应用程序的弹性时。 - 我只关心第一个完成的 Promise,无论它是成功还是失败?
使用Promise.race()。这主要用于实现超时或其他竞争条件,其中第一个结果(任何类型)是唯一重要的。 - 我只关心第一个成功的 Promise,并且可以忽略任何失败的?
使用Promise.any()。这适用于涉及冗余的场景,例如为同一资源尝试多个端点。
高级模式与现实世界考量
虽然 Promise 组合器非常强大,但专业的开发工作通常需要更多的细微差别。
并发限制与节流
如果您有一个包含 1000 个 ID 的数组,并且想为每个 ID 获取数据,会发生什么?如果您天真地将所有 1000 个生成 Promise 的调用传递给 `Promise.all()`,您将立即发出 1000 个网络请求。这可能会带来几个负面后果:
- 服务器过载: 您可能会压垮您请求的服务器,导致所有用户出现错误或性能下降。
- 速率限制: 大多数公共 API 都有速率限制。您很可能会达到限制并收到 `429 Too Many Requests` 错误。
- 客户端资源: 客户端(浏览器或服务器)可能难以同时管理那么多的开放网络连接。
解决方案是通过分批处理 Promise 来限制并发性。虽然您可以为此编写自己的逻辑,但像 `p-limit` 或 `async-pool` 这样的成熟库可以优雅地处理这个问题。以下是一个您可能如何手动处理它的概念性示例:
async function processInBatches(items, batchSize, processingFn) {
let results = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
console.log(`Processing batch starting at index ${i}...`);
const batchPromises = batch.map(processingFn);
const batchResults = await Promise.allSettled(batchPromises);
results = results.concat(batchResults);
}
return results;
}
// Example usage:
const userIds = Array.from({ length: 20 }, (_, i) => i + 1);
// We will process 20 users in batches of 5
processInBatches(userIds, 5, id => mockApiCall(`user_${id}`, Math.random() * 1000))
.then(allResults => {
console.log('\nBatch processing complete.');
const successful = allResults.filter(r => r.status === 'fulfilled').length;
const failed = allResults.filter(r => r.status === 'rejected').length;
console.log(`Total Results: ${allResults.length}, Successful: ${successful}, Failed: ${failed}`);
});
关于取消的说明
原生 Promise 的一个长期挑战是它们不可取消。一旦您创建了一个 Promise,它就会运行到完成。虽然 `Promise.race` 可以帮助您忽略一个缓慢的结果,但底层的操作仍在继续消耗资源。对于网络请求,现代的解决方案是 `AbortController` API,它允许您向一个 `fetch` 请求发送信号,告知它应该被中止。将 `AbortController` 与 Promise 组合器集成,可以提供一种健壮的方式来管理和清理长时间运行的并发任务。
结论:从顺序思维到并发思维
掌握异步 JavaScript 是一个旅程。它始于理解单线程事件循环,发展到使用 Promise 和 `async/await` 以提高清晰度,并最终达到以并发方式思考以最大化性能。从顺序的 `await` 思维模式转变为并行优先的方法,是开发者为提高应用程序响应能力可以做出的最具影响力的改变之一。
通过利用内置的 Promise 组合器,您有能力以优雅和精确的方式处理各种现实世界的场景:
- 使用 `Promise.all()` 处理关键的、全有或全无的数据依赖。
- 依赖 `Promise.allSettled()` 构建具有独立组件的弹性 UI。
- 采用 `Promise.race()` 来强制执行时间限制并防止无限期等待。
- 选择 `Promise.any()` 来创建具有冗余数据源的快速且容错的系统。
下次您发现自己连续写下多个 `await` 语句时,请停下来问问自己:“这些操作真的相互依赖吗?”如果答案是否定的,那么您就有一个绝佳的机会来重构您的代码以实现并发。开始将您的 Promise 一起启动,为您的逻辑选择正确的组合器,然后看着您的应用程序性能飙升。