Linux多线程_(线程池,单例模式,读者写者问题,自旋锁)

Posted 楠c

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux多线程_(线程池,单例模式,读者写者问题,自旋锁)相关的知识,希望对你有一定的参考价值。

1. 线程池

1.1 是什么

一种线程使用模式。可以避免大面积请求引起的服务器宕机。

1.2 为什么

  • 线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。
  • 降低资源消耗:通过重用已经创建的线程来降低线程创建和销毁的消耗
  • 提高线程的可管理性:线程池可以统一管理、分配、调优和监控

1.3 怎么用

线程池的应用场景:

  1. 需要大量的线程来完成任务,且完成任务的时间比较短。 WEB服务器完成网页请求这样的任务,使用线程池技
    术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。 但对于长时间的任务,比如一个
    Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。
  2. 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
  3. 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,出现错误.

1.5 线程池代码

这个线程池类,需要一个队列,来存取任务,创建5个线程,让main线程塞任务,这5个线程去执行任务。多线程所以他还要有个锁,main线程放任务,需要让多个线程需要同步,所以需要一个条件变量。

在这里插入图片描述

ThreadPool.hpp

#include<iostream>
#include<queue>
#include<math.h>
#include<unistd.h>
#include<stdlib.h>
using namespace std;

#define NUM 5
class Task 
{
  private:
    int _a;
    
  public:
    Task(){}
    Task(int a)
      :_a(a)
    {};
    void run()
    {
      
     cout<<"pthread["<<pthread_self()<<"] : "<<_a <<":"<<pow(_a,2)<<endl;
    }
};


class Pool
{
  private:
    queue<Task*> q;
    int max_num;
    pthread_mutex_t lock;
    pthread_cond_t cond;
  public:
    Pool(int max=NUM)
    :max_num(max)
    {}

    bool IsEmpty()
    {
      return q.size()==0;
    }
    void Threadwait()
    {
      pthread_cond_wait(&cond,&lock);
    }
    void ThreadWakeUp()
    {
      //一次唤醒一个
     // pthread_cond_signal(&cond);
      pthread_cond_broadcast(&cond);
    }
    //外部放任务
    void put(Task& in)
    {
     LockQueue();
     q.push(&in); 
     UnlockQueue();
     //放任务就把你唤醒,一次唤醒一个
     ThreadWakeUp();
    }

    //线程池线程拿任务
    void Get(Task& out)
    {
      
      //调用的地方加了
       Task *t=q.front(); 
       q.pop();
       out=*t;
       
    }
    static void* routine(void* args)
    {
       Pool* this_p=(Pool*)args;
       this_p->LockQueue();
       while(1)
       {
       //为空时不拿
       pthread_detach(pthread_self());
       while(this_p->IsEmpty())
       {
         this_p->Threadwait();
       }
       
       Task t;
       this_p->Get(t);
       
       this_p->UnlockQueue();
        
       t.run();

    }
    }

    void PoolInit()
    {
     pthread_mutex_init(&lock,NULL);
     pthread_cond_init(&cond,NULL);

     //不关心线程id,所以用一个变量当参数就行了
     pthread_t tid;
     for(int i=0;i<max_num;i++)
     {
       //因为成员函数routine中有两个参数,这里要传函数名和形参,形参只能传一个。用static
       // 又因为静态函数没有this指针,而里面又要调用成员函数,所以传递this指针
       
       pthread_create(&tid,NULL,routine,this);
     }

    }
    void LockQueue()
    {
      pthread_mutex_lock(&lock);
    }
    void UnlockQueue()
    {
      pthread_mutex_unlock(&lock);
    }
     

};

main.cc

#include"ThreadPool.hpp"

int main()
{
  Pool p;
  p.PoolInit();
  sleep(2);
  while(true)
  {
    int x=rand()%100+1;
    
    Task t(x);
    
    p.put(t);
    sleep(1); 
  }
  return 0;
}

1.6 实验现象

在这里插入图片描述

1.7 实验总结

  • 可以看到由于我们是一次唤醒一个线程(signal),所以他是按顺序一次执行任务,假如队列为空然后等待被唤醒,这也正说明了条件变量cond中存在一个等待队列。任务执行完成。

  • 假如一次唤起一群的话(broadcast),由于你只有一个任务执行,其他线程又会休眠,引起性能震荡,也叫做惊群效应。

  • 线程池,耗费少量资源,但是程序健壮性不强。(一个线程异常,整个进程崩溃)

  • 进程池,耗费较多资源,程序健壮性较强。(进程之间具有独立性,互不影响)

2. 单例模式

在很多服务器开发场景中, 经常需要让服务器加载很多的数据 (上百G) 到内存中. 此时往往要用一个单例的类来管理这些数据。

  • 吃完饭, 立刻洗碗, 这种就是饿汉方式. 因为下一顿吃的时候可以立刻拿着碗就能吃饭.
  • 吃完饭, 先把碗放下, 然后下一顿饭用到这个碗了再洗碗, 就是懒汉方式.

2.1 饿汉方式

template <typename T>
class Singleton {
static T data;
public:
static T* GetInstance() 
   {
   return &data; 
   }
};

由于他是一个静态对象,所以类加载的时候,他就会创建出来,用的时候通过这个类的成员方法,直接可以拿到地址使用。但是存在一个问题,假如程序中存在大量的不同单例,类加载的时候,启动会十分慢

2.2 懒汉方式

template <typename T>
class Singleton {
static T* inst;
public:
static T* GetInstance() {
if (inst == NULL) 
  {
     inst = new T();
  } 
return inst;
  }
};

他是一个静态指针,所以类加载的时候不会创建对象,当需要用这个对象时,调用方法才会创建出来供我们使用。所以它的核心思想是"延时加载",从而能够优化服务器的启动速度。

2.3 懒汉模式(线程安全)

饿汉是不存在线程安全的,因为类加载,对象已经创建地址唯一,即使多个线程进来也只是拿到他的地址。
而懒汉呢,当多个线程进来,对象还没有创建,假如同时判空,这样就创建了多个对象。那就不是单例模式了。所以需要加锁。

// 懒汉模式, 线程安全
template <typename T>
class Singleton {
       volatile static T* inst; // 需要设置 volatile 关键字, 否则可能被编译器优化.
       static std::mutex lock;
public:
static T* GetInstance() {
     if (inst == NULL) 
         { // 双重判定空指针, 降低锁冲突的概率, 提高性能.
         lock.lock(); // 使用互斥锁, 保证多线程情况下也只调用一次 new.
     if (inst == NULL) 
       {
          inst = new T();
       } 
          lock.unlock();
       } 
       return inst;
       }
};

双重判空是因为,假如不同时进来,就没有必要加锁,所以在外面再加一个判定,假如另一个线程进来想创建对象就会自己返回,假如同时进来大不了我再加个锁。其实不同时进来的概率是更高的,同时进来我们的锁也可以处理,所以效率更高。

2.4 STL线程安全问题

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

2.5 智能指针线程安全问题

对于 unique_ptr, 由于只是在当前代码块范围内生效, 因此不涉及线程安全问题.
对于 shared_ptr, 多个对象需要共用一个引用计数变量, 所以会存在线程安全问题. 但是标准库实现的时候考虑到了这个问题, 基于原子操作(CAS)的方式保证 shared_ptr 能够高效, 原子的操作引用计数.

3. 其他常见的各种锁

3.1 悲观锁

在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。所以之前用的到全部是悲观锁

3.2 乐观锁

每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,
会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。

3.3 CAS操作

Compare and Swap
当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。

3.4 读者写者问题(读写锁)

对于这个问题,也可以简单总结。
3种关系:
在这里插入图片描述
与生产者,消费者不同的是,消费者是会取走数据的。

3.4.1 初始化

在这里插入图片描述

3.4.2 销毁

在这里插入图片描述

3.4.3 加锁和解锁

在这里插入图片描述
有些公共数据修改的机会比较少。相比较改写,它们读的机会反而高的多。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率。那么有没有一种方法,可以专门处理这种多读少写的情况呢? 有,那就是读写锁

因为应用场景,可以分为3类,读优先,写优先,公平。但是常见于读多写少的场景。
以读优先为例,他是怎么实现的呢?
在这里插入图片描述
当第一个读者进来,rc==1,所以对写者加锁,这样就实现了写者线程阻塞,读者线程优先。注意判断的时候由于可能多个线程同时进入判断,需要加锁。当读取完数据后,在进行判断如果读者线程为0,那么就对写者线程解锁。

3.5 自旋锁

之前,信号量,互斥锁,条件变量,申请不到就一直阻塞。

而自旋锁,spin,因为占有临界资源的线程,在临界区呆的时间特别短,无需挂起,让当前线程处于自旋状态,不断去检测锁的状态。自旋锁为我们提供了上述功能。锁在操作系统都是汇编实现的。而实际应用场景可以根据任务要求在临界区的时间长短,来区分使用哪种锁。

以上是关于Linux多线程_(线程池,单例模式,读者写者问题,自旋锁)的主要内容,如果未能解决你的问题,请参考以下文章

LINUX多线程(线程池,单例模式,线程安全,读者写者模型)

Linux线程池 | 线程安全的单例模式 | STL智能指针与线程安全 | 读者写者问题

Linux----多线程(下)

Linux操作系统多线程

Linux操作系统多线程

Linux练习_线程练习_读者写者问题