C++ 休眠线程池:用条件变量做任务调度
本文记录一个同步批量任务执行库中,带休眠的线程池 (Sleeping Thread Pool)实现思路。目标接口很简单:调用方执行 run(runnable, N) 时,用最多若干个 worker 并行跑完 N 个任务,全部完成后 run 才返回。
相比「每次 run 都新建线程」或「没任务时空转等待」,休眠线程池在空闲时用条件变量睡觉,有任务再被唤醒,既复用线程,又避免空转占满 CPU。
1. 要解决什么问题
一次 run(runnable, N) 表示:对 task_id = 0 .. N-1 调用 runnable->runTask(id, N)。
约束:
- 最多使用构造时给定的
num_threads个工作线程 run是同步的:返回时本批任务必须全部结束- 线程应常驻复用,多次
run之间不要反复创建/销毁 - 没有任务时不要忙等(spin)
这本质上就是 生产者-消费者:主线程投放一批任务,worker 领取并执行。
2. 核心状态
用一把互斥锁保护共享状态,再用两个条件变量分别叫醒 worker 和主线程:
| 成员 | 作用 |
|---|---|
threads_ |
常驻 worker |
mtx_ |
保护下面所有共享字段 |
cv_workers_ |
worker 没活时在此等待;有活 / 退出时被唤醒 |
cv_main_ |
主线程在 run 里等待本批完成 |
runnable_ / num_total_tasks_ |
当前批次的任务对象与总数 |
next_task_ |
下一个待领取的 task_id |
tasks_done_ |
已完成数量 |
has_work_ |
当前是否有一批任务在进行 |
stop_ |
析构时通知 worker 退出 |
条件变量的 wait 必须配合 std::unique_lock:进入等待时原子地释放锁并睡眠,被 notify 叫醒后重新持锁,避免「检查条件」和「入睡」之间丢失唤醒。
3. 整体流程
ini
构造:
创建 num_threads 个线程,进入 workerLoop
run(runnable, N):
加锁,设置本批状态(runnable、N、计数清零、has_work=true)
notify_all → 叫醒 worker
wait(cv_main) 直到 tasks_done == N
返回
workerLoop:
没活可领则 wait(cv_workers)
领一个 id,放锁,执行 runTask
再加锁,tasks_done++
若已全部完成 → notify 主线程
继续循环
析构:
stop = true
notify_all 叫醒仍在 wait 的 worker
join 所有线程
4. 构造:线程一开始就在跑
构造时创建常驻线程,并传入成员函数与 this:
css
for (int i = 0; i < num_threads_; i++) {
threads_.emplace_back(
&TaskSystemParallelThreadPoolSleeping::workerLoop, this);
}
此时通常还没有任务,worker 进入循环后会发现没活,在 cv_workers_ 上睡眠。
5. worker:睡到有活,放锁干活
ini
void workerLoop() {
std::unique_lock<std::mutex> lock(mtx_);
while (true) {
while (!stop_ && (!has_work_ || next_task_ >= num_total_tasks_)) {
cv_workers_.wait(lock);
}
if (stop_) {
break;
}
int id = next_task_++;
IRunnable* r = runnable_;
int N = num_total_tasks_;
lock.unlock();
r->runTask(id, N); // 真正耗时的部分,必须无锁
lock.lock();
tasks_done_++;
if (tasks_done_ == num_total_tasks_) {
has_work_ = false;
cv_main_.notify_one();
}
}
}
几点说明:
为什么 wait 要用 while?
防止假唤醒,以及「醒来后任务已被别人领完」的情况,需要重新判断条件。
等待条件为什么包含 next_task_ >= num_total_tasks_?
只看 has_work_ 不够:本批任务可能都已领完,但最后一个还在执行,has_work_ 仍为 true。此时不应再领新 id,应继续睡,否则会执行非法的 task_id。
为什么 runTask 前要 unlock?
领号、改计数必须互斥;执行任务是并行的大头。若持锁跑任务,所有 worker 会串行化,线程池失去意义。
next_task_++ 在解锁之前完成,因此不会出现两个线程领到同一个 id。
谁叫醒主线程?
完成最后一个任务的那个 worker 调用 cv_main_.notify_one()。
6. run:投放任务并同步等待
ini
void run(IRunnable* runnable, int num_total_tasks) {
if (num_total_tasks <= 0) {
return;
}
std::unique_lock<std::mutex> lock(mtx_);
runnable_ = runnable;
num_total_tasks_ = num_total_tasks;
next_task_ = 0;
tasks_done_ = 0;
has_work_ = true;
cv_workers_.notify_all();
while (tasks_done_ != num_total_tasks_) {
cv_main_.wait(lock);
}
}
- 多个字段在同一把锁下一起更新,避免 worker 看到半新半旧的状态
notify_all可以在持锁时调用;worker 醒来后会在 mutex 上短暂排队,主线程进入cv_main_.wait时会释放锁,worker 才能领任务- 主线程用条件变量等待完成,而不是空转检查
tasks_done_
notify_all 会唤醒所有睡觉的 worker,短时间内大家抢锁领号,属于可接受的轻微「惊群」;一批任务往往需要多个 worker 一起干,这比只 notify_one 更合适。
7. 析构:必须叫醒再 join
ini
~TaskSystemParallelThreadPoolSleeping() {
{
std::unique_lock<std::mutex> lock(mtx_);
stop_ = true;
}
cv_workers_.notify_all();
for (auto& t : threads_) {
t.join();
}
}
只设 stop_ = true 而不 notify,睡在 wait 里的线程看不到标志,永远不退出,join 就会一直卡住。
join 本身不会强杀线程,只是阻塞等待线程函数返回;worker 被叫醒、发现 stop_、跳出循环后,join 才会成功。
8. 和另外两种实现的对比(简要)
| 策略 | 特点 |
|---|---|
| Always Spawn | 每次 run 新建线程再 join,实现简单,轻量频繁任务时创建开销大 |
| Spin 线程池 | 线程常驻,没活时空转检查,实现相对简单,空闲时浪费 CPU |
| Sleep 线程池 | 线程常驻 + 条件变量休眠,空闲更省,实现要注意唤醒与谓词 |
计算密集型负载上,后两者通常都能相对串行获得明显加速;大量轻量、频繁 run 时,线程池明显优于每次 Spawn。
9. 调试时容易踩的坑
- 丢唤醒 :
notify发生在wait之前,且之后不再通知 → worker 永久等待。用 gdb:Ctrl-C后thread apply all bt,常见形态是一个线程在condition_variable::wait,另一个在join。 - 完成计数无保护 :多线程同时
tasks_done_++会丢更新,导致run永远等不到结束。 - 跑任务时握锁:正确性可能还在,但性能退化成近似串行。
- 析构忘记
notify_all:进程退出或对象销毁时假死。
条件变量相关状态检查,记住固定写法:while (!条件) cv.wait(lock);。
10. 小结
休眠线程池可以概括为三句话:
- 构造时拉起常驻 worker,空闲时在条件变量上睡
run设置一批任务并notify_all,自己wait到tasks_done == N- worker 持锁领号、放锁执行、再持锁更新完成计数;最后一个完成者叫醒主线程;析构时
stop + notify_all + join
这就是单机场景下最常见的多线程模型之一:线程池 + 生产者-消费者 + mutex/condition_variable。理解这一套,也就理解了多数业务框架背后那层「任务怎么被多个线程执行完」的基本机制。