线程池
线程池
ThreadPool.hpp
class ThreadPool
{
public:
ThreadPool(size_t);
template<class F, class... Args>
auto enqueue(F&& f, Args&&... args)
-> std::future<typename std::result_of<F(Args...)>::type>;
~ThreadPool();
private:
// 1.工作线程数组:保存所有创建的线程对象,最后要join它们
std::vector<std::thread> workers;
// 2.任务队列:存放用户提交的所有待执行任务
// std::function<void()> 是一个"万能函数包装器",可以存任何无参无返回值的函数
std::queue<std::function<void()>> tasks;
// 3.同步三件套(线程安全的核心)
std::mutex queue_mutex; // 互斥锁:保护任务队列和stop标志的访问
std::condition_variable condition; // 条件变量:让线程"没事干就睡觉,有活干再醒"
bool stop; // 停止标志:告诉所有线程"该下班了"
};
//构造函数只是启动若干个工作线程
inline ThreadPool::ThreadPool(size_t threads): stop(false)
{
for (size_t i = 0; i < threads; ++i)
workers.emplace_back(
[this]
{
for (;;)
{
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(this->queue_mutex);
this->condition.wait(lock,[this] { return this->stop || !this->tasks.empty(); });
if (this->stop && this->tasks.empty()) return;
task = std::move(this->tasks.front());
this->tasks.pop();
}
task();
}
}
);
}
//向池中添加新的工作项
template<class F, class... Args>
auto ThreadPool::enqueue(F&& f, Args&&... args)
-> std::future<typename std::result_of<F(Args...)>::type>
{
using return_type = typename std::result_of<F(Args...)>::type;
auto task = std::make_shared< std::packaged_task<return_type()> >(std::bind(std::forward<F>(f), std::forward<Args>(args)...));
std::future<return_type> res = task->get_future();
{
std::unique_lock<std::mutex> lock(queue_mutex);
// 不允许在池停止后继续入队任务
if (stop) throw std::runtime_error("enqueue on stopped ThreadPool");
tasks.emplace([task]() { (*task)(); });
}
condition.notify_one();
return res;
}
// the destructor joins all threads
inline ThreadPool::~ThreadPool()
{
{
std::unique_lock<std::mutex> lock(queue_mutex);
stop = true;
}
condition.notify_all();
for (std::thread& worker : workers)
worker.join();
}
ThreadPool.cpp
#include <iostream>
#include "ThreadPool.hpp"
using namespace std;
int main()
{
// 1. 创建一个有4个工作线程的线程池
ThreadPool pool(4);
// 2. 提交一个任务
auto future = pool.enqueue([](int a, int b) {
std::this_thread::sleep_for(std::chrono::seconds(1));
return a + b;
}, 10, 20);
// 3. 此时发生了什么?
// - 任务被包装成packaged_task,加入任务队列
// - 唤醒一个工作线程
// - 工作线程从队列取出任务,执行1+2=3
// - 执行完后,把结果存入future
// - 工作线程回到休眠状态
// 4. 获取任务结果(会阻塞直到任务执行完毕)
int result = future.get(); // result = 30
// 5. 当pool离开作用域时,析构函数自动调用
// - 设置stop=true
// - 唤醒所有线程
// - 线程们检查stop=true且任务队列为空,退出循环
// - 析构函数join所有线程,程序安全退出
return 0;
}
1.整体架构:一句话搞懂线程池
线程池 = 一堆预先创建好的工作线程 + 一个线程安全的任务队列
工作原理:
- 构造时一次性创建 N 个工作线程,它们都处于休眠等待状态
- 用户调用enqueue()提交任务,任务被加入队列
- 唤醒一个休眠的工作线程,它从队列取出任务执行
- 执行完后,线程回到休眠状态,等待下一个任务
- 析构时,通知所有线程停止,等待它们执行完剩余任务后退出
2.类成员变量详解(先搞懂每个东西是干嘛的)
private:
// 1. 工作线程数组:保存所有创建的线程对象,最后要join它们
std::vector<std::thread> workers;
// 2. 任务队列:存放用户提交的所有待执行任务
// std::function<void()> 是一个"万能函数包装器",可以存任何无参无返回值的函数
std::queue<std::function<void()>> tasks;
// 3. 同步三件套(线程安全的核心)
std::mutex queue_mutex; // 互斥锁:保护任务队列和stop标志的访问
std::condition_variable condition; // 条件变量:让线程"没事干就睡觉,有活干再醒"
bool stop; // 停止标志:告诉所有线程"该下班了"
✅ 核心逻辑:所有对tasks和stop的读写操作,必须先加queue_mutex锁,否则会出现竞态条件。
3.构造函数详解:启动所有工作线程
// 构造函数:参数threads是要创建的工作线程数量
inline ThreadPool::ThreadPool(size_t threads): stop(false)
{
// 循环创建threads个工作线程
for (size_t i = 0; i < threads; ++i)
// emplace_back:直接在vector里构造线程对象,比push_back更高效
workers.emplace_back
(
// 每个线程执行这个lambda函数(无限循环,直到收到停止信号)
[this]
{
for (;;)
{
std::function<void()> task;
// 🔒 加锁:只有拿到锁的线程才能访问任务队列
{
// unique_lock:比lock_guard更灵活,支持解锁和重新加锁
std::unique_lock<std::mutex> lock(this->queue_mutex);
// 💤 条件变量等待:线程在这里睡觉,直到被唤醒
// wait会自动做3件事:
// 1. 释放锁(让其他线程可以加锁)
// 2. 阻塞当前线程,进入休眠
// 3. 被唤醒后,重新加锁
this->condition.wait(lock,
// 唤醒条件:要么收到停止信号,要么任务队列不为空
// 这个lambda会在每次被唤醒时执行,检查条件是否满足
// 解决"虚假唤醒"问题(线程可能无缘无故被唤醒)
[this] { return this->stop || !this->tasks.empty(); });
// ✅ 被唤醒后,先检查是不是该下班了
// 如果已经停止,并且任务队列已经空了,就退出循环,线程结束
if (this->stop && this->tasks.empty()) return;
// 📥 从队列头部取出一个任务
// std::move:转移所有权,避免拷贝,提高效率
task = std::move(this->tasks.front());
this->tasks.pop();
} // 🔓 这里锁自动释放(unique_lock出作用域自动析构)
// 🚀 执行任务!
// 注意:执行任务的时候**没有持有锁**!
// 这是关键:如果执行任务时还拿着锁,其他线程就无法往队列里加任务了
task();
}
}
);
}
4.enqueue 函数详解:提交任务(最复杂但最巧妙的部分)
这是整个线程池的核心 API,支持任意参数、任意返回值的任务,还能通过std::future获取任务的返回结果。
// 模板函数:支持任何类型的函数f和任意数量的参数args
// 返回值是std::future<返回值类型>,用户可以用它来获取任务的执行结果
template<class F, class... Args>
auto ThreadPool::enqueue(F&& f, Args&&... args)
-> std::future<typename std::result_of<F(Args...)>::type>
{
// 第一步:推导任务的返回值类型
// std::result_of<F(Args...)>::type 就是函数f调用args后的返回值类型
using return_type = typename std::result_of<F(Args...)>::type;
// 第二步:把用户的任务包装成一个packaged_task
// packaged_task是C++11提供的工具,它可以把一个函数包装成一个"可调用对象"
// 并且能通过get_future()获取一个std::future,用来接收返回值
// 为什么用shared_ptr?因为packaged_task不可拷贝,只能移动
// 而std::function要求里面的对象是可拷贝的,所以用shared_ptr来管理它的生命周期
auto task = std::make_shared<std::packaged_task<return_type()>>(
// std::bind:把函数f和参数args绑定成一个无参函数
// std::forward:完美转发,保持参数的左值/右值属性
std::bind(std::forward<F>(f), std::forward<Args>(args)...)
);
// 第三步:获取future,用来返回给用户
std::future<return_type> res = task->get_future();
// 🔒 加锁:往任务队列里加任务必须加锁
{
std::unique_lock<std::mutex> lock(queue_mutex);
// 检查:如果线程池已经停止了,就不能再提交任务了
// 这就是你之前问的"don't allow enqueueing after stopping the pool"
if (stop)
throw std::runtime_error("enqueue on stopped ThreadPool");
// 📤 把任务加入队列
// 这里又包了一层lambda,把packaged_task变成一个无参无返回值的函数
// 这样才能放进std::queue<std::function<void()>>里
tasks.emplace([task]() { (*task)(); });
} // 🔓 锁自动释放
// 🔔 唤醒一个正在睡觉的工作线程,告诉它"有活干了"
// 为什么用notify_one而不是notify_all?
// 因为只需要一个线程来执行这个任务,唤醒多个会导致"惊群效应",浪费CPU
condition.notify_one();
// 返回future,用户可以用res.get()来获取任务的返回值
return res;
}
解析:
// 模板函数:支持任何类型的函数f和任意数量的参数args
// 返回值是std::future<返回值类型>,用户可以用它来获取任务的执行结果
template<class F, class... Args>
auto ThreadPool::enqueue(F&& f, Args&&... args)
-> std::future<typename std::result_of<F(Args...)>::type>
📦 class F 的含义
F是一个类型模板参数(type template parameter)。- 在调用
enqueue时,编译器会根据你传入的第一个实参的类型自动推导出F是什么类型。 - 通常
F代表一个可调用对象(callable object),比如函数指针、lambda 表达式、std::function等。
举例:
pool.enqueue(print, 42);
这里 print 是一个函数,编译器推导 F 为 void(int) 或函数指针类型。
📦 class... Args 的含义
Args是一个模板参数包(template parameter pack)。- 它可以代表零个或多个类型。
- 在调用
enqueue时,编译器根据你传入的额外实参(在f之后的所有参数)的类型推导出Args包中的各个类型。
举例:
pool.enqueue(print, 42, "hello");
这里:
F推导为print的类型(函数类型)Args推导为int, const char*两个类型,因为print之后有42(int) 和"hello"(const char*)。
如果 enqueue 只传一个参数(比如 pool.enqueue(func)),那么 Args 就是空的。
📦&&在这里是万能引用
这里的
&&是为了让enqueue这个函数能接受任何参数 ,不管你是传一个临时值,还是传一个变量进去,它都能正确工作,并且不会多拷贝。
-> std::future<typename std::result_of<F(Args...)>::type>
📦这一行是 enqueue 函数的返回类型声明。
这个函数返回一个
std::future对象,这个future里装的数据类型,就是传进来的函数F在调用参数Args...后的返回值类型。
ThreadPool pool(4);
auto future = pool.enqueue([](int a, int b) { return a + b; }, 3, 5);
这里:
F是那个 lambda 函数,参数是int, int,返回值是int。Args...是{3, 5}(类型是int, int)。
那么 std::result_of<F(Args...)>::type 就会被推导为 int。
所以整个 enqueue 返回的类型就是 std::future<int>。
你在外面调用 future.get() 就能拿到 8。
📦为什么写得这么绕?
因为 enqueue 是一个模板函数,F 和 Args 可以是任意类型(函数、lambda、函数对象、任意参数)。
编译器在编译时才知道具体返回什么类型,所以必须用 std::result_of 这个“类型计算器”去推算出来。
📦几个关键点
| 符号 | 含义 |
|---|---|
std::future<T> |
一个容器,将来会装一个 T 类型的值,你可以 get() 它 |
std::result_of<F(Args...)>::type |
F 用 Args... 调用后的返回值类型 |
typename |
必须加,因为 ::type 这个东西是依赖类型(依赖模板参数),不加编译器不认识 |
-> 尾置返回类型 |
允许你在参数列表之后写返回类型,这样就能用 F 和 Args 来推导了(如果写在前面,它们还没声明) |
一句话记住
这一行就是:提交一个任务,返回一个能拿到任务结果的
future,结果的类型由你提交的函数和参数自动决定。
如果你还是觉得复杂,没关系——你只需要知道:
这个 enqueue 会返回一个 future,你用 future.get() 就能得到任务的返回值,至于它怎么推导类型,那是编译器的事。
第一步:推导任务的返回值类型
// std::result_of<F(Args...)>::type 就是函数f调用args后的返回值类型
using return_type = typename std::result_of<F(Args...)>::type;
第二步:把用户的任务包装成一个packaged_task
把用户提供的函数 f 和参数 args 打包成一个可以“延迟调用”并且能“返回结果”的任务对象,然后用 shared_ptr 管理起来,方便放进队列。
auto task = std::make_shared<std::packaged_task<return_type()>>(
// std::bind:把函数f和参数args绑定成一个无参函数
// std::forward:完美转发,保持参数的左值/右值属性
std::bind(std::forward<F>(f), std::forward<Args>(args)...)
);
先看最里层:std::bind(...)
std::bind(std::forward<F>(f), std::forward<Args>(args)...)
std::bind的作用是:把函数f和它的参数args绑定在一起,生成一个新的“无参数的可调用对象”。- 原本你可能要这样调用
f(arg1, arg2, ...),经过bind之后,你只需要像bound()这样无参调用,它就会自动用之前保存的参数去调用f。 std::forward保证参数的左右值属性不丢失(完美转发)。
举例:
假设 f 是 int add(int a, int b),args 是 3 和 5。
那么 bind 产生的对象等价于:[](){ return add(3,5); }。
第二层:std::packaged_task<return_type()>
std::packaged_task<return_type()>
packaged_task是一个模板类,它包装一个可调用对象,并且这个可调用对象的返回值类型是return_type,参数列表是空(即())。- 它有两个关键能力:
- 可以像函数一样调用(
operator()),执行内部包装的任务。 - 可以关联一个
std::future,通过get_future()拿到这个future,未来通过future.get()就能拿到任务的返回值。
- 可以像函数一样调用(
所以,我们把 std::bind 的结果(一个无参、返回 return_type 的可调用对象)交给 std::packaged_task,就能得到一个既能执行任务、又能返回 future 的包装器。
第三层:std::make_shared<...>
auto task = std::make_shared<std::packaged_task<return_type()>>( ... );
make_shared在堆上动态分配一个packaged_task对象,并用shared_ptr管理它。- 为什么要用
shared_ptr? 因为packaged_task是只移动、不可拷贝的类型(拷贝构造函数被删除)。而我们后面要把这个任务对象放进队列tasks,队列里存的是std::function<void()>,而std::function要求内部的对象是可拷贝的。 - 解决办法:把
packaged_task放进shared_ptr,然后shared_ptr本身是可拷贝的。在队列中存一个 lambda:[task]() { (*task)(); },这个 lambda 拷贝的是shared_ptr,而不是packaged_task本身,因此合法。
第三步:获取future,用来返回给用户
// 第三步:获取future,用来返回给用户
std::future<return_type> res = task->get_future();
// 🔒 加锁:往任务队列里加任务必须加锁
{
std::unique_lock<std::mutex> lock(queue_mutex);
// 检查:如果线程池已经停止了,就不能再提交任务了
// 这就是你之前问的"don't allow enqueueing after stopping the pool"
if (stop) throw std::runtime_error("enqueue on stopped ThreadPool");
// 📤 把任务加入队列
// 这里又包了一层lambda,把packaged_task变成一个无参无返回值的函数
// 这样才能放进std::queue<std::function<void()>>里
tasks.emplace([task]() { (*task)(); });
} // 🔓 锁自动释放
// 🔔 唤醒一个正在睡觉的工作线程,告诉它"有活干了"
// 为什么用notify_one而不是notify_all?
// 因为只需要一个线程来执行这个任务,唤醒多个会导致"惊群效应",浪费CPU
condition.notify_one();
// 返回future,用户可以用res.get()来获取任务的返回值
return res;
(1)获取 future 对象
std::future<return_type> res = task->get_future();
task是一个std::shared_ptr<std::packaged_task<return_type()>>。packaged_task内部持有一个promise,调用get_future()可以获取与该promise关联的future对象。- 这个
future将来会被返回给enqueue的调用者,调用者可以通过res.get()阻塞等待任务的执行结果。 - 注意:此时任务还没有被执行,
future只是一个“凭证”,表示将来会有结果。
(2)加锁并检查线程池状态
{
std::unique_lock<std::mutex> lock(queue_mutex);
if (stop)
throw std::runtime_error("enqueue on stopped ThreadPool");
// ...
}
- 用
std::unique_lock管理互斥锁queue_mutex,保护任务队列tasks的访问。 - 为什么需要锁? 因为可能有多个线程同时调用
enqueue往队列里加任务,也可能工作线程同时从队列中取任务,必须互斥。 if (stop)检查线程池是否已经被销毁(或调用了析构函数)。如果stop == true,说明线程池正在停止,不能再接受新任务,直接抛出异常。这可以防止在析构后继续提交任务导致未定义行为。
(3)将任务包装成无参、无返回值的函数并放入队列
tasks.emplace([task]() { (*task)(); });
tasks是std::queue< std::function<void()> >,队列中每个元素是一个可调用对象,签名是void()(无参数、无返回值)。packaged_task<return_type()>本身是可调用的,签名也是return_type(),但这里队列要求void()类型,不能直接放进去。- 解决方法:用一个 lambda 表达式
[task]() { (*task)(); }包装。- 捕获
task(shared_ptr按值捕获,拷贝一份shared_ptr,引用计数增加)。 - 执行
(*task)()会调用packaged_task的operator(),从而执行绑定的任务(即用户函数f与参数args的执行)。 - 执行完毕后,
packaged_task内部会自动将返回值设置到关联的promise中,进而唤醒等待future的线程。
- 捕获
- 这个 lambda 的返回类型是
void,参数为空,符合队列的std::function<void()>要求。 emplace直接在队列尾部构造这个 lambda,避免了额外的拷贝。
(4)解锁
} // 🔓 锁自动释放
unique_lock走出作用域后自动析构,释放queue_mutex。这保证了锁不会长时间占用,其他线程可以继续操作队列。
(5)唤醒一个工作线程
condition.notify_one();
condition是std::condition_variable,工作线程在无事可做时会调用condition.wait(...)阻塞自己。notify_one()会唤醒一个正在等待的工作线程(如果有的话)。这个被唤醒的线程会重新检查条件(队列非空或停止),然后从队列中取出一个任务执行。- 为什么用
notify_one而不是notify_all?- 因为只添加了一个任务,只需要一个线程来执行它。唤醒所有线程会导致“惊群效应”:多个线程被唤醒,但只有一个能抢到任务,其余线程发现队列为空又继续睡眠,浪费 CPU。
notify_one更加高效,符合“一个任务配一个线程”的逻辑。
5.安全地销毁线程池
这段代码是 ThreadPool 的析构函数,负责安全地销毁线程池。它的核心任务是:通知所有工作线程停止,并等待它们全部退出,最后释放线程资源。
inline ThreadPool::~ThreadPool()
{
{
std::unique_lock<std::mutex> lock(queue_mutex);
stop = true;
}
condition.notify_all();
for (std::thread& worker : workers) worker.join();
}
(1)单独的作用域 { ... }
{
std::unique_lock<std::mutex> lock(queue_mutex);
stop = true;
}
- 这里用一对花括号创建了一个独立的代码块。
- 在块内,我们获得互斥锁
queue_mutex的保护,然后将stop标志设置为true。 - 为什么需要锁? 因为工作线程在
condition.wait中会检查stop和tasks.empty(),这个检查也在锁的保护下进行。不加锁修改stop可能导致数据竞争。 - 为什么单独开一个块? 为了让
lock对象在块结束时立即析构,从而提前释放锁。这样在后续调用condition.notify_all()时,锁已经释放,被唤醒的工作线程能立刻获得锁并检查停止条件,效率更高,也避免死锁。
如果没有这个单独块,我们可以写作:
std::unique_lock<std::mutex> lock(queue_mutex);
stop = true;
lock.unlock();
condition.notify_all();
但用花括号更简洁安全。
(2)唤醒所有工作线程
condition.notify_all();
-
condition是std::condition_variable,所有工作线程都在等待它(在构造函数中的condition.wait)。 -
调用
notify_all()会唤醒所有正在等待的线程。 -
为什么用
notify_all而不是notify_one?因为我们需要所有线程都退出,而不仅仅是一个。每个被唤醒的线程会检查条件:condition.wait(lock, [this] { return stop || !tasks.empty(); });此时
stop == true,而且tasks可能为空(也可能还有剩余任务,但析构时我们不再执行新任务,只是让线程退出)。所以每个线程会跳出wait,然后看到stop && tasks.empty()为真,于是return退出。 -
注意:此时
stop已经是true,且锁已经释放(因为前面的作用域已经解锁),所以被唤醒的线程可以顺利获得锁并检查条件。
(3)等待所有线程结束
for (std::thread& worker : workers) worker.join();
workers是存储std::thread对象的向量。join()会阻塞当前线程(这里是主线程,即调用析构的线程),直到对应的worker线程执行完毕。- 每个工作线程的
run函数(构造函数中的 lambda)会在检查到stop && tasks.empty()后return,从而自然结束线程。 - 调用
join确保所有线程都已退出,避免std::thread析构时joinable而导致的std::terminate。
(4)完整的销毁流程
- 外部代码销毁
ThreadPool对象(例如离开作用域或delete)。 - 析构函数被调用。
- 加锁将
stop设为true,立即释放锁。 - 唤醒所有工作线程。
- 每个工作线程被唤醒后:
- 获得锁。
- 检查条件:
stop == true且队列为空(或即使不为空,但stop优先,线程退出)。 - 退出无限循环,线程函数结束。
- 主线程(析构函数中)逐个调用
join,等待所有工作线程真正结束。 - 析构函数返回,
workers向量销毁,每个thread对象此时已不再是joinable,安全析构。
(5)几点关键设计考量
| 设计点 | 原因 |
|---|---|
先设置 stop 再 notify_all |
保证线程被唤醒后能立即看到停止标志 |
| 用独立作用域提前解锁 | 避免线程被唤醒后还要等待锁释放(减少锁竞争) |
使用 notify_all 而不是 notify_one |
需要所有线程都退出,而不是只唤醒一个 |
最后 join 所有线程 |
确保线程资源被正确回收,避免 std::terminate |
(6)潜在问题
在析构函数中,如果队列中还有未处理的任务,这些任务不会被执行。工作线程看到 stop == true 后直接退出,队列中的任务会被丢弃。这是一个需要注意的行为:线程池销毁时,未完成的任务将丢失。
通常的设计有两种:
- 立即停止:丢弃剩余任务(就像你代码中的做法)。
- 优雅停止:先处理完队列中所有任务,再退出。要实现后者,需要修改条件:
condition.wait的条件改为只依赖队列空或不空,并在析构时先等待队列变空,再设置stop。但你的实现选择了“立即停止”,这也是一种合理选择。
总结一句话
析构函数安全地停止线程池:先标记
stop,唤醒所有工作线程,然后等待它们全部结束,从而正确释放资源。

浙公网安备 33010602011771号