任务队列与线程池
环境:Windows 11 x64,GCC 13.1.0(MinGW-w64),
g++ -std=c++17 -O2 -Wall -Wextra -static -lpsapi。本机 6 物理核 / 12 逻辑处理器。所有绝对时间都在同期有其他编译/基准任务抢核的条件下测,比值与拐点比绝对值可靠;“1ms 的 CPU 活”是先标定出来的(本机burn(1176470)≈ 1ms),不是假设。
一、无界队列不是”不会满”,是”迟早炸”
线程池最常见的写法是:任务无限往里塞,队列不设上限。它不会阻塞你,只会一直吃内存,直到某个不相干的地方崩掉。
起一个不启动 worker 的池(模拟”生产速度远大于消费速度”最极端的情况),只往队列里堆 std::function<void()>:
堆积 250000 个:排队 250000,用时 9.8 ms,私有提交 0.72 → 9.55 MB(每任务约 37.0 字节)
堆积 500000 个:排队 500000,用时 10.4 ms,私有提交 0.72 → 18.05 MB(每任务约 36.3 字节)
堆积 750000 个:排队 750000,用时 11.3 ms,私有提交 0.72 → 26.18 MB(每任务约 35.6 字节)
堆积 1000000 个:排队 1000000,用时 10.6 ms,私有提交 0.72 → 35.30 MB(每任务约 36.3 字节)
sizeof(std::function<void()>)=32
三条读数:
- 线性、无上限、无警告:100 万个任务 = 35.30MB 私有提交(
PROCESS_MEMORY_COUNTERS_EX.PrivateUsage)。每任务约 36 字节 = 32 字节的std::function本体 + deque 节点开销。任务捕获的变量另算——如果一个任务捕获了一个 1MB 的 vector,1 万个任务就是 10GB,这才是真实事故现场。 - 入队便宜到让人放松警惕:25 万次
post只花 9.8ms(约 39ns/次,就是一次加锁 + push +notify_one)。便宜的入口 + 无界的队列 = 你可以在几秒内把内存吃掉几个 G。 - 入队速度远大于处理能力:消费端一个任务 1ms,生产端 39ns,差 25000 倍。任何”上游是事件流、下游要干活”的结构都是这个比例,队列只是把崩溃延后。
所以第一原则:队列必须有界,满了要体现为可观测的行为。可选的三种反应,语义完全不同:
| 满了之后 | 效果 | 适用 |
|---|---|---|
阻塞生产者(push 等待) | 反压回到上游,端到端不变慢也不丢 | 上游能等(IO 线程、批处理) |
| 丢弃新任务 + 计数/告警 | 保当前负载,牺牲增量 | 采样、指标、可丢的事件 |
| 丢最老的(环形覆盖) | 保证”看到的是最新的” | 状态快照、UI 刷新 |
“丢弃”必须带计数器,否则你和监控都看不到它发生过。
二、线程数:CPU 型和阻塞型是两套规律
200 个各约 1ms 的 CPU 密集任务(burn(REPS_1MS),已标定),只变线程数:
P= 1: 210.6 ms(理想 200 ms)单任务摊 1.05 ms
P= 2: 115.8 ms(理想 100 ms) 0.58 ms
P= 4: 73.0 ms(理想 50 ms) 0.37 ms
P= 6: 49.8 ms(理想 33 ms) 0.25 ms
P= 8: 38.1 ms(理想 25 ms) 0.19 ms ← 实测最优
P=12: 50.9 ms(理想 17 ms) 0.25 ms ← 等于逻辑处理器数,退回去了
P=16: 37.0 ms(理想 12 ms) 0.19 ms
换成 120 个阻塞型任务(每个 Sleep(1),本机一个 tick 约 15ms):
P= 1:1804.7 ms(理想 1800 ms)
P= 2: 905.3 ms(理想 900 ms)
P= 4: 451.2 ms(理想 450 ms)
P= 8: 228.5 ms(理想 225 ms)
P=16: 121.6 ms(理想 112 ms)
同一个池、同一批工作量,两组曲线完全不同:
- CPU 型在 P=8 见顶(38.1ms,只有 5.5 倍,而不是 8 倍),P=12 退回 50.9ms——和《线程与进程模型》里独立测到的”8 线程最优、12 线程变慢”完全吻合。两次实验、不同代码、同一个结论。
- 阻塞型几乎线性到 16(120 个任务 ÷ 16 线程 × 15ms = 112ms,实测 121.6ms,效率 92%)。因为等待不吃核,线程数是”允许多少人同时挂在等待里”,不是”需要多少个核”。
于是”线程池开多大”这个问题必须先分类任务:
CPU 密集:P ≈ 物理核数(本机 6~8),再加线程只会更慢
阻塞型 :P ≈ 需要的并发数,可以远大于核数;用 吞吐 = P / 单任务等待时长 反推
混合型 :按 1/(1 - 阻塞占比) 放大,或者直接拆成两个池(计算池 + IO 池)
不要把 IO 任务丢进 CPU 池。它们挤在同一队列里时,CPU 任务会把 8 个槽占满,IO 任务在队列里等 300ms 才轮到一次 Sleep——这是”线程池明明空闲却在排队”的常见原因。
三、优雅关闭:能不能兑现”一个不丢”
同一个池,提交 2000 个任务,等 5ms(此时大部分还在排队),然后关闭:
优雅关闭:提交 2000,排队未启动 1865,实际执行 2000 个 ⇒ 一个不丢
直接丢队列:排队 1860,丢弃 1860,实际执行 140 个(2000-1860=140 对得上)
两种关闭的差别是数量级的:140 个 vs 2000 个。差别全在实现的那几行:
// 优雅:置停止位,但 worker 必须先把队列清空才退出
void shutdown_graceful() {
{ std::lock_guard<std::mutex> lk(m); stop = true; }
cv.notify_all();
for (auto& t : w) t.join();
}
// worker 侧的关键:stop 时也要先看队列,不能一看到 stop 就走
cv.wait(lk, [&] { return stop || !q.empty(); });
if (q.empty()) return; // 队列空了才收工
job = std::move(q.front()); q.pop_front();
最常见的错误写法是 if (stop) return; 放在取任务之前——于是 stop 一置,队列里剩下的任务永远不会被执行,且没有任何报错。上面的”直接丢队列”那一行就是这个错误的显式版本。
还有第三种更隐蔽的:detach 掉 worker,让主线程自己走完。 4 个 detached worker、400 个排队任务、主线程立即 return,统计进程退出前实际完成任务并打印的行数,连跑三轮:
detached-pool 退出前完成任务数: 0
detached-pool 退出前完成任务数: 0
detached-pool 退出前完成任务数: 0
0/400,三轮都是 0。 因为 main 返回即 ExitProcess,它不等待任何 detached 线程——那 400 个任务连同队列一起被抹掉,无声无息。要用 detached 线程,就必须自己在退出点等一个”全部完成”的信号(std::barrier/计数信号量/condition_variable 等 done 计数),否则等价于丢弃。
关闭协议里还有两个坑,本节实测不到但必须说清:
join()之前不能再post():worker 已经退出,任务永远躺在队列里(我的实现里stop=true后 worker 会陆续退出,此时入队的任务无人取)。关闭顺序必须是”先停止接收 → 再等队列清空 → 最后 join”。- 正在执行的任务不可中断:C++ 没有线程取消。要快停,只能把长任务切成小步 + 每步检查一个
atomic<bool> cancel。
四、负载均衡:动态领任务,但块不能太碎
上一节的 P=12 比 P=8 慢,说明”人多”不等于”均衡”。真正决定均衡的是任务粒度。用一个每个元素代价随数值增长的负载(试除到 sqrt(n)),总工作量恒定 200 万,12 线程:
静态均分 12 段 705.8 ms
动态领块,块数=线程数 635.9 ms
动态领块,块数=120 568.0 ms ← 最好
动态领块,块数=1200 590.7 ms ← 太碎,领取本身的原子开销显现
静态均分把最贵的尾巴交给最后一个线程(前面第一节的 927.5ms 空等就是它),动态领块让快的人多拿几块,于是 705.8 → 635.9ms。但如果一块只有 12 份,“动态”就退化成”发牌顺序不同的静态”(635.9ms,几乎没救回来)。甜点在 10–20 倍线程数(120 块 → 568.0ms,比静态快 19.5%);再碎 10 倍就反弹到 590.7ms,因为每次领任务是一次 fetch_add,1200 次 × 竞争下的原子争抢开始收费。
诚实说明:本节实现的是”单队共享、原子领号”(一个
atomic<int> next),不是真正的 per-thread deque + work stealing。窃取(每个线程有自己的双端队列,空闲时去别人队尾偷一块)我没有在本机实现并测量,所以只给粒度的结论,不给”窃取比领号快多少”的数字。
对线程池的实际含义:别把”N 个任务”当成”均衡”的保证。如果你的任务代价方差大(有的 1ms、有的 200ms),要么切小、要么按优先级/大小分池;否则一个慢任务就能占住一个 worker,效果等同于第一节里那个”最后一个 worker 拖 927.5ms”的场景。
五、写线程池之前先想清楚的六件事
- 队列上限:多大、到顶了怎么办(阻塞/丢弃/覆盖)、丢弃计数放哪。本篇第一小节的 35.30MB/百万任务就是定价依据。
- 关闭协议:
stop → 排空 → join三步;worker 的判断条件必须是stop || !q.empty();detach 的池要么自己等 done 计数,要么承认会丢(实测 0/400)。 - 异常边界:worker 里抛出未捕获异常 =
std::terminate(上一节实测退出码 3)。任务体必须整体包try/catch,异常要么存进future、要么记日志,绝不能让它逃到 worker 循环。 - 任务需要结果吗:需要就用
packaged_task/future,但future.get()会阻塞调用者——UI 线程get()就是自杀;要么异步续接,要么轮询wait_for(0)。 - 要不要分池:计算与阻塞分开(本篇第二小节的曲线形状完全不同,混用只会互相拖累)。
- 可观测性:队列长度、拒绝次数、任务耗时直方图、worker 空闲率。没有这四个数,本篇第二小节那种”加线程反而变慢”的问题你永远只能靠猜。
六、小结
- 无界队列的价格:100 万个
std::function任务 = 35.30MB 私有提交(36 字节/任务),入队只要 39ns,比消费快 25000 倍——它不会给你任何警告,只会延后崩溃。队列必须有界,且满了要有明确语义 + 计数。 - 线程数按任务类型定:CPU 型本机 P=8 最优(38.1ms,5.5x),P=12 退回 50.9ms(与独立实验一致);阻塞型 P=16 只用 121.6ms(效率 92%)。计算池与 IO 池分开。
- 关闭协议决定不丢还是丢 93%:优雅版 2000 个全部执行(其中 1865 个当时还在排队),粗暴版只跑了 140 个;detach 掉 worker 后 400 个任务完成 0 个,三轮皆 0。
- 均衡靠粒度不靠线程数:动态领块在 120 块时最快(568.0ms,比静态均分快 19.5%),块数等于线程数几乎无效(635.9ms),块数 1200 反而回吐(590.7ms)。真正的 work stealing 本机未实测,已在文中注明。