C++标准库在C++11之后新增了线程特性,C++的线程在此之后可以进行跨平台使用。在其他的OOP语言(例如Java、C#等)中有线程池来避免过多过频创建线程,提升了不少的性能,在网络库中也有用到线程池的情况,因此了解一个线程池的实现可以更加熟悉多线程的使用。本文将用C++17实现一个简单的线程池,github地址:GitHub - Nevermore1994/ThreadPool: A simple C++11 Thread Pool implementation。
首先介绍下类的声明
class ThreadPool
{
public:
ThreadPool(size_t size);
~ThreadPool();
template<class F, typename ... Args>
decltype(auto) Enqueue(F&&, Args&& ... args); //通过完美转发进行任务入队列
private:
std::vector<std::thread> workers; //用来工作的线程
std::queue<std::function<void()>> tasks; //线程池的任务队列
std::condition_variable cond_;
std::mutex mutex_;
bool stop;
};
ThreadPool中只有三个函数,构造函数,析构函数以及一个Enqueue(push任务队列的函数)。
这些声明看上去平淡无奇,但看到decltype(auto)是不是有些懵?如果你不熟悉C++14可能是不会明白这是个啥东西。decltype是用来推导类型的,和auto有什么区别呢?decltype需要一个参数来推导类型,是依葫芦画瓢进行推导的,比如以下例子
int x = 1;
int& rx = x;
decltype(x) y = 2; //x 是int
decltype(rx) ry = y; //ry 是int&
auto的推导是比较复杂的推导,有点类似于模板的推导(除大括号初始化推导外都是相同的),会进行引用折叠(这里不再赘述,《Effective Modern C++》有详细的篇幅介绍)。decltype(auto)的意思就是用decltype的推导规则,来推导auto的型别,在这里可以获取到任务的返回值类型,在具体Enqueue函数再说。
先介绍构造函数,代码实现如下图。
inline ThreadPool::ThreadPool(size_t size) :stop(false)
{
for (int i = 0; i < size; i++)
{
workers.emplace_back([this]()
{
while (true) //死循环等待
{
std::function<void()> task;
{
//临界区开始 等待线程池的销毁或者是任务的带来
std::unique_lock<std::mutex> lock(mutex_);
cond_.wait(lock, [this] { return this->stop || !this->tasks.empty(); }); //条件变量的wait函数 防止虚假唤醒
if (this->stop && this->tasks.empty())
{
return; //线程池销毁 跳出循环
}
task = std::move(this->tasks.front()); //将队列的头部移动给task
this->tasks.pop(); //记得出队列
//临界区结束 自动释放锁 mutex_
}
task(); //执行任务
}
});
}
}
由于是在头文件定义的构造函数实体,因此用了inline标记(不然会报重复定义错误),其实这里可以在cpp文件实现,这样就可以不用inline,想一想为什么Enqueue可以不用inline?在函数体中用whlile(true)循环等待任务的到来或者是线程池的析构(VS2013可能会有警告,可以换成for(;;))。循环内是取队列头执行。代码中已经详细注释。
析构函数实现代码如下
inline ThreadPool::~ThreadPool()
{
{
//临界区开始 锁住stop 将其赋值为true 通知线程退出循环
std::unique_lock<std::mutex> lock(mutex_);
stop = true;
//临界区结束 自动释放锁 mutex_
}
cond_.notify_all();//通知所有的线程退出循环
for_each(workers.begin(), workers.end(), std::mem_fn(&std::thread::join)); //等待
}
析构函数就是一些收尾工作,通知线程池里所有的线程准备析构,停止任务进队列,阻塞到所有的任务完成。
下面就是Enqueue的实现
//原来的函数是用返回值尾序来获取类型 在C++14之后支持decltype(auto)进行类型的推导(不熟悉decltype推导规则可以看看Effective Modern C++)
template<class F, typename ...Args>
decltype(auto) ThreadPool::Enqueue(F&& f, Args && ...args)
{
using return_type = typename std::result_of_t<F(Args...)>; //获取返回值类型
auto task = std::make_shared< std::packaged_task<return_type()> >(
std::bind(std::forward<F>(f), std::forward<Args>(args)...) //通过bind和完美转发将函数和参数绑定
);
std::future<return_type> res = task->get_future();
{
//临界区开始 锁任务队列
std::unique_lock<std::mutex> lock(mutex_);
//如果已经将线程池析构,就不允许再入队列
if (stop)
throw std::runtime_error("enqueue on stopped ThreadPool");
tasks.emplace([task]() { (*task)(); });
}
cond_.notify_one(); //通知任意一个线程接收任务
return res;
}
不熟悉可变模板参数,可以自行搜索相关知识。result_of_t获取到返回值类型,bind将函数和参数进行绑定,packaged_task用来包装成异步的函数,返回一个shared_ptr。这里介绍下,如果是C++11的话,那么
decltype(auto) ThreadPool::Enqueue(F&& f, Args && ...args)
应该换成如下
auto ThreadPool::enqueue(F&& f, Args&&... args)
-> std::future<typename std::result_of<F(Args...)>::type>
或者是
auto ThreadPool::enqueue(F&& f, Args&&... args)
-> std::future<typename std::result_of_t<F(Args...)>>
前面介绍过了,这种叫返回值类别尾序语法。到这里一个简单的线程池就完成了,你可以愉快地进行多线程玩耍了。运用起来非常简单,样例在github上有,你可以去看看。
对了,前文中提到为什么Enqueue为什么可以不用inline呢?应该是成员函数是模板函数的原因,模板函数并非在定义时实例化,而在调用处实例化,这并不违反ODR原则(一处定义原则)。具体的实例化点(POI),是调用模板函数后,模板类和模板函数的实例化点并不一样,模板类在调用前,模板函数在调用后,实例化点并非真正实例化,通常真正的实例化会在翻译单元末尾处,详见《C++ template》的10.3节。(如果是错误的,希望大神能解释下)
其他:C++20出来了,Modules和Coroutines、约束新特性看起来有些意思,最近在看C++的协程,有空的也会写个协程的实现。