并发控制:pLimit vs pQueue

pLimit vs pQueue

两者都是「并发控制 + 队列」,但 pQueue 是完整版(含优先级 / 暂停 / 超时),pLimit 是精简版(只做并发限流)。


一、对比表

维度 p-limit p-queue
并发限制 ✅ ✅
优先级 ❌ ✅(数字越小越先)
超时自动取消 ❌ ✅(timeout)
暂停 / 恢复 ❌ ✅(pause() / start())
事件订阅 ❌ ✅(next / idle / completed)
取消单个任务 靠 AbortController ✅ 直接通过 signal 或 id
打包体积 ~1KB ~5KB
实现 数组 + 计数器 最小堆(heap)
典型场景 简单批量、面试手写 复杂调度、需要插队

一句话:pQueue = pLimit + 优先级 + 暂停 + 超时。如果不需要这些,选 pLimit(更轻、更易理解)。


二、为什么 pQueue 需要堆

pLimit 的任务全是同优先级 → 用普通数组就够(FIFO 排队):

queue: [t1, t2, t3, t4, t5]
        ↓ 跑完一个 → shift 一个

pQueue 有优先级 → 数组头部插入是 O(n),太慢:

// ❌ 数组头部插入:O(n)
queue.unshift({ priority: 1, fn })

// ✅ 二叉堆:插入 O(log n)
heap.push({ priority: 1, fn })
heap.up()

这就是为什么 pQueue 内部是最小堆(数字越小、堆顶越靠前):

class MinHeap<T> {
  private data: T[] = []
  constructor(private compare: (a: T, b: T) => number) {}

  size() { return this.data.length }

  push(v: T) {
    this.data.push(v)
    this.bubbleUp(this.data.length - 1)
  }

  pop(): T | undefined {
    if (this.data.length === 0) return undefined
    const top = this.data[0]
    const end = this.data.pop()!
    if (this.data.length > 0) {
      this.data[0] = end
      this.sinkDown(0)
    }
    return top
  }

  private bubbleUp(n: number) {
    while (n > 0) {
      const parent = (n - 1) >> 1
      if (this.compare(this.data[n], this.data[parent]) < 0) {
        [this.data[n], this.data[parent]] = [this.data[parent], this.data[n]]
        n = parent
      } else break
    }
  }

  private sinkDown(n: number) {
    const len = this.data.length
    while (true) {
      const l = 2 * n + 1, r = 2 * n + 2
      let smallest = n
      if (l < len && this.compare(this.data[l], this.data[smallest]) < 0) smallest = l
      if (r < len && this.compare(this.data[r], this.data[smallest]) < 0) smallest = r
      if (smallest === n) break
      [this.data[n], this.data[smallest]] = [this.data[smallest], this.data[n]]
      n = smallest
    }
  }
}

p-queue 的核心就是把待执行任务放最小堆,按 priority 排序取出。


三、API 对比

p-limit

import pLimit from 'p-limit'

const limit = pLimit(3)
await Promise.all([
  limit(() => fetchA()),
  limit(() => fetchB()),
])

p-queue

import PQueue from 'p-queue'

const queue = new PQueue({
  concurrency: 3,                 // 并发上限
  timeout: 30_000,                // 单任务超时(到时间自动 abort)
  throwOnTimeout: true,           // 超时是否抛错
  autoStart: true,                // false = add() 后不立即跑,要 queue.start()
})

// 1) 默认加任务
queue.add(() => fetchA())

// 2) 加优先级(数字越小越先执行)
queue.add(() => fetchHigh(),  { priority: 1 })
queue.add(() => fetchLow(),   { priority: 10 })

// 3) 加 signal(取消单个)
const ctrl = new AbortController()
queue.add(() => fetchX(ctrl.signal), { signal: ctrl.signal })

// 4) 事件
queue.on('next', () => console.log('跑完一个'))
queue.on('idle',  () => console.log('全部跑完'))

// 5) 暂停 / 恢复 / 清空
queue.pause()
queue.start()
queue.clear()

四、优先级方向:priority 越小越先

queue.add(() => console.log('A'), { priority: 5 })
queue.add(() => console.log('B'), { priority: 1 })   // B 先
queue.add(() => console.log('C'), { priority: 10 })  // C 最后

这是 p-queue 的约定(与 Linux nice 相反)。记住:小数字 = 高优先级。


五、典型场景选型

场景 选谁 原因
批量导出 100 条 p-limit 任务平等
100 条导出,但用户取消后希望剩余任务立刻停 p-queue clear() 一键清空
列表查询 + 字典预加载(字典插队) p-queue 字典 priority 更高
后台轮询 + 用户主动操作 p-queue 用 pause() 暂停轮询
上传大文件分片 p-limit 任务平等 + 已知并发
用户输入搜索防抖 + 高频请求合并 p-queue + signal AbortController 取消旧请求
面试手写 p-limit 100 行能写完
生产复杂调度 p-queue 踩过坑的成熟库

六、本项目能用在哪

场景 用什么 怎么用
批量导出(10~20 条) p-queue concurrency: 3 + 用户点取消时 queue.clear()
批量上传附件 p-limit 任务平等,固定并发 2~3
详情页同时拉 N 个关联查询 p-queue(或 useQuery 自带) TanStack Query 已经自动并发
字典预加载 p-queue + priority priority: 1,列表数据 priority: 5

注意:用了 TanStack Query 后,很多"批量拉取"的场景不需要手动 p-queue —— 框架按 key 自动并发、自动取消。
真正需要 p-queue 的场景是写操作批量(批量上传、批量删除、批量导出)。


七、用 p-queue 做"可取消的批量导出"

import PQueue from 'p-queue'

const exportQueue = new PQueue({ concurrency: 3, timeout: 30_000 })

async function batchExport(ids: number[], onProgress: (n: number) => void) {
  let done = 0
  const tasks = ids.map(id =>
    exportQueue.add(
      () => exportOne(id).finally(() => {
        onProgress(++done)
      }),
      { priority: 5 },
    ),
  )
  await Promise.allSettled(tasks)
}

// 用户点取消
function cancelExport() {
  exportQueue.clear()           // 排队的清掉
  exportQueue.pause()           // 已在跑的停不下来(需要 task 内部配合 signal)
}

⚠️ clear() 只能清掉"还没跑"的任务。已经在跑的三个,要靠 task 内部检查 signal.aborted 或自己实现"取消标记"。


八、自检题

  1. p-limit 内部用数组就够了;p-queue 为什么要用最小堆?如果 p-limit 强行加 priority,会出什么问题?
  2. p-queue 的 priority: 1 和 priority: 10,谁先执行?方向和 nice 一样吗?
  3. 批量导出 50 个报表,用户点取消按钮:
    • 怎么清掉排队中的任务?
    • 怎么停掉正在跑的 3 个?
    • 如果正在跑的是 fetch 请求,怎么真的中止它?
  4. p-limit 的代码只有 80 行左右,手写过的核心是哪三段(任务入队、启动下一个、避免死锁)?
答案
  1. 数组头部插入是 O(n),每来一个高优先级任务就要把后面所有任务后移,并发大时性能差;堆插入/取出是 O(log n),是经典做法。p-limit 加 priority 也能用数组,但性能上不去,所以 p-queue 选堆。
  2. priority: 1 先执行。和 nice 相反(nice 数字越大越"友好"=越让给其他进程=越低优先)。p-queue 是"游戏里 1 级最高"的直觉。
  3. ① queue.clear();② queue.pause() 不能停已经在跑的,要靠 task 内部检查取消标志;③ AbortController + fetch(url, { signal }) 或 axios({ signal }),真正关闭底层 TCP 连接,浏览器 Network 里会显示 canceled。
  4. ① queue.push(() => runWith(fn, resolve, reject)) + 立刻尝试启动(if (active < concurrency) queue.shift()!());② 任务结束(成功/失败)finally 里 active-- 并启动下一个;③ 必须在 finally 里释放名额,否则失败任务会让池子永久卡死。
posted @ 2026-10-16 15:32  寅信  阅读(12)  评论(0)    收藏  举报