使用 C++11 实现的动态线程池项目学习
ThreadPool.hpp实现
.hpp
和 .h
都是用于 C++ 头文件的文件扩展名,.h
通常用于 C 语言头文件或早期的 C++ 项目。它遵循 C 语言的命名习惯。.hpp
是 C++ 头文件的推荐扩展名,它有助于区分 C++ 头文件和 C 头文件,特别是在大型项目或混合语言项目中。
设置守卫机制:
#ifndef THREADPOOL_H
#define THREADPOOL_H
// hpp文件具体内容
#endif
线程池类
一个线程池的实现一定离不开锁机制,因为线程池是公有资源,所以取一个线程以及放回线程都应该加锁;
使用std::thread
实现单个线程;
public:
using MutexGuard = std::lock_guard<std::mutex>;
using UniqueLock = std::unique_lock<std::mutex>;
using Thread = std::thread;
using ThreadID = std::thread::id;
using Task = std::function<void()>; // 回调函数,每个线程执行的任务本质上就是执行一个回调函数
为用到的类型重新命名;
成员变量
private:
static constexpr size_t WAIT_SECONDS = 2;
bool quit_; // 标识线程池状态,如果线程池状态为true,说明即将销毁,不再接受新任务,并且已经接受的任务要尽快执行完成
size_t currentThreads_; // 当前可用线程数
size_t idleThreads_; // 空闲状态的线程数(没有分配任务,等待分配任务的线程)
size_t maxThreads_; // 最大线程数
mutable std::mutex mutex_; // 互斥锁
std::condition_variable cv_; // 条件变量,用于通知线程执行
std::queue<Task> tasks_; // 任务队列
std::queue<ThreadID> finishedThreadIDs_;
std::unordered_map<ThreadID, Thread> threads_; // TreadID一般就是PID,每个线程PID唯一
构造函数
ThreadPool() : ThreadPool(Thread::hardware_concurrency()) {}
explicit ThreadPool(size_t maxThreads)
: quit_(false),
currentThreads_(0),
idleThreads_(0),
maxThreads_(maxThreads)
{}
// 删除复制构造函数
ThreadPool(const ThreadPool &) = delete;
ThreadPool &operator=(const ThreadPool &) = delete;
删除复制构造函数意味着我们不能通过一个线程池构造另一个线程池;多组线程池可能导致资源混乱,比如一组线程可能同时在多个线程池中,导致资源混乱;其实更好的方法是使用单例模式,让全局仅存在一个线程池;
析构函数
// 析构函数
~ThreadPool()
{
{
MutexGuard guard(mutex_); // 修改线程池的状态,线程池的状态是一个公共变量,所有线程共享,所以要加锁
quit_ = true;
}
cv_.notify_all(); // 唤醒所有线程
for (auto &elem: threads_) // 遍历线程池,依次释放每一个线程的资源
{
assert(elem.second.joinable());
elem.second.join();
}
}
作用域机制:
{
MutexGuard guard(mutex_); // 修改线程池的状态,线程池的状态是一个公共变量,所有线程共享,所以要加锁
quit_ = true;
}
用一个大括号定义一个作用域,保证了锁MutexGuard guard(mutex_);
在作用域打开时创建,在作用结束时自动销毁,避免了手动解锁;
这是C++中的RAII(资源获取即初始化)机制的一个应用实例;
而且,即使作用域内部发生了异常,只要退出了作用域,就会自动解锁,避免了作用域异常导致解锁操作没有执行,从而阻塞所有线程;
最小化了锁定范围:将加锁操作放在最内层的作用域可以有效减少锁持有的时间,从而减少线程竞争,提高并发性能;
清晰的逻辑结构:使用大括号清晰地界定了代码块的开始和结束,使得代码逻辑结构更加明确,易于阅读和维护;
防止资源泄露:大括号退出以后自然释放资源,防止资源泄露;
避免死锁:通过限制锁的作用域,可以降低因为嵌套锁或锁顺序错误而导致的死锁风险;
唤醒等待条件变量的线程
cv_.notify_all(); // 唤醒所有线程
在多线程编程中,notify_all()
函数是一个用于线程同步的重要操作,通常与条件变量(condition variables)一起使用。这个函数的作用是唤醒所有正在等待特定条件变量的所有线程。
notify_all()
会唤醒所有因等待同一个条件变量而处于等待状态的线程。这与 notify_one()
不同,后者只唤醒一个等待的线程。
如果说任务队列中有待执行的任务,我们只需要用cv_.notify_one()
,因为只需要唤醒一个线程去执行该任务;
为什么要唤醒所有线程?
因为当线程池要关闭的时候,可能还有任务需要执行,我们最好让任务都执行完之后再关闭线程池,所以唤醒所有线程去任务队列中找任务执行;此外有些任务可能在阻塞,或者等待某些资源,如果不唤醒,就会导致线程池被销毁时,这些线程资源无法被一起释放;
唤醒所有等待的线程可以帮助实现线程池的优雅关闭。线程池可以设置一个标志 quit_
,告诉所有线程不再接受新的任务,并且尽快完成当前的任务后退出。
线程资源的回收:线程池析构时,需要回收分配给线程的所有资源,包括线程本身、任务队列、同步原语等。唤醒所有线程有助于确保这些资源可以被正确地回收。
使线程安全退出:唤醒等待的线程可以让它们在安全的状态退出。例如,它们可以在退出前检查是否有未完成的工作,或者执行一些清理工作。
在线程池销毁前唤醒所有线程可以更好地保证资源被释放以及任务队列中的任务执行完成,避免资源泄露;
逐个释放线程池中线程
// std::unordered_map<ThreadID, Thread> threads_;
for (auto &elem: threads_) // 遍历线程池,依次释放每一个线程的资源
{
assert(elem.second.joinable());
elem.second.join(); // elem.second代表TreadID,该ID唯一标识一个线程
}
当调用一个 std::thread
对象的 join()
函数时,当前线程会阻塞,直到被 join()
的线程执行结束(即保证在销毁线程池前,已经接受的任务被合理地执行完成)。
当被join()
的线程执行结束时,join()
也会确保该线程使用的资源被适当地回收,这包括线程栈的释放等。
一个线程对象std::thread
的 join()
被调用,该 std::thread
对象就不能再被用来调用 join()
或 detach()
,否则将抛出 std::system_error
异常。所以在调用join()
之前,最好检查是否已经被调用过了,即assert(elem.second.joinable());
,使用joinable()
成员函数来检查;
相当于顺序执行了线程池中的所有线程,让所有线程都执行完任务,然后各自释放资源;
这也解释了为什么之前要唤醒所有线程,就是为了之后遍历线程池,为每个线程都join()
做准备;
提交任务到任务队列
任务队列的实现使用 queue
实现;
template<typename Func, typename... Ts>
auto submit(Func &&func, Ts &&... params)
-> std::future<typename std::result_of<Func(Ts...)>::type>
{
// 绑定参数,使用完美转发机制,之后可以调用execute执行
auto execute = std::bind(std::forward<Func>(func), std::forward<Ts>(params)...);
// using和typedef类似,指定别名
using ReturnType = typename std::result_of<Func(Ts...)>::type;
using PackagedTask = std::packaged_task<ReturnType()>;
// 使用shared_ptr智能指针机制,避免手动管理
auto task = std::make_shared<PackagedTask>(std::move(execute));
auto result = task->get_future();
// 加互斥锁,因为任务队列是公共资源,访问要加锁
MutexGuard guard(mutex_);
assert(!quit_); // 保证此时没有进行线程池销毁工作,如果进行销毁,则不能添加新任务
// 将任务添加到任务队列
tasks_.emplace([task]()
{
(*task)();
});
// 找到一个线程执行任务
if (idleThreads_ > 0) // 如果有空闲线程
{
cv_.notify_one(); // 唤醒一个线程(用cv_条件变量)
}
else if (currentThreads_ < maxThreads_) // 没有空闲线程,看是否到达容量上限,没有则创建新线程
{
Thread t(&ThreadPool::worker, this);
assert(threads_.find(t.get_id()) == threads_.end()); //该线程没有被添加
threads_[t.get_id()] = std::move(t); // 添加该线程到线程池
++currentThreads_; //现存线程数量自增
}
return result;
} // 生命周期结束,互斥锁自动销毁
细节分析:
模板参数
template<typename Func, typename... Ts> // 使用typename比class更好
在 C++ 模板编程中,模板参数可以根据它们所代表的类型被分类为两种:
- 类型模板参数:使用
typename
关键字声明,它们代表类型。例如,在模板声明template<typename T>
中,T
是一个类型模板参数。 - 非类型模板参数:使用
template<类名... Args>
或template<int N>
这样的形式声明,它们可以是任意类型,包括整数、浮点数、指针、引用等,但它们不是类型参数。
在上述代码中,Func
是一个类型模板参数,代表一个类型,通常用于指定一个函数或可调用对象的类型;TS...
是一个类型模板参数包,可以传入零个或者任意多个类型参数;
后置返回类型
auto submit(Func &&func, Ts &&... params)
-> std::future<typename std::result_of<Func(Ts...)>::type>
{}
后置返回类型:后置返回类型是 C++11 引入的语法特性,它允许开发者在函数声明或定义的末尾指定返回类型,而不是在函数名后面。这种语法特别适用于模板编程,尤其是当你需要根据函数体来推导返回类型时,例如:
template <typename T>
auto make_pair(T first, T second) -> std::pair<T, T> {
return {first, second};
}
使用decltype(auto)
进行类型推导:
template <typename T>
auto get_first(const std::pair<T, T>& p) -> decltype(auto) {
return p.first;
}
typename std::result_of<Func(Ts...)>::type
中typename
确保编译器将 std::result_of<Func(Ts...)>::type
视为一个类型,而不是值或类型成员;
绑定可执行对象
传入Func &&func, Ts &&... params
右值引用,可以支持移动语义,避免不必要的拷贝和销毁,直接将资源从源对象转移到目标对象;
auto execute = std::bind(std::forward<Func>(func), std::forward<Ts>(params)...);
std::forward<>()
实现了完美转发机制,即如果传入的是一个右值引用,则将它作为右值引用传递给std::bind
,如果传递的是一个左值引用,则将他作为左值引用传递给std::bind
;
std::bind()
的用法:
template< class Callable, class... Args >
auto bind(Callable&& func, Args&&... args);
// Callable 是要绑定的可调用对象的类型。
// Args 是要传递给 Callable 的参数类型。
// func 是原始的可调用对象。
// args 是传递给 Callable 的参数。
该函数用于创建一个新的可调用对象(函数或函数对象),这个新对象是原始可调用对象的绑定版本。该可调用对象将在将来某个时刻被调用,而不是立即执行。
使用 std::bind
创建的可调用对象通常是一个 std::function
或一个 lambda 表达式。
使用此种方法,可以避免传入一堆参数,而且可以延迟执行;使用 std::bind
的一个常见场景是将一个函数的一部分参数固定,然后创建一个可以稍后调用的新函数,这样可以简化参数传递和延迟执行。
例如:
void myFunction(int a, int b, int c) {
// ...
}
int main() {
auto boundFunc = std::bind(myFunction, 1, std::placeholders::_1, 3);
boundFunc(2); // Calls myFunction(1, 2, 3)
return 0;
}
如果要调用myFunction()
则要传入三个参数,而调用boundFunc()
只用传入一个参数;减少了参数数量;
封装可调用对象到智能指针中
// 使用shared_ptr智能指针机制,避免手动管理
auto task = std::make_shared<PackagedTask>(std::move(execute));
auto result = task->get_future();
std::shared_ptr<PackagedTask>
指向的 PackagedTask
是 C++ 标准库中 <future>
头文件定义的一个模板类,它封装了一个可调用对象(例如函数、lambda 表达式或函数对象),并提供了异步执行的能力。
因此,auto task = std::make_shared<PackagedTask>(std::move(execute));
这行代码创建的 task
不是一个函数指针,而是一个可调用对象的智能指针。
将可执行对象封装为shared_ptr
智能指针,就可以实现自动管理生命周期;
此外还可以异步执行可调用对象,实现延迟调用;
注意一个细节:在make_shared<>()
调用时,传入了一个右值引用move(excute)
,在大型项目中,如果execute
是一个大型对象或者资源密集型对象,使用拷贝会很低效,而是用移动语义直接作为右值移动到PackagedTask
对象中就可以避免不必要的拷贝;
智能指针的构造:shared_ptr<Mystruct> sp(Mystruct(1));
,同样如果使用auto sp = make_shared<Mystruct>(Mystruct(1))
也类似;
Lambda表达式的延迟执行机制
// 将任务添加到任务队列
tasks_.emplace([task]()
{
(*task)();
});
这条语句很奇怪,因为Lambda表达式没有返回值,而且一个队列中emplace
了一个Lambda表达式;
这是因为tasks_
队列中元素是可调用对象;而Lambda表达式就是一个可调用对象。我们在此刻并不希望Lambda表达式立刻执行,而是在调用它的时候才执行。
为什么不直接传入*(task)()
?task
是一个智能指针,shared_ptr
类型,它指向一个可调用对象,所以要解引用之后才能调用;这里其实相当于Lambda表达式做了入口函数;
函数指针:
- 类型:函数指针是一种指向函数的指针类型。例如,
void (*func)(int)
是一个接受一个int
参数并返回void
的函数指针。 - 调用:可以通过解引用指针并传递参数来调用函数指针,如
(*func)(arg)
。 - 类型安全:函数指针不是类型安全的,因为它们不包含关于参数类型或返回类型信息的运行时信息。
- 泛化:函数指针通常不能直接泛化到不同类型的函数。
可调用对象:这里Lambda表达式就是可调用对象;
- 类型:可调用对象是任何可以通过
operator()
调用的对象。这包括函数、函数对象(如 lambda 表达式、函数对象类实例)、绑定表达式(std::bind
结果)、以及std::function
对象等。 - 调用:可调用对象可以直接通过
()
调用,如callable()
。 - 类型安全:可调用对象通常是类型安全的,特别是
std::function
,它在编译时检查参数和返回类型。 - 泛化:可调用对象可以泛化到不同类型的函数,特别是
std::function
,它可以存储任何具有匹配签名的可调用对象。
智能指针:智能指针是一种管理动态分配内存的模板类,如 std::unique_ptr
、std::shared_ptr
和 std::weak_ptr
。它们的主要目的是自动管理内存,防止内存泄漏。它指向一个封装了可调用对象的类实例,可以通过解引用访问;
区别:
- 类型检查:函数指针在编译时不提供类型检查,而可调用对象(如
std::function
)通常提供更严格的类型检查。 - 灵活性:可调用对象提供了更高的灵活性,因为它们可以存储不同类型的函数或函数对象,只要它们的签名匹配。
- 封装:可调用对象可以封装更复杂的逻辑,例如 lambda 表达式或绑定表达式,而函数指针通常只指向一个具体的函数。
- 智能指针:
std::function
是一个智能指针,它自动管理所存储函数或函数对象的生命周期,而函数指针需要手动管理。
在 C++ 中,使用 Lambda 表达式作为入口函数是一种常见的做法,尤其是在需要定义匿名函数对象或在容器中存储可调用对象时。而Lambda 表达式提供了一种方便的方式来创建小型、匿名的函数对象。
tasks_
是一个容器(队列queue
),它通过 emplace
成员函数添加一个新的元素。这个元素是一个 Lambda 表达式,它捕获了 task
变量,然后在函数体里调用了 *task
(即解引用 task
指针并调用它指向的函数)。此时并不会立即执行Lambda表达式;
如果不使用Lambda表达式:直接tasks_.emplace(*(task))
。*(task)
会试图直接解引用 task
指针并调用它(立即执行),但是 emplace
需要的是一个可以构造新元素的对象或值。
Lambda函数的不立即执行,让我们可以将可执行对象封装在Lambda函数体里,实现延迟调用;
Lambda 表达式的“不立即执行机制”通常被称为延迟执行(Deferred Execution)或惰性调用(Lazy Invocation)。
获取当前线程池中空闲线程数量
size_t threadsNum() const
{
MutexGuard guard(mutex_); // 加锁,依靠作用域失效时自动解锁
return currentThreads_; // 返回当前线程池内线程总数量
}
这里是一个典型的 RAII 模式的互斥锁封装类的使用;
当使用 RAII(Resource Acquisition Is Initialization,资源获取即初始化)模式时,锁的获取和释放通常通过对象的生命周期来管理。在上面的代码示例中,MutexGuard
是一个用于管理互斥锁(mutex)的 RAII 帮助类。也就是我们只用加锁,解锁操作和锁的销毁会在超出作用域之后自动执行(超出作用域之后会自动调用锁的析构函数释放资源);
线程工作函数
private:
void worker() {
while (true) { // 一直检测
Task task;
{ // 大括号限定作用域,RAII机制
UniqueLock uniqueLock(mutex_); // 加锁,独占锁
++idleThreads_; // 空闲线程数加1(当前线程变成空闲线程)
// 等待条件变量(等待条件变量期间加锁)
auto hasTimedout = !cv_.wait_for(uniqueLock, std::chrono::seconds(WAIT_SECONDS), [this]() {
return quit_ || !tasks_.empty();
});
--idleThreads_; // 恢复空闲线程数(当前线程即将执行任务,空闲线程数自减)
// 取出一个任务执行
if (tasks_.empty()) {
if (quit_) { // 线程池即将销毁
--currentThreads_;
return; // 线程池销毁时,自然会回收资源,不需要做其他处理,只需要执行完当前任务
}
if (hasTimedout) { // 超时
--currentThreads_; // 该线程即将被移除,所以不可分配,当前线程池中线程计数减一
joinFinishedThreads(); // 移除完成队列中的线程
finishedThreadIDs_.emplace(std::this_thread::get_id()); // 将该线程加入完成队列中
return;
}
}
task = std::move(tasks_.front()); // 取出任务队列中的一个任务,注意使用右值引用完美转发移动语义
tasks_.pop(); // 弹出任务
} // 解锁(上面的操作是原子的)
task(); // 执行任务,task是一个可执行对象(Lambda表达式)
}
}
// 等待条件变量(等待条件变量期间加锁)
auto hasTimedout = !cv_.wait_for(uniqueLock, std::chrono::seconds(WAIT_SECONDS), [this]() {
return quit_ || !tasks_.empty();
});
注意等待条件变量期间要加锁,因为条件变量也是公共资源;
线程使用 cv_.wait_for
调用等待条件变量。uniqueLock
是一个 std::unique_lock
对象,它在等待期间保持互斥锁的锁定状态,std::chrono::seconds(WAIT_SECONDS)
定义了等待的超时时间;
通过条件变量实现生产者-消费者模型,即主线程将任务添加到任务队列中,任务队列发出条件变量,工作线程等待条件变量;
第三个参数传入了一个Lambda表达式,判断是否应该退出线程池和是否有任务可做;这是一种高效的等待机制,因为它减少了线程在没有任务时的 CPU 使用,并允许线程在条件改变时迅速响应;
具体而言,如果Lambda表达式返回true
,wait_for
将立即返回,无论是否超时;如果返回false
,并且超时时间也已经到了,也立即返回;
如此一来,线程不需要等待条件变量,只要任务队列中有任务,或者要销毁线程池,就立刻返回, 然后根据对应逻辑执行任务;
hasTimedout
的取值情况如下:
hasTimedout
为false
: 发生这种情况,当线程池的状态变为quit_
为true
或者任务队列tasks_
中有任务时,线程将退出等待状态,此时hasTimedout
表示没有超时,而是条件满足。(没必要等待,下一步一定是执行任务)hasTimedout
为true
: 发生这种情况,当等待时间超过了WAIT_SECONDS
秒,而quit_
仍然为false
且任务队列tasks_
为空,即没有新任务到来,也没有收到退出信号。此时,hasTimedout
表示线程因为超时而唤醒。(没有等待到新任务到来,超时)
加入完成队列(从线程池中移除线程)
void joinFinishedThreads() {
// 异步移除,一个线程不要了,不是立刻移除,而是加入完成队列后,一批一批处理,避免阻塞
while (!finishedThreadIDs_.empty()) { // 逐个处理队列中线程
auto id = std::move(finishedThreadIDs_.front()); // 移动语义,获取线程ID
finishedThreadIDs_.pop();
auto iter = threads_.find(id); // 找到线程ID对应线程
assert(iter != threads_.end());
assert(iter->second.joinable()); // 判断线程是否被执行
iter->second.join(); // 切换到该ID对应的线程执行,并且执行完成后销毁线程
threads_.erase(iter); // 将该ID对应的线程移除线程池
}
}
为什么不直接移除线程池?因为该线程很可能还在执行任务,所以先join()
,让它执行完任务,释放资源之后,将线程的信息从线程池中移除;
main.cpp实现
#include "ThreadPool.hpp"
#include <iostream>
#include <chrono>
#include <mutex>
using namespace dpool;
std::mutex coutMtx; // protect std::cout
void task(int taskId)
{
{
std::lock_guard<std::mutex> guard(coutMtx); // 加锁
std::cout << "task-" << taskId << " begin!" << std::endl; // 输出
} // 自动解锁并且销毁锁,注意输入输出流也是线程共享,所以也要加锁
// executing task for 2 second 暂停执行2秒,延迟执行,让出资源
std::this_thread::sleep_for(std::chrono::seconds(2));
{
std::lock_guard<std::mutex> guard(coutMtx);
std::cout << "task-" << taskId << " end!" << std::endl;
}
}
// 这个函数的主要作用是监控线程池的状态,并在控制台上输出相关信息
void monitor(const ThreadPool &pool, int seconds)
{
for (int i = 1; i < seconds * 10; ++i)
{
{
std::lock_guard<std::mutex> guard(coutMtx);
std::cout << "thread num: " << pool.threadsNum() << std::endl; // 当前线程池中线程数量
}
// 暂停执行,让出资源
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
int main(int argc, char *argv[])
{
// max threads number is 100
ThreadPool pool(100);
// monitoring threads number for 13 seconds
// 任务就是执行monitor函数
// 注意:主线程也在线程池中,也通过同样的接口调用,所以取出一个线程,让这个线程不断输出线程池内线程数目
pool.submit(monitor, std::ref(pool), 13); // 依次添加130个任务
// submit 100 tasks
for (int taskId = 0; taskId < 100; ++taskId) // 设置100个任务
{
std::this_thread::sleep_for(std::chrono::milliseconds(100));
pool.submit(task, taskId); // 任务就是执行task函数,即输出taskId
}
return 0;
}