前端 异步任务并发控制

需求分析

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

  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 的链式调用、计数器和递归调度,实现了高效且可靠的异步任务并发控制。

相关推荐
Wiktok1 小时前
pureadmin的动态路由和静态路由
前端·vue3·pureadmin
devii661 小时前
html.
前端
掘金安东尼1 小时前
为什么浏览器要限制 JavaScript 定时器?
前端·javascript·github
学前端搞口饭吃1 小时前
react context如何使用
前端·javascript·react.js
GDAL1 小时前
为什么Cesium不使用vue或者react,而是 保留 Knockout
前端·vue.js·react.js
IT_陈寒1 小时前
《Java 21新特性实战:5个必学的性能优化技巧让你的应用快30%》
前端·人工智能·后端
小谭鸡米花2 小时前
uni小程序中使用Echarts图表
前端·小程序·echarts
芜青2 小时前
【Vue2手录11】Vue脚手架(@vue_cli)详解(环境搭建+项目开发示例)
前端·javascript·vue.js
a别念m2 小时前
前端架构-CSR、SSR 和 SSG
前端·架构·前端框架
BillKu7 小时前
Vue3 + Element-Plus 抽屉关闭按钮居中
前端·javascript·vue.js