目录
线程池的介绍
首先要知道的是,线程池和进程池一样也是一种池化技术。说简单点就是不要等待若干任务来了再创建若干线程去处理它们,处理完后又销毁这若干个线程;而是不管有无任务,在所有的逻辑开始前首先创建一批线程,等到一个任务过来,我就随便派一个线程去处理它,如果有其他任务,就再派另一个线程去处理它,这些线程处理完任务后不销毁,而是在线程池中等待下一次任务的派发。这样的做法,即线程池的做法会有很明显的优势:
- 避免在处理短时间任务时创建与销毁线程的代价以节省时间成本。
- 能够更快的开始处理任务从而让任务更快的结束,再次节省了时间成本。
然后要知道的是:其实从上一段就可以看出线程池本质就是一种生产者消费者模型。比如主线程就可以作为生产者去生产任务(虽然生产任务对象时所需数据可能来自于主线程,也可能来自于网络这样的外部来源,但生产者一定是主线程,而不可能是像网络这样的外部来源,并且将生产出的任务放进交易场所时也只能是由主线程放置的),根据生产者消费者模型的理论,主线程(即生产者)就把生产出的任务放进一个交易场所(即可以是通过条件变量实现的阻塞队列或者是通过POSIX信号量实现的环形队列)中,然后线程池里的所有线程都可以作为消费者,每次都从交易场所中获取任务后去处理任务。
(如果忘了生产者消费者模型、忘了通过条件变量实现的阻塞队列或者是通过POSIX信号量实现的环形队列,请分别回顾<<生产消费者模型的介绍以及其的模拟实现>>和<<POSIX信号量(包含通过POSIX信号量模拟实现的生产线程和消费线程并发运行的生产者消费者模型)>>)
基于线程池的生产者消费者模型的模拟实现
线程池类ThreadPool的模拟实现
ThreadPool类的成员变量
上文中说过了,线程池就是一个生产者消费者模型,让主线程作为生产者去生产任务,把生产出的任务放进一个交易场所(其可以是通过条件变量实现的阻塞队列、也可以是通过信号量实现的环形队列,在当前模拟实现时咱们就选择前者),然后线程池里的所有线程作为消费者,每次都从交易场所中获取任务后去处理任务。
既然线程池ThreadPool是一个基于阻塞队列的生产者消费者模型,那么该类对象就一定有这些成员:
-
阻塞队列queue<T> _task_queue,消费者(即线程池中的所有线程,注意线程池中的线程都是非主线程)都需要通过该阻塞队列获取任务;生产者(即主线程)需要往该阻塞队列中放置任务,本质上这个队列就是生产者消费者模型中的临界资源,形象点说叫交易场所。
-
pthread_mutex_t _lock,即分配给阻塞队列(即临界资源或者说交易场所)的锁。需要该锁的原因是需要维护【生产者和消费者的互斥关系】,即防止生产者和消费者同时访问阻塞队列,以避免发生【生产者放置任务的动作还只做到一半,消费者就跑进交易场所中接取任务导致接取到一个残缺的任务】和【消费者接取任务的动作还只做到一半,生产者就跑进交易场所中放置任务导致消费者正在接取的任务被覆盖了】。
-
pthread_cond_t _cond,即分配给消费者(线程池中的所有线程都是消费者)的条件变量,当生产者(即主线程)不断往任务队列中push时,每push一个任务都应该试图唤醒一个消费者线程,避免任务没有消费者接取从而全在任务队列中堆积。注意即使所有消费者线程都在工作,那我唤醒它也没关系,因为此时它会忽略这个唤醒信息,所以这里无脑唤醒即可。
-
vector<pthread_t*> _v。拿它作为线程池,里面存了所有线程的线程ID的地址。
-
int _num,用于统计_v中有多少个线程。
结合上面的理论,我们可以编写出以下代码。
#pragma once
#include<iostream>
using namespace std;
#include<pthread.h>
#include<unistd.h>
#include<string>
#include<vector>
#include<queue>
template<class T>
class ThreadPool//用于处理T类型数据的线程池
{
public
private:
vector<pthread_t*> _v;//线程池
int _num;//统计_v中有多少个线程
queue<T> _task_queue;//消费者(即线程池中的所有线程,注意线程池中的线程都是非主线程)都需要通过该任务队列获取任务;生产者(即主线程)需要往该任务队列中放置任务。所以本质上这个队列就是生产者消费者模型中的临界资源,形象点说叫交易场所
pthread_mutex_t _lock;//分配给任务队列(即临界资源或者说交易场所)的锁,需要该锁的原因是需要维护【生产者和消费者的互斥关系】,避免发生【生产者放置任务的动作还只做到一半,消费者就跑进交易场所中接取任务】和【消费者接取任务的动作还只做到一半,生产者就跑进交易场所中放置任务】
pthread_cond_t _cond;//分配给消费者(线程池中的所有线程都是消费者)的条件变量,当生产者(即主线程)不断往任务队列中push时,每push一个任务都应该试图唤醒一个线程,避免任务没有消费者接取从而全在任务队列中堆积。注意即使所有消费者线程都在工作,那我唤醒它也没关系,因为此时它会忽略这个唤醒信息,所以这里无脑唤醒即可。
};
ThreadPool类的构造函数和全局的Routine函数
构造函数的编写思路:
- 既然ThreadPool类中有锁和条件变量的成员,那肯定是需要在其的构造函数中初始化它们的;
- 然后要说的是何时创建线程让线程去执行线程函数。我们有两种选择,第一种是在构造函数中就创建线程,创建完毕后线程就自动开始执行线程函数了;第二是在其他函数中创建线程,假如该函数叫run函数,这样一来,我们就可以在手动调用run时再创建线程并让线程自动执行线程函数,能方便用户控制。说一下,第二种选择实际上是个不太必要的操作,因为线程池的理念就是在所有逻辑执行前先创建一批线程,这样当任务来时就能直接派发线程去处理它。而如果让我们手动控制,如果用户编写代码的逻辑出错了,即是等到任务来了后再调用run函数的,那就太慢了,虽然问题也不大,但相比于之前无疑是有瑕疵的。大家可以权衡一下自由选择,这里我们就采用第1种。
除了作为生产者的主线程(即执行main函数的线程),其他线程都是被pthread_create创建出来的新线程,这些新线程都是消费者线程,因为所有消费者线程都需要在阻塞队列中接取任务,而我们把这个接取任务的逻辑放在了全局的Routine函数中,所以所有的消费者线程的线程函数就都应该是Routine函数。Routine函数的编写思路是:
- 既然Routine函数的逻辑是用于让所有消费者线程获取阻塞队列中的任务的,又因为阻塞队列同一时间内只能被一个线程访问,所以消费者线程从阻塞队列中获取任务时肯定是需要进行加锁以及获取任务完毕后的解锁的。
- 在Routine函数中获取任务时还要注意临界区(即阻塞队列)中的资源是否就绪,如果阻塞队列中没有消费者线程关心的资源(即任务),那就需要让该消费者线程陷入阻塞(通过调用pthread_cond_wait);而如果有消费者线程关心的资源(即任务),那就让消费者线程获取该资源并完成消费。需要注意的是消费者线程获取资源完毕后,需要先解开分配给阻塞队列的锁,然后再消费,否则会造成所有消费者线程在处理任务时不能并发,这样多线程编程就失去了意义。然后要注意的是判断临界区的资源是否就绪时需要套一个while,而不是if,防止伪唤醒的情景发生,这些都是<<生产消费者模型的介绍以及其的模拟实现>>一文中老生常谈的内容。
- 说一下,因为Routine函数不是ThreadPool类的成员函数,而是全局的函数,但又要在ThreadPool类外访问类内的成员,所以1、需要让Routine函数成为ThreadPool类的友元。2、需要在pthread_create创建消费者线程时把ThreadPool对象的this指针作为实参传给Routine函数的形参。
- 因为Routine函数在ThreadPool类的上方,而Routine函数内是需要ThreadPool这个标识符的,所以需要前置声明一下ThreadPool类。
- 说一下,因为线程的创建和销毁的成本太高,所以线程池中的线程(线程池中的所有线程都是消费者线程)是不能轻易的被创建和销毁的,所以不能在消费者线程接取并处理完一次任务后就让消费者线程函数Routine结束了,消费者线程是需要不断地从阻塞队列中获取任务的,处理完一个任务后再接下一个任务,如果没有任务就陷入阻塞,等待生产者的唤醒信息。所以根据这个理论,因为Routine是所有消费者线程的线程函数,用于从阻塞队列中接取和处理任务,所以Routine函数里面的逻辑一定是套上了while或者for循环的。
结合上面的理论,ThreadPool类的构造函数的代码如下。
#pragma once
#include<iostream>
using namespace std;
#include<pthread.h>
#include<unistd.h>
#include<string>
#include<vector>
#include<queue>
template<class T>
class ThreadPool;//前置声明
//线程池中所有的线程(即消费者线程)的例行程序
template<class K>
void* routine(void* args)
{
ThreadPool<K>* tp = (ThreadPool<K>*)args;
cout<<"我是消费者线程,线程ID为:"<<pthread_self()<<",启动成功!"<<endl;
K task;
while(1)
{
//从任务队列中获取任务
//消费者线程进入交易场所接取任务时先加锁
pthread_mutex_lock(&(tp->_lock));
//如果任务队列中为空,即没有任务时,那就让消费者线程在条件变量下陷入阻塞,等待资源就绪
while(tp->_task_queue.empty() == true)
pthread_cond_wait(&(tp->_cond), &tp->_lock);
task = tp->_task_queue.front();
tp->_task_queue.pop();
//消费者线程接取完任务后再解锁并处理刚接取的任务
pthread_mutex_unlock(&(tp->_lock));
cout<<"消费者线程(线程池中的线程都是消费者线程)<"<<pthread_self()<<">解决的任务为: "<<task._x<<"+"<<task._y<<" = "<<task()<<endl;
//注意和正常的生产者消费者模型不太一样的是:这里消费者处理完任务后不用pthread_cond_signal唤醒生产者,线程池的宗旨是有任务就处理,没任务就摸鱼,并不需要催促生产者
}
}
template<class T>
class ThreadPool//用于处理T类型数据的线程池
{
template<class K> friend void* routine(void* args);
public:
ThreadPool(int num)//num表示线程池中需要多少线程
:_num(num)
{
//是需要在构造函数中初始化锁的
pthread_mutex_init(&_lock,nullptr);
//是需要在构造函数中初始化条件变量的
pthread_cond_init(&_cond,nullptr);
for(int i=0;i<num;i++)
{
_v.push_back(new pthread_t);
}
for(pthread_t* x:_v)
{
//传this指针是因为在类外部的例行函数中需要访问类内的成员。
pthread_create(x, nullptr, routine<T>, (void*)this);
}
}
private:
vector<pthread_t*> _v;//线程池
int _num;//统计_v中有多少个线程
queue<T> _task_queue;//消费者(即线程池中的所有线程,注意线程池中的线程都是非主线程)都需要通过该任务队列获取任务;生产者(即主线程)需要往该任务队列中放置任务。所以本质上这个队列就是生产者消费者模型中的临界资源,形象点说叫交易场所
pthread_mutex_t _lock;//分配给任务队列(即临界资源或者说交易场所)的锁,需要该锁的原因是需要维护【生产者和消费者的互斥关系】,避免发生【生产者放置任务的动作还只做到一半,消费者就跑进交易场所中接取任务】和【消费者接取任务的动作还只做到一半,生产者就跑进交易场所中放置任务】
pthread_cond_t _cond;//分配给消费者(线程池中的所有线程都是消费者)的条件变量,当生产者(即主线程)不断往任务队列中push时,每push一个任务都应该试图唤醒一个线程,避免任务没有消费者接取从而全在任务队列中堆积。注意即使所有消费者线程都在工作,那我唤醒它也没关系,因为此时它会忽略这个唤醒信息,所以这里无脑唤醒即可。
};
ThreadPool类的析构函数
析构函数的编写思路:
-
既然ThreadPool类中有锁和条件变量的成员,那肯定是需要在其的析构函数中销毁它们的;
-
需要对创建出来的各个线程调用pthread_join函数进行线程等待,即让OS回收掉和线程相关的资源。要注意的是虽然join函数会释放绝大多数和线程相关的资源,但因为线程ID对象(即pthread_t类的对象)是在构造函数中new出来的在堆上的变量,所以需要手动delete释放。
结合上面的理论,ThreadPool类的析构函数的代码如下。
#pragma once
#include<iostream>
using namespace std;
#include<pthread.h>
#include<unistd.h>
#include<string>
#include<vector>
#include<queue>
template<class T>
class ThreadPool//用于处理T类型数据的线程池
{
public
~ThreadPool()
{
for(vector<pthread_t*>::iterator it = _v.begin(); it != _v.end(); it++)
{
//(*it)是vector中存的元素、注意每个元素只是线程ID的地址,而不是线程ID
pthread_join(*(*it), nullptr);
//join函数会释放绝大多数和线程相关的资源,但因为这里的线程ID对象是在构造函数中new出来的在堆上的变量,所以需要手动delete释放。
delete (*it);
}
//是需要在析构函数中销毁锁的
pthread_mutex_destroy(&_lock);
//是需要在析构函数中销毁条件变量的
pthread_cond_destroy(&_cond);
}
private:
vector<pthread_t*> _v;//线程池
int _num;//统计_v中有多少个线程
queue<T> _task_queue;//消费者(即线程池中的所有线程,注意线程池中的线程都是非主线程)都需要通过该任务队列获取任务;生产者(即主线程)需要往该任务队列中放置任务。所以本质上这个队列就是生产者消费者模型中的临界资源,形象点说叫交易场所
pthread_mutex_t _lock;//分配给任务队列(即临界资源或者说交易场所)的锁,需要该锁的原因是需要维护【生产者和消费者的互斥关系】,避免发生【生产者放置任务的动作还只做到一半,消费者就跑进交易场所中接取任务】和【消费者接取任务的动作还只做到一半,生产者就跑进交易场所中放置任务】
pthread_cond_t _cond;//分配给消费者(线程池中的所有线程都是消费者)的条件变量,当生产者(即主线程)不断往任务队列中push时,每push一个任务都应该试图唤醒一个线程,避免任务没有消费者接取从而全在任务队列中堆积。注意即使所有消费者线程都在工作,那我唤醒它也没关系,因为此时它会忽略这个唤醒信息,所以这里无脑唤醒即可。
};
ThreadPool类的pushTask函数
该函数是需要被主线程调用的,让作为生产者的主线程往ThreadPool对象中的阻塞队列成员_task_queue中放置任务,所以pushTask的思路为:
- 既然是阻塞队列,那在所有线程中,同一时间内只能有一个线程访问阻塞队列,所以生产者线程即主线程往阻塞队列中放置任务时肯定是需要进行加锁以及放置完毕后的解锁的。
- 主线程作为生产者,放置一个任务到阻塞队列后,它是知道临界资源(即阻塞队列)中一定有消费者关心的数据资源就绪的(因为即使在生产者本次放置数据之前阻塞队列就为空,但因为生产者刚刚才放置了一个数据,所以就一定有一个数据资源就绪),此时就应该由生产者去唤醒消费者线程从而让消费者线程运行起来去阻塞队列中获取数据(或者说任务),这样消费者之后才有数据(或者说任务)去消费。注意即使所有消费者线程都在工作,那生产者线程调用pthread_cond_signal唤醒某个消费者线程也没关系,因为此时消费者线程会忽略这个唤醒信息,所以这里无脑唤醒即可。
结合上面的理论,ThreadPool类的pushTask函数的代码如下。
#pragma once
#include<iostream>
using namespace std;
#include<pthread.h>
#include<unistd.h>
#include<string>
#include<vector>
#include<queue>
template<class T>
class ThreadPool//用于处理T类型数据的线程池
{
public
//该函数只被作为生产者的主线程调用
void pushTask(const T& task)
{
pthread_mutex_lock(&_lock);
_task_queue.push(task);
pthread_mutex_unlock(&_lock);
//生产者(即主线程)往交易场所中放置完任务后,需要唤醒某个消费者线程去接取任务
pthread_cond_signal(&_cond);
}
private:
vector<pthread_t*> _v;//线程池
int _num;//统计_v中有多少个线程
queue<T> _task_queue;//消费者(即线程池中的所有线程,注意线程池中的线程都是非主线程)都需要通过该任务队列获取任务;生产者(即主线程)需要往该任务队列中放置任务。所以本质上这个队列就是生产者消费者模型中的临界资源,形象点说叫交易场所
pthread_mutex_t _lock;//分配给任务队列(即临界资源或者说交易场所)的锁,需要该锁的原因是需要维护【生产者和消费者的互斥关系】,避免发生【生产者放置任务的动作还只做到一半,消费者就跑进交易场所中接取任务】和【消费者接取任务的动作还只做到一半,生产者就跑进交易场所中放置任务】
pthread_cond_t _cond;//分配给消费者(线程池中的所有线程都是消费者)的条件变量,当生产者(即主线程)不断往任务队列中push时,每push一个任务都应该试图唤醒一个线程,避免任务没有消费者接取从而全在任务队列中堆积。注意即使所有消费者线程都在工作,那我唤醒它也没关系,因为此时它会忽略这个唤醒信息,所以这里无脑唤醒即可。
};
ThreadPool类的整体代码
以下是整个ThreadPool.h的代码。
#pragma once
#include<iostream>
using namespace std;
#include<pthread.h>
#include<unistd.h>
#include<string>
#include<vector>
#include<queue>
template<class T>
class ThreadPool;//前置声明
//线程池中所有的线程(即消费者线程)的例行程序
template<class K>
void* routine(void* args)
{
ThreadPool<K>* tp = (ThreadPool<K>*)args;
cout<<"我是消费者线程,线程ID为:"<<pthread_self()<<",启动成功!"<<endl;
K task;
while(1)
{
//从任务队列中获取任务
//消费者线程进入交易场所接取任务时先加锁
pthread_mutex_lock(&(tp->_lock));
//如果任务队列中为空,即没有任务时,那就让消费者线程在条件变量下陷入阻塞,等待资源就绪
while(tp->_task_queue.empty() == true)
pthread_cond_wait(&(tp->_cond), &tp->_lock);
task = tp->_task_queue.front();
tp->_task_queue.pop();
//消费者线程接取完任务后再解锁并处理刚接取的任务
pthread_mutex_unlock(&(tp->_lock));
cout<<"消费者线程(线程池中的线程都是消费者线程)<"<<pthread_self()<<">解决的任务为: "<<task._x<<"+"<<task._y<<" = "<<task()<<endl;
//注意和正常的生产者消费者模型不太一样的是:这里消费者处理完任务后不用pthread_cond_signal唤醒生产者,线程池的宗旨是有任务就处理,没任务就摸鱼,并不需要催促生产者
}
}
template<class T>
class ThreadPool//用于处理T类型数据的线程池
{
template<class K> friend void* routine(void* args);
public:
ThreadPool(int num)//num表示线程池中需要多少线程
:_num(num)
{
//是需要在构造函数中初始化锁的
pthread_mutex_init(&_lock,nullptr);
//是需要在构造函数中初始化条件变量的
pthread_cond_init(&_cond,nullptr);
for(int i=0;i<num;i++)
{
_v.push_back(new pthread_t);
}
for(pthread_t* x:_v)
{
//传this指针是因为在类外部的例行函数中需要访问类内的成员。
pthread_create(x, nullptr, routine<T>, (void*)this);
}
}
//该函数只被作为生产者的主线程调用
void pushTask(const T& task)
{
pthread_mutex_lock(&_lock);
_task_queue.push(task);
pthread_mutex_unlock(&_lock);
//生产者(即主线程)往交易场所中放置完任务后,需要唤醒某个消费者线程去接取任务
pthread_cond_signal(&_cond);
}
~ThreadPool()
{
for(vector<pthread_t*>::iterator it = _v.begin(); it != _v.end(); it++)
{
//(*it)是vector中存的元素、注意每个元素只是线程ID的地址,而不是线程ID
pthread_join(*(*it), nullptr);
//join函数会释放绝大多数和线程相关的资源,但因为这里的线程ID对象是在构造函数中new出来的在堆上的变量,所以需要手动delete释放。
delete (*it);
}
//是需要在析构函数中销毁锁的
pthread_mutex_destroy(&_lock);
//是需要在析构函数中销毁条件变量的
pthread_cond_destroy(&_cond);
}
private:
vector<pthread_t*> _v;//线程池
int _num;//统计_v中有多少个线程
queue<T> _task_queue;//消费者(即线程池中的所有线程,注意线程池中的线程都是非主线程)都需要通过该任务队列获取任务;生产者(即主线程)需要往该任务队列中放置任务。所以本质上这个队列就是生产者消费者模型中的临界资源,形象点说叫交易场所
pthread_mutex_t _lock;//分配给任务队列(即临界资源或者说交易场所)的锁,需要该锁的原因是需要维护【生产者和消费者的互斥关系】,避免发生【生产者放置任务的动作还只做到一半,消费者就跑进交易场所中接取任务】和【消费者接取任务的动作还只做到一半,生产者就跑进交易场所中放置任务】
pthread_cond_t _cond;//分配给消费者(线程池中的所有线程都是消费者)的条件变量,当生产者(即主线程)不断往任务队列中push时,每push一个任务都应该试图唤醒一个线程,避免任务没有消费者接取从而全在任务队列中堆积。注意即使所有消费者线程都在工作,那我唤醒它也没关系,因为此时它会忽略这个唤醒信息,所以这里无脑唤醒即可。
};
Task.h文件的整体代码
首先创建一个Task.h文件,在里面实现一个Task类,这样一来,以后作为生产者的主线程的工作就是不断地创建Task类的对象。Task.h的整体代码如下。
#pragma once
#include<iostream>
using namespace std;
#include<functional>
typedef function<int(int,int)> func_t;//C++11的包装器
class Task
{
public:
Task()
{}
~Task()
{}
Task(int x,int y,func_t f):_x(x), _y(y), _f(f)
{}
int operator()()
{
return _f(_x,_y);
}
int _x;
int _y;
func_t _f;
};
然后要说的是,有了上文中实现的整个ThreadPool.h的代码后,模拟实现【基于线程池的生产者消费者模型】也就很简单了,思路为:
- 首先创建一个ThreadPool<Task>线程池对象。因为其的底层实现,我们在构造出线程池对象后,所有消费者线程就已经创建完毕并开始运行了,但因为临界区即阻塞队列中并没有消费者关注的数据(或者说任务)资源,所以所有消费者线程就都被阻塞,正在等待生产者(即主线程)往阻塞队列中放置数据(或者说任务)。
- 上文中也说过,所有线程中只有主线程作为唯一的生产者线程去生产任务,因为这里我们只是举例,所以就把生产任务的过程设计的简单点,就比如任务就是计算两个整形数据之和,那在主线程中,我们只需要通过rand函数生成两个随机数,然后用户随便编写一个逻辑为加法的可调用对象,然后把这些内容作为参数传给Task对象以让Task对象完成初始化,到这里生产者就完成了一个任务的生产,然后生产者就需要把该任务放置到阻塞队列中并唤醒某个消费者线程。这样一来,某个消费者线程就可以获取任务并处理任务了。
testMain.cc文件的整体代码
结合上面的思路,以下是整个testMain.cc的代码。(注意g++编译C++文件时,其后缀名一定得是.cc)
#include"ThreadPool.h"
#include"Task.h"
int main()
{
srand(time(0));
ThreadPool<Task> p(3);
while(1)
{
int x = rand()%100;
int y = rand()%100;
Task t(x, y, [](int x, int y)->int{return x+y;});
cout<<"生产者(即主线程)生产出的任务为"<< x << '+' << y << " = ?" << endl;
//每0.7s生产一个任务,防止生产和消费的速度都太快而造成打印语句刷屏
usleep(7000);
p.pushTask(t);
}
return 0;
}
对【基于线程池的生产者消费者模型的模拟实现】的测试
把上面的testMain.cc文件进行编译运行后,结果如下,可以看到是有不同的消费者线程在处理任务的,符合我们的预期。
注意之所以下面是【生产者线程每生产一个任务,消费者线程就能立刻处理一个任务,双方的步调一致】而不是【生产者连续生产一批任务后,消费者再连续消费一批任务,双方的步调不一致】是因为我们在生产者的线程函数(即main函数)中让生产者每生产出一个任务后就usleep阻塞0.7秒。因为我们没有sleep限制消费者线程消费的速度,所以消费者消费的速度就比生产者生产的速度快,但即使消费者消费的速度比生产者生产的速度快,因为不生产是无法消费的,所以双方的步调只能是一致的,所以打印出的结果是步调一致的。