1. 线程池的概念
当我们去处理任务时,创建线程去进行处理,然后释放线程资源,在下次再有任务时,再创建线程,这样反反复复效率是比较低的。我们可以预先创建一批线程,用这些线程反复去处理任务,在处理完某一任务之后继续在队列中等待下一次任务。当任务队列里没有任务的时候,每个线程都休眠,当队里有任务的时候,就可以使用环形队列进行处理了。唤醒线程的成本比创建整个线程的成本小,这就是线程池的逻辑思想。
线程池:一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器,处理器内核,内存,网络sockets的数量。
2. 线程池的优点
- 线程池避免了在处理短时间任务时创建与销毁线程的代价。
- 线程池不仅能够保证内核充分利用,还能防止过分调度。
3. 线程池的应用场景
线程池的应用场景如下:
- 需要大量的线程来完成任务,且完成任务的时间比较短。
- 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
- 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。
相关解释:
- 像Web服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。
- 对于长时间的任务,比如Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。
- 突发性大量客户请求,在没有线程池的情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,但短时间内产生大量线程可能使内存到达极限,出现错误。
4. 线程池的实现
下面我们实现一个简单的线程池,线程池中提供了一个任务队列,及若干个线程。
- 线程池中的多个线程负责从任务队列中拿任务,并将能拿到的任务进行处理。
- 线程池对外提供一个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);
}
// 创建多线程,传入this指针
void ThreadPoolInit()
{
pthread_t tid;
for (int i = 0; i < _thread_num; ++i)
{
// 参数传入this指针
ptnread_create(&tid, nullptr, Routine, this);
}
}
// 线程池中线程的执行例程
// 每次线程的执行例程都是死循环,以达到重复执行任务的效果
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 Push(const T& task)
{
LockQueue(); // 竞争锁
_task_queue.push(task); // 将任务类放入任务队列
WakeUp(); // 唤醒线程,让其去执行任务
UnLockQueue(); // 解锁
}
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"
#include <cstdlib>
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一个任务后唤醒等待队列首部的一个线程,这个线程处理完任务后又会排到等待队列的最后,因此这五个队列在处理任务时会呈现一定的顺序性。
5. 单例模式
单例模式是一种创建型设计模式,它保证一个类只有一个实例存在,并且提供一个全局访问点来访问该实例。
单例模式的主要特点包括:
- 只能有一个实例
- 全局访问点,方便访问该实例
- 在很多服务器开发场景中,经常需要让服务器加载很多的数据(上百G)到内存中,此时往往需要用一个单例的类来管理这些数据。
饿汉模式和懒汉模式是两种常见的单例模式的实现方式。
- 先把碗洗好,等吃法的时候就直接用碗了,这是饿汉模式。
- 不洗碗,等用到碗时再洗,这就是懒汉模式。
也就是说,饿汉模式是在用之前就创建,懒汉模式是在用的时候才进行创建。
懒汉模式的核心思想是“延时加载”,从而优化服务器的启动速度。
平时用的new和malloc其实就是延时加载,也就是懒汉模式的设计思想。