目录
1、线程池的概念
线程池是一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。
线程池的优点如下:
- 线程池避免了在处理短时间任务时创建与销毁线程的代价。
- 线程池不仅能够保证内核的充分利用,还能防止过分调度。
注意:可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。
2、线程池的使用场景
线程池常见的应用场景如下:
- 需要大量的线程来完成任务,且完成任务的时间比较短。 WEB服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。 但对于长时间的任务,比如一个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。
- 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
- 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,出现错误。
线程池示例:
- 创建固定数量线程池,循环从任务队列中获取任务对象
- 获取到任务对象后,执行任务对象中的任务接口
3、线程池的代码实现
下面我们实现一个简单的线程池,线程池中提供了一个任务队列,以及若干个线程(多线程)。
- 线程池中的多个线程负责从任务队列当中拿任务,并将拿到的任务进行处理。
- 线程池对外提供一个Push接口,用于让外部线程能够将任务Push到任务队列当中。
1、线程池ThreadPool的代码逻辑:
我们把ThreadPool线程池设计成模板类。其内部私有成员变量如下:
- isStart_:表示当前线程是否已经启动
- threadNum_:记录线程的数量
- taskQueue_:用queue定义的任务队列
- mutex_:定义锁让线程互斥的去获得任务队列里的内容
- cond_:定义条件变量,让线程没有任务时在条件变量下等待,有任务时再唤醒线程
公有成员函数:
ThreadPool构造函数:
- 利用初始化列表初始化isStart_变量为false,threadNum_线程池的容量为5(全局变量gThreadNum)
- 利用assert断言threadNum_的个数 > 0
- 复用pthread_mutex_init和pthread_cond_init初始化锁和条件变量
~ThreadPool析构函数:
- 复用pthread_mutex_destroy函数释放锁
- 复用pthread_cond_destroy函数释放条件变量
start启动线程函数:
- 利用assert函数断言isStart_变量是false,也就是确保在这之前没有线程被启动
- 利用for循环创建threadNum_容量个线程,注意给pthread_create的最后一个参数传this指针
- 将isStart_设为true
threadRoutine线程函数:
- 注意这里要设计成静态成员函数,理由下文会讲
- 复用pthread_detach函数对线程先进行分离
- 定义ThreadPool<T>*的对象指针,因为静态成员函数没有this指针,想访问内部成员变量必须通过对象指针的方式访问
- 复用haveTask函数判断队列里是否有任务,若没有任务就复用waitForTask等待函数去等待线程。
- 若有任务,定义t变量,保留拿到的任务(复用pop函数)
- 复用run方法去执行任务,复用Log打印提示信息
问:为什么threadRoutine线程函数要设计成静态成员函数?
- 使用pthread_create函数创建线程时,需要为创建的线程传入一个threadRoutine(执行例程),该threadRoutine只有一个参数类型为void*的参数,以及返回类型为void*的返回值。
- 而此时threadRoutine作为类的成员函数,该函数的第一个参数是隐藏的this指针,因此这里的Routine函数,虽然看起来只有一个参数,而实际上它有两个参数,此时直接将该threadRoutine函数作为创建线程时的执行例程是不行的,无法通过编译。
- 静态成员函数属于类,而不属于某个对象,也就是说静态成员函数是没有隐藏的this指针的,因此我们需要将threadRoutine设置为静态方法,此时threadRoutine函数才真正只有一个参数类型为void*的参数。
- 但是在静态成员函数内部无法调用非静态成员函数,而我们需要在threadRoutine函数当中调用该类的某些非静态成员函数,比如Pop。因此我们需要在创建线程时,向threadRoutine函数传入的当前对象的this指针,此时我们就能够通过该this指针在threadRoutine函数内部调用非静态成员函数了。
push放任务函数:
- 因为此任务队列是临界区,将来要被多个线程访问,所以要进行加锁
- 直接复用队列里的push函数把数据放入任务队列里
- 复用choiceThreadForHandler函数选择一个线程去执行
私有成员函数:
lockQueue加锁函数:
- 复用pthread_mutex_lock函数进行加锁
unlockQueue解锁函数:
- 复用pthread_mutex_destroy函数进行解锁
haveTask检测是否有任务函数:
- 直接复用队列的empty函数判断队列是否为空,若为空说明没任务,返回真,不为空则有任务,返回假,返回值为bool类型
waitForTask等待任务:
- 复用pthread_cond_wait等待函数
choiceThreadForHandler选择某一个线程去执行:
- 复用pthread_cond_signal函数唤醒某一个线程
pop从任务队列里获取任务(线程池中的线程调用)函数:
- 注意这里的pop函数是在线程池内部被调用的,调用之前已经加锁了,所以这里不需要加锁
- 定义temp变量,复用队列里的front函数,保存到此变量中
- 返回此变量
ThreadPool.hpp文件总代码如下:
#pragma once #include <iostream> #include <queue> #include <cstdlib> #include <pthread.h> #include <unistd.h> #include <cassert> #include <memory> //智能指针 #include "Log.hpp" using namespace std; int gThreadNum = 5; // 线程池的容量 template <class T> class ThreadPool { public: // 构造函数 ThreadPool(int threadNum = gThreadNum) : threadNum_(threadNum), isStart_(false) { assert(threadNum_ > 0); pthread_mutex_init(&mutex_, nullptr); pthread_cond_init(&cond_, nullptr); } // 析构函数 ~ThreadPool() { pthread_mutex_destroy(&mutex_); pthread_cond_destroy(&cond_); } public: // 线程函数(注意这里是类内成员函数,具有隐含的this指针, 而定义成static成员函数,则没有this指针) static void *threadRoutine(void *args) { pthread_detach(pthread_self()); ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args); while (1) { tp->lockQueue(); while (!tp->haveTask()) { // 没有任务 tp->waitForTask(); } // 有任务, 这个任务被拿到线程的上下文中 T t = tp->pop(); tp->unlockQueue(); // for debug int one, two; char oper; t.get(one, two, oper); // 所有的任务都必须有一个run方法 Log() << "新线程完成计算任务: " << one << oper << two << "=" << t.run() << endl; } } // 让线程池启动, 让所有线程跑起来 void start() { assert(!isStart_); for (int i = 0; i < threadNum_; i++) { // 创建线程 pthread_t temp; pthread_create(&temp, nullptr, threadRoutine, this); } isStart_ = true; } // 向任务队列里放任务(主线程调用) void push(const T &in) { lockQueue(); taskQueue_.push(in); choiceThreadForHandler(); unlockQueue(); } private: // 加锁 void lockQueue() { pthread_mutex_lock(&mutex_); } // 解锁 void unlockQueue() { pthread_mutex_unlock(&mutex_); } // 检测是否有任务 bool haveTask() { return !taskQueue_.empty(); } // 等待任务 void waitForTask() { pthread_cond_wait(&cond_, &mutex_); } // 选择某一个线程去执行 void choiceThreadForHandler() { pthread_cond_signal(&cond_); } // 从任务队列里获取任务(线程池中的线程调用) T pop() { T temp = taskQueue_.front(); taskQueue_.pop(); return temp; } private: bool isStart_; // 表示当前线程是否已经启动 int threadNum_; // 线程池中线程的数量 queue<T> taskQueue_; // 任务队列 pthread_mutex_t mutex_; // 让线程互斥的获得任务队列里的内容 pthread_cond_t cond_; // 让线程没有任务时在条件变量下等待,有任务时再唤醒线程 };
2、Task计算任务的设计逻辑:
- 我们把此Task类任务设计成计算任务,所以在此类内部需要包含一个run成员函数,并把此成员函数设计成仿函数。该函数代表着我们想让消费者如何处理拿到的数据(+ - * / %)。内部还需要提供一个get函数,从而辅助我们后续需要获得三个参与计算的操作数,这里巧用c++的引用实现。
Task.hpp文件总代码如下:
#pragma once #include <iostream> #include <string> class Task { public: Task() : elemOne_(0), elemTwo_(0), operator_('0') {} Task(int one, int two, char op) : elemOne_(one), elemTwo_(two), operator_(op) {} // 仿函数 int operator()() { return run(); } // 执行任务 int run() { int result = 0; switch (operator_) { case '+': result = elemOne_ + elemTwo_; break; case '-': result = elemOne_ - elemTwo_; break; case '*': result = elemOne_ * elemTwo_; break; case '/': { if (elemTwo_ == 0) { std::cout << "div zero, abort" << std::endl; result = -1; } else { result = elemOne_ / elemTwo_; } } break; case '%': { if (elemTwo_ == 0) { std::cout << "mod zero, abort" << std::endl; result = -1; } else { result = elemOne_ % elemTwo_; } } break; default: std::cout << "非法操作: " << operator_ << std::endl; break; } return result; } // 获取参与计算的三个操作数 int get(int &e1, int &e2, char &op) { e1 = elemOne_; e2 = elemTwo_; op = operator_; } private: int elemOne_; int elemTwo_; char operator_; // 具体的运算符号 };
3、打印日志信息Log.hpp文件的逻辑:
- 此代码块专门用于辅助打印日志信息,便于观察一些线程的相关信息数据等
Log.hpp文件总代码如下:
#pragma once #include <iostream> #include <pthread.h> #include <ctime> std::ostream &Log() { std::cout << "Fot Debug |" << " timestamp: " << (uint64_t)time(nullptr) << " | " << "Thread[" << pthread_self() << "] | "; return std::cout; }
4、主线程ThreadPoolTest文件的逻辑:
- 主线程就负责不断向任务队列当中Push任务就行了,此后线程池当中的线程会从任务队列当中获取到这些任务并进行处理。
ThreadPoolTest.cc文件总代码如下:
#include "ThreadPool.hpp" #include "Task.hpp" #include <ctime> int main() { const string operators = "+-*/%"; unique_ptr<ThreadPool<Task>> tp(new ThreadPool<Task>()); tp->start(); // 定义一个随机数 srand((unsigned long)time(nullptr) ^ getpid() ^ pthread_self()); // 派发任务的线程 while (true) { // 构建任务 int one = rand() % 50; int two = rand() % 10; char oper = operators[rand() % operators.size()]; Log() << "主线程派发计算任务: " << one << oper << two << "=?" << endl; Task t(one, two, oper); // 派发任务 tp->push(t); sleep(1); } return 0; }
5、测试结果:
- 运行代码后一瞬间就有六个线程,其中一个是主线程,另外五个是线程池内处理任务的线程。
并且我们会发现这五个线程在处理时会呈现出一定的顺序性,因为主线程是每秒Push一个任务,这五个线程只会有一个线程获取到该任务,其他线程都会在等待队列中进行等待,当该线程处理完任务后就会因为任务队列为空而排到等待队列的最后,当主线程再次Push一个任务后会唤醒等待队列首部的一个线程,这个线程处理完任务后又会排到等待队列的最后,因此这五个线程在处理任务时会呈现出一定的顺序性。
补充:
- 在下篇博文中,我们将会学习到单例模式,在那,我会将此线程池改进成单例模式版本的线程池。