这次我手写的是一个单队列版线程池 。
在真正开始写代码之前,我先把一个问题想清楚了:
线程池不只是一个"收任务的接口",它本质上是:任务队列 + worker 线程 + 同步机制 + 生命周期管理。
也就是说,线程池对外提供的是提交任务 的能力;
而对内负责的是存任务、等任务、取任务、执行任务、关闭线程、回收线程这一整套流程。
这次我先实现的是最稳的版本:
单任务队列 + 多个 worker 消费线程 + 外部多生产者提交任务。
一、一个线程池到底包含哪些部分
我最后把线程池拆成了下面几个核心部分。
1. 任务队列
任务队列负责存放外部提交进来的任务。
这里我没有直接用普通 std::queue 暴露给线程池,而是封装成了一个阻塞队列 BlockingQueue,因为线程池里 worker 线程在没有任务时不能一直空转轮询,而应该:
- 队列为空时阻塞等待
- 队列有任务时被唤醒
- 队列关闭时退出等待并返回
所以这个队列需要考虑的功能有:
Push:安全地向队列中加入任务Pop:安全地从队列中取出任务Close:线程池关闭时唤醒所有等待线程,让 worker 能退出
2. worker 线程组
线程池的"池",本质上就是一组预先创建好的 worker 线程。
这些线程由 ThreadPool 自己创建和管理,而不是由测试代码来创建。
worker 线程做的事情很简单:
- 循环从阻塞队列中取任务
- 取到任务就执行
- 取不到且队列已关闭,就退出循环
也就是说:
消费者线程属于线程池内部资源,由线程池负责创建、管理和回收。
3. 对外提交任务接口
线程池对外最重要的接口就是 Post。
外部谁调用 Post,谁就是生产者。
这意味着即使线程池内部只有一个任务队列,它也依然可以支持:
- 单生产者 + 多消费者
- 多生产者 + 多消费者
所以"单队列"说的是存储结构设计,不代表"单生产者单消费者"。
4. 同步机制
线程池一定要考虑线程同步问题。
这里最核心的同步工具有三个:
std::mutexstd::condition_variablestd::lock_guard / std::unique_lock
需要解决的同步问题包括:
- 多个生产者并发
Push - 多个 worker 并发
Pop - 队列为空时如何等待
- 队列关闭时如何安全退出
5. 生命周期管理
线程池一定要考虑"怎么收尾"。
线程池不是只把任务跑起来就行了,它还必须考虑:
- 构造时怎么创建 worker
- 析构时怎么关闭队列
- 怎么唤醒阻塞中的 worker
- 怎么
join回收线程
我这次实现的是最稳的做法:
析构时统一关闭队列,然后回收所有 worker 线程。
二、阻塞队列怎么设计
我最后的阻塞队列是这样的:
arduino
#pragma once
#include <condition_variable>
#include <mutex>
#include <queue>
#include <utility>
template<typename T>
class BlockingQueue {
private:
std::queue<T> tasks_;
std::mutex mtx_;
std::condition_variable task_not_empty_;
bool close_ = false;
public:
bool Push(T value); // true: 成功添加任务 false: 队列已关闭
bool Pop(T& value); // true: 成功取到任务 false: 队列关闭且为空
void Close();
};
template<typename T>
bool BlockingQueue<T>::Push(T value) {
{
std::lock_guard<std::mutex> lock(mtx_);
if (close_) {
return false;
}
tasks_.push(std::move(value));
}
task_not_empty_.notify_one();
return true;
}
template<typename T>
bool BlockingQueue<T>::Pop(T& value) {
std::unique_lock<std::mutex> lock(mtx_);
task_not_empty_.wait(lock, [this]() {
return !tasks_.empty() || close_;
});
if (!tasks_.empty()) {
value = std::move(tasks_.front());
tasks_.pop();
return true;
}
return false;
}
template<typename T>
void BlockingQueue<T>::Close() {
{
std::lock_guard<std::mutex> lock(mtx_);
close_ = true;
}
task_not_empty_.notify_all();
}
三、这个阻塞队列每个部分在解决什么问题
1. Push
Push 的作用是安全地加入任务。
它要考虑两个点:
- 队列是否已经关闭
- 多个线程是否会同时往队列里加任务
所以这里必须加锁,并且在关闭后拒绝继续添加任务。
2. Pop
Pop 是阻塞队列最关键的部分。
它要考虑:
- 队列空时不能直接返回,要阻塞等待
- 队列有任务时要安全取出
- 队列关闭且空时,要返回
false告诉外层线程可以退出
这里我最终把 Pop 设计成了返回 bool,这是一个很关键的点:
true:成功取到任务false:队列关闭且没有任务了
这样线程池的 worker 就可以很优雅地退出。
3. Close
Close 不是普通队列里会有的接口,但在线程池里很重要。
它的作用是:
- 通知队列不再接收新任务
- 唤醒所有卡在
Pop里的 worker 线程 - 让 worker 在线程池析构时有机会退出
如果没有 Close(),worker 很容易永远卡在 wait 里,线程池析构就会卡死。
四、线程池主体实现
线程池主体代码如下:
arduino
#include <functional>
#include <thread>
#include <vector>
#include "BlockingQueue.h"
class ThreadPool {
private:
void Work();
void Stop();
BlockingQueue<std::function<void()>> q_;
std::vector<std::thread> workers_;
public:
explicit ThreadPool(int workers);
~ThreadPool();
bool Post(std::function<void()> task);
};
void ThreadPool::Work() {
while (true) {
std::function<void()> task;
if (!q_.Pop(task)) {
break;
}
task();
}
}
void ThreadPool::Stop() {
q_.Close();
for (auto& t : workers_) {
if (t.joinable()) {
t.join();
}
}
}
ThreadPool::ThreadPool(int workers) {
workers_.reserve(workers);
for (int i = 0; i < workers; ++i) {
workers_.emplace_back(&ThreadPool::Work, this);
}
}
ThreadPool::~ThreadPool() {
Stop();
}
bool ThreadPool::Post(std::function<void()> task) {
return q_.Push(std::move(task));
}
五、线程池主体每个部分在做什么
1. Work
Work 是 worker 线程真正执行的线程函数。
逻辑很简单:
- 从队列里
Pop - 成功取到任务就执行
- 队列关闭且没任务了,就退出
这一段实际上就把"阻塞等待任务"和"执行任务"的职责连接起来了。
2. 构造函数
构造函数负责创建 worker 线程。
kotlin
workers_.emplace_back(&ThreadPool::Work, this);
这里的含义是:创建一个线程,让它去执行当前对象的 Work() 成员函数。
所以线程池一创建出来,内部其实就已经有了一组工作线程在等任务。
3. Post
Post 是线程池对外的提交接口。
外部线程把任务包装成 std::function<void()> 后传进来,线程池再把它丢进阻塞队列。
这就形成了标准的生产者-消费者模型:
- 外部调用
Post的线程 = 生产者 - 内部 worker 线程 = 消费者
4. Stop
这次我没有把关闭接口暴露给外界,而是做成了私有函数,只在析构里调用。
因为初版线程池最稳的生命周期就是:
- 构造时创建线程
- 析构时关闭队列并回收线程
这样足够完整,也不需要再额外讨论"外部何时停止线程池"的接口设计问题。
5. 析构函数
析构函数负责统一收尾:
- 关闭队列
- 唤醒 worker
join回收线程
这是线程池"优雅关闭"的关键步骤。
六、测试代码:验证多生产者、多消费者
测试代码如下:
c
#include <iostream>
#include <functional>
#include <thread>
#include <vector>
#include "BlockingQueue.h"
#include "ThreadPool.h"
void Product(ThreadPool& thread_pool, int id) {
for (int i = 0; i < 10; i++) {
thread_pool.Post([id, i]() {
std::cout << "Producer " << id
<< ", worker thread id: " << std::this_thread::get_id()
<< ", task No." << i
<< " done." << std::endl;
});
}
}
int main() {
ThreadPool thread_pool(3);
std::vector<std::thread> products;
for (int i = 0; i < 3; i++) {
products.emplace_back(Product, std::ref(thread_pool), i);
}
for (auto& t : products) {
if (t.joinable()) {
t.join();
}
}
return 0;
}
这段测试代码里:
- 主线程创建了一个线程池,里面有 3 个 worker
- 又创建了 3 个生产者线程
- 每个生产者线程提交 10 个任务
这样就构成了:
- 多生产者
- 多消费者
- 单任务队列
这也说明了一个我一开始容易误解的问题:
单队列不代表单生产者单消费者。
只要
Push和Pop是线程安全的,单队列也完全可以支持多生产者多消费者。
七、这次实现过程中遇到的一些问题和疏忽
这次写代码时,虽然整体思路比较快理顺了,但中间还是踩了不少小坑。
1. 一开始误以为"单队列"就偏向单生产者单消费者
后来理清楚了:
- 单队列说的是任务存储结构
- 多生产者、多消费者说的是线程模型
两者不是一回事。
2. 一开始纠结生产者线程和消费者线程该由谁创建
后来明确:
- worker 线程由
ThreadPool内部创建和管理 - 生产者线程由外部测试代码按需创建
也就是说,线程池本身负责"养工人",外部线程负责"投任务"。
3. Pop 必须返回 bool
如果 Pop 只是单纯地取值,不告诉外层"队列关闭且无任务"的状态,那么线程池在析构时就很难优雅退出。
所以 bool Pop(T& value) 是这次设计里比较关键的一点。
4. condition_variable::wait 不能配 lock_guard
这个点很容易疏忽。
wait 需要的是 std::unique_lock<std::mutex>,因为它内部会自动解锁、休眠、再重新上锁。
lock_guard 不支持这套机制。
5. std::thread 传引用参数要用 std::ref
测试代码里我后面也遇到了这个问题。
因为:
java
void Product(ThreadPool& thread_pool, int id)
要的是引用,但 std::thread 默认会按值拷贝参数,
所以要写成:
css
products.emplace_back(Product, std::ref(thread_pool), i);
不然会编译不过。
6. 控制台输出很乱,不代表线程池有问题
最后跑出来的日志交叉得很厉害,一开始看起来像是输出坏掉了。
但其实这是因为多个 worker 线程同时在写 std::cout,输出不是原子的,所以行内容会互相穿插。
这个现象恰恰说明:
任务确实在被多个 worker 并发执行。
所以日志乱,不是线程池错了,而是多线程打印本来就会这样。
八、如果继续优化,我会怎么讲"双队列 swap"思路
除了这次实现的单队列版本,我还想到过一个优化方向:
双队列 + 交换(swap)
大致思路是这样的:
-
准备两个队列
- 一个给生产者写入
- 一个给消费者读取
-
消费者平时从自己的消费队列里取任务
-
当消费队列为空时,检查生产队列
-
如果生产队列不为空,就把两个队列
swap -
如果生产队列也为空,就阻塞等待
这个优化想解决什么问题
它主要是为了减少生产者和消费者在同一批任务数据上的锁竞争。
单队列版本里:
- 生产者一直在往同一个队列里
push - 消费者一直在从同一个队列里
pop
这会让两边持续竞争一把锁。
而双队列的优化思想是:
尽量让生产者写自己的、消费者读自己的,只有在"切换批次"时才交换一次。
这样消费者一次 swap 后,可以批量消费当前队列里的任务,减少频繁争锁。
这个优化要额外考虑什么
这个思路虽然能讲,但真正实现时要考虑的细节明显更多:
-
不能多个消费者同时发现队列空、同时去 swap
-
swap必须在严格保护下完成 -
空队列时不能靠
sleep轮询,还是应该用condition_variable -
关闭线程池时,要想清楚:
- 当前消费队列还有没有剩余任务
- 生产队列还有没有任务没换过来
- 多个 worker 怎么安全退出
所以面试时我更推荐这样回答:
第一版先写单队列保证正确性;如果后续要进一步降低锁竞争,可以考虑双队列 swap 的批量切换方案。
这样既体现了你能写出稳定版本,也体现了你有进一步优化的意识。
九、如果继续优化,我会怎么讲完美转发
这次我在 Post 里先用了最简单的版本:
c
bool Post(std::function<void()> task);
这是最稳的,因为调用方先把任务包装成 std::function<void()>,线程池只负责存和执行。
但后面如果进一步优化接口,可以考虑把 Post 改成模板 + 完美转发,比如支持:
ini
pool.Post(f, x, y);
pool.Post([] { ... });
核心思想是:
- 让
Post不强迫用户先手动构造std::function<void()> - 直接接收可调用对象和参数
- 在内部把它们绑定成一个最终任务
一个常见写法大概是:
c
template <typename F, typename... Args>
bool Post(F&& f, Args&&... args) {
auto task = std::bind(std::forward<F>(f), std::forward<Args>(args)...);
return q_.Push(std::function<void()>(std::move(task)));
}
完美转发这个优化在讲的时候怎么说
我会这样概括:
当前版本
Post(std::function<void()>)最简单、最稳;如果后续要减少额外拷贝并提升接口通用性,可以把
Post升级成模板接口,用完美转发保留左值/右值属性,再在内部统一封装成任务对象。