20 Linux线程池

文章目录

  • 线程池
    • 线程池的概念
    • 基于队列的线程池实现代码
    • 进程池
    • 线程池存在的价值
  • 线程安全的单例模式
    • 饿汉方式实现单例模式
    • 懒汉方式实现单例模式
    • 各自优缺点:
  • 常见的锁
  • STL中的容器是否是线程安全
  • 读者写者问题


线程池

线程池的概念

简单来讲,线程池就是有一堆已经创建好了的线程,初始它们都处于空闲等待状态,当有新的任务需要处理的时候,就从这个池子里面取一个空闲等待的线程来处理该任务,当处理完成了就再次把该线程放回池中,以供后面的任务使用。当池子里的线程全都处理忙碌状态时,线程池中没有可用的空闲等待线程,此时,根据需要选择创建一个新的线程并置入池中,或者通知任务线程池忙,稍后再试。这种方式避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。
20 Linux线程池_第1张图片

当我们的服务器接收到任务时,需要创建线程去处理任务。
如果是接收一个任务,创建一个线程的方式,会让客户端等待的时间变长。如果提前在用户层创建好线程,接收到任务后,只需要直接分配即可。
当访问量增加的时候,频繁的向内核申请,创建线程、销毁线程,很有可能导致内核过度调用,降低性能和效率。
线程池的存在,缓解了这种短时间内业务量增加,内核过分调度的情况

基于队列的线程池实现代码

ThreadPool.hpp:

#pragma once 

#include 
#include 
#include
#include
#define NUM 5//一开始默认创建5个线程

class Task{
     
public:
    int base; 
    Task(int _b=0):base(_b){
     
    
    }
    void Run(){
     
      std::cout<<"thread is["<<pthread_self()<<"]bash pow is:"<<pow(base,2)<<std::endl;
    }
    ~Task(){
     
      
    }
};

class ThreadPool{
     
private:
  std::queue<Task*> q;//任务队列
  int max_num;//线程的总数
  pthread_mutex_t lock;
  pthread_cond_t cond;//只能消费者等待,因为生产者要从外部获取任务
  
  void LockQueue(){
     
    pthread_mutex_lock(&lock);    
  }
  void UnlockQueue(){
     
    pthread_mutex_unlock(&lock);
  }
  bool IsEmpty(){
     
    return q.size()==0;
  }
  void ThreadWait(){
     
    pthread_cond_wait(&cond,&lock);
  }  
  
  void ThreadWakeup(){
     
    //唤醒线程
    pthread_cond_signal(&cond);
  }
  void ThreadsWakeup(){
     
    //唤醒所有的线程
    pthread_cond_broadcast(&cond);
  }
public:
  ThreadPool(int _max=NUM):max_num(_max){
     

  }

  static void*Routine(void*arg){
     //必须设置为静态的,因为成员函数会有this形参
    ThreadPool*this_p=(ThreadPool*)arg;
    while(true){
     //从任务队列中拿任务
      this_p->LockQueue();
      while(this_p->IsEmpty()){
     //如果任务队列为空则让线程等待
        this_p->ThreadWait();
      }
      Task t;
      this_p->Get(t);
      this_p->UnlockQueue();
      t.Run();
    }
  }
  void ThreadPoolInit(){
     
    pthread_mutex_init(&lock,nullptr);
    pthread_cond_init(&cond,nullptr);  
    pthread_t t;//创建一批线程
    for(int i=0;i<max_num;i++){
     
      pthread_create(&t,nullptr,Routine,this);
    }
  }

  void Put(Task &in){
     //往任务队列中放任务
    LockQueue();
    q.push(&in);
    UnlockQueue();
    ThreadWakeup();//放完任务后唤醒一个线程

  }
  void Get(Task &out){
     
    Task*t=q.front();
    q.pop();
    out=*t;
  }

  ~ThreadPool(){
     
    pthread_mutex_destroy(&lock);
    pthread_cond_destroy(&cond);
  }
};

main.cpp:

#include"ThreadPool.hpp"

int main(){
     
  srand((unsigned int)time(NULL));
  ThreadPool*tp=new ThreadPool();
  tp->ThreadPoolInit();

  while(true){
     
    int x=rand()%10+1;
    Task t(x);
    tp->Put(t);
    sleep(1);
  }
}

20 Linux线程池_第2张图片

主线程先创建了一批线程池,主线程先运行还是线程池的线程先运行是不确定的。
这是由CPU调度决定的,但是肯定是主线程先放入任务,因为即使线程池的新线程先运行,发现数据队列为空也会被挂起
主线程先运行,生产一个任务放到任务队列之中,然后新线程拿到任务、执行任务,然后再抢到锁被挂起,同时线程池里面的其它线程也被挂起。
在主线程sleep(1)的时间内,主线程是不会去争抢锁的,然后线程池内的线程抢到了锁,也会被挂起,因为任务队列之中没有任务。
线程池所有的线程被挂起之后,生产一个任务,唤醒线程池内的一个线程,同时,主线程sleep不会争抢锁,消费完后,又会被挂起,因为任务队列之中只有一个任务

20 Linux线程池_第3张图片

上面的代码是每次只唤醒一个线程,如果每次唤醒全部线程的话会出现这样的情况:
20 Linux线程池_第4张图片

主线程生产任务,然后进入sleep,时间片的时间是非常短的,因此当主线程时间片到了的时候,主线程身上是没有锁的此时线程池里面的一个线程拿到了锁,其它的线程没有锁被挂起等待,这个拿到锁的线程就会去消费任务,消费完任务之后由于是单核CPU,在该线程的时间片之内是没有线程与之竞争的,因此该线程会再次抢到锁进入等待队列之中之后其它线程也依次进入等待队列被挂起,从实验现象可知,每次都是同样的线程抢到任务,证明了等待队列的存在(批量唤醒也是按顺序唤醒的)

此时,在消费任务的代码出添加sleep,即使得消费任务的线程在它的当前时间片之内,不会再去争抢锁因此,使得其它的线程进入条件等待队列,从而下次全部唤醒的时候,排在首位的就不会一直都是原来的线程了因此,实验现象可以看到多个线程消费任务:
20 Linux线程池_第5张图片

进程池

fork创建一批进程,父进程不断的往任务队列塞数据,子进程不断的从任务队列拿任务
由于进程之间是独立的,因此想要进行交互,就得要用进程间的通信方式(管道、共享内存) ,使用共享内存,可以减少任务拷贝的问题

进程之间的同步:
利用信号来完成,任务来了直接发信号。
也可以用管道,如果不给管道写入数据,管道大部分时间是空的,所有的进程都会阻塞到管道的队列之中,只需要往管道塞数据,即可唤醒进程

进程间通信不可以用STL容器,因为共享内存,是向系统申请创建的,由系统维护,而STL之中的空间是STL自身维护的

线程池存在的价值

  1. 有任务就会立刻有线程进行服务,省去了线程创建的时间
  2. 有效防止,任务多时,频繁申请销毁线程,导致系统过载问题
  3. 线程池占用的资源更少,鲁棒性(健壮性)不强(有任何一个线程崩溃,整个进程就崩溃了)
  4. 进程池占用资源更多,但是健壮性很强

线程安全的单例模式

设计模式是根据优秀的代码,衍生出来的一种设计模板。
单例模式是一种 “经典的, 常用的” 设计模式.
某些类, 只应该具有一个对象(实例), 就称之为单例,在很多服务器开发场景中, 经常需要让服务器加载很多的数据 (上百G) 到内存中. 此时往往要用一个单例的类来管理这些数据。

饿汉方式实现单例模式

吃完饭,立马把碗洗了,方便下次我饿了的时候,直接拿起碗就可以吃饭,这就是饿汉模式
在使用之前,已经将类创建好了
20 Linux线程池_第6张图片

懒汉方式实现单例模式

吃完饭,不洗碗,下次吃饭的时候再洗碗,这就是懒汉模式
在使用的时候,才会进行类的创建
20 Linux线程池_第7张图片

各自优缺点:

饿汉模式在程序运行的时候,可能需要伴随大量类的创建,因此服务器的开机速度就会变慢
而懒汉模式,在运行的时候才会进行创建,延时加载了,从而可以优化服务器的启动速度

由于懒汉模式是在运行的时候才会进行创建,因此有可能出现线程安全问题,第一次调用 GetInstance 的时候, 如果两个线程同时调用, 可能会创建出两份 T 对象的实例,因此需要对其进行加锁处理:
20 Linux线程池_第8张图片

常见的锁

悲观锁
每次取数据都担心被修改,因此每次都会在取数据前对数据进行加锁处理

乐观锁
每次取数据总是认为不会被修改,因此不上锁,但是在更新数据前会判断其它数据在更新前有没有对数据进行修改,主要采用版本号机制和CAS操作
CAS(compare and swap):当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新,如果不相等则失败,失败则重试,一般是一个自旋的过程,即不断的重试

自旋锁
20 Linux线程池_第9张图片

因为占有临界资源的线程,在临界区待的时间特别短,无需挂起,让当前线程处于自旋状态不断的去检测锁的状态,而其中,自旋锁为上述所述功能。自旋锁会不断的去检测锁的状态。
自旋锁的实现:
20 Linux线程池_第10张图片

STL中的容器是否是线程安全

不是.原因是, STL 的设计初衷是将性能挖掘到极致, 而一旦涉及到加锁保证线程安全, 会对性能造成巨大的影响.而且对于不同的容器, 加锁方式的不同, 性能可能也不同(例如hash表的锁表和锁桶).因此 STL 默认不是线程安全. 如果需要在多线程环境下使用, 往往需要调用者自行保证线程安全。

读者写者问题

在编写多线程的时候,有一种情况是非常常见的。那就是有些公共数据修改的机会比较少。相对于写,他们读的机会反而很高。
通常而言,在读的过程中,往往伴随查找的操作,中间耗时很长,给这种代码段加锁,会极大的降低程序的效率。
那么如何处理这种多读少写的情况呢?那就是使用读写锁
20 Linux线程池_第11张图片

读写锁和普通锁的接口非常类似:
20 Linux线程池_第12张图片

读写者优先级问题:
20 Linux线程池_第13张图片

底层实现:
20 Linux线程池_第14张图片

20 Linux线程池_第15张图片

你可能感兴趣的:(Linux,linux)