今天是我并发手撕题练习的 Day4。
前 3 天分别做了:
- Day1:奇偶打印
- Day2:生产者–消费者
- Day3:简易读写锁
到了今天,我决定挑战一个更“像实际工程”的并发组件:线程池(ThreadPool) 。
但和 Day1~Day3 那种“小功能小练习”不同,线程池是一个真正的“并发系统”——里面有工人线程、有任务队列、有状态、有调度、有关闭流程。
我以为自己写得出,结果第一版代码完全写歪…甚至连线程入口都写错了。
这篇 Day4 的文章,就是记录我从混乱 → 理解 → 重构 → 搞懂线程池本质的整个过程。
一、我开始写第一版线程池时,其实根本不知道线程池在干嘛
我第一版 ThreadPool 的代码文件里有不少这样的注释:
“线程池是做什么的?”
“构造函数里是不是要 join?”
“worker 函数怎么写?需要 while(true) 吗?”
“ThreadPool::Work 和 Work 有什么区别?”
其实那时候的我连线程池的基本工作方式都还没成型,只是把结构照网上抄了一点,然后开始乱填。
下面我先复盘一下我当时对线程池的理解。
1.1 第一版 ThreadPool 的结构(你能明显看出我没搞懂)
我的第一版代码大概长这样:
- 我定义了
workers_(工人线程组) - 定义了
tasks_(任务队列) - 写了一个
Enqueue() - 写了一个空的构造函数和析构函数
- 甚至连 worker 应该怎么运转都还不知道
当时我心里对线程池的理解是模糊的:
“是不是只要有一个队列,然后线程在这里面取任务,就好了?”
实际上,线程池是一个完整的系统,不是简单“有队列就能跑”。
但第一版写出来之后,我马上遇到了一堆我自己都说不清的问题。
二、我第一次疑惑:线程池到底是个什么模型?
我问的第一个问题是:
线程池到底是什么?它长什么样?它在干嘛?流程是什么?
然后我才第一次真正补上一张“线程池心智模型图”:
线程池的核心组成:
- 一组“工人线程”(固定数量,不断循环执行任务)
- 一个“任务队列”(外界把任务丢进来)
- 一个大锁(保护队列和 stop 状态)
- 一个条件变量(让工人睡觉/醒来)
- 一个 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 的设计哲学
线程池关机不是:
- 直接杀线程
- 或者“等任务执行完就退出”
而是:
- 把 stop_ 置 true
- 唤醒所有 worker
- worker 自己判断
- worker 自己退出
- 析构 join 等待执行完
优雅、可控、安全。
七、结语:Day4 是我并发学习过程中最重要的一个节点
在线程池之前,我写的都是“并发小练习”。
到了线程池,我第一次理解:
- 什么是并发系统
- 什么叫“线程复用”
- 为什么要有队列
- 为什么要有停止协议
- 为什么 wait 写错一个条件整个程序就无法退出
- 为什么不能在构造函数 join
- 为什么成员函数不能直接给 thread 用
- 为什么 unlock → 执行任务 是必然的流程
Day4 让我第一次同时用到了:
- 条件变量
- 队列
- 线程入口规则
- 互斥锁的正确边界
- 锁内与锁外工作内容的区分
- 对象生命周期
- 安全退出模型
比起单纯做题,这个过程真正让我“理解了并发设计”。