一.Linux线程池
1.线程池的概念
线程池是一种线程使用模式。
线程过多会带来调度开销,进而影响缓存局部和整体性能,而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。
2.线程池的优点
- 线程池避免了在处理短时间任务时创建与销毁线程的代价。
- 线程池不仅能够保证内核充分利用,还能防止过分调度。
(1)线程池中可用线程的数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。
(2)任务来的时候创建线程是要花时间的,如果任务来的时候创建线程耗费时间是花在用户头上。
- 线程池能够去掉创建线程的时间。
- 如果任务来的时候才创建线程意味着,当大量任务同时到来,就需要创建大量线程,这时服务器就扛不住了挂掉了。但是使用多线程的方式保证线程的个数是稳定的,就决定了服务器不会因为受到大量请求到来时而造成的冲击进而导致服务器宕机。
3.线程池的应用场景
线程池常见的应用场景如下:
- 需要大量的线程来完成任务,且完成任务的时间比较短。
- 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
- 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。
相关解释:
- 像Web服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。
- 对于长时间的任务,比如Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。
- 突发性大量客户请求,在没有线程池的情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,但短时间内产生大量线程可能使内存到达极限,出现错误。
4.线程池的实现
(1)交互接口
- 线程池中的多个线程负责从任务队列当中拿任务,并将拿到的任务进行处理。
- 线程池对外提供一个Push接口,用于让外部线程能够将任务Push到任务队列当中。
(2)代码实现
①threadPool.hpp
#pragma once
#include<iostream>
#include<queue>
#include<pthread.h>
#define NUM 5
template<class T>
class ThreadPool{
private:
int thread_num;
std::queue<T> task_q;
pthread_mutex_t lock;
pthread_cond_t cond;
public:
ThreadPool(int num = NUM):thread_num(num)
{
pthread_mutex_init(&lock,nullptr);
pthread_cond_init(&cond,nullptr);
}
~ThreadPool()
{
pthread_mutex_destroy(&lock);
pthread_cond_destroy(&cond);
}
public:
void InitTheadPool()
{
pthread_t tid;
for(int i = 0 ; i < thread_num ;++i){
pthread_create(&tid , nullptr , Routine ,this); // !!
}
}
static void* Routine(void* arg)
{
pthread_detach(pthread_self());
ThreadPool* self = (ThreadPool*)arg;
while(true){
self->Lock();
while(self->IsEmpty()){
self->Wait();
}
//走到这里一定有任务
T task;
self->Pop(task);
self->Unlock();
//有些任务处理时间可能较长,解锁之后再处理,否则就成串行了
task.Run();
}
}
public:
void Push(const T& in)
{
Lock();
task_q.push(in);
Unlock();
Wakeup();
}
void Pop(T& out)
{
out = task_q.front();
task_q.pop();
}
void Lock()
{
pthread_mutex_lock(&lock);
}
void Unlock()
{
pthread_mutex_unlock(&lock);
}
void Wait()
{
pthread_cond_wait(&cond,&lock);
}
void Wakeup()
{
pthread_cond_signal(&cond);
}
bool IsEmpty()
{
return task_q.size() == 0 ? true:false ;
}
};
1.线程池中需要用到互斥锁和条件变量
- 线程池中的任务队列是会被多个执行流同时访问的临界资源,因此我们需要引入互斥锁对任务队列进行保护。
- 线程池当中的线程要从任务队列里拿任务,前提条件是任务队列中必须要有任务,因此线程池当中的线程在拿任务之前,需要先判断任务队列当中是否有任务,若此时任务队列为空,那么该线程应该进行等待,直到任务队列中有任务时再将其唤醒,因此我们需要引入条件变量。
- 当外部线程向任务队列中Push一个任务后,此时可能有线程正处于等待状态,因此在新增任务后需要唤醒在条件变量下等待的线程。
注意:
- 当某线程被唤醒时,其可能是被异常或是伪唤醒,或者是一些广播类的唤醒线程操作而导致所有线程被唤醒,使得在被唤醒的若干线程中,只有个别线程能拿到任务。此时应该让被唤醒的线程再次判断是否满足被唤醒条件,所以在判断任务队列是否为空时,应该使用while进行判断,而不是if .
- 如果执行任务特别耗费时间,任务已经从任务队列里面拿出来了,线程自己私有就不在临界资源里了。执行任务处理的代码不应该在临界区里面执行(还拿着锁呢),我们可以让一个线程把任务拿出来之后先把锁释放掉,自己再处理消化任务。好处是,它正在处理任务的同时其他线程正在拿任务,可以并行处理。
2.唤醒条件变量下等的线程用signal而不用broadcast
什么是惊群效应:
- 惊群效应是指多进程(多线程)在同时阻塞等待同一个事件的时候(休眠状态),如果等待的这个事件发生,那么他就会唤醒等待的所有进程(或者线程),但是最终却只能有一个进程(线程)获得这个时间的“控制权”,对该事件进行处理,而其他进程(线程)获取“控制权”失败,只能重新进入休眠状态,这种现象和性能浪费就叫做惊群效应。
惊群效应消耗了什么:
- Linux内核对用户进程(线程)频繁地做无效的调度、上下文切换等使系统性能大打折扣。上下文切换(context switch)过高会导致CPU像个搬运工,频繁地在寄存器和运行队列之间奔波,更多的时间花在了进程(线程)切换,而不是在真正工作的进程(线程)上面。直接的消耗包括CPU寄存器要保存和加载(例如程序计数器°)、系统调度器的代码需要执行。间接的消耗在于多核cache之间的共享数据。
- 为了确保只有一个进程(线程)得到资源,需要对资源操作进行加锁保护,加大了系统的开销。目前一些常见的服务器软件有的是通过锁机制解决的,比如Nginx(它的锁机制是默认开启的,可以关闭);还有些认为惊群对系统性能影响不大,没有去处理,比如 Lighttpd。
3.为什么线程池中的线程执行例程需要设置为静态方法?
- 使用pthread_create函数创建线程时,需要为创建的线程传入一个Routine(执行例程),该Routine只有一个为void*的参数,以及返回类型为void*的返回值。
- 而此时Routine作为类的成员函数,该函数的第一个参数是隐藏的this指针,因此这里的Routine函数,虽然看起来只有一个参数,而实际上它有两个参数,此时直接将该Routine函数作为创建线程时的执行例程是不行的,无法通过编译。
- 静态成员函数属于类,而不属于某个对象,没有隐藏的this指针的,因此我们需要将Routine设置为静态方法,此时Routine函数才真正只有一个参数类型为void*的参数。
- 静态成员函数内部无法调用非静态成员函数,而我们需要在Routine函数当中调用该类的某些非静态成员函数。因此我们需要在创建线程时,向Routine函数传入的当前对象的this指针,此时我们就能够通过该this指针在Routine函数内部调用非静态成员函数了。
②Task.hpp : 无论该任务是什么类型的,在该任务类当中都必须包含一个Run方法,当我们处理该类型的任务时只需调用该Run方法即可。
#pragma once
#include<iostream>
#include<pthread.h>
//#define int (*handler_t)(int,int,char) handler_t//自定义回调方法
class Task{
private:
int x;
int y;
char op;
public:
Task()
{}
Task(int _x,int _y,char _op):x(_x),y(_y),op(_op)
{}
~Task(){}
public:
void Run()
{
int z = 0;
switch(op){
case '+':
z = x + y;
break;
case '-':
z = x - y;
break;
case '*':
z = x * y;
break;
case '/':
if(y == 0) std::cerr << "div zero!"<< std::endl;
if(y != 0) z = x / y;
break;
case '%':
if(y == 0) std::cerr << "mod zero!"<< std::endl;
if(y != 0) z = x % y;
break;
default:
std::cerr << "operator error!" << std::endl;
break;
}
std::cout <<"thread: ["<< pthread_self() << " ]: "
<< x << op << y <<"="<< z <<std::endl;
}
};
③main.cc
#include"Task.hpp"
#include"threadPool.hpp"
#include<cstdlib>
#include<ctime>
#include<unistd.h>
int main()
{
ThreadPool<Task>* tp = new ThreadPool<Task>();
tp->InitTheadPool();
srand((unsigned long)time(nullptr));
const char* op = "+-*/%";
while(true){
int x = rand()%100 + 1;
int y = rand()%100 + 1;
Task t(x,y,op[x%5]); //生成任务
tp->Push(t); //塞任务
sleep(1);
}
return 0;
}
④结果: 线程在处理时会呈现出一定的顺序性,因为主线程是每秒Push一个任务,这五个线程只会有一个线程获取到该任务,其他线程都会在等待队列中进行等待,当该线程处理完任务后就会因为任务队列为空而排到等待队列的最后,当主线程再次Push一个任务后会唤醒等待队列首部的一个线程,这个线程处理完任务后又会排到等待队列的最后,因此这线程在处理任务时会呈现出一定的顺序性。
二.其他锁概念
1. 读者写者问题
(1)读写锁
(3)其他概念
①写独占,读共享,写锁优先级高
②“321”中: 读者和读者之间对数据是共享的,本质是因为消费者会取走数据,读者不会,读者是拷贝数据。
③读写优先
- 读优先:想尽一些办法让读的先运行,写的往后站,但是很容易造成写饥饿问题,因为这类场景是读多写少,写操作长时间得不到资源。
- 写优先: 想尽一切办法让写的先运行, 读的往后站. 这类情景是常用的,因为写的情况不多.
- 公平占有锁: 常用于读多写少,但不仅限于此。
④读者写者的伪代码
2.自旋锁
(1)自旋锁: spin 因为占有临界资源的线程,在临界区内待的时间特别短,无需挂起,让当前线程处于自旋状态,不断去检测锁的状态(非阻塞式申请锁,申请失败继续申请),而其中,自旋锁为我们提供上述功能!
(2)—个执行流在临界资源待的时间长短问题。
- ①临界资源加锁在临界资源待一个小时,其他线程最合理的方式是挂起
- ②临界资源加锁在临界资源待1分钟,其他线程最合理的方式是自旋。
- ③在临界区待的时间长短问题取决于具体的业务.
(3)小结
- 保证临界资源访问的安全性,引出了互斥
- 保证临界资源在访问的时候效率,同步且还有信号量,把我们的临界资源划分成多块,访问不同块。