前端 异步任务并发控制

需求分析

我们需要一个函数,它能:

  1. 接收一个异步任务数组。每个任务都是一个函数,调用后返回一个 Promise。
  2. 接收一个并发限制数 limit
  3. 确保同时运行的异步任务数量不超过 limit
  4. 当一个任务完成(无论是成功还是失败)后,立即启动队列中的下一个任务,直到所有任务都处理完毕。
  5. 返回一个 Promise,该 Promise 在所有任务都完成后解决(resolve),并返回所有任务的结果数组,结果顺序与输入任务数组的顺序一致。如果任何一个任务失败,最终返回的 Promise 也会被拒绝(reject)。

设计思路

我们将采用以下策略来实现:

  1. 主 Promise: 整个并发控制函数将返回一个 Promise,用于表示所有任务的最终完成状态。

  2. 结果存储: 使用一个数组 results 来存储每个任务执行后返回的 Promise。这样,即使任务完成的顺序不同,我们也能通过 Promise.all(results) 来保证最终结果的顺序与输入任务的顺序一致。

  3. 任务计数器:

    • runningCount: 记录当前正在运行的任务数量。
    • taskIndex: 记录下一个待启动的任务在 tasks 数组中的索引。
  4. 核心调度函数 startNextTask()

    • 这个函数是实现并发控制的关键。它会检查:

      • 是否有待启动的任务 (taskIndex < tasks.length)。
      • 当前运行的任务数量是否小于并发限制 (runningCount < limit)。
    • 如果满足条件,它就启动一个新任务,增加 runningCount,并将任务执行后返回的 Promise 存储到 results 数组中。

    • 当一个任务完成时(无论成功或失败),它会减少 runningCount,并再次调用 startNextTask() ,以便立即启动下一个任务。

    • 当所有任务都被启动并且所有正在运行的任务都已完成时,表示整个过程结束,此时调用主 Promise 的 resolvereject

代码实现

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)
*/

代码讲解

  1. concurrentRunner(tasks, limit) 函数定义:

    • 接收 tasks (任务数组) 和 limit (并发限制数)。
    • 返回一个 Promise,这是整个并发控制流程的最终结果。
  2. results = []

    • 这是一个空数组,用于存储每个异步任务执行后返回的 Promise。
    • 关键作用: 确保最终收集到的结果顺序与 tasks 数组的原始顺序一致。因为 Promise.all() 会按照其接收到的 Promise 数组的顺序返回结果。
  3. runningCount = 0

    • 计数器,表示当前有多少个任务正在运行中。
  4. taskIndex = 0

    • 指针,表示 tasks 数组中下一个可以被启动的任务的索引。
  5. new Promise((resolve, reject) => { ... })

    • 这是 concurrentRunner 函数返回的顶层 Promise。
    • resolvereject 函数将用于控制这个顶层 Promise 的状态。
  6. 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。
  7. startNextTask(); (初始调用):

    • Promise 构造函数内部,立即调用一次 startNextTask()
    • 这会启动第一批任务,填满最初的并发槽位(最多 limit 个任务)。后续的任务调度都由任务完成后的回调触发。

这个函数通过巧妙地结合 Promise 的链式调用、计数器和递归调度,实现了高效且可靠的异步任务并发控制。

相关推荐
Angel_girl31919 分钟前
vue项目使用svg图标
前端·vue.js
難釋懷24 分钟前
vue 项目中常用的 2 个 Ajax 库
前端·vue.js·ajax
Qian Xiaoo25 分钟前
Ajax入门
前端·ajax·okhttp
爱生活的苏苏1 小时前
vue生成二维码图片+文字说明
前端·vue.js
拉不动的猪1 小时前
安卓和ios小程序开发中的兼容性问题举例
前端·javascript·面试
炫彩@之星1 小时前
Chrome书签的导出与导入:步骤图
前端·chrome
贩卖纯净水.1 小时前
浏览器兼容-polyfill-本地服务-优化
开发语言·前端·javascript
前端百草阁1 小时前
从npm库 Vue 组件到独立SDK:打包与 CDN 引入的最佳实践
前端·vue.js·npm
夏日米米茶1 小时前
Windows系统下npm报错node-gyp configure got “gyp ERR“解决方法
前端·windows·npm
且白2 小时前
vsCode使用本地低版本node启动配置文件
前端·vue.js·vscode·编辑器