多线程 --- [ 线程同步, 生产者消费者模型 ]

目录

1. 条件变量

1.1. 认识条件变量的接口

1.1.1. 条件变量初始化

1.1.2. 销毁条件变量

1.1.3. 等待条件满足

1.1.4. 唤醒等待

1.2. 条件变量的demo

2. 生产者消费者模型

2.1. 用实例说明CP

2.2. 站在工程师思维,重新理解CP

2.3. 通过C++ queue 模拟阻塞队列式的生产者消费者模型


1. 条件变量

引入同步

情况一:

在多线程场景下,多个执行流同时在被调度的时候,假设这几个执行流在执行同样的任务,并且都是通过加锁和解锁的方式进入临界区访问临界资源,如果此时,在整个调度期间,如果大部分时间甚至所有时刻都只是某个线程获得了锁并访问临界资源,这种情况虽然满足了互斥,即临界资源在被访问的任意时刻都只有一个执行流在访问 (保证了临界资源的安全性)。

上面的情况虽然不算错误情况,但是不合理。不合理的点在于,每次或者大部分情况下都是某个特定的线程申请到了资源,那其他的线程怎么办呢?

对于上面这种场景而言,如果某个执行流频繁的申请到了锁,并进入临界区访问临界资源,可能会导致其他执行流获取不到锁,导致自身一直处于无限阻塞或者等待状态,而这种现象我们称之为饥饿问题。

饥饿问题可能导致系统性能下降,降低了并发性和吞吐量,同时也违背了公平性原则。饥饿问题的出现通常是由于某些线程被其他线程持续地抢先占用资源,导致等待的线程无法获取所需资源而一直处于等待状态。

情况二: 

小徐准备买一款华为 Mate50,就到了手机专卖店问售货员,你们这里有Mate50吗? 售货员摇了摇头,说没有! 你也没有说什么,立马就走了。 第二天,你又来问服务员了,说,今天有Mate50吗? 售货员回答: 没有。 于是你转身又走了! 从此,周而复始,一个月过去了。

在上面这个场景中, 小徐的目的是为了获得 Mate50,而售货员是为了帮助消费者,提供服务。小徐一直在手机专卖店询问售货员是否有 Mate50。尽管售货员每次都如实回答了他的问题。而上面的过程虽然没有任何错,但是不合理!在生活中,我们应该是,第一次去了解,售货员说没有货,正常情况下,我们应该是加一下售货员的联系方式,让他在有货的情况下及时联系我!而不应该是我每天都去询问一次! 因为这样的效率太低了,既浪费了我的时间,也浪费了售货员的时间,最重要的是,没有解决任何问题 (小徐从始至终都没有获得 Mate50)!

上面这两种情况虽然都没有错,但是不合理!

为了解决上面的问题,我们需要引入线程同步,主要是解决访问临界资源合理性的问题。

例如策略一: 某个执行流申请到了锁资源,进入临界区访问临界资源,当释放锁后,我们规定,这个线程不能立即在申请锁,而应该在等待锁资源的队列中排队 (队列的为尾部),让队列中的下一个线程去获取锁资源,这样我们就一定程度降低了饥饿问题发生的概率,并且我们可以保证让若干个线程按照一定的顺序去申请锁访问临界资源。

例如策略二:当某个执行流申请到了锁并进入了临界区,我们第一步不应该是直接访问临界资源,而是应该检测临界资源是否就绪,如果临界资源就绪,直接访问即可!如果不就绪,那么该线程应该要被挂起等待!避免发生某个执行流频繁申请释放锁,而又因为临界资源不就绪,导致执行流效率低下。

线程同步 && 竞争条件的概念

线程同步:在多线程场景下,若干个执行流按照一定顺序进行临界资源的访问,从而有效避免饥饿问题,我们称之为线程同步!

竞争条件(Race Condition):竞态条件是指在多个执行流并发执行时,由于时序问题 (程序结果与执行顺序有关),导致程序异常,我们称之为竞争条件。

条件变量

而为了实现线程同步,于是有了条件变量这个实现方案。

首先我们看看下面的这个理解链:

当线程访问临界资源前   --->   并不是直接访问,而是要先检测临界资源是否就绪! --->  而要检测临界资源,其本质:也是在访问临界资源!--->   因此,对临界资源的检测,也一定是需要在加锁和解锁之间的!

常规方式: 检测临界资源的这个过程,如果临界资源不就绪,那么很有可能产生线程频繁申请和释放锁的这个过程!换言之,此时执行流所做的工作就非常低效!虽然这种方案没有错误,但是不合理。

因此,提出了新的解决方案:

当线程检测到临界资源不就绪时,让该线程挂起等待,避免频繁检测。

当临界资源就绪的时候,那么应该通知被挂起等待的线程,以便它们能够按照一定的顺序访问临界资源。

1.1. 认识条件变量的接口

下面的所有接口,其返回值都是int,当成功时,返回0; 失败,返回相应的错误码。

1.1.1. 条件变量初始化

静态初始化

// 初始化全局/静态条件变量
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;  

与互斥锁同理,由于生命周期伴随着整个进程,因此全局/静态条件变量我们不需要显示销毁。

动态初始化

int pthread_cond_init(pthread_cond_t *restrict cond,
              const pthread_condattr_t *restrict attr);

cond:指向条件变量对象的指针。在函数调用之后,该指针将指向已初始化的条件变量对象。

attr:指向条件变量的属性对象的指针。一般情况下将该参数设为 nullptr,表示使用默认的条件变量属性。条件变量属性对象可以用于在条件变量的创建时设置特定的属性,例如设置条件变量的类型或设置进程间共享的条件变量等特性。

对于非全局/静态的条件变量,在使用完毕后,需要我们进行手动释放,以免造成资源泄露。

1.1.2. 销毁条件变量

int pthread_cond_destroy(pthread_cond_t *cond);

一定要注意,对于非全局/静态的条件变量,要手动释放。 

1.1.3. 等待条件满足

pthread_cond_wait  函数执行时会将当前线程阻塞,直到满足某个特定条件。在被唤醒后,它会重新获取互斥锁,并且认为该条件满足,继续执行后续的操作。

int pthread_cond_wait(pthread_cond_t *restrict cond,
              pthread_mutex_t *restrict mutex);

cond:要再该条件变量上进行等待。在调用 pthread_cond_wait 前,要确保该条件变量已被初始化。

mutex:在调用 pthread_cond_wait 前,必须确保当前线程已经获取到该互斥锁,因为 pthread_cond_wait 会在等待之前自动释放该互斥锁,并在被唤醒后重新获取它。

补充:pthread_cond_wait 函数应该配合互斥锁一起使用,以免发生竞争条件 (race condition)。通常的使用方法是:在调用 pthread_cond_wait 之前,先获取互斥锁;在条件就绪之前,使用 pthread_cond_wait 来等待就绪满足,并释放互斥锁;当条件满足后,重新获取互斥锁,并继续执行后续操作。

1.1.4. 唤醒等待

int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond); // 广播线程

pthread_cond_signal :用于仅唤醒一个正在等待在特定条件变量上的线程。如果有多个线程在同一个条件变量上等待,那么只会唤醒其中的一个线程,让它继续执行。
pthread_cond_broadcast:用于唤醒所有正在等待在特定条件变量上的线程。即使有多个线程在条件变量上等待,调用该函数也会将它们全部唤醒,让它们重新竞争资源。

1.2. 条件变量的demo

#define TNUM 3
volatile bool flag = false;

class thread_info
{
public:
  thread_info(const std::string& name, std::function<void(thread_info*)> func, pthread_mutex_t* Pmtx, pthread_cond_t* _Pcond)
    :_name(name)
     ,_func(func)
     ,_Pmtx(Pmtx)
     ,_Pcond(_Pcond)
  {}
public:
  std::string _name;
  std::function<void(thread_info*)> _func;
  pthread_mutex_t* _Pmtx;
  pthread_cond_t* _Pcond;
};

void func1(thread_info* arg)
{
  while(!flag)
  {
    pthread_mutex_lock(arg->_Pmtx);
    // 假设临界资源不就绪
    pthread_cond_wait(arg->_Pcond, arg->_Pmtx);
    std::cout << arg->_name << " running --- a" << std::endl;
    pthread_mutex_unlock(arg->_Pmtx);
  }
}

void func2(thread_info* arg)
{
  while(!flag)
  {
    pthread_mutex_lock(arg->_Pmtx);
    pthread_cond_wait(arg->_Pcond, arg->_Pmtx);
    std::cout << arg->_name << " running --- b" << std::endl;
    pthread_mutex_unlock(arg->_Pmtx);
  }
}

void func3(thread_info* arg)
{
  while(!flag)
  {
    pthread_mutex_lock(arg->_Pmtx);
    pthread_cond_wait(arg->_Pcond, arg->_Pmtx);
    std::cout << arg->_name << " running --- c" << std::endl;
    pthread_mutex_unlock(arg->_Pmtx);
  }
}

void* Entry(void* arg)
{
  thread_info* info = static_cast<thread_info*>(arg);
  info->_func(info);
  delete info;
  return nullptr;
}

void Test(void)
{
  pthread_t tid[TNUM];
  pthread_mutex_t mtx;
  pthread_cond_t cond;
  pthread_mutex_init(&mtx, nullptr);
  pthread_cond_init(&cond, nullptr);

  std::vector<std::function<void(thread_info*)>> Vfunc;
  Vfunc.push_back(func1);
  Vfunc.push_back(func2);
  Vfunc.push_back(func3);

  for(size_t i = 0; i < TNUM; ++i)
  {
    std::string name = "thread";
    name += std::to_string(i + 1);
    thread_info* info = new thread_info(name, Vfunc[i], &mtx, &cond);
    pthread_create(tid + i, nullptr, Entry, static_cast<void*>(info));
  }
  
  sleep(3);

  for(size_t i = 0; i < 10; ++i)
  {
    pthread_cond_signal(&cond);
    sleep(1);
  }

  flag = true;
  // 因为新线程可能又进入了临界区,
  // 并执行了pthread_cond_wait,导致自身被挂起
  // 因此在这里在广播一下
  std::cout << "------------" << std::endl;
  pthread_cond_broadcast(&cond);

  for(size_t i = 0; i < TNUM; ++i)
  {
    pthread_join(tid[i], nullptr);
  }

  pthread_mutex_destroy(&mtx);
  pthread_cond_destroy(&cond);
}

测试现象如下:

从上面的现象我们可以看出,在多线程场景下,当有了条件变量后,我们解决了两个问题。

其一:有了条件变量后,先申请到锁的线程,先进行临界资源的检测,如果临界资源不就绪,那么该执行流会先被挂起,等待临界资源就绪,再由主线程唤醒,继续执行,这样就避免了某个执行流频繁的申请释放锁,但却没有实质性的进展。

其二:有了条件变量后,在多执行流场景下,执行流是按照一定顺序执行的,当一个执行流访问完毕后,如果还想继续访问,那么必须要到条件变量队列的尾部排队,因此,我们避免了一个线程频繁的申请到临界资源而导致其他执行流的饥饿问题。

为了更深入的理解条件变量,我们需要先理解一下生产者消费者模型。 

2. 生产者消费者模型

首先,我们提出两个问题:

其一:在上面的代码中,当条件满足后,主线程唤醒新线程执行,可是,主线程怎么知道条件满足的呢?

其二:为什么 pthread_cond_wait 第二个参数是一把锁呢?

为了解决上面的问题,我们需要了解下生产者消费者模型。

2.1. 用实例说明CP

在日常生活中,有没有生产者消费者模型呢? 答案是有的。例如,超市。

超市目的是为了售卖商品,那么自然需要有消费者。而超市并不生产产品,那么超市的产品从哪里来呢?事实上,超市一般都有自己的供货商,也就是产品的生产者。

超市就是一种交易场所。供货商 (生产者) 将产品生产后直接交付给超市 (交易场所),消费者购物时直接去超市根据自己的需求购买相应的商品。

如图所示:

那么为什么不让消费者直接和生产者进行交互呢? 原因是因为,效率太低了。

例如在现实生活中,生产者不可能因为某个消费者的需求而去生产相应的产品,因为有可能成本远大于利润且交互成本也过高。在现实生活中,应该是超市将众多消费者的需求聚集起来,然后让生产者进行统一生产,生产后,将产品统一汇集到超市中,供消费者集中消费。这样做的目的是:通过将生产者和消费者进行逻辑解耦,达到提高效率的目的。

目前我们发现,在生产者和消费者模型中,有两个角色:生产者 && 消费者。

事实上,有这两种角色会产生三种关系:

消费者与消费者:假设某种资源非常少甚至只有一个,如果此时有很多个消费者都想获得该资源,那么这些消费者互相是什么关系呢? 答案显而易见,这些消费者互相是一种竞争关系,即互斥关系 (资源被独占)

生产者和生产者:我们不考虑太复杂的情况,我们假设消费者只需要一种商品,那么生产这件商品的生产者们,是什么关系呢? 答案:竞争关系,即互斥关系。 这很好理解,一个生产者生产了该商品,那么意味着消费者对于该商品的需求就下降了,其他生产者的生产量就会得到抑制。

生产者和消费者:当生产者在生产产品的时候,消费者不可以获取该产品,因此存在着互斥关系。只有生产者生产完产品并将其导入交易场所后,消费者才能获得该产品。当交易场所没有产品时,消费者需要通知生产者进行生产。因此存在着同步关系 (生产完在消费,消费完在生产)。

总结:就目前而言,我们可以得知:

三种关系:消费者和消费者 (互斥关系),生产者和生产者 (互斥关系),消费者与生产者 (互斥、同步关系)。

两种角色: 消费者 && 生产者,线程承担 (线程角色化) 。

一种交易场所:数据的缓冲区,一般都是某种数据结构表示的缓冲区。

我们简称 "321"原则。该原则,教科书并没有明确说明,在这里只是为了方便记忆和理解。

未来我们的编码一定是:

某些线程生产数据 (生产者),Load到特定数据结构表示的缓冲区里 (交易场所),另外一些线程从该缓冲区获取相应的数据进行处理 (消费者)。

最后,再补充一下:

交易场所有没有商品 (数据),谁最清楚,生产者最清楚!

交易场所还剩多少空间供生产者生产商品谁最清楚? 消费者最清楚!

因此当我们对线程角色化后,我们就可以回答第一个问题了 (当条件满足后,主线程唤醒新线程执行,可是,主线程怎么知道条件满足的呢? ,即当生产者生产了商品后,此时生产者就可以立即知道有商品可以供消费者消费了,进而通知消费者处理 (消费) 商品。

同理,当消费者消费完了商品后,此时消费者就可以立即知道交易场所有新的空间供生产者生产商品了,进而通知生产者生产(产生数据)商品!此时我们就可以让生产者线程和消费者线程互相同步完成对应的业务逻辑!

2.2. 站在工程师思维,重新理解CP

有了上面的理解,我们如果想要编写一个生产者消费者模型,那么我们所要做的工作就是:构造2种角色 (对线程角色化),构造一个交易场所 (某种数据结构表示的缓冲区),并通过代码维护好上面的三种关系。

如果只有一个生产者、一个消费者:

那么此时不需要维护生产者和生产者的关系以及消费者和消费者的关系,只需要维护好生产者和消费者的关系即可。

最后再补充一点,生产者生产的数据也是需要有来源,是需要花时间的。消费者消费从交易场所获得的数据也是需要花时间的。如图所示:

2.3. 通过C++ queue 模拟阻塞队列式的生产者消费者模型

思路:构造一个生产者,构造一个消费者,并通过阻塞队列的形式实现我们的生产者消费者模型,具体代码如下:

ProdCon.hpp:

#pragma once
#include <iostream>
#include <queue>
#include <pthread.h>
#include <unistd.h>
#include "LockGuard.hpp"

#define DEFAULT_CAP 4

template<class T>
class block_queue
{
private:
  bool isFull()
  {
    return _que.size() == _capacity;
  }

  bool isEmpty()
  {
    return _que.size() == 0;
  }
public:
  block_queue(size_t capacity = DEFAULT_CAP)
    :_capacity(capacity)
  {
    pthread_mutex_init(&_mtx, nullptr);
    pthread_cond_init(&_full, nullptr);
    pthread_cond_init(&_empty, nullptr);
  }

  void push(const T& val)
  {
    pthread_mutex_lock(&_mtx);
    // 第一步并不是直接访问临界资源
    // 而是应该先检测临界资源是否就绪
    // 对于push而言,线程的角色应该是生产者
    // 那么条件就是阻塞队列是否为满
    // 如果满了就应该阻塞挂起
    
    // 需要注意的是, pthread_cond_wait是有可能调用失败的,
    // 如果我们用if判断,那么如果pthread_cond_wait失败了,那么
    // 该线程很有可能没有被挂起并继续向后执行
    // 但是此时临界资源是不就绪的,导致进程出现异常
    // 这种情况称之为 "伪唤醒",含义是:线程在没有收到条件变量的通知的情况下,
    // 换言之就是临界资源不就绪,该线程仍然从等待队列中被唤醒
    //if(isFull()) 
    // 因此实际上,我们应该用while循环检测,确保线程被唤醒时
    // 临界资源一定是就绪的。
    while(isFull())
      // 当我进行pthread_cond_wait时,我惊奇的发现
      // 我此时竟然是处于临界区内的,换言之,我此时是持有锁的
      // 而我为什么要pthread_cond_wait呢?不就是因为临界资源不就绪吗?
      // 而pthread_cond_wait会导致当前线程被挂起等待,那这把锁该怎么办?
      // 如果让我这个线程继续占有,那么其他线程就无法获得这把锁(不可剥夺条件)
      // 就无法进入临界区, 而其他线程很有可能就是帮助我这个线程
      // 解决临界资源不就绪的问题,可是现在锁被占用着。
      // 因此,实际上,pthread_cond_wait为什么要有第二个参数,即这把锁呢?
      // 原因就是因为,当成功调用pthread_cond_wait之后,该线程占用的锁
      // 也就是这把传入的锁会被自动释放。
      pthread_cond_wait(&_full, &_mtx);

    // 那么当我这个线程被唤醒之后,该线程从哪里醒来呢?
    // 答案是:从哪里被挂起的,就从那里唤醒。
    // 于是我们又发现了,线程被唤醒后,依旧还是处于临界区内的
    // 因此,当线程被唤醒的时候,pthread_cond_wait会自动帮助该线程
    // 获取这把锁。
    
    _que.push(val);
    // 当生产者将数据导入了交易场所之后,那么其另外一层意义就是
    // 消费者可以立即来消费数据了。因此我们可以通过生产者来
    // 通知消费者来消费数据
    pthread_cond_signal(&_empty);
    pthread_mutex_unlock(&_mtx);
  }

  void pop(T* Pval)
  {
    // RAII风格的加锁方式
    mutex_guard guard(&_mtx);
    while(isEmpty())
      pthread_cond_wait(&_empty, &_mtx);

    *Pval = _que.front();
    _que.pop();
    // 当消费了一个数据后,此时队列就一定有空间在供生产者生产
    // 因此消费者可以通知生产者生产数据
    pthread_cond_signal(&_full);
  }

  ~block_queue()
  {
    pthread_mutex_destroy(&_mtx);
    pthread_cond_destroy(&_full);
    pthread_cond_destroy(&_empty);
  }

private:
  std::queue<T> _que; // 队列
  size_t _capacity; // 阻塞队列的容量
  pthread_mutex_t _mtx;
  // 两种条件场景,需要两个条件变量:
  // 其一: 阻塞队列已满,生产者通知消费者消费数据,生产者被阻塞。
  pthread_cond_t _full; // 满了的条件变量 (生产者)
  // 其二: 阻塞队列为空,消费者通知生产者生产数据,消费者被阻塞
  pthread_cond_t _empty; // 空了的条件变量 (消费者)
};

LockGuard.hpp:

#pragma once
#include <iostream>
#include <pthread.h>

class mutex
{
public:
  mutex(pthread_mutex_t* mtx) :_mtx(mtx){}

  void lock()
  {
    pthread_mutex_lock(_mtx);
  }

  void unlock()
  {
    pthread_mutex_unlock(_mtx);
  }

  ~mutex() {}
private:
  pthread_mutex_t* _mtx;
};

// RAII风格的加锁方式
class mutex_guard
{
public:
  mutex_guard(pthread_mutex_t* lock) :_mtx(lock)
  {
    _mtx.lock();
  }

  ~mutex_guard()
  {
    _mtx.unlock();
  }

private:
  mutex _mtx;
};

 ProdCon.cxx:

#include "ProdCon.hpp"

void* consume(void* arg)
{
  block_queue<int>* Pbque = static_cast<block_queue<int>*>(arg);
  int a = 0;
  while(true)
  {
    Pbque->pop(&a);
    std::cout << "消费者获得了一个数据:" << a << std::endl;
    //sleep(1);
  }
  return nullptr;
}

void* product(void* arg)
{
  block_queue<int>* Pbque = static_cast<block_queue<int>*>(arg);
  int a = 0;
  while(true)
  {
    Pbque->push(a);
    std::cout << "生产者生产了一个数据:" << a << std::endl;
    a++;
    sleep(1);
  }
  return nullptr;
}

int main()
{
  block_queue<int>* Pbque = new block_queue<int>();

  pthread_t con, prod;
  pthread_create(&con, nullptr, consume, static_cast<void*>(Pbque));
  pthread_create(&prod, nullptr, product, static_cast<void*>(Pbque));

  pthread_join(con, nullptr);
  pthread_join(prod, nullptr);

  delete Pbque;
  return 0;
}

此时的现象如下: 

因为生产者生产数据慢,消费者消费数据快,故我们们看到的现象就是生产者生产一个数据,消费者消费一个。 

这就是我们阻塞队列式的生产者消费者模型。该实例为我们解决了第二个问题,即 pthread_cond_wait 为什么要有第二个参数,即这把锁呢?

原因有两个,具体如下:

线程在等待条件时需要释放锁:因为调用pthread_cond_wait时,该线程是持有锁的,由于要挂起等待,并且不占用锁资源,因此需要传入这把锁,在函数内部进行释放这把锁。

等待条件满足后重新获得锁:当线程被唤醒后,此时这个线程仍在临界区内,因此需要有pthread_cond_wait 在帮助该线程获取这把锁。

生产者消费者模型的效率提升,我们不能仅仅局限于生产者将数据拷贝到缓冲区,消费者再从缓冲区内获得数据,这样的理解是片面的。

当消费者使用从交易场所获得的数据这个过程是需要时间的,而此时这个消费者就没有进入交易场所了,那么在这个过程中,生产者就可以生产数据并导入交易场所中。同理,

生产者生产的数据也是需要来源的,即也是需要花时间的。在获取数据这个过程中,消费者就可以进入交易场所获取数据。

因此实际上,生产者消费者模型提高效率的另一大原因是:可以提高生产消费的并发度,进而调高整体的效率。

为了提高效率,可以通过提高生产者和消费者的并发度来解决这个问题。如下:

引入多个生产者和消费者:通过引入多个生产者和消费者,可以让它们并行地进行生产和消费操作,提高系统的整体吞吐量。

一般而言,我们可以根据生产数据和消费数据的时间长短来决定是否使用多生产多消费。

当生产数据和消费数据的过程比较长,那我们可以采用多生产多消费,提高生产消费的并发度,进而提高效率;

当生产数据和消费数据的过程比较短,那我们没有必要使用多生产多消费,因为此时CPU调度不同线程的时间就可能成为影响效率的主要原因,整体效率可能并不会有太大提升。

  • 28
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值