【Linux】多线程 —— 线程同步 | 生产者消费者模型 | POSIX 信号量

🌈欢迎来到Linux专栏 ~~ 线程同步

线程同步

一、线程同步

✨同步的相关概念

同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免 饥饿问题

至于该如何正确理解 饥饿问题,需要再次请张三出场

话说张三在早上 6:00 抢到了自习室的钥匙,并开开心心的进入了自习室自习

到了中午12点,此时张三有些饿了,想出去吃个饭,吃饭就意味着张三需要把钥匙归还(这是规定)

张三很明显不想放弃这个自习室的钥匙(因为现在放弃钥匙,吃完饭回来还需排队)

于是法外狂徒张三决定放弃吃饭,强忍着饥饿再次拿起钥匙进入了自习室自习;刚进入自习室没几分钟,肚子就饿的咕咕叫,于是张三就又想出去吃饭,刚出门归还了钥匙,扭头看见大批同学就感觉很亏,一咬牙就又拿起钥匙进入了自习室,就这样张三反复横跳,直到下午 6:00 都还没吃上午饭,不仅自己没吃上午饭、没好好自习,还导致其他同学无法自习!

这种方法:张三没错,属于是合理利用规则,但学校层面觉得很不合理

因为张三这种不合理的行为,导致 自习室 资源被浪费了,在外等待的同学也失去了自习,陷入 饥饿状态 ,活生生被张三 "饿惨了"

为此校方更新了 自习室 的规则:

  • 所有自习完的同学在归还钥匙之后,不能立即再次申请
  • 在外面等待钥匙的同学必须排队,遵守规则

规则更新之后,就不会出现这种 饥饿问题 了,所以解决 饥饿问题 的关键是:在临界资源安全的前提下,让访问临界资源具有一定的顺序性! ------ 线程同步

即通过 线程同步 解决 饥饿问题


原生线程库 中提供了 条件变量 这种方式来实现 线程同步

逻辑链:通过条件变量 -> 实现线程同步 -> 解决饥饿问题

条件变量:当一个线程互斥的访问某个变量时,它可能发现在其他线程改变状态之前,什么也做不了

比如当一个线程访问队列时,发现队列为空,它只能等待,直到其他线程往队列中添加数据,此时就可以考虑使用 条件变量

条件变量的本质就是 衡量访问资源的状态
通俗意思:当你不符合条件时,就让你去等待,满足条件时(通过敲铃铛)把等待线程唤醒去继续执行

举个例子来彻底理解条件变量

现在小美和小梁在游戏开始前都被蒙上了眼,接着小梁需要把苹果放进盘子里供小美去拿(申请🔒 ------ 放下苹果🍎 ------ 解锁🔓

小梁视角 :但此时小梁不知道苹果什么时候被拿走了于是乎频繁的申请锁进来看看 ~ 苹果还在不在?(这不就是恶意竞争?你一直申请锁进来看,小美怎么办?

小美视角: 进去看看有没有苹果: 申请🔒 ------ 拿走苹果🍎 ------ 解锁🔓 ,拿完出来后,但此时不知道对方还有没有继续放苹果 ------ 于是也继续频繁申请锁进去看(也是恶意竞争

因为他们都不知道对方的状态 :有没有拿? 有没有放 ------ 都频繁的申请锁去里面看看 ------ 说明只维护互斥关系不够 ,还需要引入同步

引入铃铛🔔(条件变量),生产者就知道了:苹果被消费者拿走了,于是继续去竞争锁 ------ 把苹果放进去... 不断循环

生产者拿到锁 → 发现条件不满足(苹果还在) → 调用 wait → 原子解锁 + 阻塞等待


那么生产者和消费者在执行的过程中,有一种顺序性了(同步),不再是混乱的竞争。

如果是多生产者呢?

也可以是多个条件变量(铃铛)去协同

✨同步的操作接口

📌条件变量创建与销毁

作为出自 原生线程库条件变量 ,使用接口与 互斥锁 风格差不多,比如 条件变量 的类型为 pthread_cond_t同样在创建后需要初始化

cpp 复制代码
#include <pthread.h>

pthread_cond_t cond; // 定义一个条件变量
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);

pthread_cond_t* :表示想要初始化的条件变量

const pthread_condattr_t* :表示初始化时的相关属性 ,设置为 nullptr 表示使用默认属性

返回值成功返回 0,失败返回 error number

条件变量在使用结束后需要销毁

cpp 复制代码
#include <pthread.h>

int pthread_cond_destroy(pthread_cond_t *cond);

pthread_cond_t*想要销毁的条件变量

返回值成功返回 0,失败返回 error number

注:同互斥锁一样,条件变量支持静态分配,即在创建全局条件变量时,定义为PTHREAD_COND_INITIALIZER,表示自动初始化、自动销毁 ------ 这种定义方式只支持全局条件变量

📌条件等待

原生线程库 中提供了 pthread_cond_wait 函数用于等待

cpp 复制代码
#include <pthread.h>

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

pthread_cond_t*想要加入等待的条件变量

pthread_mutex_t*互斥锁,用于辅助条件变量

返回值成功返回 0,失败返回 error number

💥💥为什么这里需要传一把互斥锁呢?首先要明白 条件变量是需要配合互斥锁使用的,需要在获取 锁资源 之后,再通过条件变量判断条件是否满足

传递互斥锁的理由:(后续部分会剖析对应细节 ~ 这里知道个大概)

  • 条件变量也是临界资源,需要保护
  • 当条件不满足时(没有被唤醒),当前持有锁的线程就会被挂起,其他线程还在等待锁资源呢,为了避免死锁问题,条件变量需要具备自动释放锁的能力

📌唤醒线程

条件变量 中的线程是需要被唤醒的,否则它也不知道何时对 队头线程 进行判断,可以使用 pthread_cond_signal 函数进行唤醒单个线程

cpp 复制代码
#include <pthread.h>

int pthread_cond_signal(pthread_cond_t *cond);

pthread_cond_t :表示想要从哪个条件变量中唤醒线程

返回值成功返回 0,失败返回 error number

如果想唤醒全部线程 ,可以使用 pthread_cond_broadcast广播📢哈哈

cpp 复制代码
#include <pthread.h>

int pthread_cond_broadcast(pthread_cond_t *cond);

挨个通知该 条件变量 中的所有线程访问 临界资源

✨同步小Demo

一个主线程控制其他线程按顺序的打印

接下来简单使用一下 线程同步 相关接口

目标:创建 5 个次线程,等待条件满足,主线程负责唤醒

这里演示 单个唤醒广播 两种方式,此处无非是两行代码的区别!

cpp 复制代码
#include<iostream>
#include<string>
#include<unistd.h>
#include<pthread.h>

pthread_mutex_t gmutex = PTHREAD_MUTEX_INITIALIZER; //都静态定义
pthread_cond_t gcond = PTHREAD_COND_INITIALIZER;

void *Print(void *args)
{
    std::string name = static_cast<char*>(args);
    while (true)
    {
        //向显示器:共享资源打印
        pthread_mutex_lock(&gmutex);
        std::cout << "我是新线程: " << name << std::endl;
        //????
        pthread_cond_wait(&gcond, &gmutex); //起码要把锁给释放了,才能让其他线程拿到锁,继续打印
        pthread_mutex_unlock(&gmutex);
        // sleep(1);
    }
    return nullptr;
}

int main()
{
    pthread_t tids[4];
    for(int i = 0; i < 4; i++)
    {
        char *name = new char[64];
        snprintf(name, 64, "thread: %d", i+1);
        pthread_create(tids+i, nullptr, Print, (void*)name);
    }

    //主线程控制其他线程
    while (true)
    {
        pthread_cond_signal(&gcond); //唤醒一个线程
        pthread_cond_broadcast(&gcond); //唤醒所有线程
        sleep(1);
    }
    
    for(int i = 0; i < 4; i++)
    {
        pthread_join(tids[i], nullptr);
    }

    return 0;
}

单个唤醒:

全部唤醒:

二、生产者消费者模型

「生产者消费者模型」是通过一个容器来解决生产者与消费者的强耦合关系,生产者与消费者之间不直接进行通讯,而是利用 「容器」来进行通讯

这些晦涩难懂的名词难免让人打起退堂鼓,其实它们都很好理解,比如接下来我们可以借助一个 超市 的例子来深刻理解 生产者消费者模型


接下来让我们看看 买下至真园超市 的工作模式

超市的工作模式

  • 超市从工厂进货,工厂需要向超市提供商品
  • 顾客在超市选购,超市需要向顾客提供商品

超市盈利的关键在于 平衡顾客与工厂间的供需关系

简单来说就是要做到 顾客可以在超市买到想要购买的商品,工厂也能同超市完成足量的需求订单,满足条件后,超市就可以盈利了

超市盈利的同时可以给供给双方带来便利

  • 顾客不需要跑到工厂购买商品
  • 工厂也不需要将商品配送到顾客手中

这就叫做 解决生产者与消费者间的强耦合关系

得益于 超市 做缓冲区,整个 生产消费 的过程十分高效,即便顾客没有在超市中找到想要的商品,也可借助超市之手向工厂进行反映,从而生产对应的商品,即 允许生产消费步调不一致

现实中的 超市工作模式 就是一个生动形象的 「生产者消费者模型」

  • 顾客 -> 「消费者」
  • 工厂 -> 「生产者」
  • 超市 -> 「交易场所(容器)」

生产者消费者模型的本质忙闲不均

其中的 「交易场所」 是进行 生产消费 的容器,通常是一种特定的 缓冲区 ,常见的有 阻塞队列 和 环形队列

超市不可能只面向一个顾客及一个工厂,「交易场所」 也是如此,会被多个 生产者消费者(多个线程) 看到,也就是说 「交易场所」 注定是一个共享资源 ;在多线程环境中,需要保证 共享资源被多线程并发访问时的安全

♻️三大关系

回归现实中,多个工厂供应同一种商品时,为了抢占更多的市场,总会通过一些促销手段来排除竞品,比如经典的 泡面巨头 <康师傅与统一> 的大战,市场(超市中的货架位置)是有限的 ,在工厂竞争之下,势必有一家工厂失去市场,因此可以得出 生产者与生产者之前需要维持 「互斥」 关系

1️⃣生产者与生产者:「互斥」


张三和李四在超市偶遇,俩人同时看中了 「广式叉烧包」 ,但最近超市货源紧张,这个商品仅有一份,张三李四互不谦让,都在奋力争夺这个商品,显然当商品只有一份时 消费者与消费者之间也需要维护 「互斥」关系

2️⃣消费者与消费者:「互斥」


某天张三又来到了超市,打算购买他最喜欢的 老坛酸菜牛肉面 ,但好巧不巧,超市的最后一桶 老坛酸菜牛肉面 已经售出,张三只能通知超市进行备货,超市老板记下了这个需求,张三失落的回了家,刚到家,张三的肚子就饿的咕咕叫,十分想念 老坛酸菜牛肉面,于是火速赶往超市,看看超市是否有货,答案是没有,法外狂徒张三是一个执着的人,总是反复跑到超市查看是否有货,导致张三这一天什么事也干不成,只想着自己的 老坛酸菜牛肉面 ;其实张三不必这样做,只需要在第一次告诉超市老板自己的需求,并添加老板的联系方式,让老板在商品备货完成后通知张三前来购买,将商品信息同步给消费者,这样可以避免张三陷入循环 ,同理对于工厂来说,超市老板也应该添加工厂负责人的联系方式,将商品信息同步给生产者,也就是说 生产者与消费者之间存在 「同步」关系 ;除此之外,为了保证上架时候的安全性(原子性),张三是不能来购买的,即 生产者与消费者之间还存在 「互斥」关系

3️⃣生产者与消费者:同步、互斥

注意: 生产者与消费者之间的「互斥」关系不是必备的,目的是为了让 生产、消费 之间存在顺序

「生产者消费者模型」是一个存在 生产者、消费者、交易场所 三个条件,以及不同角色间的 同步、互斥 关系的高效模型

✅️特点与优点

「生产者消费者模型」 的最根本特点是 321原则(好记忆 ~ 但不官方)

3 种关系

  • 生产者与生产者:互斥

  • 消费者与消费者:互斥

  • 生产者与消费者:互斥与同步
    2 种角色(通常由线程承担)

  • 生产者

  • 消费者
    1 个交易场所

  • 通常是一个特定的缓冲区(内存空间)(阻塞队列、环形队列等容器或数据结构)

ps:321 原则并非众所周知的概念,仅供辅助记忆 「生产者消费者模型」的特点

任何 「生产者消费者模型」 都离不开这些必备特点

生产者与消费者间的同步关系

  • 生产者不断生产,交易场所堆满商品后,需要通知消费者进行消费
  • 消费者不断消费,交易场所为空时,需要通知生产者进行生产

通知线程需要用到条件变量,即维护 同步 关系

其实之前在 Linux 进程间通信 【管道通信】 中学习到的 管道 本质上就是一个天然的 「生产者消费者模型」 ,因为它允许多个进程同时访问,并且不会出现问题,意味着它维护好了 「互斥、同步」 关系;当写端写满管道时,无法再写,通知读端进行读取;当管道为空时,无法读取,通知写端写入数据

「生产者消费者模型」为何高效?

  • 生产者、消费者 可以在同一个交易场所中进行操作
  • 生产者在生产时,无需关注消费者的状态,只需关注交易场所中是否有空闲位置
  • 消费者在消费时,无需关注生产者的状态,只需关注交易场所中是否有就绪数据
  • 可以根据不同的策略,调整生产者于与消费者间的协同关系

「生产者消费者模型」可以根据供需关系灵活调整策略(谁快谁慢),做到1️⃣ 忙闲不均

2️⃣解耦让产生任务和处理任务这两件事彼此独立,不直接绑定。(时间、空间解耦)

3️⃣支持并发 :生产者、消费者线程完全隔离,互不直接调用依赖,各自独立并发执行

三、基于阻塞队列实现生产者消费者模型

🌊阻塞队列

阻塞队列 Blocking Queue 是一种特殊的队列,作为队列家族的一员,它具备 先进先出 FIFO 的基本特性,与普通队列不同的是: 阻塞队列的大小是固定的 ,也就说它存在 容量 的概念

阻塞队列可以为空,也可以为满

将其带入 「生产者消费者模型」 中,入队 就是 生产商品 ,而 出队 则是 消费商品

  • 阻塞队列为满时:无法入队 -> 无法生产(阻塞)
  • 阻塞队列为空时:无法出队 -> 无法消费(阻塞)

🌊单生产单消费模型

首先来实现最简单的 单生产单消费者模型,只需要手动创建两个线程即可

创建 Main.cc 源文件

cpp 复制代码
#include "BlockQueue.hpp"

void *ConsumerRoutine(void *arg)
{
    BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(arg);
    while (true)
    {
        sleep(5); // 模拟5s消费一个
        int data;
        bq->Pop(&data);
        std::cout << "消费: " << data << std::endl;
    }

    return nullptr;
}

void *ProducerRoutine(void *arg)
{
    BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(arg);
    int data = 10; // 生产数据
    while (true)
    {
        //sleep(5); // 模拟5s生产一个
        bq->Push(data);
        std::cout << "生产: " << data++ << std::endl;
    }

    return nullptr;
}

int main()
{
    BlockQueue<int> *bq = new BlockQueue<int>(); // 缓冲区
    pthread_t c, p;
    pthread_create(&c, nullptr, ConsumerRoutine, bq);
    pthread_create(&p, nullptr, ProducerRoutine, bq);

    pthread_join(c, nullptr);
    pthread_join(p, nullptr);
    return 0;
}

接着是重头戏:BlockQueue.hpp

cpp 复制代码
#ifndef BLOCK_QUEUE_H   //if not defined :防止头文件被重复包含
#define BLOCK_QUEUE_H

#include <iostream>
#include <queue>
#include <unistd.h>
#include <pthread.h>

const int defaultcap = 5; 

template <typename T>
class BlockQueue
{
public:
    BlockQueue(int cap = defaultcap): _cap(cap)
    {
        //锁和条件变量的初始化
        pthread_mutex_init(&_mutex, nullptr);
        pthread_cond_init(&_consumer_cond, nullptr);
        pthread_cond_init(&_producer_cond, nullptr);
    }

    void Push(T &in) //生产者
    {
        //因为阻塞队列有可能被消费者在使用 ------ 加锁
        pthread_mutex_lock(&_mutex);
        //如果队列满了
        while (_bq.size() == _cap)
        {
            pthread_cond_wait(&_producer_cond, &_mutex); //生产者等待
        }
        //要么阻塞队列不满 or 生产者被唤醒了
        _bq.push(in);
        pthread_cond_signal(&_consumer_cond); 
        pthread_mutex_unlock(&_mutex);
    }

    //消费者
    void Pop(T *out) //输出型参数
    {
        //删除队列数据 ~ 先加锁
        pthread_mutex_lock(&_mutex);
        //为了增强代码的健壮性,我们在这里使用while而不是if来判断,防止误唤醒导致的线程被唤醒后直接往下走了,结果发现队列还是空的了!所以要用while来判断,只有当条件满足了才会往下走,否则继续等待!
        while (_bq.empty())
        {
            pthread_cond_wait(&_consumer_cond, &_mutex); //1.过量唤醒信息 2. 函数调用失败 3. 线程被意外唤醒了(伪唤醒)
        }
        //100%保证 bq走到这里绝对是有数据的
        *out = _bq.front();
        _bq.pop();
        pthread_cond_signal(&_producer_cond); //如果唤醒对方时,对方本来就是醒来的,唤醒信息自动忽略
        pthread_mutex_unlock(&_mutex); //解锁
    }

    ~BlockQueue()
    {
        //销毁锁和条件变量
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_consumer_cond);
        pthread_cond_destroy(&_producer_cond);
    }
private:
    std::queue<T> _bq; //缓冲队列
    int _cap; //缓冲区容量上限

    pthread_mutex_t _mutex; 
    pthread_cond_t _consumer_cond; //消费者条件变量
    pthread_cond_t _producer_cond; //生产者
};
#endif 

跑出来结果是也是对的上的:

重头戏都在下面细节里 ------

细节1️⃣:条件等待pthread_cond_wait时,为什么要把锁传递进去??

了解清楚条件等待函数做了什么就恍然大悟了

  • 🌈等待的时候,是在临界区内部等待的,需要把锁传进去,否则抱着锁一起阻塞了
  • 把锁传递进入, 让pthread_cond_wait自动释放锁,再去阻塞等待!
  • 唤醒线程时,我是在临界区内部醒来的!!(哪里跌倒哪里起来哈哈)
  • 接着把锁传递进入, 让pthread_cond_wait自动竞争并获取锁!整个过程是对用户是透明的,是此函数自己做的

细节2️⃣:线程为什么会在临界区内部等待呢? 奇怪

因为访问临界资源,必然在临界区内部访问,判断资源不就绪,本质也是访问临界资源!

  • 因为在判断_bq就已经在访问临界资源 , 后续无论成功与否(成功:执行后续代码,不成功:等待),判断的结果也就必然在临界区内部了!所以线程会在临界区内部等待!
  • 进入到了临界区判断是因,所以结果就是线程为什么会在临界区内部等待

细节3️⃣:唤醒消费者线程,放在锁里和锁外都可以

  • 锁内:把消费者唤醒了,但消费者此时去竞争锁是失败的,还是要等到生产者把锁释放了才能竞争成功
  • 锁外:不用担心误唤醒,如果被其他消费者竞争锁成功,指定的消费者唤醒后也会去判断,发现是空还是会接着继续阻塞等待!

细节4️⃣:只要当条件满足时,才能进行生产/消费

没改之前,只使用一个 if 进行判断过于草率

  • pthread_cond_wait 函数可能调用失败(误唤醒、伪唤醒)和过量的唤醒信息,此时如果是 if 就会向后继续运行,导致在条件不满足的时候进行了生产/消费

  • 为增强代码的健壮性,在条件等待的循环体内是用while来替代if,保证只要条件满足!

细节5️⃣:生产者消费者模型的高效体现在 「解耦」

消费者在进行业务处理时,生产者可以直接向队列中 push 数据

  • 比如 消费者 在获取到数据后,需要进行某种高强度的运算,当然这个操作与 生产者 是没有任何关系的,得益于阻塞队列作为缓冲区生产者可以在消费者进行运算时 push 数据

  • 这就好比你买了一桶泡面回家吃,厂商并不需要关心你吃完没有,直接正常向超市供货就行了

生产者在进行数据生产时,消费者可以直接向队列中 pop 数据

  • 同上,消费者不需要关心生产者 的状态,只要阻塞队列中还有数据,正常 pop 获取就行了;也就是说你在超市购物时,无需关心工厂的生产情况,因为这与你无关

一句话总结:生产者不必关心消费者的消费情况,消费者也不需要关心生产者的生产情况 ------ 只看阻塞队列里面的情况

细节6️⃣:阻塞队列中不止能放 int,还能放对象Task

创建 Task.hpp 头文件

cpp 复制代码
#ifndef __TASK_HPP
#define __TASK_HPP

#include <string>
#include <iostream>

class Task
{
public:
    Task()
    {}
    Task(int x, int y): _x(x), _y(y)
    {}
    void Execute()
    {
        _result = _x + _y;
    }
    std::string GetResult()
    {
        return std::to_string(_x) + "+" + std::to_string(_y) + "=" + std::to_string(_result);
    }

    std::string Question()
    {
        return std::to_string(_x) + "+" + std::to_string(_y) + "=?";
    }
    
    ~Task()
    {}
private:
    int _x;
    int _y;
    int _result;
};

#endif

紧接着就是在Main.cc中改对应的生产者、消费者线程的处理逻辑了

cpp 复制代码
#include "BlockQueue.hpp"
#include "Thread.hpp"
#include "Task.hpp"
#include <memory>
#include <unistd.h>
#include <ctime>
#include <unistd.h>

int num = 1;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

int GetNum()
{
    pthread_mutex_lock(&lock);
    int number = num++;
    pthread_mutex_unlock(&lock);

    return number;
}

using namespace ThreadModule;

int main()
{
    srand(time(nullptr) ^ getpid());
    // 智能指针
    std::unique_ptr<BlockQueue<Task>> bq = std::make_unique<BlockQueue<Task>>();

    Thread Consumer([&bq]() {
        while (true)
        {
            sleep(1); 
            Task t;
            // 1. 取出任务 --- 消费
            bq->Pop(&t);
            // 2. 处理任务 ------ 重要!
            t.Execute();
            std::cout << " 消费: " << t.GetResult() << std::endl;
        }
    });

    Thread Producer([&bq]() {
        while (true)
        {
            //1. 获取数据
            int datax = rand() % 10 + 1;
            usleep(123456); 
            int datay = rand() % 10 + 1;
            Task t(datax, datay);
            //2. 生产到缓冲区中
            bq->Push(t);
            std::cout << " 生产: " << t.Question() << std::endl;
        }
    });

    Consumer.Start();
    Producer.Start();

    Consumer.Join();
    Producer.Join();
    return 0;
}

格局打开,这里只是放了一个简单计算的任务,我们实际还可以放入更复杂的任务,比如 网络请求、SQL 查询、并行 IO

尤其是 IO,使用 「生产者消费者模型」 可以大大提高效率,包括后面的 多路转接 ,也可以接入 「生产者消费者模型」 来提高效率

后续还可以把我们之前封装过的线程、锁以及条件变量等等都换成我自己的,去替换系统的 ...此处涉及太多文件就没放上来

🌊多生产多消费模型

现在我们可以尝试修改代码以适应多生产多消费场景

需要改吗?不需要 ,至少在当前的代码设计中,我们的代码完全可以应付 多线程多消费

只需要在原有的代码里多创建几个线程

cpp 复制代码
int main()
{
    BlockQueue<int> *bq = new BlockQueue<int>(); // 缓冲区
    // 多线程竞争
    pthread_t c[3], p[2];
    pthread_create(c, nullptr, ConsumerRoutine, bq);
    pthread_create(c + 1, nullptr, ProducerRoutine, bq);
    pthread_create(c + 2, nullptr, ProducerRoutine, bq);
    pthread_create(p, nullptr, ProducerRoutine, bq);
    pthread_create(p + 1, nullptr, ProducerRoutine, bq);

    pthread_join(c[0], nullptr);
    pthread_join(c[1], nullptr);
    pthread_join(c[2], nullptr);
    pthread_join(p[0], nullptr);
    pthread_join(p[1], nullptr);
    return 0;
}

原因有如下两点:

  • 当前的 _bq 始终是被当作一个整体使用的,无需再增加锁区分生产者、消费者都是在对同一个 _bq 操作,所以用一把锁
  • 多个消费者和生产者都会去消费和生产,每个人都是要竞争同一把锁 ,所以任何时刻都只允许一个线程进来(不管你是生产or消费) ------------ 所以天然支持多生产多消费

🌊重谈细节

任何时刻都只允许一个线程在访问阻塞队列啊? 何谈高效呢??

🔹阻塞队列虽然同一时刻只有一个线程操作队列,但整体仍然是高效并发模型

锁保护的是"临界区",不是整个线程生命周期。

  • 真正的耗时的工作不在阻塞队列里,更多的是在队列外的获取任务和处理任务
  • 消费者1取走任务自己独立去处理,其他消费者同理,所以在处理任务和获取任务这个阶段是属于线程并行运行的 ------ 在队列外线程是无锁的并行运行的
  • 所以高效从获取任务和处理任务谈起
cpp 复制代码
线程1  [执行任务80ms] [push 100ns]

线程2  [执行任务70ms] [push 100ns]

线程3  [执行任务90ms] [push 100ns]

其实真正大段的时间都在
Execute()
BuildTask()

只有最后交接一下任务时:pushpop需要互斥进入阻塞队列(纳秒级别)


为什么要在临界区内部做判断??

在这个代码模型里,我们把阻塞队列当成一个整体来使用,即临界资源被我们当成一个完整的资源,要么不用,要用就全部拿完!

  • 因为资源被整体使用了
  • 加锁的本质就是预定这个资源,后续操作都是对一整块资源做处理

四、POSIX 信号量

互斥、同步 不只能通过 互斥锁、条件变量 实现,还能通过 信号量 sem、互斥锁 实现(出自 POSIX 标准) 此处有稍微提及信号量的知识➡️ 【Linux 进程间通信】 可以复习一下

简单回顾:

信号量本质是一个计数器(描述资源数目) ,申请信号量的本质是:对资源的预定机制

  • 申请到资源,计数器 --P 操作)
  • 释放完资源,计数器 ++V 操作)

✨信号量基本知识

互斥、同步 不只能通过 互斥锁、条件变量 实现,还能通过 信号量 sem、互斥锁 实现(出自 POSIX 标准)

「信号量」PV 操作都是原子的,假设将 「信号量」 的值设为 1,用来表示 「生产者消费者模型」阻塞队列 _queue 的使用情况

  • sem 值为 1 时,线程可以进行 「生产 / 消费」,sem--
  • sem 值为 0 时,线程无法进行 「生产 / 消费」,只能阻塞等待

此时的 「信号量」 只有两种状态:1、0,即可实现 线程互斥,像这种只有两种状态的信号量称为 「二元信号量」 等同于互斥锁(核心思想与功能上)

下面用伪代码来理解信号量

cpp 复制代码
struct sem
{
    int count;
    pthread_mutex_t mutex;
    thread_queue;  
}
//P操作
lock()
	if(count > 0)
	{
		count--;
		unlock();
		return;
	}
	else
	{
		挂起进程 ------ 阻塞 //此处会释放锁
		gotolock(); //醒来再去竞争锁
	}
//V操作
	lock();
	count++;
	unlock();

「信号量」 不止可以用于 互斥 ,它的主要目的是 描述临界资源中的资源数目 ,比如我们可以把 阻塞队列 切割成 N 份,初始化 「信号量」 的值为 N,当某一份资源就绪时,sem--,资源被释放后,sem++,如此一来可以像 条件变量 一样实现 同步

  • space_sem == N 时,阻塞队列已经空了,消费者无法消费
  • space_sem == 0 时,空位置耗尽,阻塞队列已经满了,生产者无法生产

用来实现 互斥、同步 的信号量称为 「多元信号量」

✨信号量接口函数

头文件:#include <semaphore.h>

定义信号量:

cpp 复制代码
sem_t *sem;

初始化信号量

cpp 复制代码
#include <semaphore.h>

int sem_init(sem_t *sem, int pshared, unsigned int value);

sem_t *sem需要初始化的信号量,sem_t 实际就是一个联合体,里面包含了一个 char 数组,以及一个 long int 成员

c 复制代码
typedef union
{
  char __size[__SIZEOF_SEM_T];
  long int __align;
} sem_t;

int pshared:表示当前信号量的共享状态传递 0 表示线程间共享,传递 非0 表示进程间共享

unsigned int value信号量的初始值,可以设置为双元或多元信号量

返回值:初始化成功返回 0,失败返回 -1,并设置错误码

销毁信号量

c 复制代码
#include <semaphore.h>

int sem_destroy(sem_t *sem);

参数:待销毁的信号量

返回值:成功返回 0,失败返回 -1,并设置错误码

申请信号量:P操作

cpp 复制代码
#include <semaphore.h>

int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);

int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);

主要使用 sem_wait

参数:表示从哪个信号量中申请

返回值:成功返回 0,失败返回 -1,并设置错误码(手册上) ------ 一般调用sem_wait失败,线程会阻塞在那,一般不会直接返回

其他两种申请方式分别是:尝试申请,如果没有申请到资源,就会放弃申请;每隔一段时间进行申请,即 timeout

释放信号量(发布信号量)

cpp 复制代码
#include <semaphore.h>

int sem_post(sem_t *sem);

参数:将资源释放到哪个信号量中

返回值:成功返回 0,失败返回 -1,并设置错误码

接下来直接用信号量实现 环形队列「生产者消费者模型」

五、基于环形队列实现生产者消费者模型

♻️环形队列

「生产者消费者模型」 中的交易场所是可更换的,可以使用 环形队列 ,所谓的环形队列并非 队列 ,而是用数组模拟实现的 "队列" , 并且它的 判空、判满 比较特殊

如何让 环形队列 "转" 起来?

  • 可以通过取模的方式(可以重复获取一段区间值),确定下标

环形队列 如何判断当前为满、为空?

策略一:牺牲一块空间,headtail 位于同一块空间中时,表示当前队列为空;在进行插入、获取数据时,都是对下一块空间中的数据进行操作,因为多开了一块空间,当待生产的数据落在 head 指向的空间时,就表示已经满了

策略二:参考阻塞队列,搞一个计数器,当计数器的值为 0 时,表示当前为空,当计数器的值为容量时,表示队列为满

至于这里肯定是选择策略二,因为 「信号量」 本身就是一个天然的计数器

环形队列 中,生产者消费者 关心的资源不一样:生产者只关心是否有空间放数据,消费者只关心是否能从空间中取到数据

除非两者相遇,其他情况下生产者、消费者可以并发运行(同时访问环形队列)

两者错位时正常进行生产消费就好了,但两者相遇时需要特殊处理,也就是处理 空和满 两种情况,这就是 环形队列的运转模式

  • 环形队列为空时:消费者阻塞,只能由生产者进行生产,生产完商品后,消费者可以消费商品
  • 环形队列为满时:生产者阻塞,只能由消费者进行消费,消费完商品后,生产者可以生产商品
  • 其他情况:生产者、消费者并发运行,各干各的事,互不影响

张三和李四也就只能在 满、空 时相遇了

站在生产者角度:空格子是自愿;消费者角度:数据是自愿 。所以不就需要两个信号量来描述资源:sem_t blank_semsem_t data_sem

在逻辑上:申请了一个空格子,把数据放进去空格,走的时候空格还被占用,但是数据资源多了一个

  • 两个信号量 就是环形队列的精髓 ,显然,刚开始的时候,生产者信号量初始值为环形队列的大小N,消费者信号量初始值为 0
cpp 复制代码
//一开始
sem_t blank_sem = N;
sem_t data_sem = 0;

//生产者角度
P(black_sem);//申请空格信号量

//生产数据
ring[tail++] = data;
data %= N; //此时格子还是被占用着的

V(data_sem);//释放多一个数据信号量

同理站在消费者角度:P(data_sem)之后,多了一个空格资源V(black_sem)

♻️单生产单消费模型

有了上述的逻辑认识,接着简单写一下代码

首先简单封装一下信号量Sem

cpp 复制代码
#ifndef Sem_hpp
#define Sem_hpp

#include <iostream>
#include <semaphore.h>

class Sem
{
public:
    Sem(int init_value)
    {
        if (init_value > 0) 
        {
            int n = sem_init(&_sem, 0, init_value); //传入信号量的初始值
            (void)n; 
        }
    }
    void P()
    {
        int n = sem_wait(&_sem); //等待信号量
        (void)n;
    }
    void V()
    {
        int n = sem_post(&_sem); //释放信号量
        (void)n;
    }
    ~Sem()
    {
        int n = sem_destroy(&_sem);
        (void)n;
    }
private:
    sem_t _sem;
};

#endif

创建 RingQueue.hpp 头文件和Main.cc

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <vector>
#include <pthread.h>
#include "Sem.hpp"

const int default_cap = 5;

template <typename T>
class RingQueue
{
public:
    RingQueue(int cap = default_cap)
        : _cap(cap),
          _rq(cap), // 预先开好容量
          _consumer_step(0),
          _producer_step(0),
          _blank_sem(cap), // 初始时,格子资源计数器等于cap
          _data_sem(0)     // 初始时,数据计数器为0
    {}
    void Enqueue(T &in) //生产者调用:入队列
    {
        //1.预定空格资源
        _blank_sem.P(); 
        //2.找位置生产
        _rq[_producer_step++] = in;
        _producer_step %= _cap; 
        //3.因生产了一个,所以释放一个数据资源
        _data_sem.V();

    }
    void Pop(T *out) //消费者调用
    {
        _data_sem.P(); 
        *out = _rq[_consumer_step++];
        _consumer_step %= _cap; 
        _blank_sem.V();
    }
    ~RingQueue()
    {}
private:
    int _cap;           // 循环队列容量
    std::vector<T> _rq; // 环型队列

    int _consumer_step; // 消费位置
    int _producer_step; // 生产位置

    pthread_mutex_t mutex; // 互斥锁

    Sem _blank_sem; // 空格子资源计数器,生产者关心
    Sem _data_sem;  // 数据计数器,消费者关心
};

注意以下细节:

OS里,谁先调度是不知道的, 但能知道谁先执行有效代码,谁会阻塞住?

  • 比如一开始OS先调度了谁不清楚,但是唯一确定是生产者先跑 ,因为一开始队列里数据资源为0,消费者就算先跑起来了也是会阻塞住的

在没有互斥锁 的情况下,是如何 确保生产者与消费者间的互斥和同步关系的?

  • 通过两个信号量 ,当两个信号量 都不为 0 时,双方可以并发操作,这是 环形队列 最大的特点
  • 互斥 :当 空格子信号量为 0 时,生产者陷入阻塞等待,等待消费者消费;同理当 数据信号量为 0 时,消费者也会阻塞住
  • 同步 :当对方完成 生产 / 消费 后,自己会解除阻塞状态,按顺序去执行
cpp 复制代码
#include "RingQueue.hpp"
#include <unistd.h>

void *ProducerRoutine(void *args)
{
    RingQueue<int> *rq = static_cast<RingQueue<int> *>(args);
    int data = 1;
    while(true)
    {
        rq->Enqueue(data);
        std::cout << "生产: " << data++ << std::endl;
        sleep(3);
    }
}

void *ConsumerRoutine(void *args)
{
    RingQueue<int> *rq = static_cast<RingQueue<int> *>(args);
    int data = 0;
    while(true)
    {
        rq->Pop(&data);
        std::cout << "消费: " << data << std::endl;
    }
} 

int main() 
{
    RingQueue<int> *rq = new RingQueue<int>();
    //单生产单消费
    pthread_t c, p;
    pthread_create(&c, nullptr, ConsumerRoutine, rq); //环形队列传递给新线程
    pthread_create(&p, nullptr, ProducerRoutine, rq);

    //主线程等待子线程结束
    pthread_join(c, nullptr);
    pthread_join(p, nullptr);

    return 0;
}

运行结果:

结果符合预期,证明当前的 「生产者消费者模型」单生产单消费场景中没有问题

注:如果想要提高并发度,可以增大环形队列的容量

♻️多生产多消费模型

接下来思考如何改成多生产多消费模型,在上述的单生产单消费场景 下已经实现了三大关系里的消费者与生产者的互斥和同步 ,只剩下生产者和生产者的互斥以及消费者与消费者的互斥关系

  • 在此消息队列,并非天然的支持生产者和生产者的互斥,也就与阻塞队列不一样(整块的使用资源:二元信号量)
  • 生产者1号、生产者2号都可以预定资源,但producer_step只有一个,也就说明环形队列里会进入多个生产者来抢
  • 于是就要对共享资源进行加锁!那加多少把锁呢?

是不是需要两把锁🔒?因为当前的 生产者和消费者 关注的资源不一样,一个关注剩余空间,另一个关注是否有商品,一把锁是无法锁住两份不同资源的,所以需要给 生产者、消费者 各配一把锁

  • 让多个生产者、消费者先去竞争同一把锁,谁先竞争成功就谁可以预定资源进入临界区
  • 这样进行设计,其内核上不就转化成单生产单消费了吗? 是的!

阻塞队列 中为什么只需要一把锁?

因为阻塞队列中的共享资源是一整个队列生产者和消费者访问的是同一份资源,所以一把锁就够了

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <vector>
#include <pthread.h>
#include "Sem.hpp"
#include "Mutex.hpp"

const int default_cap = 5;

template <typename T>
class RingQueue
{
public:
    RingQueue(int cap = default_cap)
        : _cap(cap),
          _rq(cap), // 预先开好容量
          _consumer_step(0),
          _producer_step(0),
          _blank_sem(cap), // 初始时,格子资源计数器等于cap
          _data_sem(0)     // 初始时,数据计数器为0
    {}
    void Enqueue(T &in) //生产者调用:入队列
    {
        //0.预定空格资源
        _blank_sem.P(); 

        _pmutex.Lock(); //1.加锁
        //2.找位置生产
        _rq[_producer_step++] = in;
        _producer_step %= _cap; 
        //3.因生产了一个,所以释放一个数据资源
        _data_sem.V();
        
        _pmutex.Unlock();

    }
    void Pop(T *out) //消费者调用
    {
        _data_sem.P(); 
        _cmutex.Lock();

        *out = _rq[_consumer_step++];
        _consumer_step %= _cap; 
        _blank_sem.V();

        _cmutex.Unlock();
    }
    ~RingQueue()
    {}
private:
    int _cap;           // 循环队列容量
    std::vector<T> _rq; // 环型队列

    int _consumer_step; // 消费位置
    int _producer_step; // 生产位置

    Mutex _cmutex; 
    Mutex _pmutex; 

    Sem _blank_sem; // 空格子资源计数器,生产者关心
    Sem _data_sem;  // 数据计数器,消费者关心
};

细节: 加锁行为放在信号量申请成功之后,可以提高并发度

环形队列 中,可以在申请 「信号量」 前进行加锁,也可以在申请 「信号量」 后进行加锁,这里比较推荐的是 在申请 「信号量」 后加锁

  • 先加锁(效率低):要先去到电影院排队 ------ 再允许你买票 ------ 进去看电影
  • 为什么不能:先买好票 ------ 再排队呢??
  • 并且信号量的操作是原子的,不需要加锁保护

加锁意味着串行化,一定会降低效率 ,但因为 「信号量」 的操作是原子的,可以确保线程安全,也就不需要加锁保护;也就是可以并发申请「信号量」,再串行化访问临界资源

运行结果:

为什么会出现生产数据没有按照顺序呢??

阻塞队列 效率已经够高了,那么创造 环形队列 的意义在哪呢?

首先要明白 「生产者消费者模型」 高效的地方从来都不是往缓冲区中放数据、从缓冲区中拿数据

对缓冲区的操作对于计算机说就是小 case,需要关注的点在于 获取数据和消费数据 ,这是比较耗费时间的,阻塞队列 至多支持获取 一次数据获取 或 一次数据消费 (以单资源为操作粒度),在代码中的具体体现就是 所有线程都在使用一把锁,并且每次只能 pushpop 一个数据

环形队列 就不一样了,生产者、消费者 可以通过 信号量 知晓数据获取、数据消费次数,并且由于数据获取、消费操作没有加锁,支持并发,因此效率十分高

环形队列 中允许 N 个生产者线程一起进行数据获取,也允许 N 个消费者线程一起进行数据消费,简单任务处理感知不明显,但复杂任务就不一样了,这就有点像同时下载多份资源,是可以提高效率的

注意: 一起操作并非同时操作,任务开始时间有先后,但都是在进行处理的

环形队列 一定优于 阻塞队列 吗?

答案是否定的,存在即合理,如果 环形队列 能完全碾压 阻塞队列 ,那么早就不用学习 阻塞队列 了,这两种都属于 「生产者消费者模型」 常见的交易场所,有着各自的适用场景

📢写在最后

接下来是线程池 ------ 冲冲冲🚀

相关推荐
猪脚踏浪1 小时前
linux 拷贝文件或目录到指定的位置
linux
大树8817 小时前
金刚石散热越强,管路越先见顶
大数据·运维·服务器·人工智能·ai
摇滚侠17 小时前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
霸道流氓气质17 小时前
领域驱动设计(DDD)在 Spring Boot 微服务中的实践指南
运维·spring boot·微服务
bush418 小时前
嵌入式linux学习记录十四、术语
linux·嵌入式
载数而行52018 小时前
Linux 11 动态监控指令top
linux
小宇宙Zz18 小时前
Maven依赖冲突
java·服务器·maven
Inhand陈工19 小时前
基于台达PLC与映翰通IG502的智慧水产养殖精准投喂与远程运维解决方案
运维·人工智能·物联网·阿里云·信息与通信
酣大智19 小时前
ARP代理--工作原理
运维·网络·arp·arp代理
不会C语言的男孩19 小时前
Linux 系统编程 · 第 8 章:进程基础
linux·c语言