前言
在 Linux 后端开发里,线程池几乎是绕不开的基础组件。
Web 服务器要处理大量客户端请求,日志系统要异步落盘,批量计算任务要并行执行。如果每来一个任务就临时创建一个线程,不仅会产生频繁的线程创建和销毁开销,还可能在请求高峰期创建出过多线程,导致 CPU 调度压力陡增,甚至把系统资源打满。
线程池解决的正是这个问题:提前创建一批工作线程,把任务统一放入任务队列,由空闲线程不断取任务执行。线程不再频繁创建和销毁,而是被反复复用。
本文从线程池的核心思想讲起,一步步拆到任务队列、工作线程、条件变量、线程回收、拒绝策略和 C++ 代码实现,把线程池这条链路完整吃透。
一、为什么需要线程池
1.1 直接创建线程的问题
最直观的多线程写法是:来一个任务,创建一个线程处理。
std::thread t([]{
// 执行业务逻辑
});
t.detach();
这种方式看起来简单,但在高并发场景下有几个明显问题。
- 线程创建和销毁不是零成本操作,需要操作系统参与;
- 线程数量不可控,任务暴增时容易创建大量线程;
- 线程过多会增加上下文切换成本,CPU 时间被浪费在调度上;
- 线程生命周期分散,异常退出和资源回收都不好管理;
- 任务提交方和任务执行方耦合得太紧,不利于统一限流和监控。
也就是说,临时创建线程适合少量、偶发任务,不适合长期运行的高并发服务。
1.2 线程池的核心思想
线程池本质上是一种池化思想。
池化的核心不是"多开几个线程",而是把昂贵资源提前准备好,然后重复利用。
常见的池化组件包括:
- 线程池:复用线程;
- 连接池:复用数据库连接或网络连接;
- 内存池:复用内存块;
- 对象池:复用创建成本较高的对象。
线程池做的事情可以概括成三句话:
- 提前创建固定数量的工作线程;
- 外部提交任务时,只把任务放入队列;
- 工作线程从队列中取任务并执行,执行完继续等待下一个任务。
这样一来,线程创建成本被摊薄,任务执行过程也更容易统一管理。
二、线程池的核心组成
一个最小可用的线程池,一般由下面几部分组成:
- 工作线程集合;
- 任务队列;
- 互斥锁;
- 条件变量;
- 运行状态标记;
- 启动和停止接口。

2.1 工作线程集合
工作线程是线程池真正执行任务的角色。
线程池启动时,会创建若干个工作线程。每个线程都会进入一个循环:
- 尝试从任务队列取任务;
- 如果队列为空,就阻塞等待;
- 如果拿到任务,就执行任务;
- 执行完成后,再回到第 1 步。
这个循环是线程复用的关键。线程不会因为执行完一个任务就退出,而是继续等待下一个任务。
2.2 任务队列
任务队列用于解耦任务提交方和任务执行方。
提交方只负责把任务放进去,不需要关心哪个线程执行;工作线程只负责从队列中取任务,不需要关心任务是谁提交的。
任务队列通常需要满足两个要求:
- 多个提交方可能同时 Push 任务;
- 多个工作线程可能同时 Pop 任务。
因此任务队列必须配合互斥锁使用,保证内部结构不会被并发修改破坏。
2.3 互斥锁
互斥锁主要保护任务队列。
无论是提交任务,还是工作线程取任务,只要涉及队列的增删操作,都必须进入临界区。
std::mutex _mutex;
std::queue<Task> _tasks;
典型操作如下:
{
std::lock_guard<std::mutex> lock(_mutex);
_tasks.push(task);
}
这里锁保护的是队列本身,而不是任务执行过程。任务真正执行时,通常应该离开临界区,避免一个耗时任务长期占用锁,阻塞其他线程取任务。
2.4 条件变量
如果任务队列为空,工作线程不能一直空转检查,否则会浪费 CPU。
条件变量的作用就是:当没有任务时,让工作线程睡眠;当有新任务提交时,再唤醒工作线程。
std::condition_variable _cond;
工作线程等待:
_cond.wait(lock, [&] {
return !_tasks.empty() || !_running;
});
提交方唤醒:
_cond.notify_one();
条件变量和互斥锁通常成对出现:锁用于保护共享状态,条件变量用于等待共享状态发生变化。
三、任务在线程池中的完整流转过程
一个任务从提交到执行完成,大致会经历下面几步。

3.1 提交任务
外部调用 Submit() 接口,把一个可调用对象提交给线程池。
这个可调用对象可以是普通函数、lambda、函数对象,也可以是绑定了参数的任务。
3.2 进入任务队列
线程池先加锁,然后把任务放入队列。
{
std::lock_guard<std::mutex> lock(_mutex);
_tasks.push(std::move(task));
}
放入队列之后,提交方就可以返回。任务什么时候执行、由哪个线程执行,都交给线程池内部调度。
3.3 唤醒工作线程
任务入队后,需要唤醒一个等待中的工作线程。
_cond.notify_one();
如果当前有线程正在等待,它会被唤醒;如果没有线程等待,说明可能所有线程都在忙,新任务就继续留在队列中排队。
3.4 工作线程取任务
工作线程被唤醒后,会重新检查条件。
注意这里必须用 while 或者带谓词版本的 wait,不能简单用 if。
原因是条件变量存在伪唤醒:线程被唤醒,并不一定代表队列里真的有任务。所以唤醒后必须再次检查条件。
3.5 执行任务
工作线程拿到任务后,应该先释放锁,再执行任务。
Task task;
{
std::unique_lock<std::mutex> lock(_mutex);
_cond.wait(lock, [&] {
return !_tasks.empty() || !_running;
});
task = std::move(_tasks.front());
_tasks.pop();
}
task();
这个顺序非常关键。
如果拿着锁执行任务,那么其他工作线程就无法继续从队列中取任务,提交方也可能无法继续投递任务。线程池就会退化成"多个线程排队抢一把大锁",并发能力会被严重削弱。
四、C++ 手写线程池核心代码
下面实现一个简洁但完整的线程池版本,支持:
- 固定数量工作线程;
- 提交无返回值任务;
- 条件变量阻塞等待;
- 优雅停止;
- 自动回收线程。
4.1 头文件与成员变量
#pragma once
#include <condition_variable>
#include <functional>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>
class ThreadPool
{
public:
using Task = std::function<void()>;
explicit ThreadPool(size_t thread_num)
: _running(true)
{
for (size_t i = 0; i < thread_num; ++i) {
_workers.emplace_back([this] {
WorkerLoop();
});
}
}
~ThreadPool()
{
Stop();
}
void Submit(Task task)
{
{
std::lock_guard<std::mutex> lock(_mutex);
if (!_running) {
return;
}
_tasks.push(std::move(task));
}
_cond.notify_one();
}
void Stop()
{
{
std::lock_guard<std::mutex> lock(_mutex);
if (!_running) {
return;
}
_running = false;
}
_cond.notify_all();
for (auto& worker : _workers) {
if (worker.joinable()) {
worker.join();
}
}
}
private:
void WorkerLoop()
{
while (true) {
Task task;
{
std::unique_lock<std::mutex> lock(_mutex);
_cond.wait(lock, [this] {
return !_tasks.empty() || !_running;
});
if (!_running && _tasks.empty()) {
return;
}
task = std::move(_tasks.front());
_tasks.pop();
}
task();
}
}
private:
std::vector<std::thread> _workers;
std::queue<Task> _tasks;
std::mutex _mutex;
std::condition_variable _cond;
bool _running;
};
4.2 使用示例
#include "ThreadPool.hpp"
#include <chrono>
#include <iostream>
int main()
{
ThreadPool pool(4);
for (int i = 0; i < 10; ++i) {
pool.Submit([i] {
std::cout << "task " << i << " running" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(200));
});
}
return 0;
}
程序结束时,ThreadPool 析构函数会调用 Stop(),通知所有工作线程退出,并通过 join() 等待它们回收。
五、线程池关闭与资源回收
线程池最容易写错的地方,不是提交任务,而是停止线程池。
停止逻辑如果处理不好,常见问题包括:
- 工作线程永远睡在条件变量上,程序无法退出;
- 主线程析构线程池时,工作线程还在访问已经销毁的对象;
- 队列里还有任务,但线程池提前退出,导致任务丢失;
- 重复调用
Stop(),触发不可预期行为。

5.1 为什么停止时要 notify_all
如果线程池停止时,部分工作线程正阻塞在条件变量上,它们不会主动醒来。
所以 Stop() 里设置 _running = false 后,必须调用:
_cond.notify_all();
这样所有等待线程都会醒来,重新检查退出条件。
5.2 为什么退出条件是 running 和 empty 一起判断
工作线程里这句判断很关键:
if (!_running && _tasks.empty()) {
return;
}
它表达的意思是:
- 线程池已经停止;
- 并且队列里没有剩余任务;
- 此时工作线程才真正退出。
如果线程池停止了,但队列里还有任务,工作线程会继续把剩余任务执行完。这样就能做到相对优雅的关闭。
5.3 为什么任务执行不能放在锁里
错误写法:
std::unique_lock<std::mutex> lock(_mutex);
Task task = std::move(_tasks.front());
_tasks.pop();
task(); // 错误:拿着锁执行任务
正确写法:
Task task;
{
std::unique_lock<std::mutex> lock(_mutex);
task = std::move(_tasks.front());
_tasks.pop();
}
task();
锁只保护队列操作,不保护任务执行。任务执行时间不可控,如果放在锁里,会拖慢整个线程池。
六、线程池常见参数与拒绝策略
真实工程里的线程池通常不会只有一个固定线程数,还会涉及更多配置。
6.1 核心参数
| 参数 | 含义 | 作用 |
|---|---|---|
| 核心线程数 | 常驻工作线程数量 | 保证基础处理能力 |
| 最大线程数 | 允许创建的最大线程数量 | 应对短时峰值 |
| 任务队列容量 | 等待执行的任务上限 | 防止任务无限堆积 |
| 空闲超时时间 | 非核心线程空闲多久后退出 | 回收临时扩容线程 |
| 拒绝策略 | 队列满且无法继续接收任务时的处理方式 | 保护系统不被压垮 |
本文代码是固定线程数版本,适合入门理解线程池原理。工程版本可以在此基础上扩展队列容量、返回值、动态扩缩容和拒绝策略。
6.2 为什么任务队列不能无限大
很多初学者会觉得:队列设成无限大,就不会丢任务。
但在服务端开发里,无限队列往往更危险。
如果生产速度长期大于消费速度,任务会在内存里越堆越多。短时间看只是延迟升高,继续发展就可能导致内存耗尽。
所以线程池一般需要有界队列,并在队列满时触发拒绝策略或反压机制。
6.3 常见拒绝策略
| 策略 | 处理方式 | 适用场景 |
|---|---|---|
| 直接拒绝 | 提交失败,返回错误 | 对延迟敏感的在线服务 |
| 调用者执行 | 提交线程自己执行任务 | 轻量反压,降低提交速度 |
| 丢弃最旧任务 | 移除队列头部旧任务 | 只关心最新数据的场景 |
| 阻塞等待 | 提交方等待队列有空位 | 后台任务、允许排队的场景 |
拒绝策略的本质不是"放弃任务",而是告诉系统:当前处理能力已经到边界了,必须做取舍。
七、面试高频问题总结
1. 线程池为什么能提升性能?
线程池减少了频繁创建和销毁线程的系统开销,并通过固定数量的工作线程限制并发度,降低线程过多带来的上下文切换成本。
2. 线程池里的条件变量为什么要配合 while 或谓词使用?
因为条件变量存在伪唤醒。线程被唤醒后,队列不一定真的有任务,所以必须重新检查条件。
带谓词的 wait 内部等价于循环判断,写起来更安全。
3. 为什么取出任务后要先解锁再执行?
锁保护的是任务队列,不是任务执行。
如果拿着锁执行任务,其他线程无法取任务,提交方也可能被阻塞,线程池并发能力会明显下降。
4. notify_one 和 notify_all 怎么选?
提交一个普通任务时,一般使用 notify_one,唤醒一个工作线程即可。
线程池停止时,必须使用 notify_all,因为所有等待线程都需要醒来检查退出条件。
5. 线程池线程数是不是越多越好?
不是。
线程数过少,CPU 利用不充分;线程数过多,会增加上下文切换和锁竞争。
一般来说:
- CPU 密集型任务:线程数接近 CPU 核心数;
- IO 密集型任务:线程数可以适当多一些;
- 混合任务:需要结合压测结果调整。
6. 线程池析构时应该怎么做?
析构时应该:
- 设置停止标记;
- 唤醒所有工作线程;
- 等待线程退出;
- 回收线程资源。
不能直接销毁线程池对象,否则工作线程可能还在访问已经释放的成员变量。
结尾
线程池表面上只是"提前创建一批线程",但真正重要的是它背后的几个并发控制点:
- 用任务队列解耦提交方和执行方;
- 用互斥锁保护共享队列;
- 用条件变量避免空转;
- 用运行标记控制生命周期;
- 用
join()保证线程资源被正确回收; - 用有界队列和拒绝策略保护系统边界。
把这条链路理解清楚之后,再看 Web 服务器、异步日志、任务调度器、数据库连接池等组件,就会发现它们背后的设计思路其实是相通的:控制资源数量,削峰填谷,复用昂贵对象,让系统在高并发下依然稳定。
线程池不是一个孤立知识点,而是理解高并发工程设计的一扇门。