Linux篇第十六篇——多线程(读写锁+线程池)

Posted 呆呆兽学编程

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux篇第十六篇——多线程(读写锁+线程池)相关的知识,希望对你有一定的参考价值。

⭐️ 本篇博客主要介绍读写锁和线程池相关的内容。我会给大家简单实现一个内存池,方便大家理解。

目录


🌏读写锁

🌲介绍

读写锁: 为了处理多线程中读数据比写数据更频繁(读多写少),给读加锁会带来效率降低的问题,引入了一种新的锁——读写锁。读写锁实际是一种特殊的自旋锁,它把对共享资源的访问者划分成读者和写者,读者只对共享资源进行读访问,写者则需要对共享资源进行写操作。
自旋锁: 对应自旋锁,只有一个线程获得锁资源(与互斥锁),其他未得到锁资源的线程不是挂起等待,而是处于自旋状态,不断去检测锁的状态(自旋锁应用于线程占在临界区内待的时间特别短的场景)

特点:

  • 三种关系: 读者和读者(共享)、读者和写者(互斥和同步) 和 写者和写者(互斥)
  • 两个角色: 读者和写者
  • 一份资源: 写操作,读读取

生产消费模型和读写锁的区别:

读写锁中读者不会拿走数据,但生产消费模型中的消费者会拿走数据,所以读写锁中读者与读者直接是可以共享数据,同时读的

读写锁的三种同步方案:

  1. 读优先: 想尽一切办法让读者先读。当前为读锁,读者可直接进入,为写锁,当前为写锁,写者写完之后,让读者先进入读(可能会造成写饥饿问题)
  2. 写优先: 想尽一切办法让写者先写。当前为读锁或写锁,写者都不可以进入,读者读完或写者写完之后,写者可以先进入写
  3. 公平占有锁: 读者写者公平竞争锁

读写锁的行为:

当前锁的状态读锁请求写锁请求
无锁允许允许
读锁允许阻塞
写锁阻塞阻塞

🌲相关接口

初始化:

函数原型:

int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,const pthread_rwlockattr_t *restrict attr); 

参数:

  • restrict rwlock:要初始化的锁
  • restrict attr:不关心,可以设置为空

返回值: 成功返回0,失败返回错误码

销毁:

函数原型:

int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

参数:

  • rwlock:要销毁的锁

返回值: 成功返回0,失败返回错误码

加锁和解锁:

函数原型:

// 读加锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
// 写解锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
// 解锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

参数:

  • rwlock:锁

返回值: 成功返回0,失败返回错误码

🌏线程池

🌲介绍

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

线程池的价值:

  1. 需要大量的线程来完成任务,且完成任务的时间比较短。可同时处理多任务,多请求。
  2. 有任务可以立即从线程池中调取线程取处理,节省了线程创建的时间
  3. 有效防止服务端线程过多而导致系统过载的问题

线程池与进程池:

  • 线程池占用资源少,但是健壮性(鲁棒性)不强
  • 进程池占用资源多,但是健壮性(鲁棒性)更强(涉及进程间通信,可利用管道的阻塞队列实现进程间同步和互斥)

🌲实现

🍯概述

线程池中首先需要有很多个线程,用户可以自己选择创建多少个线程。为了实现线程间的同步与互斥,还需要增加两个变量——互斥量和条件变量。我们还需要一个任务队列,主线程不断往里面塞任务,线程池的线程不断去处理。需要注意的是:这里的任务队列可以为空,但不能满,所以任务队列的容量不限定(实际场景中,任务队列容量不够就需要考虑换一台更大的服务器)

线程池的四个成员变量:

  • 一个队列: 存放任务
  • 线程池中线程数: 记录线程池中创建的线程数
  • 互斥量: 一个互斥锁
  • 条件变量: 队列为空时的条件变量

首先封装一个任务:

class Task

public:
  Task(int a = 0, int b = 0)
    :_a(a)
    ,_b(b)
  
  void Run()
  
    std::cout << "pthread:" << pthread_self() << " has dealt with a task: " << _a << " + " << _b << " = "<< _a + _b << std::endl;
  
private:
  int _a;
  int _b;
;

线程池的主要代码框架(唤醒和等待操作都已经封装好):

#define DEFAULT_MAX_PTHREAD 5

class ThreadPool

public:
  ThreadPool(int max_pthread = DEFAULT_MAX_PTHREAD)
    :_max_thread(max_pthread)
  
  ~ThreadPool()
  
    pthread_mutex_destroy(&_mutex);
    pthread_cond_destroy(&_cond);
  
public:
  void LockQueue()
  
    pthread_mutex_lock(&_mutex);
  
  void UnlockQueue()
  
    pthread_mutex_unlock(&_mutex);
  
  void ThreadWait()
  
    pthread_cond_wait(&_cond, &_mutex);
  
  void WakeUpThread()
  
    pthread_cond_signal(&_cond);
    //pthread_cond_broadcast(&_cond);
  
  bool IsEmpty()
  
    return _q.empty();
   
private:
  std::queue<Task*>  _q;
  int             _max_thread;
  pthread_mutex_t _mutex;
  pthread_cond_t  _cond;
;

注意:

  • 线程池中的任务队列存放的是任务指针类型,节省任务队列的空间开销
  • 线程池的初始化操作不在构造函数中进行,而是提供了一个初始化接口(下面介绍),让用户自行调用。这是为了不让构造函数做有风险的事情(如果函数调用失败,会导致线程池创建失败)

🍯创建多个线程

创建多个线程可以用一个循环进行创建。需要注意的是,创建一个线程还需要提供一个线程启动后要执行的函数,这个启动函数只能有一个参数。如果把这个函数设置为成员函数,那么这个函数的第一个参数默认是this指针,这样显然是不可行的,所以这里我们考虑把这个启动函数设置为静态的。但是设置为静态的成员函数又会面临一个问题:如何调用其他成员函数和成员变量? 所以这里我们考虑创建线程的时候,把this指针传过去,让启动函数的arg 参数去接收即可

创建线程代码如下:

static void* Runtine(void* arg)

  pthread_detach(pthread_self());
  ThreadPool* this_p = (ThreadPool*)arg;

  while (1)
    this_p->LockQueue();
    while (this_p->IsEmpty())
      this_p->ThreadWait();
    
    Task* t;
    this_p->Get(t);
    this_p->UnlockQueue();
    // 解锁后处理任务
    t->Run();
    delete t;
  

void ThreadPoolInit()

  pthread_mutex_init(&_mutex, nullptr);
  pthread_cond_init(&_cond, nullptr);
  pthread_t t[_max_thread];
  for(int i = 0; i < _max_thread; ++i)
  
    pthread_create(t + i, nullptr, Runtine, this);
  

注意: 线程创建后,执行启动函数,在这个函数中,线程会去任务队列中取任务并处理,取任务前需要进行加锁的操作(如果队列为空需要挂起等待),取完任务然后进行解锁,然后处理任务,让其它线程去任务队列中取任务

🍯取任务和放任务

放任务: 主线程无脑往任务队列中塞任务,塞任务之前进行加锁,塞完任务解锁,然后唤醒在条件变量下等待的队列
取任务: 线程池中的线程从任务队列中取任务,这里不需要加锁,因为这个动作在启动函数中加锁的那一段区间中被调用的,其实已经上锁了

代码如下:

// 放任务
void Put(Task* data)

  LockQueue();
  _q.push(data);
  UnlockQueue();
  WakeUpThread();

// 取任务
void Get(Task*& data)

  data = _q.front();
  _q.pop();

🍯主线程的代码

主线程负责创建线程池,然后无脑塞任务即可

代码如下:

int main()

  srand((size_t)time(nullptr));
  ThreadPool* tp = new ThreadPool;
  tp->ThreadPoolInit();

  while (1)
    int x = rand()%10 + 1;
    int y = rand()%10 + 1;
    // 主线程一直塞任务
    sleep(1);
    tp->Put(new Task(x, y));
   
  return 0;

🍯结果分析

上面的代码运行结果如下:

结果分析: 可以看到的是,五个线程是按照特定顺序去取并执行任务的。这是因为五个线程会在条件变量下的等待队列下进行等待,且主线程每次只唤醒队列的第一个线程,所以这五个线程是有一定的次序性的,如果使用pthread_cond_board去执行唤醒的动作,结果会有所不同

🌏总结

多线程的内容就介绍到这里了,喜欢的话,欢迎点赞、收藏和关注~

以上是关于Linux篇第十六篇——多线程(读写锁+线程池)的主要内容,如果未能解决你的问题,请参考以下文章

Linux篇第十六篇——多线程(读写锁+线程池)

Linux篇第十九篇——网络套接字编程(TCP套接字的编写+多进程版本+多线程版本+线程池版本)

Linux篇第十九篇——网络套接字编程(TCP套接字的编写+多进程版本+多线程版本+线程池版本)

Linux篇第十九篇——网络套接字编程(TCP套接字的编写+多进程版本+多线程版本+线程池版本)

多线程面试题系列(16):多线程十大经典案例之一 双线程读写队列数据

Linux:详解多线程(线程池读写锁和CAS无锁编程)