需求分析
我们需要一个函数,它能:
- 接收一个异步任务数组。每个任务都是一个函数,调用后返回一个 Promise。
- 接收一个并发限制数
limit
。 - 确保同时运行的异步任务数量不超过
limit
。 - 当一个任务完成(无论是成功还是失败)后,立即启动队列中的下一个任务,直到所有任务都处理完毕。
- 返回一个 Promise,该 Promise 在所有任务都完成后解决(resolve),并返回所有任务的结果数组,结果顺序与输入任务数组的顺序一致。如果任何一个任务失败,最终返回的 Promise 也会被拒绝(reject)。
设计思路
我们将采用以下策略来实现:
-
主 Promise: 整个并发控制函数将返回一个 Promise,用于表示所有任务的最终完成状态。
-
结果存储: 使用一个数组
results
来存储每个任务执行后返回的 Promise。这样,即使任务完成的顺序不同,我们也能通过Promise.all(results)
来保证最终结果的顺序与输入任务的顺序一致。 -
任务计数器:
runningCount
: 记录当前正在运行的任务数量。taskIndex
: 记录下一个待启动的任务在tasks
数组中的索引。
-
核心调度函数
startNextTask()
:-
这个函数是实现并发控制的关键。它会检查:
- 是否有待启动的任务 (
taskIndex < tasks.length
)。 - 当前运行的任务数量是否小于并发限制 (
runningCount < limit
)。
- 是否有待启动的任务 (
-
如果满足条件,它就启动一个新任务,增加
runningCount
,并将任务执行后返回的 Promise 存储到results
数组中。 -
当一个任务完成时(无论成功或失败),它会减少
runningCount
,并再次调用startNextTask()
,以便立即启动下一个任务。 -
当所有任务都被启动并且所有正在运行的任务都已完成时,表示整个过程结束,此时调用主 Promise 的
resolve
或reject
。
-
代码实现
JS
/**
* 异步任务并发控制函数
* @param {Array<Function>} tasks - 异步任务数组,每个任务都是一个返回 Promise 的函数。
* @param {number} limit - 并发限制数。
* @returns {Promise<Array<any>>} - 返回一个 Promise,当所有任务完成时解决,并返回所有任务的结果数组,顺序与输入任务一致。如果任何任务失败,则该 Promise 会被拒绝。
*/
function concurrentRunner(tasks, limit) {
// 存储每个任务执行后返回的 Promise,用于保证结果顺序
const results = [];
// 记录当前正在运行的任务数量
let runningCount = 0;
// 记录下一个待启动的任务在 tasks 数组中的索引
let taskIndex = 0;
return new Promise((resolve, reject) => {
/**
* 启动下一个可用任务的调度函数
*/
const startNextTask = () => {
// 1. 检查是否所有任务都已启动并且所有正在运行的任务都已完成
if (taskIndex === tasks.length && runningCount === 0) {
// 如果满足条件,说明所有任务都已处理完毕
// 使用 Promise.all 来等待所有任务的 Promise 都解决
// Promise.all 会保证结果顺序,并且如果其中任何一个 Promise 被拒绝,它就会立即拒绝
Promise.all(results)
.then(resolve) // 所有任务成功,解决主 Promise
.catch(reject); // 某个任务失败,拒绝主 Promise
return; // 结束调度
}
// 2. 循环尝试启动新任务,直到达到并发限制或没有更多任务
while (taskIndex < tasks.length && runningCount < limit) {
// 获取当前任务在原始数组中的索引,用于存储结果
const currentTaskOriginalIndex = taskIndex;
// 获取下一个待启动的任务函数
const task = tasks[taskIndex];
// 移动到下一个任务的索引
taskIndex++;
// 增加正在运行的任务计数
runningCount++;
// 执行当前任务函数,它应该返回一个 Promise
// 将这个 Promise 存储到 results 数组的对应位置
results[currentTaskOriginalIndex] = task()
.then(res => {
// 任务成功完成,减少正在运行的任务计数
runningCount--;
// 立即尝试启动下一个任务
startNextTask();
// 返回任务的结果,供 Promise.all 收集
return res;
})
.catch(err => {
// 任务失败,减少正在运行的任务计数
runningCount--;
// 立即尝试启动下一个任务
startNextTask();
// 抛出错误,以便 Promise.all 能够捕获并拒绝主 Promise
throw err;
});
}
};
// 初始调用调度函数,启动第一批任务,填满并发槽位
startNextTask();
});
}
// --- 示例用法 ---
// 模拟一个异步任务,它返回一个 Promise
// 参数 ms: 模拟任务执行的时间(毫秒)
// 参数 value: 任务成功时返回的值
// 参数 shouldFail: 任务是否应该失败
const createAsyncTask = (id, ms, value, shouldFail = false) => {
return () => {
console.log(`任务 ${id}: 开始执行 (预计 ${ms}ms)`);
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldFail) {
console.error(`任务 ${id}: 执行失败`);
reject(new Error(`任务 ${id} 失败`));
} else {
console.log(`任务 ${id}: 执行完成 (值: ${value})`);
resolve(value);
}
}, ms);
});
};
};
// 定义任务数组
const myTasks = [
createAsyncTask(1, 1000, 'Result 1'),
createAsyncTask(2, 2000, 'Result 2'),
createAsyncTask(3, 500, 'Result 3'),
createAsyncTask(4, 1500, 'Result 4'),
createAsyncTask(5, 800, 'Result 5'),
createAsyncTask(6, 2500, 'Result 6'),
createAsyncTask(7, 300, 'Result 7', true), // 模拟一个失败的任务
createAsyncTask(8, 1200, 'Result 8'),
createAsyncTask(9, 700, 'Result 9'),
createAsyncTask(10, 100, 'Result 10'),
];
// 设置并发限制
const concurrencyLimit = 3;
console.log(`--- 开始并发任务,限制为 ${concurrencyLimit} ---`);
concurrentRunner(myTasks, concurrencyLimit)
.then(results => {
console.log('--- 所有任务成功完成 ---');
console.log('最终结果:', results);
})
.catch(error => {
console.error('--- 至少一个任务失败 ---');
console.error('错误信息:', error.message);
});
/*
预期输出示例(顺序可能因 setTimeout 实际执行时间略有不同,但并发和结果顺序会保持一致):
--- 开始并发任务,限制为 3 ---
任务 1: 开始执行 (预计 1000ms)
任务 2: 开始执行 (预计 2000ms)
任务 3: 开始执行 (预计 500ms)
任务 3: 执行完成 (值: Result 3)
任务 4: 开始执行 (预计 1500ms)
任务 1: 执行完成 (值: Result 1)
任务 5: 开始执行 (预计 800ms)
任务 5: 执行完成 (值: Result 5)
任务 7: 开始执行 (预计 300ms)
任务 7: 执行失败
--- 至少一个任务失败 ---
错误信息: 任务 7 失败
// 即使任务 7 失败,其他任务也会继续执行直到完成,但主 Promise 会立即拒绝
任务 4: 执行完成 (值: Result 4)
任务 6: 开始执行 (预计 2500ms)
任务 2: 执行完成 (值: Result 2)
任务 8: 开始执行 (预计 1200ms)
任务 9: 开始执行 (预计 700ms)
任务 10: 开始执行 (预计 100ms)
任务 10: 执行完成 (值: Result 10)
任务 9: 执行完成 (值: Result 9)
任务 8: 执行完成 (值: Result 8)
任务 6: 执行完成 (值: Result 6)
*/
代码讲解
-
concurrentRunner(tasks, limit)
函数定义:- 接收
tasks
(任务数组) 和limit
(并发限制数)。 - 返回一个
Promise
,这是整个并发控制流程的最终结果。
- 接收
-
results = []
:- 这是一个空数组,用于存储每个异步任务执行后返回的 Promise。
- 关键作用: 确保最终收集到的结果顺序与
tasks
数组的原始顺序一致。因为Promise.all()
会按照其接收到的 Promise 数组的顺序返回结果。
-
runningCount = 0
:- 计数器,表示当前有多少个任务正在运行中。
-
taskIndex = 0
:- 指针,表示
tasks
数组中下一个可以被启动的任务的索引。
- 指针,表示
-
new Promise((resolve, reject) => { ... })
:- 这是
concurrentRunner
函数返回的顶层 Promise。 resolve
和reject
函数将用于控制这个顶层 Promise 的状态。
- 这是
-
startNextTask()
函数:-
这是实现并发控制的核心调度逻辑。
-
if (taskIndex === tasks.length && runningCount === 0)
:-
这是终止条件。它检查两个条件:
taskIndex === tasks.length
: 表示tasks
数组中的所有任务都已经被"拿出来"并启动了(或者正在等待启动)。runningCount === 0
: 表示所有已经启动的任务都已完成,没有任务在运行了。
-
当这两个条件都满足时,说明整个并发控制流程已经结束。
-
Promise.all(results).then(resolve).catch(reject);
:- 此时,
results
数组中包含了所有任务执行后返回的 Promise。 Promise.all()
会等待results
数组中的所有 Promise 都解决。- 如果所有任务都成功,
Promise.all()
会将所有结果按顺序组成一个数组,并传递给外部的resolve
。 - 如果
results
数组中有一个 Promise 被拒绝,Promise.all()
会立即拒绝,并将第一个拒绝的错误传递给外部的reject
。 - 这是确保结果顺序和错误处理的关键步骤。
- 此时,
-
-
while (taskIndex < tasks.length && runningCount < limit)
:- 这是一个循环,用于启动新任务。
taskIndex < tasks.length
: 确保还有未启动的任务。runningCount < limit
: 确保当前运行的任务数量没有超过限制。- 只要这两个条件都满足,就继续从
tasks
数组中取出任务并启动。
-
任务启动逻辑:
-
const currentTaskOriginalIndex = taskIndex;
: 记录当前任务在原始数组中的位置,以便在results
数组中正确存放其 Promise。 -
const task = tasks[taskIndex];
: 获取任务函数。 -
taskIndex++;
: 任务已取出,taskIndex
前进一位。 -
runningCount++;
: 增加正在运行的任务计数。 -
results[currentTaskOriginalIndex] = task().then(...)
:-
调用
task()
执行异步任务,它会返回一个 Promise。 -
这个 Promise 被赋值给
results
数组的对应位置。 -
.then(res => { ... })
:当task()
返回的 Promise 成功解决时执行。runningCount--;
: 减少正在运行的任务计数。startNextTask();
: 核心! 立即再次调用startNextTask()
,这样一旦有任务完成,就会立刻尝试启动下一个任务,保持并发数。return res;
: 返回任务的实际结果,供最终的Promise.all
收集。
-
.catch(err => { ... })
:当task()
返回的 Promise 失败时执行。runningCount--;
: 减少正在运行的任务计数。startNextTask();
: 核心! 同样立即尝试启动下一个任务,即使前一个失败了,也要继续调度。throw err;
: 关键! 重新抛出错误。这个错误会被results[currentTaskOriginalIndex]
这个 Promise 捕获,并导致它被拒绝。最终,Promise.all(results)
会捕获到这个拒绝,从而拒绝整个concurrentRunner
返回的 Promise。
-
-
-
-
startNextTask();
(初始调用):- 在
Promise
构造函数内部,立即调用一次startNextTask()
。 - 这会启动第一批任务,填满最初的并发槽位(最多
limit
个任务)。后续的任务调度都由任务完成后的回调触发。
- 在
这个函数通过巧妙地结合 Promise 的链式调用、计数器和递归调度,实现了高效且可靠的异步任务并发控制。