什么是线程池
线程池是一种线程使用模式。线程过多会带来调度开销,进而影响整个进程的缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时线程创建和销毁线程的代价。线程池不仅能够保证内核充分利用多线程,还能防止过分调度。此外,可用线程数量应该取决于可用的并发处理器,处理器内核,内存,网络sockets等的数量。
为什么要有线程池
若不使用线程池:
1、线程的创建销毁都要自己来完成
2、没有统一的管理,若每次请求都开启多个线程,无限制的请求袭来,可能造成资源耗尽
3、不够灵活
线程池的出现能让你双手游离于多线程之外专注于其他代码,帮你管理线程。所以如果是一个大型系统,建议不论何种场景,都直接使用线程池。
举个例子:你的面前有三台电脑,你可能同时用三台,也可能同时用两台或者一台。但是分配给你的电脑每次都是随机的。假设三台电脑编号123,第一次:【你要用1台分配到1号,然后开机->使用->关机】,第二次:【你要用1台分配到2号,然后开机->使用->关机】,以此类推。假设你的使用时间是10秒,而开机关机是20秒,然后这个过程要重复100次,每次都随机分配。那么无疑开机和关机的过程浪费的大量的时间和资源。
每次使用一个线程,都要经历三个步骤:创建线程->使用线程->销毁线程,类似上述使用场景,频繁的使用线程,频繁的开启和关闭,频繁的浪费时间和资源,额...........如果这时候你面前的三台电脑虽然每次都随机分配,但是却从不关机,坐那就能用,就好了,yes!线程池就是完成了这样的操作!包括:数据库连接池也是同样的道理。线程池中每次创建的线程,如果执行完毕,不会立即进行销毁,而是处于等待状态,下一个任务来了以后无需开启直接使用,方便快捷!!!【线程池可以使已经开启的线程长期处于激活状态,节省创建和销毁线程的时间,实现线程复用!】
线程池的优点
- 降低资源消耗。通过重复利用已创建好的线程来降低线程创建和销毁时给系统带来的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等待线程创建就能立即得到处理。
- 提高线程的可管理性。我们可以对线程池里的线程进行统一的分配,调优和监控。
问题:创建的线程越多性能越高,对吗?
答:不对。线程数量越多,可能会导致线程切换越频繁, 进而还有可能导致程序运行效率降低。多线程程序的运行效率, 呈现为正态分布, 线程数量从最开始的1开始逐渐增加, 程序的运行效率也逐渐变高, 直到线程数量达到一个临界值, 然后再次增加线程数量时, 程序的运行效率会减小(主要是由于频繁地线程切换影响到了整体线程运行效率)。
线程池的应用场景
线程池常见的应用场景:
1.需要大量的线程来完成任务,且完成任务的时间比较短。
2.对性能要求苛刻的应用,比如要求服务器迅速相应客户请求.
3.接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。
相关解释:
- 像Web服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。
- 对于长时间的任务,比如Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。
- 突发性大量客户请求,在没有线程池的情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,但短时间内产生大量线程可能使内存到达极限,出现错误。
线程池的实现
下面我们实现一个简单的线程池,线程池中提供了一个任务队列,以及若干个线程(多线程)。
- 线程池中的多个线程负责从任务队列当中拿任务,并将拿到的任务进行处理。
- 线程池对外提供一个Push接口,用于让外部线程能够将任务Push到任务队列当中。
#pragma once #include <iostream> #include <unistd.h> #include <queue> #include <pthread.h> #define NUM 5 //线程池 template<class T> class ThreadPool { private: bool IsEmpty() { return _task_queue.size() == 0; } void LockQueue() { pthread_mutex_lock(&_mutex); } void UnLockQueue() { pthread_mutex_unlock(&_mutex); } void Wait() { pthread_cond_wait(&_cond, &_mutex); } void WakeUp() { pthread_cond_signal(&_cond); } public: ThreadPool(int num = NUM) : _thread_num(num) { pthread_mutex_init(&_mutex, nullptr); pthread_cond_init(&_cond, nullptr); } ~ThreadPool() { pthread_mutex_destroy(&_mutex); pthread_cond_destroy(&_cond); } //线程池中线程的执行例程 static void* Routine(void* arg) { pthread_detach(pthread_self()); ThreadPool* self = (ThreadPool*)arg; //不断从任务队列获取任务进行处理 while (true){ self->LockQueue(); while (self->IsEmpty()){ self->Wait(); } T task; self->Pop(task); self->UnLockQueue(); task.Run(); //处理任务 } } void ThreadPoolInit() { pthread_t tid; for (int i = 0; i < _thread_num; i++){ pthread_create(&tid, nullptr, Routine, this); //注意参数传入this指针 } } //往任务队列塞任务(主线程调用) void Push(const T& task) { LockQueue(); _task_queue.push(task); UnLockQueue(); WakeUp(); } //从任务队列获取任务(线程池中的线程调用) void Pop(T& task) { task = _task_queue.front(); _task_queue.pop(); } private: std::queue<T> _task_queue; //任务队列 int _thread_num; //线程池中线程的数量 pthread_mutex_t _mutex; pthread_cond_t _cond; };
为什么线程池中需要有互斥锁和条件变量?
线程池中的任务队列是会被多个执行流同时访问的临界资源,因此我们需要引入互斥锁对任务队列进行保护。
线程池当中的线程要从任务队列里拿任务,前提条件是任务队列中必须要有任务,因此线程池当中的线程在拿任务之前,需要先判断任务队列当中是否有任务,若此时任务队列为空,那么该线程应该进行等待,直到任务队列中有任务时再将其唤醒,因此我们需要引入条件变量。
当外部线程向任务队列中Push一个任务后,此时可能有线程正处于等待状态,因此在新增任务后需要唤醒在条件变量下等待的线程。
注意:
- 当某线程被唤醒时,其可能是被异常或是伪唤醒,或者是一些广播类的唤醒线程操作而导致所有线程被唤醒,使得在被唤醒的若干线程中,只有个别线程能拿到任务。此时应该让被唤醒的线程再次判断是否满足被唤醒条件,所以在判断任务队列是否为空时,应该使用while进行判断,而不是if。
- pthread_cond_broadcast函数的作用是唤醒条件变量下的所有线程,而外部可能只Push了一个任务,我们却把全部在等待的线程都唤醒了,此时这些线程就都会去任务队列获取任务,但最终只有一个线程能得到任务。一瞬间唤醒大量的线程可能会导致系统震荡,这叫做惊群效应。因此在唤醒线程时最好使用pthread_cond_signal函数唤醒正在等待的一个线程即可。
- 当线程从任务队列中拿到任务后,该任务就已经属于当前线程了,与其他线程已经没有关系了,因此应该在解锁之后再进行处理任务,而不是在解锁之前进行。因为处理任务的过程可能会耗费一定的时间,所以我们不要将其放到临界区当中。
- 如果将处理任务的过程放到临界区当中,那么当某一线程从任务队列拿到任务后,其他线程还需要等待该线程将任务处理完后,才有机会进入临界区。此时虽然是线程池,但最终我们可能并没有让多线程并行的执行起来。
为什么线程池中的线程执行例程需要设置为静态方法?
使用pthread_create函数创建线程时,需要为创建的线程传入一个Routine(执行例程),该Routine只有一个参数类型为void*的参数,以及返回类型为void*的返回值。
而此时Routine作为类的成员函数,该函数的第一个参数是隐藏的this指针,因此这里的Routine函数,虽然看起来只有一个参数,而实际上它有两个参数,此时直接将该Routine函数作为创建线程时的执行例程是不行的,无法通过编译。
静态成员函数属于类,而不属于某个对象,也就是说静态成员函数是没有隐藏的this指针的,因此我们需要将Routine设置为静态方法,此时Routine函数才真正只有一个参数类型为void*的参数。
但是在静态成员函数内部无法调用非静态成员函数,而我们需要在Routine函数当中调用该类的某些非静态成员函数,比如Pop。因此我们需要在创建线程时,向Routine函数传入的当前对象的this指针,此时我们就能够通过该this指针在Routine函数内部调用非静态成员函数了。
任务类型的设计
我们将线程池进行了模板化,因此线程池当中存储的任务类型可以是任意的,但无论该任务是什么类型的,在该任务类当中都必须包含一个Run方法,当我们处理该类型的任务时只需调用该Run方法即可。
例如,下面我们实现一个计算任务类:
#pragma once #include <iostream> //任务类 class Task { public: Task(int x = 0, int y = 0, char op = 0) : _x(x), _y(y), _op(op) {} ~Task() {} //处理任务的方法 void Run() { int result = 0; switch (_op) { case '+': result = _x + _y; break; case '-': result = _x - _y; break; case '*': result = _x * _y; break; case '/': if (_y == 0){ std::cerr << "Error: div zero!" << std::endl; return; } else{ result = _x / _y; } break; case '%': if (_y == 0){ std::cerr << "Error: mod zero!" << std::endl; return; } else{ result = _x % _y; } break; default: std::cerr << "operation error!" << std::endl; return; } std::cout << "thread[" << pthread_self() << "]:" << _x << _op << _y << "=" << result << std::endl; } private: int _x; int _y; char _op; };
此时线程池内的线程不断从任务队列拿出任务进行处理,而它们并不需要关心这些任务是哪来的,它们只需要拿到任务后执行对应的Run方法即可。
主线程逻辑
主线程就负责不断向任务队列当中Push任务就行了,此后线程池当中的线程会从任务队列当中获取到这些任务并进行处理。
#include "Task.hpp" #include "ThreadPool.hpp" int main() { srand((unsigned int)time(nullptr)); ThreadPool<Task>* tp = new ThreadPool<Task>; //线程池 tp->ThreadPoolInit(); //初始化线程池当中的线程 const char* op = "+-*/%"; //不断往任务队列塞计算任务 while (true){ sleep(1); int x = rand() % 100; int y = rand() % 100; int index = rand() % 5; Task task(x, y, op[index]); tp->Push(task); } return 0; }
运行代码后一瞬间就有六个线程,其中一个是主线程,另外五个是线程池内处理任务的线程。
并且我们会发现这五个线程在处理时会呈现出一定的顺序性,因为主线程是每秒Push一个任务,这五个线程只会有一个线程获取到该任务,其他线程都会在等待队列中进行等待,当该线程处理完任务后就会因为任务队列为空而排到等待队列的最后,当主线程再次Push一个任务后会唤醒等待队列首部的一个线程,这个线程处理完任务后又会排到等待队列的最后,因此这五个线程在处理任务时会呈现出一定的顺序性。
注意: 此后我们如果想让线程池处理其他不同的任务请求时,我们只需要提供一个任务类,在该任务类当中提供对应的任务处理方法就行了。