线程池

线程池

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.整体架构:一句话搞懂线程池

线程池 = 一堆预先创建好的工作线程 + 一个线程安全的任务队列

工作原理:

  1. 构造时一次性创建 N 个工作线程,它们都处于休眠等待状态
  2. 用户调用enqueue()提交任务,任务被加入队列
  3. 唤醒一个休眠的工作线程,它从队列取出任务执行
  4. 执行完后,线程回到休眠状态,等待下一个任务
  5. 析构时,通知所有线程停止,等待它们执行完剩余任务后退出

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 是一个函数,编译器推导 Fvoid(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 是一个模板函数,FArgs 可以是任意类型(函数、lambda、函数对象、任意参数)。
编译器在编译时才知道具体返回什么类型,所以必须用 std::result_of 这个“类型计算器”去推算出来。

📦几个关键点

符号 含义
std::future<T> 一个容器,将来会装一个 T 类型的值,你可以 get()
std::result_of<F(Args...)>::type FArgs... 调用后的返回值类型
typename 必须加,因为 ::type 这个东西是依赖类型(依赖模板参数),不加编译器不认识
-> 尾置返回类型 允许你在参数列表之后写返回类型,这样就能用 FArgs 来推导了(如果写在前面,它们还没声明)

一句话记住

这一行就是:提交一个任务,返回一个能拿到任务结果的 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 保证参数的左右值属性不丢失(完美转发)。

举例
假设 fint add(int a, int b)args35
那么 bind 产生的对象等价于:[](){ return add(3,5); }

第二层:std::packaged_task<return_type()>
std::packaged_task<return_type()>
  • packaged_task 是一个模板类,它包装一个可调用对象,并且这个可调用对象的返回值类型return_type参数列表是空(即 ())。
  • 它有两个关键能力:
    1. 可以像函数一样调用operator()),执行内部包装的任务。
    2. 可以关联一个 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)(); });
  • tasksstd::queue< std::function<void()> >,队列中每个元素是一个可调用对象,签名是 void()(无参数、无返回值)。
  • packaged_task<return_type()> 本身是可调用的,签名也是 return_type(),但这里队列要求 void() 类型,不能直接放进去。
  • 解决方法:用一个 lambda 表达式 [task]() { (*task)(); } 包装。
    • 捕获 taskshared_ptr 按值捕获,拷贝一份 shared_ptr,引用计数增加)。
    • 执行 (*task)() 会调用 packaged_taskoperator(),从而执行绑定的任务(即用户函数 f 与参数 args 的执行)。
    • 执行完毕后,packaged_task 内部会自动将返回值设置到关联的 promise 中,进而唤醒等待 future 的线程。
  • 这个 lambda 的返回类型是 void,参数为空,符合队列的 std::function<void()> 要求。
  • emplace 直接在队列尾部构造这个 lambda,避免了额外的拷贝。
(4)解锁
} // 🔓 锁自动释放
  • unique_lock 走出作用域后自动析构,释放 queue_mutex。这保证了锁不会长时间占用,其他线程可以继续操作队列。
(5)唤醒一个工作线程
condition.notify_one();
  • conditionstd::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 中会检查 stoptasks.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();
  • conditionstd::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)完整的销毁流程

  1. 外部代码销毁 ThreadPool 对象(例如离开作用域或 delete)。
  2. 析构函数被调用。
  3. 加锁将 stop 设为 true,立即释放锁。
  4. 唤醒所有工作线程。
  5. 每个工作线程被唤醒后:
    • 获得锁。
    • 检查条件:stop == true 且队列为空(或即使不为空,但 stop 优先,线程退出)。
    • 退出无限循环,线程函数结束。
  6. 主线程(析构函数中)逐个调用 join,等待所有工作线程真正结束。
  7. 析构函数返回,workers 向量销毁,每个 thread 对象此时已不再是 joinable,安全析构。

(5)几点关键设计考量

设计点 原因
先设置 stopnotify_all 保证线程被唤醒后能立即看到停止标志
用独立作用域提前解锁 避免线程被唤醒后还要等待锁释放(减少锁竞争)
使用 notify_all 而不是 notify_one 需要所有线程都退出,而不是只唤醒一个
最后 join 所有线程 确保线程资源被正确回收,避免 std::terminate

(6)潜在问题

在析构函数中,如果队列中还有未处理的任务,这些任务不会被执行。工作线程看到 stop == true 后直接退出,队列中的任务会被丢弃。这是一个需要注意的行为:线程池销毁时,未完成的任务将丢失。

通常的设计有两种:

  • 立即停止:丢弃剩余任务(就像你代码中的做法)。
  • 优雅停止:先处理完队列中所有任务,再退出。要实现后者,需要修改条件:condition.wait 的条件改为只依赖队列空或不空,并在析构时先等待队列变空,再设置 stop。但你的实现选择了“立即停止”,这也是一种合理选择。

总结一句话

析构函数安全地停止线程池:先标记 stop,唤醒所有工作线程,然后等待它们全部结束,从而正确释放资源。

posted @ 2026-05-15 23:06  CodeMagicianT  阅读(8)  评论(0)    收藏  举报