linux下的多线程基础

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. 调试时容易踩的坑

  1. 丢唤醒notify 发生在 wait 之前,且之后不再通知 → worker 永久等待。用 gdb:Ctrl-Cthread apply all bt,常见形态是一个线程在 condition_variable::wait,另一个在 join
  2. 完成计数无保护 :多线程同时 tasks_done_++ 会丢更新,导致 run 永远等不到结束。
  3. 跑任务时握锁:正确性可能还在,但性能退化成近似串行。
  4. 析构忘记 notify_all:进程退出或对象销毁时假死。

条件变量相关状态检查,记住固定写法:while (!条件) cv.wait(lock);


10. 小结

休眠线程池可以概括为三句话:

  1. 构造时拉起常驻 worker,空闲时在条件变量上睡
  2. run 设置一批任务并 notify_all,自己 waittasks_done == N
  3. worker 持锁领号、放锁执行、再持锁更新完成计数;最后一个完成者叫醒主线程;析构时 stop + notify_all + join

这就是单机场景下最常见的多线程模型之一:线程池 + 生产者-消费者 + mutex/condition_variable。理解这一套,也就理解了多数业务框架背后那层「任务怎么被多个线程执行完」的基本机制。

相关推荐
IT_陈寒16 小时前
Vite静态资源路径这个坑差点让我加班到凌晨
前端·人工智能·后端
神经蛙199616 小时前
🌍 别再硬编码中文了!Python Web 项目国际化(i18n)完全指南
后端·python
二月龙16 小时前
Spring 事务失效的 8 种场景,很多老手依然频繁踩雷
后端
掘金酱16 小时前
「TRAE Work 实战帮」征文启动!你沉淀的经验,值得被看见!
前端·人工智能·后端
长大198816 小时前
MyBatis 常见性能陷阱:N+1 查询、一级缓存踩坑解决方案
后端
用户18615580086016 小时前
MinIO Java 对接试用:从连接、上传到下载的完整示例
后端
爱勇宝16 小时前
DeepSeek V4-Flash 更新:代码与 Agent 能力全面增强
前端·后端·deepseek
极客悟道16 小时前
SDKMAN vs jEnv vs JetTUI,JDK 版本管理到底选哪个
后端
长大198816 小时前
Java8 新特性到底要不要吃透?工作中高频使用的 5 个功能总结
后端