每个任务 new 一个 std::thread 是并发编程里最贵的偷懒方式:线程创建要走系统调用,栈空间按兆算,销毁又要再来一遍。当任务本身只有几微秒时,你花在「造线程」上的时间会比干活多几十倍。 线程池的思路很朴素:线程只创建一次,任务排队,谁空谁取。这篇从零把它写出来,然后把三个最容易写错的设计点讲透。
1. 引子:算一笔线程的账
// 片段:每个任务一个线程 —— 反例,不要这么写
for (int i = 0; i < 10000; ++i) {
std::thread([i] { do_something_small(i); }).detach(); // 一万次创建 + 一万次销毁
}
这段代码有三个问题叠在一起:① 线程数不受控,任务一多直接把系统线程上限打满(std::system_error: Resource temporarily unavailable 就是这么来的);② 创建/销毁的开销和任务量成正比,而不是和「峰值并发度」成正比;③ 拿不到返回值、接不住异常(《std::async、future、promise:异步任务的返回值、异常与三个经典坑》讲过)。
账大概是这个量级:
| 创建/销毁一个线程 | 数十微秒 | 内核态操作,含栈映射 |
| 一次无竞争的 mutex 加解锁 | 数十纳秒 | 用户态原子操作 + 少量指令 |
| 一次 notify + 唤醒 | 微秒以内 | 有竞争时会走 futex 系统调用 |
| 一个「微不足道」的任务本身 | 可能只有几十纳秒 | 典型如加一次计数 |
结论很清楚:任务越短,线程池的收益越大。三条路里,detach 是失控,逐任务建线程是浪费,线程池才是稳态。
官方文档:std::thread — cppreference · std::condition_variable — cppreference
2. 结构长什么样
┌────────────────────────────────────────────────┐
│ ThreadPool │
提交任务 ──────────► │ enqueue(task) │
(任意线程都能调) │ │ │
│ ▼ ① 加锁,把任务推进队列 │
│ ┌──────────────────────────────┐ │
│ │ tasks_ : std::queue<…> │ │
│ │ [t3][t2][t1] ← 先进先出 │ 任务队列 │
│ └──────────────────────────────┘ │
│ │ ② 解锁,再 notify_one │
│ ▼ │
│ ╔════════════════════════════════╗ │
│ ║ mutex_ + cv_ 保护队列与标志 ║ │
│ ╚════════════════════════════════╝ │
│ │ ③ 某一个正在 wait 的工作线程被唤醒 │
│ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ worker 0 │ │ worker 1 │ │ worker 2 │ … │
│ │ 循环: │ │ 循环: │ │ 循环: │ │
│ │ 等 → 取 │ │ 等 → 取 │ │ 等 → 取 │ │
│ │ → 执行 │ │ → 执行 │ │ → 执行 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└────────────────────────────────────────────────┘
↑ 线程只在构造时创建,之后被任务反复复用
四个零件缺一不可:任务队列(存放待办,std::queue 的 FIFO 语义天然公平)、工作线程数组(复用实体)、std::mutex(保护队列和停止标志)、std::condition_variable(让空闲线程真正睡着,而不是忙等烧 CPU)。
| 任务容器 | std::queue<std::function<void()>> | 需要 FIFO,且要能装任意可调用对象 |
| 任务类型 | std::function<void()> | 抹平 lambda / 函数指针 / 仿函数的类型差异 |
| 同步 | std::mutex + std::condition_variable | 队列要互斥,线程要能被高效唤醒 |
| 停止标志 | bool + 同一把锁保护(或 std::atomic<bool>) | 被锁保护时读写天然互斥,不必再多一个原子量 |
| 返回值通道 | std::packaged_task<…> + std::future<…> | 让 enqueue 的调用方拿到结果和异常 |
3. 最小可用版本:先跑起来
先做一版 enqueue 只收 std::function<void()> 的,把骨架跑通:
// pool_min.cpp — 编译: g++ -std=c++17 -Wall -O2 -pthread pool_min.cpp -o pool_min
#include <atomic>
#include <condition_variable>
#include <cstdio>
#include <functional>
#include <mutex>
#include <queue>
#include <thread>
#include <utility>
#include <vector>
class ThreadPool {
public:
explicit ThreadPool(std::size_t workers) {
workers_.reserve(workers);
for (std::size_t i = 0; i < workers; ++i) {
workers_.emplace_back([this] { worker_loop(); }); // 构造即开工
}
}
~ThreadPool() { shutdown(); }
ThreadPool(const ThreadPool&) = delete; // 线程不可拷贝,池也不该
ThreadPool& operator=(const ThreadPool&) = delete;
void enqueue(std::function<void()> task) {
{
std::lock_guard<std::mutex> lock(mutex_);
tasks_.push(std::move(task)); // ① 持锁改队列
}
cv_.notify_one(); // ② 解锁后再通知
}
private:
void shutdown() {
{
std::lock_guard<std::mutex> lock(mutex_);
stopping_ = true; // ① 先置停止标志(持锁)
}
cv_.notify_all(); // ② 再唤醒所有睡着的线程
for (auto& worker : workers_) { // ③ 最后 join
if (worker.joinable()) worker.join();
}
}
void worker_loop() {
for (;;) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [this] { return stopping_ || !tasks_.empty(); });
if (stopping_ && tasks_.empty()) return; // 停止且已排空 → 才能退出
task = std::move(tasks_.front());
tasks_.pop();
}
task(); // ④ 必须在锁外执行
}
}
std::mutex mutex_;
std::condition_variable cv_;
std::queue<std::function<void()>> tasks_;
std::vector<std::thread> workers_;
bool stopping_{false}; // 受 mutex_ 保护,无需 atomic
};
int main() {
constexpr int kTasks = 100;
std::atomic<int> done{0};
{
ThreadPool pool(4);
for (int i = 0; i < kTasks; ++i) {
pool.enqueue([&done] { done.fetch_add(1, std::memory_order_relaxed); });
}
} // pool 析构 → 等所有已提交任务跑完
std::printf("提交 %d 个任务, 完成 %d 个\\n", kTasks, done.load());
}
提交 100 个任务, 完成 100 个
100 == 100 是确定的,和 4 个工作线程谁先谁后无关 —— 这是析构必须把队列排空换来的保证。
4. 设计点 ①:让调用方拿到返回值
上面的 enqueue 只能收 void(),用完就是「扔出去不问结果」。标准做法是把它改成可变参数模板,内部用 std::packaged_task 包一层,返回 std::future:
// 片段:enqueue 的模板版核心
template <typename F, typename... Args>
auto enqueue(F&& func, Args&&... args)
-> std::future<std::invoke_result_t<F, Args...>> {
using Result = std::invoke_result_t<F, Args...>;
auto bound = std::make_shared<std::packaged_task<Result()>>( // ① 包起来
[func = std::forward<F>(func),
args = std::make_tuple(std::forward<Args>(args)...)]() mutable -> Result {
return std::apply(func, std::move(args)); // 参数从 tuple 里摊开
});
std::future<Result> result = bound->get_future(); // ② 先取 future
{
std::lock_guard<std::mutex> lock(mutex_);
tasks_.emplace([bound] { (*bound)(); }); // ③ 再入队
}
cv_.notify_one();
return result; // ④ 把通道交给调用方
}
这段代码里有四个必须这么写的地方,每一个都是踩出来的:
| std::make_shared<std::packaged_task<…>> | packaged_task 只可移动,装不进 std::function<void()>;用 shared_ptr 包一层后 lambda 变成可拷贝,问题消失 |
| std::make_tuple + std::apply 而不是 std::bind | std::bind 会丢失引用传递、对重载函数也难处理;泛型 lambda + apply 是 C++17 的直球写法 |
| 先 get_future(),再入队 | 顺序反过来会出现「任务已被执行完,future 还没建」的窗口(少数实现会直接抛 std::future_error) |
| lambda 标 mutable | 捕获进来的 args 是成员,std::apply 要把它移出去,不改 mutable 编译不过 |
// pool_future.cpp — 编译: g++ -std=c++17 -Wall -O2 -pthread pool_future.cpp -o pool_future
#include <atomic>
#include <condition_variable>
#include <cstdio>
#include <functional>
#include <future>
#include <memory>
#include <mutex>
#include <queue>
#include <stdexcept>
#include <thread>
#include <tuple>
#include <type_traits>
#include <utility>
#include <vector>
class ThreadPool {
public:
explicit ThreadPool(std::size_t workers) {
workers_.reserve(workers);
for (std::size_t i = 0; i < workers; ++i) {
workers_.emplace_back([this] { worker_loop(); });
}
}
~ThreadPool() { shutdown(); }
ThreadPool(const ThreadPool&) = delete;
ThreadPool& operator=(const ThreadPool&) = delete;
template <typename F, typename... Args>
auto enqueue(F&& func, Args&&... args)
-> std::future<std::invoke_result_t<F, Args...>> {
using Result = std::invoke_result_t<F, Args...>;
auto bound = std::make_shared<std::packaged_task<Result()>>(
[func = std::forward<F>(func),
args = std::make_tuple(std::forward<Args>(args)...)]() mutable -> Result {
return std::apply(func, std::move(args));
});
std::future<Result> result = bound->get_future();
{
std::lock_guard<std::mutex> lock(mutex_);
tasks_.emplace([bound] { (*bound)(); });
}
cv_.notify_one();
return result;
}
private:
void shutdown() {
{
std::lock_guard<std::mutex> lock(mutex_);
stopping_ = true;
}
cv_.notify_all();
for (auto& worker : workers_) {
if (worker.joinable()) worker.join();
}
}
void worker_loop() {
for (;;) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [this] { return stopping_ || !tasks_.empty(); });
if (stopping_ && tasks_.empty()) return;
task = std::move(tasks_.front());
tasks_.pop();
}
task();
}
}
std::mutex mutex_;
std::condition_variable cv_;
std::queue<std::function<void()>> tasks_;
std::vector<std::thread> workers_;
bool stopping_{false};
};
int main() {
ThreadPool pool(3);
std::vector<std::future<long long>> futures;
futures.reserve(10);
for (int i = 1; i <= 10; ++i) {
futures.push_back(pool.enqueue([](int x) { return static_cast<long long>(x) * x; }, i));
}
long long total = 0;
for (auto& fut : futures) total += fut.get(); // 每个 future 只 get 一次
std::printf("1..10 的平方和 = %lld, 期望 = %d\\n", total, 385);
// 任务内部抛出的异常,同样沿着 future 传回调用方
std::future<int> bad = pool.enqueue([]() -> int {
throw std::runtime_error("任务内部出错");
});
try {
bad.get();
} catch (const std::runtime_error& e) {
std::printf("捕获任务异常: %s\\n", e.what());
}
}
1..10 的平方和 = 385, 期望 = 385
捕获任务异常: 任务内部出错
注意最后那段:线程池不需要自己写异常处理框架,因为 packaged_task 已经把「异常存进共享状态」做掉了(机制见《std::async、future、promise:异步任务的返回值、异常与三个经典坑》)。工作线程只管 task(),异常不会漏到线程外面去触发 std::terminate。
官方文档:std::packaged_task — cppreference · std::invoke_result — cppreference
5. 设计点 ②③:停止标志与「置标志 → notify → join」的顺序
停止标志只有两个要求:读它和写它不能数据竞争。两种合法做法:
| 受锁保护(这里用法) | bool stopping_ | 必须持 mutex_ | 队列操作本来就要持锁,顺手就保护了,不引入额外原子操作 |
| 独立原子量 | std::atomic<bool> | 读用 acquire、写用 release | 适合「停止标志要在不持锁的路径上频繁读」的场景;这里用不上 |
关键在析构的顺序,三步一步都不能换:
~ThreadPool() 的正确顺序与每一步的必要性
① { lock_guard lk(mutex_); stopping_ = true; } ← 置标志必须在锁内
│ 因为 wait 的谓词要读它,必须和队列状态看到同一个「快照」
│
② cv_.notify_all(); ← 叫醒所有人
│ 只置标志不通知 → 线程还睡在 wait 上,join 会永远卡住
│ (靠虚假唤醒"碰巧"醒来的那种代码不能要)
│
③ for (worker : workers_) worker.join(); ← 等所有人退出
join 必须在 notify 之后 → 线程会先在循环里把队列排空再退出
反过来先 join 再置标志 → 死锁:线程等队列,join 等线程
三种典型写错的后果:
| 先 join 再置 stopping_ | 死锁:工作线程永远等不到停止标志,join 永不返回 |
| 置了标志但不 notify_all | 卡住:睡着的线程收不到通知,join 挂住 |
| worker_loop 里写成 if (stopping_) return; | 丢任务:队列里还没跑的任务被直接遗弃 |
| 用 notify_one 代替 notify_all | 不确定:只有一个线程醒了,其余永远睡着,join 挂住 |
顺带一句 C++20:标准新增了 std::jthread,它在析构时自动「请求停止 + join」(走 std::stop_token),把上面这三步做成了语言级保证 —— 需要 C++20,且它解决的是「一个线程」,池的停止逻辑还是得自己写。
官方文档:std::jthread — cppreference(C++20,析构自动 join,对比看更清楚 RAII 该做什么)
6. 设计点 ④:任务必须在锁外执行
worker_loop 里 task() 那行如果在 lock 的作用域内调用,会出两个问题:
所以标准姿势是只在临界区里「取任务」,出了花括号再执行:
临界区尽可能短:只包住「队列状态」的读写
┌── 持锁 ────────────────────┐
│ 检查谓词(stopping_ / 队列) │
│ 取走一个任务 │
└────────────────────────────┘
┌── 不持锁 ──────────────────┐
│ task() ← 耗时全在这里 │
│ 任务里可以再 enqueue │
└────────────────────────────┘
官方文档:C++ Core Guidelines · 并发章节(CP.20 「用 RAII 而不是裸 lock/unlock」、CP.43 「临界区开得越小越好」)
7. 完整示例:200 个任务 + 析构排空验证
把上面的设计合起来,跑一个带公式核对的完整程序:
// pool_sum.cpp — 编译: g++ -std=c++17 -Wall -O2 -pthread pool_sum.cpp -o pool_sum
#include <atomic>
#include <condition_variable>
#include <cstdio>
#include <functional>
#include <future>
#include <memory>
#include <mutex>
#include <queue>
#include <stdexcept>
#include <thread>
#include <tuple>
#include <type_traits>
#include <utility>
#include <vector>
class ThreadPool {
public:
explicit ThreadPool(std::size_t workers) {
workers_.reserve(workers);
for (std::size_t i = 0; i < workers; ++i) {
workers_.emplace_back([this] { worker_loop(); });
}
}
~ThreadPool() { shutdown(); }
ThreadPool(const ThreadPool&) = delete;
ThreadPool& operator=(const ThreadPool&) = delete;
template <typename F, typename... Args>
auto enqueue(F&& func, Args&&... args)
-> std::future<std::invoke_result_t<F, Args...>> {
using Result = std::invoke_result_t<F, Args...>;
auto bound = std::make_shared<std::packaged_task<Result()>>(
[func = std::forward<F>(func),
args = std::make_tuple(std::forward<Args>(args)...)]() mutable -> Result {
return std::apply(func, std::move(args));
});
std::future<Result> result = bound->get_future();
{
std::lock_guard<std::mutex> lock(mutex_);
tasks_.emplace([bound] { (*bound)(); });
}
cv_.notify_one();
return result;
}
private:
void shutdown() {
{
std::lock_guard<std::mutex> lock(mutex_);
stopping_ = true;
}
cv_.notify_all();
for (auto& worker : workers_) {
if (worker.joinable()) worker.join();
}
}
void worker_loop() {
for (;;) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [this] { return stopping_ || !tasks_.empty(); });
if (stopping_ && tasks_.empty()) return;
task = std::move(tasks_.front());
tasks_.pop();
}
task();
}
}
std::mutex mutex_;
std::condition_variable cv_;
std::queue<std::function<void()>> tasks_;
std::vector<std::thread> workers_;
bool stopping_{false};
};
int main() {
constexpr int kTasks = 200;
constexpr long long kExpected = 2686700LL; // 1²+2²+…+200² = 200·201·401/6
std::atomic<int> executed{0};
ThreadPool pool(4);
std::vector<std::future<long long>> futures;
futures.reserve(kTasks);
for (int i = 1; i <= kTasks; ++i) {
futures.push_back(pool.enqueue([&executed](int x) {
executed.fetch_add(1, std::memory_order_relaxed);
return static_cast<long long>(x) * x;
}, i));
}
long long total = 0;
for (auto& fut : futures) total += fut.get(); // 主线程等所有结果,顺序无关
std::printf("任务数 = %d, 实际执行 = %d\\n", kTasks, executed.load());
std::printf("平方和 = %lld, 与公式一致 = %d\\n",
total, static_cast<int>(total == kExpected));
{ // 析构排空验证:池销毁时队列里剩的任务必须跑完,一个都不能丢
std::atomic<int> drained{0};
{
ThreadPool inner(2);
for (int i = 0; i < 50; ++i) {
inner.enqueue([&drained] { drained.fetch_add(1, std::memory_order_relaxed); });
}
} // inner 析构 → 排空 → 线程退出
std::printf("析构前提交 50 个, 析构后完成 = %d\\n", drained.load());
}
}
任务数 = 200, 实际执行 = 200
平方和 = 2686700, 与公式一致 = 1
析构前提交 50 个, 析构后完成 = 50
三个数字全部与调度顺序无关:任务一个不多一个不少(200 = 200、50 = 50,证明析构确实排空了队列),平方和等于公式值(证明每个任务的参数都正确送到了执行方)。这里刻意只打印「计数、总和、布尔核对」这类顺序无关的结果。工作线程谁先谁后、哪个线程跑了几个任务,都不影响这三行。
8. 延伸阅读
- std::thread — cppreference —— 线程创建/销毁的成本与 join/detach 的语义,理解「线程池省的是什么」
- std::condition_variable — cppreference —— wait(lock, pred) 为什么必须是谓词版,是 worker_loop 写法正确性的依据
- std::packaged_task — cppreference —— 「返回值 + 异常」通道的关键零件,注意它只可移动
- std::future — cppreference —— 调用方拿到的句柄,get() 一次性消费
- std::invoke_result — cppreference —— enqueue 返回值类型推导的出处
- std::jthread — cppreference —— C++20 的「析构自动 join」,对照它理解这篇的 shutdown() 顺序
- C++ Core Guidelines · 并发章节 —— CP.20/CP.43 等临界区与锁的规则
本知识库内的相关篇目:
- 《std::thread 入门:启动、join、detach 与生命周期》 —— std::thread 析构前必须 join 或 detach
- 《std::async、future、promise:异步任务的返回值、异常与三个经典坑》 —— std::thread 拿不到返回值、也传不出异常
- 《mutex 与 lock_guard / unique_lock:共享数据的正确加锁姿势》 —— 多个线程改同一个变量会怎样?这篇先讲清数据竞争(读-改-写被打断)为什么让结果随机
9. 一句话总结
线程池 = 一次创建、反复复用:构造时起 N 个工作线程,各自循环「等任务 → 持锁取任务 → 锁外执行」,任务队列由 mutex 保护、由 condition_variable 唤醒;enqueue 用可变参数模板 + packaged_task + future 把返回值和异常送回调用方;停止时顺序铁定是「持锁置标志 → notify_all → join」,反了就是死锁或丢任务;执行任务一定要放在锁外,否则任务里再 enqueue 会自锁、并行度也归零。
网硕互联帮助中心


评论前必须登录!
注册