目录
asyncPool ES9 为什么只用 Promise.race 而不用 Promise.all
并发控制简介
并发控制是指在处理多个任务时,限制同时进行的任务数量的一种技术。这在资源有限或需要优化性能的场景中非常重要。在 JavaScript 中,我们可以通过控制异步操作的执行来实现并发控制。
假设有 6 个待办任务要执行,而我们希望限制同时执行的任务个数,即最多只有 2 个任务能同时执行。当正在执行任务列表中的任何一个任务完成后,程序会自动从待办任务列表中获取新的待办任务并把该任务添加到正在执行任务列表中。
在这里推荐一个第三方库来异步任务并发控制:async-pool
async-pool官网介绍:
该库的目标是使用原生异步迭代器(ES9)、原生异步函数和原生 Promise 来实现并发行为(查看我们的源代码)。
如果您需要 ES6 作为基准,请使用我们的1.x版本。
并发控制的实现
下面只解析ES9的实现代码,ES6,ES7的实现代码可参考该文章
asyncPool 的使用
asyncPool(concurrency, iterable, iteratorFn)
是一个用于在有限的并发池中运行多个返回 Promise 的异步函数的工具。它的设计目的是在控制并发数量的同时,尽快处理所有异步操作。
功能
- 控制并发:在指定的并发限制下运行多个异步任务。
- 自动拒绝:一旦其中一个 Promise 被拒绝,它会立即拒绝所有任务。
- 结果迭代:它返回一个异步迭代器,能够在每个 Promise 完成后立即产出结果。
参数
concurrency:
- 类型:数字
- 描述:并发限制数,必须大于或等于 1。决定了最多可以同时运行多少个异步任务。
iterable:
- 类型:可迭代对象(例如 String、Array、TypedArray、Map 和 Set)
- 描述:输入的可迭代对象,将对其中的每个元素应用 iteratorFn。
iteratorFn:
- 类型:函数
- 描述:迭代器函数,接受两个参数:
1、当前迭代的值
2、可迭代对象本身 - 函数返回值:应该返回一个 Promise 或是一个异步函数。
对于以上来说,在使用了 asyncPool 函数之后,对应的执行过程如下所示:
const timeout = ms => new Promise(resolve => setTimeout(() => resolve(ms), ms));
for await (const ms of asyncPool(2, [1000, 5000, 3000, 2000], timeout)) {
console.log(ms);
}
// Call iterator timeout(1000)
// Call iterator timeout(5000)
// Concurrency limit of 2 reached, wait for the quicker one to complete...
// 1000 finishes
// for await...of outputs "1000"
// Call iterator timeout(3000)
// Concurrency limit of 2 reached, wait for the quicker one to complete...
// 3000 finishes
// for await...of outputs "3000"
// Call iterator timeout(2000)
// Itaration is complete, wait until running ones complete...
// 5000 finishes
// for await...of outputs "5000"
// 2000 finishes
// for await...of outputs "2000"
asyncPool ES9 实现
async function* asyncPool(concurrency, iterable, iteratorFn) {
const executing = new Set(); // 用来跟踪当前正在执行的异步操作的集合
async function consume() {
// 等待并返回最先完成的 Promise 的结果值
const [promise, value] = await Promise.race(executing);
executing.delete(promise); // 完成后从执行集合中删除该 Promise
return value; // 返回该 Promise 的结果值
}
for (const item of iterable) {
// 对可迭代对象中的每个元素执行迭代器函数 iteratorFn
// 使用异步函数包装 iteratorFn 以确保得到一个 Promise
const promise = (async () => await iteratorFn(item, iterable))().then(
value => [promise, value] // 将 Promise 及其结果值作为数组返回
);
executing.add(promise); // 将 Promise 添加到执行集合中
// 如果执行集合中的 Promise 数量达到并发限制 concurrency,则暂停迭代并等待一个 Promise 完成
if (executing.size >= concurrency) {
yield await consume(); // 返回一个已完成的 Promise 的结果值
}
}
// 当迭代结束后,继续消费执行集合中的剩余 Promise 的结果值
while (executing.size) {
yield await consume();
}
}
module.exports = asyncPool; // 导出 asyncPool 函数
这是一个用于限制并发执行异步操作的工具函数。它接受三个参数:并发数量(concurrency)、可迭代对象(iterable)和迭代器函数(iteratorFn)。
在这个函数中,我们使用了一个Set来跟踪当前正在执行的异步操作。在consume函数中,我们使用Promise.race来等待任一正在执行的Promise完成,并返回其结果值。
在主循环中,我们遍历可迭代对象,并为每个元素调用iteratorFn。然后以异步函数的形式包装iteratorFn,确保我们得到一个Promise,并将其添加到执行集合中。如果执行集合的大小达到设定的并发数量上限,则暂停迭代,等待其中一个Promise完成并返回其结果值。
最后,在主循环结束后,我们等待剩余的所有Promise完成,并返回它们的结果值。
这个工具函数可以帮助我们控制同时执行的异步操作数量,避免出现过多的并发请求对系统造成压力。
从 1.x 迁移
主要区别:1.x API等待所有承诺完成,然后返回所有结果(见下例)。新 API(得益于异步迭代)允许每个结果在完成后立即返回(见上例)。
您可能更愿意保留 1.x 样式的语法,而不是for await2.x 中的迭代方法。定义如下所示的函数来包装asyncPool,此函数将允许您升级到 2.x,而无需大量修改现有代码。
// 定义一个异步函数 asyncPoolAll,用来执行并发控制的异步任务池,并收集所有结果
async function asyncPoolAll(...args) {
const results = [];
// 使用 for await 循环遍历 asyncPool 生成的异步迭代器,将结果逐一推入 results 数组
for await (const result of asyncPool(...args)) {
results.push(result);
}
// 返回收集到的所有结果
return results;
}
// ES7 语法:使用 async/await 调用 asyncPoolAll 函数并等待其完成
const results = await asyncPoolAll(concurrency, iterable, iteratorFn);
// ES6 语法:使用 Promise 风格调用 asyncPoolAll 函数,并在其完成后处理结果
return asyncPoolAll(2, [1000, 5000, 3000, 2000], timeout).then(results => {
// 在这里处理 results 结果...
});
解释
- asyncPoolAll 函数接收任意数量的参数,并传递给 asyncPool 函数。
- asyncPool 函数返回一个异步迭代器,for await 循环用于异步地迭代这个迭代器,逐个获取结果并将其推入 results 数组中。
- 最终 asyncPoolAll 返回包含所有结果的数组。
注意:
- ES7 语法:使用 await 关键字等待 asyncPoolAll 函数的执行完成,并获取结果。
- ES6 语法:使用 .then 方法处理 asyncPoolAll 返回的 Promise。
asyncPool ES9 为什么只用 Promise.race 而不用 Promise.all
在asyncPool ES9 实现代码片段中,Promise.all 并没有被使用,因为它的场景和 Promise.race 的使用场景不同。为了更好地理解为什么只用 Promise.race 而不是 Promise.all,让我们深入了解一下。
Promise.all的作用
Promise.all 用于当所有给定的 Promise 都完成(或其中一个失败)时,返回一个新的 Promise,这个新的 Promise 的 resolve 值是一个包含所有给定 Promise 结果的数组。如果任何一个 Promise 失败,Promise.all 会立即拒绝,并抛出那个失败的原因。
Promise.race的作用
Promise.race 返回一个 Promise,一旦某个给定的 Promise 完成或失败,它就会完成或失败。也就是说,Promise.race 会返回第一个完成的 Promise 的结果或错误。
为什么这里没有使用Promise.all
在这个特定的代码片段中,我们关注的是并发控制和逐步消费异步任务的结果,而不是等待所有任务都完成。
使用Promise.race的原因
- 并发控制: asyncPool 函数通过控制并发数量,确保同时只有有限数量的任务在运行。这是通过 executing.size >= concurrency 和 Promise.race 来实现的。
- 逐步处理结果: 我们需要逐步获取每个任务的结果,而不是一次性等待所有任务完成。这是因为我们希望能够在一些任务完成后立即处理其结果,而不是等到所有任务都完成。
- 减少内存消耗: 如果使用 Promise.all,将需要等待所有任务完成后才能处理所有结果,这可能会导致在处理大数据集或长时间运行的异步任务时占用大量内存。
async function* asyncPool(concurrency, iterable, iteratorFn) {
const executing = new Set(); // 用来跟踪当前正在执行的异步操作的集合
async function consume() {
// 等待并返回最先完成的 Promise 的结果值
const [promise, value] = await Promise.race(executing);
executing.delete(promise); // 完成后从执行集合中删除该 Promise
return value; // 返回该 Promise 的结果值
}
for (const item of iterable) {
// 对可迭代对象中的每个元素执行迭代器函数 iteratorFn
// 使用异步函数包装 iteratorFn 以确保得到一个 Promise
const promise = (async () => await iteratorFn(item, iterable))().then(
value => [promise, value] // 将 Promise 及其结果值作为数组返回
);
executing.add(promise); // 将 Promise 添加到执行集合中
// 如果执行集合中的 Promise 数量达到并发限制 concurrency,则暂停迭代并等待一个 Promise 完成
if (executing.size >= concurrency) {
yield await consume(); // 返回一个已完成的 Promise 的结果值
}
}
// 当迭代结束后,继续消费执行集合中的剩余 Promise 的结果值
while (executing.size) {
yield await consume();
}
}
总结
- 并发控制: Promise.race 用于确保我们可以在达到并发限制时暂停新任务的启动,并等待现有任务的完成。
- 逐步处理: 每次从 Promise.race 中获得一个完成的任务结果,然后处理下一个任务,从而实现了逐步处理而不是一次性等待所有任务完成。
这就是为什么在此实现中没有使用 Promise.all,而是选择了 Promise.race。
手写Promise.all和Promise.race
在 asyncPool 这个库的 ES7 和 ES6 的具体实现中,都使用到了 Promise.all 和 Promise.race 函数。其中手写 Promise.all 是一道常见的面试题。
手写Promise.all
Promise.all 是一个 Promise 静态方法,它接收一个 iterable 对象(例如数组),其中的每个元素都是一个 Promise。只有当所有的 Promise 都 resolve 时,返回的 Promise 才会 resolve;如果任何一个 Promise reject,返回的 Promise 也会立即 reject,并且返回第一个 reject 的原因。
/**
* 模拟 Promise.all 函数,等待数组中的所有承诺都解决后,才解决返回的承诺。
* @param {Array} promises 承诺的数组。
* @returns {Promise} 一个新的承诺,当数组中的所有承诺都解决时,该承诺将被解决。
*/
function PromiseAll(promises) {
return new Promise((resolve, reject) => {
// 检查输入是否为数组,如果不是,则抛出类型错误
if (!Array.isArray(promises)) {
throw new TypeError('参数必须是数组');
}
// 用于存储已解决承诺的结果的数组
const results = [];
// 计数器,追踪已完成承诺的数量
let completedCount = 0;
// 遍历承诺数组
promises.forEach((p, index) => {
// 确保输入为承诺,并处理已解决的值
Promise.resolve(p).then(value => {
results[index] = value;
// 增加已完成计数
completedCount++;
// 如果所有承诺都已完成,解决新承诺
if (completedCount === promises.length) {
resolve(results);
}
}, reject); // 将拒绝原因传递给外层承诺
});
// 如果承诺数组为空,立即解决新承诺
if (promises.length === 0) {
resolve([]);
}
});
}
手写Promise.race
Promise.race 同样是一个 Promise 静态方法,它也接收一个 iterable 对象,其中包含多个 Promise。这个方法会返回一个新的 Promise,该 Promise 在 iterable 中的任意一个 Promise 解析或拒绝后,会立刻以相同的状态解析或拒绝。
/**
* 实现了一个Promise竞赛功能,即从多个Promise中选择第一个解决(resolve)或拒绝(reject)的Promise。
* @param {Array} promises - 一个包含多个Promise对象的数组。
* @returns {Promise} - 返回一个新的Promise对象,该对象将跟随第一个解决或拒绝的Promise的结果。
*/
function PromiseRace(promises) {
// 返回一个新的Promise对象
return new Promise((resolve, reject) => {
// 检查传入的参数是否为数组
if (!Array.isArray(promises)) {
// 如果不是数组,则抛出类型错误
throw new TypeError('Argument must be an array');
}
// 遍历传入的Promise数组
promises.forEach(promise => {
// 将每个Promise对象转换为确定的状态(已解决或已拒绝),然后绑定解决和拒绝函数
Promise.resolve(promise).then(resolve, reject);
});
});
}
Promise.allSettled
Promise.allSettled 提供了一种解决 Promise.all 存在的问题的方法。为了更好地理解这一点,让我们先讨论一下 Promise.all 的工作机制和它的局限性,然后详细说明 Promise.allSettled 如何解决这些问题。
Promise.all 的工作机制
Promise.all 接受一个包含多个 Promise 的可迭代对象(通常是一个数组),并返回一个新的 Promise。这个新的 Promise 在所有传入的 Promise 都成功(即全部 fulfilled)时完成,并以一个数组形式返回每个 Promise 的值。如果其中任何一个 Promise 失败(rejected),则整个 Promise.all 调用也会立即失败,并返回第一个被拒绝的 Promise 的原因。
示例:
// 定义一个Promise对象,解析值为3
const promise1 = Promise.resolve(3);
// 定义一个Promise对象,解析值为42
const promise2 = 42;
// 定义一个Promise对象,在100毫秒后解析值为"foo"
const promise3 = new Promise((resolve, reject) => {
setTimeout(resolve, 100, 'foo');
});
// 使用Promise.all方法,等待所有Promise对象解析
Promise.all([promise1, promise2, promise3]).then(values => {
console.log(values); // [3, 42, "foo"]
}).catch(error => {
console.error(error);
});
Promise.all 的局限性
- 单点故障: 如果传入的 Promise 中有一个被拒绝(rejected),Promise.all 会立即拒绝,而不等待其他 Promise 完成。这在某些情况下可能不是期望的行为,尤其是当开发者希望了解所有 Promise 的结果时。
- 错误处理复杂: 由于 Promise.all 在遇到第一个拒绝时就会停止,开发者需要额外的逻辑来处理和调试哪些 Promise 失败了,以及它们失败的原因。
Promise.allSettled 的工作机制
Promise.allSettled 解决了上述问题。它也是接受一个包含多个 Promise 的可迭代对象,并返回一个新的 Promise。然而,与 Promise.all 不同的是,Promise.allSettled 不管传入的 Promise 是成功还是失败,都会等待所有 Promise 完成,然后返回一个数组,每个元素都是一个对象,表示对应 Promise 的结果。
示例:
// 创建一个 Promise,状态为已解析,值为 3
const promise1 = Promise.resolve(3);
// 创建一个 Promise,状态为已拒绝,延迟 100 毫秒后,值为 'error'
const promise2 = new Promise((resolve, reject) => {
setTimeout(reject, 100, 'error');
});
// 创建一个 Promise,状态为已解析,延迟 200 毫秒后,值为 'foo'
const promise3 = new Promise((resolve) => {
setTimeout(resolve, 200, 'foo');
});
// 等待所有 Promise 状态被设置为已解决的或已拒绝
Promise.allSettled([promise1, promise2, promise3]).then(results => {
// 遍历结果数组
results.forEach((result) => console.log(result));
/*
{ status: 'fulfilled', value: 3 }
{ status: 'rejected', reason: 'error' }
{ status: 'fulfilled', value: 'foo' }
*/
});
主要区别和优势
- 不会提前终止: Promise.allSettled 会等待所有 Promise 都完成,无论它们是成功还是失败。这样可以确保开发者能够获得每个 Promise 的完整结果,而不会因为其中一个失败而丢失其他 Promise 的信息。
- 状态信息: 返回的结果数组中,每个对象都包含 status 属性,可以告诉开发者该 Promise 是 fulfilled 还是 rejected,以及相应的 value 或 reason。这使得错误处理更加直观和简单。
- 容错能力: 由于它不会因为单个 Promise 的失败而拒绝整个调用,所以在需要处理多个独立的异步操作时特别有用。例如,如果开发者需要同时发送多个网络请求,即便其中一些失败,开发者仍然希望知道哪些请求成功,哪些请求失败。
总结
Promise.allSettled 提供了一种更为健壮的方法来处理多个 Promise,特别是在你需要跟踪所有结果而不仅仅是成功的结果时。通过等待所有 Promise 完成并报告每个 Promise 的状态和结果,它极大地简化了错误处理和调试过程。