线程池实现:从原理到实战
线程池这东西,说白了就是「提前招一批工人,有活就干,没活待命」。我早年刚接触并发编程时,总觉得每次需要异步任务就 new 一个线程挺爽的——直到线上服务被频繁创建线程的开销打垮,才明白线程池有多重要。
为什么需要线程池?
你想想看,每次创建线程都要做系统调用、分配栈空间、注册到调度器。如果任务本身只执行几毫秒,那创建线程的开销可能比任务本身还大。更糟糕的是,无限制创建线程会导致系统资源耗尽——我见过一个同事的代码,高峰期开了两万个线程,直接把服务器搞 OOM 了。
线程池的核心价值就三点:
- 复用线程:避免频繁创建/销毁的开销
- 控制并发度:防止资源耗尽
- 任务队列缓冲:平滑处理突发流量
核心公式:线程池 = 线程集合 + 任务队列 + 调度策略
线程池设计原理
一个基础的线程池,内部其实就两个核心组件:
- 工作线程数组:固定数量或动态调整的线程
- 任务队列:通常是线程安全的队列(比如用 mutex + condition_variable 保护)
工作线程的典型循环是这样的:
// 工作线程主循环(伪代码)
while (!stop) {
Task task;
if (queue.try_pop(task)) {
task(); // 执行任务
} else {
wait_for_task(); // 队列空时阻塞等待
}
}
嗯,这里要注意:wait_for_task() 通常用 condition_variable 实现。当有新任务入队时,通知一个等待的线程起来干活。
固定大小线程池
这是最经典的实现。线程数量在构造时确定,之后保持不变。适合 CPU 密集型任务——说白了,线程数通常设为 std::thread::hardware_concurrency(),也就是 CPU 核心数。
我在项目中遇到过一个问题:某次给一个 I/O 密集型的服务也用固定线程池,结果线程数设成核心数,大量时间花在等待 I/O 上,CPU 利用率极低。后来改成动态线程池才解决问题。
固定线程池的代码骨架:
class FixedThreadPool {
std::vector<std::thread> workers;
std::queue<std::function<void()>> tasks;
std::mutex queue_mutex;
std::condition_variable condition;
bool stop;
public:
FixedThreadPool(size_t threads) : stop(false) {
for (size_t i = 0; i < threads; ++i)
workers.emplace_back([this] {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(queue_mutex);
condition.wait(lock, [this] {
return stop || !tasks.empty();
});
if (stop && tasks.empty()) return;
task = std::move(tasks.front());
tasks.pop();
}
task();
}
});
}
void enqueue(std::function<void()> task) {
{
std::lock_guard<std::mutex> lock(queue_mutex);
tasks.push(std::move(task));
}
condition.notify_one();
}
~FixedThreadPool() {
{
std::lock_guard<std::mutex> lock(queue_mutex);
stop = true;
}
condition.notify_all();
for (auto &worker : workers) worker.join();
}
};
个人建议:析构函数中先设置 stop 再 notify_all,顺序不能反。我曾经写反过,导致线程醒来后检查 stop 还是 false,又回去睡了——死锁排查了半天。
动态线程池
动态线程池的核心思想:线程数不是固定的,而是根据负载自动调整。通常有两个阈值:
- 核心线程数:即使空闲也保留的线程数
- 最大线程数:允许创建的最大线程数
当任务队列变长时,创建新线程;当线程空闲超过一定时间,销毁多余的线程。Java 的 ThreadPoolExecutor 就是这种思路。
C++ 实现时,我习惯用 std::chrono::steady_clock 记录线程的空闲时间:
// 动态线程池的核心逻辑
void worker_loop() {
auto last_active = std::chrono::steady_clock::now();
while (!stop) {
Task task;
if (queue.try_pop_for(task, idle_timeout)) {
task();
last_active = std::chrono::steady_clock::now();
} else {
// 超时未获取到任务
if (current_threads > core_threads) {
// 销毁当前线程
return;
}
}
}
}
避坑指南:动态调整线程数时,一定要加锁保护线程计数。我曾经用 atomic 变量做计数,但创建线程和销毁线程的时序没处理好,导致实际线程数比计数多了一个——那个 bug 查了我整整一个下午。
任务窃取(Work-Stealing)概念
固定线程池和动态线程池都有一个共同问题:每个线程从同一个全局队列取任务。当任务执行时间不均匀时,有的线程忙死,有的线程闲死。
任务窃取就是解决这个问题的:每个线程有自己的本地任务队列。当某个线程的本地队列空了,它就去「偷」其他线程队列尾部的任务来执行。
为什么偷尾部而不是头部?因为头部通常是其他线程即将执行的任务,偷尾部可以减少竞争。
我画了一张图来说明任务窃取的流程:
任务窃取的实现要点:
- 每个线程持有一个
deque(双端队列)作为本地任务队列 - 线程从队列头部取任务执行
- 空闲线程从其他线程队列尾部窃取任务
- 窃取操作需要加锁,但只锁目标队列的尾部
关键设计决策:窃取时使用 try_lock 而非 lock,避免死锁。如果目标队列正在被操作,就换一个目标——我见过一个实现因为锁顺序问题导致所有线程都在等锁,谁也干不了活。
三种线程池的对比
| 特性 | 固定大小线程池 | 动态线程池 | 任务窃取线程池 |
|---|---|---|---|
| 线程数 | 固定 | 动态调整 | 通常固定 |
| 任务队列 | 全局一个队列 | 全局一个队列 | 每个线程本地队列 |
| 适用场景 | CPU 密集型 | I/O 密集型、负载波动大 | 任务粒度不均、递归分治 |
| 实现复杂度 | 低 | 中 | 高 |
| 负载均衡 | 差(全局队列竞争) | 中 | 好 |
实际项目中的选择建议
我个人习惯这样选:
- 如果任务都是纯计算,用固定大小线程池,线程数 = 核心数
- 如果任务涉及网络 I/O、数据库查询,用动态线程池,核心线程数设为核心数,最大线程数设为核心数的 2-4 倍
- 如果任务是递归分治(比如并行快排、矩阵乘法),用任务窃取——C++17 的
std::execution::parallel_policy底层就是类似思路
一个小技巧:实现动态线程池时,可以用 std::future 来获取任务执行结果,但要注意 future 的析构会阻塞等待结果——我踩过这个坑,后来改用 std::packaged_task 手动管理生命周期。
线程池的实现看似简单,但真正做好却不容易。从固定大小到动态调整,再到任务窃取,每一步都是为了解决特定的性能问题。嗯,如果你刚开始接触,建议先从固定大小线程池入手,跑通后再逐步增加动态调整和窃取逻辑——一口吃不成胖子,并发编程更是如此。
公众号:蓝海资料掘金营,微信deep3321