Day4:我第一次从零写线程池——从乱成一锅粥到真正理解它的运转方式**

19 阅读8分钟

今天是我并发手撕题练习的 Day4。
前 3 天分别做了:

  • Day1:奇偶打印
  • Day2:生产者–消费者
  • Day3:简易读写锁

到了今天,我决定挑战一个更“像实际工程”的并发组件:线程池(ThreadPool)

但和 Day1~Day3 那种“小功能小练习”不同,线程池是一个真正的“并发系统”——里面有工人线程、有任务队列、有状态、有调度、有关闭流程。
我以为自己写得出,结果第一版代码完全写歪…甚至连线程入口都写错了。

这篇 Day4 的文章,就是记录我从混乱 → 理解 → 重构 → 搞懂线程池本质的整个过程。


一、我开始写第一版线程池时,其实根本不知道线程池在干嘛

我第一版 ThreadPool 的代码文件里有不少这样的注释:

“线程池是做什么的?”
“构造函数里是不是要 join?”
“worker 函数怎么写?需要 while(true) 吗?”
“ThreadPool::Work 和 Work 有什么区别?”

其实那时候的我连线程池的基本工作方式都还没成型,只是把结构照网上抄了一点,然后开始乱填。

下面我先复盘一下我当时对线程池的理解。


1.1 第一版 ThreadPool 的结构(你能明显看出我没搞懂)

我的第一版代码大概长这样:

  • 我定义了 workers_(工人线程组)
  • 定义了 tasks_(任务队列)
  • 写了一个 Enqueue()
  • 写了一个空的构造函数和析构函数
  • 甚至连 worker 应该怎么运转都还不知道

当时我心里对线程池的理解是模糊的:

“是不是只要有一个队列,然后线程在这里面取任务,就好了?”

实际上,线程池是一个完整的系统,不是简单“有队列就能跑”。

但第一版写出来之后,我马上遇到了一堆我自己都说不清的问题。


二、我第一次疑惑:线程池到底是个什么模型?

我问的第一个问题是:

线程池到底是什么?它长什么样?它在干嘛?流程是什么?

然后我才第一次真正补上一张“线程池心智模型图”:

线程池的核心组成:

  1. 一组“工人线程”(固定数量,不断循环执行任务)
  2. 一个“任务队列”(外界把任务丢进来)
  3. 一个大锁(保护队列和 stop 状态)
  4. 一个条件变量(让工人睡觉/醒来)
  5. 一个 stop 标志(告诉大家:今天收工了!)

线程池的生命周期:

  • 构造:启动工人线程,让他们在 while(true) 里等任务
  • 运行:任务入队 → 唤醒工人 → 工人取任务执行 → 再睡
  • 关闭:设置 stop → 唤醒所有工人 → 等他们把活干完 → join 退出

当我第一次真正明白“线程池不是一次执行、而是工人循环复用”的时候,我整个心思就清晰了。

于是我回去改了第二版。


三、第二版:我开始写 worker 循环,但写着写着又暴露问题了

第二版里,我第一次尝试写出 worker 的主循环 Work()

while (true) {
    wait直到队列有任务;
    取任务;
    执行任务;
}

但我马上遇到三个问题:


3.1 我问:构造函数里需不需要 join?

我第一直觉是:

ThreadPool::ThreadPool() {
    // 启动线程
    for (...) workers_.emplace_back(...);

    for (...) workers_[i].join();   // ❌ 我一度以为要在构造里 join
}

后来我被提醒,这会直接导致:

构造函数永远出不来
→ 析构永远不会调用
→ stop 永远不会设置
→ worker 永远不会退出
→ 整个程序直接死锁

当时我才真正意识到:

join 必须放在析构里,而不是构造里。

这个错误我会永远记住。


3.2 我问:为什么 worker 作为线程入口必须写 ThreadPool::Work?

我起初写的是:

workers_.emplace_back(Work, i);

编译器直接报错。

这让我懵了:

“Work 是类里的函数,构造和 Work 都是 ThreadPool 的成员,我写 Work 不行吗?”

后来我才真正理解:

成员函数和普通函数完全不同。

  • 普通函数:void Foo(int)
  • 成员函数:void ThreadPool::Work(int) 其实是 void (ThreadPool::*)(int)

也就是说:

成员函数必须附着一个对象(this)才能调用。

因此线程启动必须写成:

workers_.emplace_back(ThreadPool::Work, this, i);

因为 thread 默认不知道 Work 要在哪个对象上执行。

这个知识点是我第一次在 Day4 真正“完全理解”的。


3.3 我问:wait 里的谓词该怎么写?我一直写错

我第二版写的是这样的谓词:

cv_.wait(lock, [this] { return !tasks_.empty() && !stop_; });

看起来逻辑上很合理,但它会导致:

  • stop = true 后
  • 谓词永远为 false
  • worker 被 notify_all 唤醒后,又继续 wait → 程序永远无法退出

最终我才理解:

worker 要醒的条件是:

  • 有任务(继续干活)
  • 或者收到 stop(该收工了)

因此正确谓词必须是:

return !tasks_.empty() || stop_;

四、第三版:我终于写出了正确的退出逻辑,但还有两点没搞懂

第三版整体已经“跑得起来”了,但我又问了两个“更深层”的问题。


4.1 为什么取任务必须在锁下,但执行任务必须在锁外?

我当时写的是:

fn = tasks_.front();
tasks_.pop();
fn();  // 我一开始在锁下执行

但这其实会导致:

  • 任务执行期间占着互斥锁
  • 队列完全堵死
  • 其它 worker 看不到任务,也不能入队
  • 性能直接炸裂

正确方式是:

fn = tasks_.front();
tasks_.pop();
lock.unlock();  // 🔥 重要!
fn();

也就是说:

锁只保护队列,不保护任务执行本身。

这个点是我在 Day4 里领悟到的关键概念。


4.2 stop_ 什么时候读?什么时候写?为什么必须加锁?

我起初以为:

if (stop_) break;

不需要加锁。

但后来我理解:

  • stop_ 在析构中写入
  • worker 在 Work() 中读取
  • 如果没有统一的锁保护,就是典型的数据竞争
  • C++ 并发模型里,这是 undefined behavior

因此 stop_ 必须在 mutex 下访问。


五、最终版:我第一次真正写出能“优雅关闭”的线程池

经历了前面三版的“撞墙式学习”,我才终于写出了一个结构正确、逻辑连贯、能正常运行的线程池。

它包含:

✔ 一个正确的 worker 循环
✔ 一个正确的等待谓词
✔ 合理的任务 pop + unlock 时机
✔ 成员函数线程入口的正确写法
✔ 析构中的 stop + notify_all + join
✔ Enqueue 的断言防护
✔ 没有内存泄漏、死锁、忙等

#pragma once
#include <vector>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <functional>
#include <assert.h>

class ThreadPool {
public:
    explicit ThreadPool(size_t thread_count);
    ~ThreadPool();
    void Enqueue(std::function<void()> task);
    void Work(int id);
private:
    std::vector<std::thread> workers_;
    std::queue<std::function<void()>> tasks_;
    std::mutex mtx_;
    std::condition_variable cv_;
    bool stop_ = false;
};

void ThreadPool::Work(int id) {
  while (true) {
    std::unique_lock<std::mutex> lock(mtx_);
    cv_.wait(lock, [this] { return !tasks_.empty() || stop_; });
    if (stop_ && tasks_.empty()) break;
    std::function<void()> fn = std::move(tasks_.front());
    tasks_.pop();
    lock.unlock();
    fn();
  }
}

ThreadPool::ThreadPool(size_t thread_count) {
  for (int i = 1; i <= static_cast<int>(thread_count); ++i) {
    workers_.emplace_back(ThreadPool::Work, this, i);
  }
}

ThreadPool::~ThreadPool() {
  std::unique_lock<std::mutex> lock(mtx_);
  stop_ = true;
  lock.unlock();
  cv_.notify_all();
  for (auto &t : workers_) {
    t.join();
  }
}

void ThreadPool::Enqueue(std::function<void()> task) {
  std::unique_lock<std::mutex> lock(mtx_);
  assert(!stop_ && "已停止添加任务!");
  tasks_.push(std::move(task));
  lock.unlock();
  cv_.notify_one();
}

六、Day4 的收获:我第一次真正理解并发组件的“系统性”

写线程池让我理解了很多 Day1~Day3 没法教给我的东西。


6.1 我理解了线程池不是“代码”,而是“系统”

线程池有:

  • 生命周期
  • 工人管理
  • 任务状态
  • 同步模型
  • 退出协议
  • 队列保护策略
  • 内存可见性要求

这不是“写几行代码能糊出来”的。

这是一个真正的“小系统”。


6.2 我理解了 wait 的真正作用不是“睡觉”,而是“等待条件变真”

这和 Day1 的“轮流打印”完全不是一个级别。

线程池里的 wait 是:

  • 睡眠
  • 被 notify 唤醒
  • 自动重新抢锁
  • 再次检查谓词
  • 再决定是否继续睡 or 开始工作

真正关键的是“检查谓词”而不是“通知”。


6.3 我理解了 stop 的设计哲学

线程池关机不是:

  • 直接杀线程
  • 或者“等任务执行完就退出”

而是:

  1. 把 stop_ 置 true
  2. 唤醒所有 worker
  3. worker 自己判断
  4. worker 自己退出
  5. 析构 join 等待执行完

优雅、可控、安全。


七、结语:Day4 是我并发学习过程中最重要的一个节点

在线程池之前,我写的都是“并发小练习”。
到了线程池,我第一次理解:

  • 什么是并发系统
  • 什么叫“线程复用”
  • 为什么要有队列
  • 为什么要有停止协议
  • 为什么 wait 写错一个条件整个程序就无法退出
  • 为什么不能在构造函数 join
  • 为什么成员函数不能直接给 thread 用
  • 为什么 unlock → 执行任务 是必然的流程

Day4 让我第一次同时用到了:

  • 条件变量
  • 队列
  • 线程入口规则
  • 互斥锁的正确边界
  • 锁内与锁外工作内容的区分
  • 对象生命周期
  • 安全退出模型

比起单纯做题,这个过程真正让我“理解了并发设计”。