SoftGLRender源码:线程池ThreadPool类

特性

文件:Base/ThreadPool.h

线程池:预先创建一组线程(对象),用于并发完成用户指定任务的机制,避免频繁创建、销毁线程,而降低效率.

关键点:

  1. 如何创建这组线程?何时创建?
  2. 线程组的子线程如何运行?
  3. 如何维护用户任务?
  4. 线程组如何取出并执行用户任务?如何传递参数给用户任务?
  5. 如何终止、销毁线程池?
  6. 如何确保线程安全?

线程池模型

典型线程池模型:

img

ThreadPool数据成员

ThreadPool包含哪些数据成员?

  1. 一组线程对象threads_及其数量threadCnt_,用于执行用户任务;
  2. 一个任务队列tasks_及其数量tasksCnt_,包含用户要执行的任务;
  3. 互斥锁mutex_,确保对用户队列的操作的线程安全.
public:
	...
	// 暂停子线程,用于控制是否暂停正在执行用户任务的子线程
	std::atomic<bool> paused{ false };
private:
	mutable std::mutex mutex_ = {};
	// 线程池运行状态,用于控制是否退出线程池内部工作循环
	std::atomic<bool> running_{ true };

	// 工作线程数组
	std::unique_ptr <std::thread[]> threads_;
	// 工作线程组包含线程数量
	std::atomic<size_t> threadCnt_{ 0 };

	// 任务队列,由用户传入,工作线程组执行
	std::queue<std::function<void(size_t)>> tasks_ = {};
	// 任务队列中任务数量
	std::atomic<size_t> tasksCnt_{ 0 };

ThreadPool实现

线程池的构造、析构

线程池构造时,创建工作线程组;析构时,等待用户任务完成后,销毁线程(连接线程).

tips: 子线程的销毁(连接),必须在用户任务执行完毕后,否则可能造成用户任务的异常.

class ThreadPool {
public:
	explicit ThreadPool(const size_t threadCnt = std::thread::hardware_concurrency()) 
		: threadCnt_(threadCnt), threads_(new std::thread[threadCnt]) {
		createThreads(); // 创建线程组
	}

	~ThreadPool() {
		waitTasksFinish(); // 等待所有用户任务执行完
		running_ = false;
		joinThreads();     // 连接所有子线程
	}
	...
};

创建线程组

ThreadPool用C++ 11引入的std::thread创建、管理线程;用std::unique_ptr<std::thread[]>来管理子线程数组threads_,默认子线程数量为逻辑核心数std::thread::hardware_concurrency()(非物理核心数).

class ThreadPool {
public:
	explicit ThreadPool(const size_t threadCnt = std::thread::hardware_concurrency()) 
		: threadCnt_(threadCnt), threads_(new std::thread[threadCnt]) {
		createThreads();
	}
	...
private:

	void createThreads() {
		for (size_t i = 0; i < threadCnt_; i++) {
			threads_[i] = std::thread(&ThreadPool::taskWorker, this, i);
		}
	}
	...
}

创建std::thread对象时,传递成员函数ThreadPool::taskWorker的指针作为参数,但编译器并不知道该函数属于哪个对象,因此,必须传递对象指针,即this. taskWorker是子线程的线程函数.

销毁线程组

并不能通过直接销毁线程池对象,而销毁线程池,因为线程池还持有线程组资源、用户任务资源. 必须执行完所有用户任务后,再统一销毁所有子线程,避免用户任务出现异常.

因此,析构函数中设置waitTasksFinish(),等待用户任务完成.

public:
	~ThreadPool() {
		waitTasksFinish();
		running_ = false;
		joinThreads();
	}

	void waitTasksFinish() const { // 等待执行用户任务完成
		while (true) {
			if (!paused) {
				if (tasksCnt_ == 0) {
					break;
				}
				else {
					if (tasksRunningCnt() == 0) {
						break;
					}
					 // 暂停当前线程,即析构ThreadPool对象的线程
					std::this_thread::yield();
				}
			}
		}
	}

private:
	size_t tasksRunningCnt() const { // 当前正在执行的用户任务数
		// 正在执行的用户任务数 = 总任务数 - 队列中待执行任务数
		return tasksCnt_ - tasksQueueCnt();
	}

	void joinThreads() { // 连接所有子线程,系统回收其资源
		for (size_t i = 0; i < threadCnt_; i++) {
			threads_[i].join();
		}
	}

向任务队列添加用户任务

线程池的目的是提供一组线程,执行用户任务,需要为用户提供添加待执行用户任务接口.

使用std::queue(FIFO队列)管理用户任务,特点是队列尾插入数据、队列头取出数据,遵循FIFO规则.

public:
	// 入队列
	// 添加用户任务,不带参数
	// 模板函数接受任何可调用对象,作为参数task,代表用户任务
	// F是模板参数,也是函数类型,由于内部转型,F对应实参必须是形如 void(size_t) 的可调用对象
	template<typename F>
	void pushTask(const F& task) {
		tasksCnt_++;
		{
			const std::lock_guard<std::mutex> lock(mutex_); // 线程安全
			// task转型,否则可能无法通过编译
			tasks_.push(std::function<void(size_t)>(task));
		}
	}

	template<typename F, typename... A> // 模板参数包
	void pushTask(const F& task, const A &...args) { // 函数参数包
		pushTask([task, args...] { task(args...); }); // 包扩展
	}

e.g. 客户端向线程池添加任务(调用第1个pushTask()):

ThreadPool pool(6);
pool.pushTask([&](int thread_id) { skyboxTex[0] = loadTextureFile(filepath + "right.jpg"); });
...

这里实参是形如 [&](int thread_id) {...}的lambda.

关于变长模板函数pushTask的定义,个人认为是有问题的,因为:

  1. 没有定义终止条件;
  2. 变长版本pushTask中调用的pushTask是递归版本,还是非递归版本?

如果前者,由于前面我们已经知道,模板参数F对应实参,必须是形如void(size_t)的lambda,而这里,显然没有提供;
如果后者,实参[task, args...] { task(args...); }显然只是单个lambda,并没有后面的参数.

  1. 不像第1个版本,没有明确指示模板参数F对应的类型是什么.

因为程序并未使用该变长参数版本,因此不深究.

从任务队列取出用户任务

用户并不直接从任务队列取出任务,只有线程组才会取,因此接口设计为private.

private:
	// 出队列
	bool popTask(std::function<void(size_t)>& task) {
		const std::lock_guard<std::mutex> lock(mutex_);
		if (tasks_.empty()) {
			return false;
		}
		else{
			task = std::move(tasks_.front());
			tasks_.pop();
			return true;
		}
	}

为什么这里使用std::move,而不是直接拷贝?

因为为了避免不必要的拷贝开销,提高效率. 队列tasks_存放元素类型std::function<void(size_t)>,如果直接赋值,则会拷贝整个对象,而std::function本质是一个函数包装器,内部可能包含堆分配(如捕获大量变量),造成很大开销.

子线程执行用户任务

子线程组自动从任务队列取出用户任务,并执行,是线程池内部自动完成的,无需外部参与.

taskWorker是每个子线程的线程函数,线程id是createThreads创建线程组时传入的顺序编号(1,2,...,n),并非OS分配的. 传入线程id是为了方便调试.

private:
	// 子线程调用的工作线程
	// 维护一个主循环:不断取出用户任务并执行
	void taskWorker(size_t threadId) {
		while (running_) {
			std::function<void(size_t)> task;
			if (!paused && popTask(task)) { // 从队列取出用户任务
				task(threadId); // 执行用户任务
				tasksCnt_--;
			}
			else {
				std::this_thread::yield();
			}
		}
	}

任务队列空时,如何处理?

线程池子线程由于取不到用户任务,因此处于空闲状态,可以主动让出(yield())CPU资源.

子线程的暂停与退出

  • 暂停:子线程通过paused实现线程暂停,暂停执行任务,主动让出CPU资源;

  • 退出:子线程通过running_实现退出线程的主循环. 子线程退出后,才能被主线程连接,以回收线程资源;否则,未执行完的用户任务,可能产生异常.

线程安全

要什么会涉及线程安全?为什么只给队列操作pushTask(插入任务),popTask(弹出任务)加锁?

因为主线程创建线程池后,运行在主线程的用户,会往任务队列添加用户任务,而子线程会不断从队列取出任务. 也就是说,只有这2个操作时,存在不同线程同时访问队列的情形,因此需要用互斥锁mutex_确保线程安全.

posted @ 2025-05-18 16:53  明明1109  阅读(29)  评论(0)    收藏  举报