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

浙公网安备 33010602011771号