Linux下 线程同步与互斥(一)互斥与同步详解

欢迎来到我的频道 【点击跳转专栏】

码云链接 【点此转跳】

文章目录

  • [1. 线程互斥](#1. 线程互斥)
    • [1.1 进程线程间的互斥相关背景概念](#1.1 进程线程间的互斥相关背景概念)
    • [1.2 互斥量mutex](#1.2 互斥量mutex)
      • [1. tickets`--`](#1. tickets--)
      • [2. tickets减到负数的主要矛盾](#2. tickets减到负数的主要矛盾)
      • [3. 如何解决这个问题(互斥量)](#3. 如何解决这个问题(互斥量))
      • [4. 锁的接口](#4. 锁的接口)
      • [5. 修改我们的代码](#5. 修改我们的代码)
      • [6. 锁的几个问题](#6. 锁的几个问题)
      • [7. 关于局部类内锁初始化的案例](#7. 关于局部类内锁初始化的案例)
    • [1.3 互斥量(锁)实现原理探究](#1.3 互斥量(锁)实现原理探究)
      • [1. 简单聊聊硬件实现](#1. 简单聊聊硬件实现)
      • [2. 原理探究](#2. 原理探究)
    • [1.4 互斥锁的封装](#1.4 互斥锁的封装)
      • [1. 封装 Mutex 类(资源管理者)](#1. 封装 Mutex 类(资源管理者))
      • [2. 实现 LockGuard(RAII 锁守卫)](#2. 实现 LockGuard(RAII 锁守卫))
      • [3. 核心思路解析](#3. 核心思路解析)
      • [4. 实战演示](#4. 实战演示)
  • [2. 线程同步](#2. 线程同步)
    • [2.1 条件变量](#2.1 条件变量)
    • [2.2 ⽣产者消费者模型](#2.2 ⽣产者消费者模型)
    • [2.3 条件变量函数](#2.3 条件变量函数)
    • [2.4 测试接口](#2.4 测试接口)
    • [2.5 基于BlockingQueue的⽣产者消费者模型](#2.5 基于BlockingQueue的⽣产者消费者模型)
      • [2.5.1 BlockingQueue](#2.5.1 BlockingQueue)
      • [2.5.2 C++ queue模拟阻塞队列的⽣产消费模型](#2.5.2 C++ queue模拟阻塞队列的⽣产消费模型)
    • [2.6 条件变量的封装](#2.6 条件变量的封装)
    • [2.7 线程相关接口对阻塞队列的二次升级(并以任务的形式看待生产者消费者模型)](#2.7 线程相关接口对阻塞队列的二次升级(并以任务的形式看待生产者消费者模型))
      • [1. 代码升级](#1. 代码升级)
      • [2. 任务](#2. 任务)
      • [3. 测试代码和效果展示](#3. 测试代码和效果展示)
    • [2.8 重谈生产消费者模型](#2.8 重谈生产消费者模型)
    • [2.9 POSIX信号量](#2.9 POSIX信号量)
      • [2.9.1 信号量的接口](#2.9.1 信号量的接口)
      • [2.9.2 基于环形队列的⽣产消费模型](#2.9.2 基于环形队列的⽣产消费模型)
      • [2.9.3 信号量的封装](#2.9.3 信号量的封装)
      • [2.9.4 基于环形队列的⽣产消费模型代码实现](#2.9.4 基于环形队列的⽣产消费模型代码实现)
      • [1. 思路与代码](#1. 思路与代码)
      • [2. 关键技术点总结](#2. 关键技术点总结)
      • [3. 测试代码](#3. 测试代码)

1. 线程互斥

1.1 进程线程间的互斥相关背景概念

  • 共享资源
  • 临界资源:多线程执行流被保护的共享的资源就叫做临界资源,即 特指那些在同一时刻只允许一个执行流访问的共享资源
  • 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
  • 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
  • 原子性(后面讨论如何实现): 不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成!

1.2 互斥量mutex

⼤部分情况,线程使⽤的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程⽆法获得这种变量;但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。

多个线程并发的操作共享变量,会带来⼀些问题。

⚠️:这里使用的代码 是由线程库封装的 自己的线程库 感兴趣可以点击转跳 去了解下

cpp 复制代码
#include "Thread.hpp"
#include <unistd.h>
#include <queue>
#include <vector>

int tickets = 10000;//共享资源 导致数据不一致!!!

void GetTicket()
{
    char name[64];
    pthread_getname_np(pthread_self(),name,sizeof(name));

     while(1)
    {
        if (tickets > 0)
        {
            usleep(1000);//模拟具体抢票花的时间
            printf("%s get ticket:%d\n", name, tickets);
            tickets--;
        }
        else
        {
            break;
        }
    }
}

int main()
{
    ThreadModule::Thread t1(GetTicket);
    ThreadModule::Thread t2(GetTicket);
    ThreadModule::Thread t3(GetTicket);
    ThreadModule::Thread t4(GetTicket);

    t1.Start();
    t2.Start();
    t3.Start();
    t4.Start();

    t1.Join();
    t2.Join();
    t3.Join();
    t4.Join();
}

我们明明判断的是 if (tickets > 0) 为什么票数会减到负数?

在判断tickets>0 时 需要把数据传入CPU的寄存器中,接着CPU内部就需要对寄存器内的值进行判断!如果判断>0CPU内的PC指针直接跳转到代码内部 继续执行! 当tickets--的时候 就会修改tickets值然后直接写入内存(存在PCB中)!

数据是整个进程的,但是CPU中的判断 是属于某一个线程的(假设线程1) 假设 票数抢到1后 将1放入寄存器 刚判断准备执行usleep(1000);(此时1已经被读入寄存器),此时线程1被切走了,此时线程1会带走 寄存器的上下文数据!

正在此时 CPU开始调度另一个线程(假设线程2) 因为此时内存中的tickets还是1,所以寄存器也会读取1!此时线程在判断后执行usleep时又切走 又切线程3、4 寄存器里还是1 执行同样的操作 !

此时 当CPU重新调用 线程1、2、3、4, 恢复上下文,然后都执行tickets-- 因为执行tickets-- 是直接从原寄存器中读tickets,减1后又写回内存 但是如果这么说按道理说不会出现-2啊?

  • 如果 线程2 基于旧值 1 计算: 1 − 1 = 0 1 - 1 = 0 1−1=0。写回内存。
  • 如果 线程2 从内存读到了 T1 写入的 0: 0 − 1 = − 1 0 - 1 = -1 0−1=−1。写回内存。
  • 结果:内存中 tickets 变为 0-1
    其实这一切都取决于编译器的优化!很明显我们这种情况是因为情况2 但大部分情况--、++ 操作是会让值变大的!!

问题的根源就在于 if 判断和 tickets-- 不是一个不可分割的原子操作。在这两个动作之间,只要发生了线程切换,数据的一致性就被打破了!因为 if 语句判断条件为真以后,代码可以并发的切换到其他线程!!!而usleep 这个模拟漫⻓业务的过程,在这个漫⻓的业务过程中,可能有很多个线程会进⼊该代码段!


1. tickets--

ticket-- 操作本⾝就不是⼀个原⼦操作:

-- 操作并不是原子操作,而是对应三条汇编指令:

  • load:将共享变量 ticket 从内存加载到寄存器中
  • update:更新寄存器里面的值,执行 -1 操作
  • store:将新值,从寄存器写回共享变量 ticket 的内存地址

汇编部分:

前两条就是第一步!而后面则是分别对应 二、三步!此汇编代码是编译器未优化后的结果 是否更新数据 还是取决于编译器本身版本!!

结论:对于多线程而言,++、--操作都有可能会导致数据不一致问题,从而可能会引发线程安全问题!

2. tickets减到负数的主要矛盾

tickets减到负数 由多个原因引起,其中最主要的因为if判断的问题,让多个线程进入! 因为--、++只会让数据变大(三个线程都从1-1变成0,这才是大部分编译器会做的) 而这里会减到-2本质跟编译器是否在--操作 再次读取内存更新寄存器有关!

而线程切换的主要时机为:时钟中断,检测时间片,然后进行调度,而usleep(会让线程进入阻塞态),而状态切换也需要陷入内核以保障能多次触发中断!

3. 如何解决这个问题(互斥量)

要解决以上问题,需要做到三点:

  • 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
  • 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
  • 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

即保护临界资源本质就是保护临界区(访问临界资源的代码)!

要做到这三点,本质上就是需要⼀把。Linux上提供的这把锁叫互斥量


4. 锁的接口

pthread_mutex_t是锁的类型。

使用方式:

  • 静态初始化 :适用于全局或静态变量,使用宏 PTHREAD_MUTEX_INITIALIZER
cpp 复制代码
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
  • 动态初始化 :适用于堆分配或需要自定义属性的场景,调用 pthread_mutex_init() 函数。
cpp 复制代码
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const
pthread_mutexattr_t *restrict attr);
参数:
mutex:要初始化的互斥量
attr:NULL,表示使用 默认属性
返回值:成功返回0,失败返回错误号
  • 加锁与解锁 :在访问共享资源前调用 pthread_mutex_lock(),访问结束后调用 pthread_mutex_unlock()。若未正确配对使用,可能导致死锁或数据损坏。
cpp 复制代码
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号

调用 pthread_ lock 时,可能会遇到以下情况:

  • 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
  • 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
  • 销毁互斥量 :使⽤ PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁;不要销毁⼀个已经加锁的互斥量;已经销毁的互斥量,要确保后⾯不会有线程再尝试加锁。
cpp 复制代码
int pthread_mutex_destroy(pthread_mutex_t *mutex);

5. 修改我们的代码

我们把锁加入我们的抢票系统:

cpp 复制代码
int tickets = 10000;//共享资源 导致数据不一致!!!
pthread_mutex_t mutex =PTHREAD_MUTEX_INITIALIZER;


void GetTicket()
{
    char name[64];
    pthread_getname_np(pthread_self(),name,sizeof(name));

     while(1)
    {
        pthread_mutex_lock(&mutex);
        if (tickets > 0)
        {
            usleep(1000);//模拟具体抢票花的时间
            printf("%s get ticket:%d\n", name, tickets);
            tickets--;
            pthread_mutex_unlock(&mutex);
        }
        else
        {
            pthread_mutex_unlock(&mutex);
            break;
        }
    }
}

此时执行时:

6. 锁的几个问题

问题1: 加锁的原则问题

加锁是为了保护访问临界资源的安全,但是也会导致效率降低,会增加多线程串形执行的场景!!加锁的力度,必须足够细!!即 加锁范围越短越好!!


问题2: mutex是共享资源,他保护别人,那么谁来保护他?

lockunlock 被设计成了 原子的(原子性后面讲!)


问题3: 如果出现一些线程先加锁再解锁,但是一些线程不遵守呢?

实际是 对于访问临界资源问题,所有线程必须遵守加锁解锁的规则,不存在任何例外!!如果你硬要 有的线程访问临界资源加锁,另一个不加锁,这叫恶意写bug!!

所以 对临界资源进行保护,加锁的过程,本质是所有相关线程的共识!


问题4: 如果申请不成功,那么这些线程会做什么?

申请不成功的线程,会在锁上进行阻塞等待!

注:加锁是会保证原子性的,即 如果只有一行汇编,我们就可以理解成原子的!因为一行汇编对CPU而言 只有做与没做之分!!


问题5: 临界区有多行代码,会发生线程切换吗?

答案是 是可以发生切换的 ,因为在CPU 角度 所谓的加锁解锁也不过只是代码罢了!

具体原理后面再谈!!

7. 关于局部类内锁初始化的案例

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

// 定义全局共享资源:剩余票数
int ticket = 100;

// 自定义数据结构,用于向线程传递参数
class thread_data {
public:
    // 构造函数:初始化线程名称和指向互斥锁的指针
    thread_data(const std::string &n, pthread_mutex_t *p) : name(n), pmutex(p) {}

public:
    std::string name;           // 线程标识名称
    pthread_mutex_t *pmutex;    // 指向外部互斥锁的指针(所有线程共享同一把锁)
};

// 线程执行函数
void *route(void *arg) {
    // 将 void* 参数转换回 thread_data 结构体指针
    thread_data *td = static_cast<thread_data*>(arg);

    while (1) {
        // 1. 加锁:尝试获取互斥锁。如果锁被占用,当前线程会在此阻塞等待
        pthread_mutex_lock(td->pmutex);

        if (ticket > 0) {
            // 模拟业务处理耗时(如打印、网络请求等),增加并发冲突概率
            usleep(1000);

            // 2. 临界区操作:安全地访问并修改共享变量 ticket
            printf("%s sells ticket:%d\n", td->name.c_str(), ticket);
            ticket--;

            // 3. 解锁:释放锁,允许其他等待的线程进入临界区
            pthread_mutex_unlock(td->pmutex);
        } else {
            // 票已售罄,在退出前必须释放锁,否则会导致死锁
            pthread_mutex_unlock(td->pmutex);
            break; // 跳出循环,结束线程
        }
    }
    return nullptr;
}

int main(void) {
    // 声明互斥锁变量
    pthread_mutex_t mutex;

    // 动态初始化互斥锁,使用默认属性(nullptr)
    pthread_mutex_init(&mutex, nullptr);

    // 创建4个线程ID
    pthread_t t1, t2, t3, t4;

    // 创建4个参数对象,每个对象包含不同的线程名,但都指向同一个互斥锁 &mutex
    thread_data td1("thread 1", &mutex), td2("thread 2", &mutex),
                td3("thread 3", &mutex), td4("thread 4", &mutex);

    // 启动4个线程,传入对应的参数对象地址
    pthread_create(&t1, NULL, route, (void *)&td1);
    pthread_create(&t2, NULL, route, (void *)&td2);
    pthread_create(&t3, NULL, route, (void *)&td3);
    pthread_create(&t4, NULL, route, (void *)&td4);

    // 主线程阻塞等待,直到所有子线程执行完毕
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);

    // 销毁互斥锁,释放相关资源
    pthread_mutex_destroy(&mutex);

    return 0;
}

这段代码实现了一个基于 POSIX 线程(pthread) 的多线程并发售票系统,核心逻辑是通过 互斥锁(pthread_mutex_t 保护全局共享变量 ticket( 以动态初始化的方式 )。程序定义了包含线程名称和锁指针的结构体作为参数传递给 4 个子线程;在子线程的循环中,严格遵循 "加锁 -> 检查余票 -> 模拟耗时操作并扣减票数 -> 解锁" 的流程,确保同一时刻只有一个线程能修改票数,从而防止数据竞争和超卖;主线程则负责初始化资源、创建线程并阻塞等待所有任务完成后销毁锁,完整演示了多线程环境下共享资源的同步控制机制。

1.3 互斥量(锁)实现原理探究

随着不同的锁,有不同的原理,有软件实现有硬件实现! 我们今天用到的互斥锁就是软件实现锁的一种方式!

1. 简单聊聊硬件实现

为什么代码会出现并发问题,本质是因为 这个资源是共享资源,同时出现了多线程切换所导致的。

那么只要不让线程发生切换 不就也是一种串形执行吗?

那么直接禁止 时钟中断 就不会再出现切换了!因为调度检查都发生在中断返回时 那么临界区在跑时 直接关闭中断 代码跑完再打开!

不过这种方式很危险 了解即可!

2. 原理探究

  • 经过上面的例子,大家已经意识到单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题
  • 为了实现互斥锁操作,大多数体系结构都提供了swapexchange指令,该指令的作用是 把寄存器和内存单元的数据相交换 ,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。

    锁的本质 就相当于一个 标志位(整数)!所谓的锁 就是这个整数为1

在我们CPU内部 有个寄存器 叫al 一个寄存器叫a 为了兼容以前16位 讲寄存器分为两部分 al(低16位)ah(高16位)

我们以线程A、B为例子:假设A线程先调度 第一件事 就是把0 moval里 ,然后用一条指令 直接将mutexal内容做交换!

CPU内寄存器只有一套,但是寄存器内部的数据,可以有多份,因为每次切换线程的时候,CPU内部寄存器存储的都是该线程的硬件上下文;而内存中的变量,是被线程所共享的(只要内拿到虚拟地址!),所以说 把内存变量 交换到CPU内部的寄存器中,本质是:把共享数据变成某个线程的私有数据!

所以 哪怕线程进行切换 此时也会把其上下文带走!所以并不会出现并发问题!

接着刚才的例子 当线程A 进行lock 会将mutex的值(假设为1)与al寄存器交换,此时当执行到if(al寄存器内容>0)的部分 切换到线程B 此时al里的内容会作为线程A硬件上下文保存到其PCB中,此时 线程B 执行lock操作 因为此时mutex=0,交换后al=0,当进行if判断时 因为al==0所以 执行else语句 该线程挂起等待!当再次执行线程A时 al的上下文恢复成1 继续判断 发现al>0, return 0 此时线程A 加锁成功!!

因为有 xchgb 这条汇编进行交换(单条汇编就是原子的!) ,当有线程执行到这条指令的时候 就已经成功竞争锁 而锁的本质 就是那个1!!

因为使用了 交换方式 所以并没有产生拷贝,从始至终只有一个1,而谁有这个1,谁就拥有锁!!

  • 竞争锁的本质 就是竞争执行 exchange操作!
  • 而锁能挡住其他执行流的本质,是因为竞争失败的执行流,会陷入挂起等待,从而被挡住!并按照一定的算法不断从lock的第一行汇编开始执行 不断重复 申请锁->申请失败->挂起->申请锁 的流程 直到有线程释放锁后申请成功为止!
  • 关于解锁也是同理 利用交换操作保证原子

问题: 获得锁的线程能否在临界区随意切换代码?

答: 可以 随意切 因为只要不把锁换回去,那么这个锁就会一直处于该线程的硬件上下文!其他线程就不可能申请锁成功!因为站在CPU角度,不存在所谓的加锁,所以CPU会随便切!!但是切换并不换影响运行!


问题: 互斥锁的本质是什么?

互斥的本质就是独占!独占的本质是我们认为临界资源只有一份,所以可以把互斥锁理解成信号量,只不过信号量值为1,表示一份资源!!

所以互斥锁的本质就是对资源的预定机制!

1.4 互斥锁的封装

在C++中 也有对应的互斥锁 对应接口请自行查阅 这里我们将以面向对象的方式对互斥锁进行封装!

在 C 语言或早期的 C++ 编程中,手动管理方式存在两个巨大的隐患:

  1. 忘记解锁 :如果在临界区代码中因为逻辑复杂提前 return,或者抛出了异常,unlock 语句就会被跳过,导致其他线程永远无法获取锁(死锁)。
  2. 资源泄漏 :如果忘记调用 destroy,系统资源将无法被回收。

为了解决这些问题,我们需要将底层的 C 接口封装成 C++ 对象,并引入 RAII(资源获取即初始化)机制。

1. 封装 Mutex 类(资源管理者)

首先,我们将 pthread_mutex_t 封装进一个 Mutex 类中。这个类的核心职责是管理锁本身的生命周期

cpp 复制代码
#pragma once // 防止头文件被重复包含

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

// 互斥锁封装类:将 C 风格的 pthread_mutex_t 包装成 C++ 对象
class Mutex
{
public:
    //为后面2.6 条件变量封装提供的接口
     pthread_mutex_t* Ptr()
    {
        return &_lock;
    } 
    // 构造函数:初始化互斥锁,使用默认属性(nullptr)
    Mutex()
    {
        pthread_mutex_init(&_lock, nullptr);
    }

    // 加锁:阻塞当前线程直到成功获取互斥锁
    void Lock()
    {
        pthread_mutex_lock(&_lock);
    }

    // 解锁:释放当前线程持有的互斥锁
    void Unlock()
    {
        pthread_mutex_unlock(&_lock);
    }

    // 析构函数:销毁互斥锁,释放相关资源
    ~Mutex()
    {
        pthread_mutex_destroy(&_lock);
    }

private:
    pthread_mutex_t _lock; // POSIX 互斥锁句柄
};

通过这种封装,Mutex 对象在创建时自动初始化,销毁时自动清理内核资源,彻底杜绝了忘记 initdestroy 的问题。

2. 实现 LockGuard(RAII 锁守卫)

有了 Mutex 类还不够,我们还需要一个机制来保证"无论发生什么,锁一定会被释放"。这就是 LockGuard 登场的时刻。它采用了经典的 RAII(Resource Acquisition Is Initialization) 风格设计。

cpp 复制代码
// RAII 风格的锁守卫类
class LockGuard 
{
public:
    // 构造函数:传入互斥锁的引用,并在构造时自动加锁
    LockGuard(Mutex& lock):_lockref(lock)
    {
        _lockref.Lock(); // 资源获取即初始化(加锁)
    }

    // 析构函数:当 LockGuard 对象生命周期结束时,自动解锁
    ~LockGuard()
    {
        _lockref.Unlock(); // 资源自动释放(解锁)
    }

private:
    Mutex& _lockref; // 保存外部互斥锁的引用(注意:锁不能被拷贝,必须用引用)
};

3. 核心思路解析

  1. 利用局部对象的生命周期LockGuard 通常被定义为函数或代码块内的局部变量。C++ 语言标准保证,无论函数是正常执行完毕、中途 return,还是抛出异常,当局部对象离开作用域时,其析构函数一定会被调用
  2. 构造时加锁,析构时解锁 :我们将 Lock() 放在构造函数中,将 Unlock() 放在析构函数中。这意味着,只要 LockGuard 对象存在,临界区就受到保护;一旦对象销毁,锁自动释放。这从根本上杜绝了"漏解锁"导致的死锁。
  3. 引用传递的必要性 :在 LockGuard 中,我们使用 Mutex&(引用)来持有锁。这是因为互斥锁代表的是唯一的系统资源,绝对不能被拷贝(如果允许拷贝,两个 LockGuard 可能会尝试解锁同一个锁两次,导致未定义行为)。

4. 实战演示

封装完成后,我们的多线程代码将变得极其简洁且安全,优化之前的抢票系统

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string>
#include <unistd.h>
#include <pthread.h>
#include "Mutex.hpp"

int ticket = 1000;
Mutex lock; // 全局共享锁

// 线程数据只需要保留名字即可
class thread_data
{
public:
    thread_data(const std::string &n) : name(n) {}
    std::string name;
};

void *route(void *arg)
{
    thread_data *td = static_cast<thread_data *>(arg);
    
    while (1)
    {   
        {   // 临界区开始
            LockGuard lockguard(lock); // 直接使用全局锁
            if (ticket > 0)
            {
                usleep(1000); // 模拟出票耗时,放大并发冲突的概率
                printf("%s sells ticket:%d\n", td->name.c_str(), ticket);
                ticket--;
            }
            else
            {
                break; // 票卖完了,退出循环
            }
        } // 临界区结束,自动解锁
    }
    return nullptr;
}

int main(void)
{
    pthread_t t1, t2, t3, t4;
    // 创建线程数据,不再需要传递锁的地址
    thread_data td1("thread 1"), td2("thread 2"), td3("thread 3"), td4("thread 4");

    pthread_create(&t1, NULL, route, (void *)&td1);
    pthread_create(&t2, NULL, route, (void *)&td2);
    pthread_create(&t3, NULL, route, (void *)&td3);
    pthread_create(&t4, NULL, route, (void *)&td4);

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);
    
    printf("All tickets sold out!\n");
    return 0;
}

我们通过 {} 花括号来显式限定 LockGuard 的作用域:

cpp 复制代码
{   
    LockGuard lockguard(lock); // 进入作用域,自动加锁
    // ... 抢票逻辑 ...
} // 离开作用域,lockguard 析构,自动解锁

如注释所说,被括起来的部分就是临界区。通过这种方式,不仅明确了加锁的范围,还保证了只要出了这个花括号,锁就一定会被释放,极大地增加了代码的可读性和安全性。

效果:

2. 线程同步

如果因为竞争不合理 就会导致效率低下等问题 即 让一把锁被同一个线程一直申请 时:

当一个线程互斥地访问某个变量时,发现在其它线程改变状态之前,它什么也做不了。

所以 再释放锁后,不能立刻申请,如果要再用就必须要排队!!而外部的线程都需要排队。

说人话 就是有条狗一直占着盆吃饭,别的狗都吃不到,从而产生线程饥饿问题!现在就是让大家乖乖排队,每狗吃两口排后面换下一个!

即 在临界资源安全的前提下,让访问临界区资源具有一定的顺序性,这种就叫 线程同步!!

2.1 条件变量

咱们拿限时 VIP 自习室举例,这间屋子一次只能进一个人,门外的锁就是进入资格。

一开始没有排队机制,所有人都在门口盯着锁。张三学完走出来,刚把锁松开,就因为离得最近一把抢回去继续用,反反复复霸占自习室,后面排队的人永远抢不到,只能干等着,这种无效等待就是线程饥饿

就算张三出来后如果一群人一起冲上去抢,最后也只会有一个人进去,其他人全白等。大家就想,如果装一台叫号机,每个人拿号按顺序排队,屋子空了就叫最前面的人,剩下的人不用死蹲门口,能先去忙别的事。

可以设置一个🔔,所有等待的人都排在铃铛后面等待,当自习室的人出来的时候,他会先把门口的铃铛敲一下,通知排头的人,然后自己就排到队尾了。这样其实还有个好处就是在排队的时候如果你不是队头,你都不需要关心铃铛而是只要等着就好。

而这个"铃铛"就是条件变量!!其实就是一个判断资源是否就绪的变量!!

pthread_cond_t 是 POSIX 线程库中用于实现线程同步的条件变量类型。它并非一个普通的"变量",而是一种同步机制,允许线程在某个条件不满足时进入等待状态(挂起),并在条件被其他线程改变并通知后唤醒,从而避免无效的忙等待(轮询),提升系统效率!

2.2 ⽣产者消费者模型

在生活中,我们会存在超市,伴随两个角色 消费者,当然 超市本身并不会生产产品,给消费者提供产品的就是生产者(方便面生产商),假设这个超市只卖方便面

在现实生活中,消费者存在多个,生产者也是存在多个的(白象、康师傅、统一等多家泡面生产商)

这里就可以存在多个 消费者线程生产者线程,而超市就是 交易场所!更加是一个临界资源!!

生产和消费的过程中 如果没有协调好,就会出现各种问题,资源过剩或者没货等情况!

生产者与生产者之间?竞争关系
消费者与消费者之间?竞争关系
生产者与消费者之间?同步&&互斥关系

ps:保证生产与消费安全性,让生产者把货品上架完,消费者再买等等 所以在一定程度上是 原子的。

⽣产者消费者模式就是通过⼀个容器来解决⽣产者和消费者的强耦合问题。⽣产者和消费者彼此之间不直接通讯,⽽通过阻塞队列来进⾏通讯,所以⽣产者⽣产完数据之后不⽤等待消费者处理,直接扔给阻塞队列,消费者不找⽣产者要数据,⽽是直接从阻塞队列⾥取,阻塞队列就相当于⼀个缓冲区,平衡了⽣产者和消费者的处理能⼒。这个阻塞队列就是⽤来给⽣产者和消费者在"时间"上解耦的。

是一种多线程协同的模式,提高协作效率,本质是一种通信过程!!

速记:

  • 3种关系: 生产者和生产者,消费者和消费者,生产者和消费者
  • 2种角色: 生产者和消费者线程
  • 1个交易场所 超市,即"内存空间",通常由特定数据结构承担!

生产者消费者模型的三大核心优点:

  1. 解耦
  • 含义:生产者和消费者之间不直接打交道,而是通过一个"中间商"(通常是阻塞队列/缓冲区)来传递数据。
  • 通俗解释:就像去餐厅吃饭,你(消费者)不需要认识厨师(生产者),也不需要站在灶台边等菜。你只需要对着服务员点单(从队列取数据),厨师做好菜放在出餐口(向队列放数据)。哪怕换了厨师或者换了顾客,只要出餐口这个机制不变,双方都不受影响。这降低了代码模块之间的依赖关系。
  1. 支持并发
  • 含义:生产者和消费者可以是两个独立的执行流(线程或进程),它们可以同时运行,互不干扰。
  • 通俗解释:以前可能是"做完一件事再做下一件"(串行),比如必须等包子蒸好了才能开始卖。现在有了这个模型,蒸包子的师傅可以一边蒸,卖包子的阿姨可以一边卖,两边同时干活,效率大大提高。
  1. 支持忙闲不均
  • 含义:该模型能够平衡生产速度和消费速度之间的差异,起到"削峰填谷"的作用。
  • 通俗解释:消费者很多,超市很空,生产者就生产快一点;如果消费者很闲,超市东西很多,那么生产者就生产慢一点!!大大减少出现消费者一窝蜂,没有产品的尴尬情况(因为有超市的库存作为缓冲!),也会大大减少没什么消费者,生产者过度生产问题(超市货架满了,我就不生产了)

2.3 条件变量函数

初始化

该函数用于动态初始化一个条件变量,使其处于可用状态。在使用条件变量进行线程同步之前,必须先对其进行初始化。

cpp 复制代码
//动态初始化
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t
*restrict attr);
参数:
cond:要初始化的条件变量
attr:我们一般设置为NULL
返回值:成功返回0,失败返回错误码。
cpp 复制代码
// 静态初始化示例
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
适用于全局变量或静态变量!不需要手动销毁

销毁

该函数用于销毁一个已初始化的条件变量,并释放其占用的相关资源。销毁后,该条件变量对象实际上变为未初始化状态。如果后续需要再次使用,必须重新调用 pthread_cond_init 进行初始化。

cpp 复制代码
int pthread_cond_destroy(pthread_cond_t *cond)

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

等待条件满⾜:

pthread_cond_wait 是 POSIX 线程库中用于线程同步的核心函数,它允许线程在某个条件不满足时挂起(阻塞),直到被其他线程通知条件可能已满足。

cpp 复制代码
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
参数:
cond:要在这个条件变量上等待
 - 含义:指向当前线程需要等待的条件变量的指针。当线程调用此函数时,它会被移入该条件变量的等待队列中。

mutex:互斥量,调用此函数前当前线程必须已经持有该锁;函数内部会原子性地释放该锁并使线程进入阻塞等待;当线程被唤醒并返回时,会重新自动获取该锁
 - 含义:指向与条件变量关联的互斥锁的指针。
 - 前置条件:在调用 pthread_cond_wait 之前,当前线程必须已经持有该互斥锁。如果未加锁就调用,将产生未定义行为。

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

核心工作原理:

pthread_cond_wait 的执行过程是一个原子操作,主要包含以下步骤:

  1. 释放锁并进入等待 :函数内部会先原子地释放传入的互斥锁(mutex),然后让当前线程进入阻塞状态。
  2. 等待唤醒 :线程被移入条件变量的等待队列,直到被其他线程通过 pthread_cond_signalpthread_cond_broadcast 唤醒。
  3. 重新获取锁 :线程被唤醒后,必须重新竞争并获取(争夺)互斥锁。只有在成功拿到锁之后,pthread_cond_wait 函数才会返回,线程继续向下执行。

必须使用 while 循环检查条件:

cpp 复制代码
pthread_mutex_lock(&mutex);
while (!condition) { // 必须用 while 循环
    pthread_cond_wait(&cond, &mutex);
}
// 条件满足,继续执行...
pthread_mutex_unlock(&mutex);

为什么要用 while 循环检查条件?

原因有二:

  1. 虚假唤醒 :操作系统允许线程在没有收到 signalbroadcast 的情况下醒来。
  2. 多线程竞争 :假设 signal 唤醒了线程 A 和线程 B。线程 A 抢到了锁,消耗了资源(比如拿走了队列里的任务),然后解锁。线程 B 随后抢到锁,如果它是用 if 判断的,它会以为还有任务,结果去操作空队列导致崩溃。用 while 可以让线程 B 醒来后再次检查,发现没任务了继续睡。

来个例子直观感受下:

  1. 初始状态queue_size = 0
  2. 线程 A(生产者) :检查 if (queue_size >= 1) -> 不满足,进入 wait 睡眠。
  3. 线程 B(生产者) :检查 if (queue_size >= 1) -> 不满足,进入 wait 睡眠。
    • 注意:现在有两个生产者在等空位。
  4. 线程 C(消费者) :拿走任务,queue_size 变为 0。调用 signal 唤醒一个生产者。
  5. 线程 A 醒来 :抢到锁,从 wait 返回。因为是 if,它不再检查条件 ,直接执行放入任务操作。queue_size 变为 1。解锁。
  6. 线程 B 醒来 (假设系统因为某种原因唤醒了它,或者之前的 signal 是 broadcast):抢到锁。
    • 关键点 :如果是 if,线程 B 会认为既然我醒来了,肯定就有空位。
    • 实际 :线程 A 已经把坑占了!queue_size 已经是 1 了。
    • 结果 :线程 B 强行放入任务,queue_size 变为 2。缓冲区溢出,程序崩溃或数据错乱。

使用 while 的正确姿势:

在第 6 步,线程 B 醒来并抢到锁后:

  1. 回到 while (queue_size >= 1) 循环判断。
  2. 发现 queue_size 确实是 1(被 A 填满了)。
  3. 再次调用 wait,主动释放锁并去睡觉。
  4. 把机会留给下一次消费者消费完再唤醒。

唤醒等待

cpp 复制代码
int pthread_cond_broadcast(pthread_cond_t *cond);
参数:
cond:指向条件变量的指针;该函数会唤醒所有正在该条件变量上阻塞等待的线程;被唤醒的线程将重新竞争互斥锁,若条件未满足通常会再次进入等待

int pthread_cond_signal(pthread_cond_t *cond);
参数:
cond:指向条件变量的指针;该函数至少唤醒一个正在该条件变量上阻塞等待的线程(具体唤醒哪一个由系统调度策略决定);被唤醒的线程将重新获取互斥锁并继续执行

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

注: 如果唤醒的时候,对方本来就是醒的,那么该唤醒信息就会被忽略!

2.4 测试接口

cpp 复制代码
#include<iostream>
#include<string>
#include<pthread.h>
#include<unistd.h>
pthread_mutex_t gmutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t gcond=PTHREAD_COND_INITIALIZER;

void* Print(void* args)
{
    std::string name = static_cast<const char*>(args);
    while (true)
    {
        pthread_mutex_lock(&gmutex);
        //???
        pthread_cond_wait(&gcond,&gmutex);
        std::cout<<"我是新线程:"<<name<<std::endl;
        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);
    
   }
}

这段代码实现了一个 "主线程定时唤醒、工作线程轮流打印" 的多线程同步模型。程序在主线程中创建了4个并发的工作线程,这些线程启动后会立即加锁并调用 pthread_cond_wait 进入阻塞等待状态;与此同时,主线程在一个死循环中每隔1秒调用一次 pthread_cond_signal 发出唤醒信号。由于 signal 每次至少唤醒一个等待线程,被唤醒的线程会重新获取锁、打印自己的名字、解锁并休眠1秒,从而形成主线程不断发号施令、子线程依次被唤醒并打印输出的协同工作效果。

2.5 基于BlockingQueue的⽣产者消费者模型

2.5.1 BlockingQueue

在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞。)

2.5.2 C++ queue模拟阻塞队列的⽣产消费模型

1. 设计思路

阻塞队列的本质是一个线程安全的队列,它在普通队列的基础上增加了"阻塞"特性:

  1. 队列为空时:消费者线程必须阻塞等待,直到有数据被放入。
  2. 队列满时:生产者线程必须阻塞等待,直到有数据被取出。

为了实现这种线程间的"等待"与"通知"机制,我们需要借助 Linux 下的 POSIX 线程库(pthread),主要依赖以下三个核心组件:

  • 互斥锁(pthread_mutex_t):保证对队列(临界资源)的互斥访问。
  • 条件变量(pthread_cond_t) :用于线程间的同步与通信(消费者等待 _consumer_cond,生产者等待 _productor_cond)。
  • 标准库队列(std::queue):作为底层的实际数据存储容器。
2.代码实现与注释
cpp 复制代码
#ifndef _BLOCK_QUEUE_H
#define _BLOCK_QUEUE_H

#include <iostream>
#include <pthread.h>
#include <queue>
#include <unistd.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(&_productor_cond, nullptr);
        
        // 初始化休眠线程计数器(可用于高级唤醒策略或监控)
        sleep_consumer_num = 0;
        sleep_productor_num = 0;
    }

    // 入队操作(生产者调用)
    void Enqueue(T &in)
    {
        // 1. 加锁:进入临界区,保护底层队列 _bq
        pthread_mutex_lock(&_mutex);
        
        // 2. 判断队列是否已满
        // 【核心重点】:为什么这里必须使用 while 而不是 if?
        // 原因:防止"伪唤醒"(Spurious Wakeup)。即使没有线程显式调用 signal/broadcast,
        // 等待在条件变量上的线程也可能被系统意外唤醒。使用 while 循环可以确保唤醒后
        // 再次检查条件,如果队列依然满,则继续等待。
        while(_bq.size() == _cap)
        {
            sleep_productor_num++; // 记录休眠的生产者数量
            
            // 3. 条件等待
            // pthread_cond_wait 内部会做两件事:
            // (1) 自动释放互斥锁 _mutex,让其他线程(如消费者)能获取锁并操作队列。
            // (2) 将当前线程挂起,进入阻塞状态,等待被唤醒。
            // 当线程被唤醒时,该函数返回前会重新自动获取 _mutex 锁。
            pthread_cond_wait(&_productor_cond, &_mutex);
            
            sleep_productor_num--;
        }
        
        // 4. 生产数据:此时锁在手,且队列不满,安全地将数据推入队列
        _bq.push(in);
        
        // 5. 通知消费者
        // 唤醒一个正在等待的消费者线程(如果有的话)。
        // 注意:signal 放在锁内或锁外均可。放在锁内,消费者被唤醒后会立即竞争锁;
        // 放在锁外,消费者被唤醒后会在 pthread_mutex_lock 处竞争锁。
        pthread_cond_signal(&_consumer_cond);
        
        // 6. 解锁:退出临界区
        pthread_mutex_unlock(&_mutex);
    }

    // 出队操作(消费者调用,使用输出型参数带回数据)
    void Pop(T *out)
    {
        // 1. 加锁:进入临界区
        pthread_mutex_lock(&_mutex);
        
        // 2. 判断队列是否为空
        // 【防御性编程】:同样必须使用 while 循环,防止伪唤醒导致在空队列上执行 front() 引发崩溃。
        while(_bq.empty())
        {
            sleep_consumer_num++; // 记录休眠的消费者数量
            
            // 3. 条件等待
            // 队列空,释放锁并阻塞等待,直到生产者放入数据并唤醒。
            pthread_cond_wait(&_consumer_cond, &_mutex);
            
            sleep_consumer_num--;
        }

        // 4. 消费数据:此时锁在手,且队列非空,安全地取出队头元素
        *out = _bq.front();
        _bq.pop();
        
        // 5. 通知生产者
        // 队列腾出了空间,唤醒一个正在等待的生产者线程。
        pthread_cond_signal(&_productor_cond);
        
        // 6. 解锁:退出临界区
        pthread_mutex_unlock(&_mutex);
    }

    // 析构函数:销毁锁和条件变量,防止资源泄漏
    ~BlockQueue() 
    {
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_consumer_cond);
        pthread_cond_destroy(&_productor_cond);
    }

private:
    std::queue<T> _bq;      // 底层实际存储数据的队列
    int _cap;               // 队列的最大容量

    pthread_mutex_t _mutex; // 互斥锁,保证线程安全
    //pthread_cond_t _consumer_cond; // 消费者条件变量(队列为空时消费者在此等待)
   // pthread_cond_t _productor_cond;// 生产者条件变量(队列满时生产者在此等待)
    
    // 扩展字段:可用于实现高级唤醒策略(如批量唤醒)或监控线程状态
   // int sleep_productor_num; // 当前休眠的生产者线程数
   // int sleep_consumer_num;  // 当前休眠的消费者线程数
};

#endif

3.注意事项

pthread_cond_wait 为什么要传入互斥锁(在临界区)?

① 释放锁,让其他线程能操作临界资源;② 将自己挂起。这两个动作必须是原子的 。如果先释放锁再挂起,在释放锁和挂起之间的极短空窗期内,其他线程可能获取锁、修改条件并发出信号,导致当前线程错过信号而永久阻塞。pthread_cond_wait 内部保证了"释放锁"与"进入等待"的原子性。同理,当线程被唤醒时,它会在函数返回前重新自动获取该互斥锁,从而保证线程醒来后依然处于临界区的保护之下。

唤醒信号(signal)放在锁内还是锁外?

在代码中,我们将 pthread_cond_signal 放在了 pthread_mutex_unlock 之前。这完全是可以的,甚至在某些场景下是更优的。如果放在锁内,被唤醒的线程会立即尝试获取互斥锁,但由于当前线程还持有锁,被唤醒的线程会立刻在锁上阻塞(从条件变量的等待队列转移到互斥锁的等待队列)。一旦当前线程解锁,被唤醒的线程能无缝衔接拿到锁。这种方式的上下文切换开销较小。如果放在锁外,被唤醒的线程可能会在锁释放前就开始运行,但在正式运行前依然获取锁时依然要经历一次竞争。两者在正确性上都没有问题,具体选择可根据实际业务场景的性能测试决定。

扩展思考:水位线与唤醒策略

代码中预留了 _blockqueue_low_water(低水位线)和 _blockqueue_high_water(高水位线)的注释。在实际的高并发工程中,简单的"一满就停、一空就等"可能导致频繁的线程切换。引入水位线后,我们可以实现更平滑的流控:例如,只有当队列达到高水位线时才阻塞生产者,只有当队列低于低水位线时才唤醒生产者。结合代码中的 sleep_productor_numsleep_consumer_num,我们甚至可以定制唤醒策略(如使用 pthread_cond_broadcast 批量唤醒),以应对海量数据吞吐的场景。

4. 多线程问题与代码测试

你会问 这个不是个单生产对单消费嘛?? 其实因为互斥锁的原因 任何和一个消费者和生产者进入临界区都要竞争这唯一的一把锁,也就是说,他天然就是多线程安全的!!支持多消费者和生产者的

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

// 全局自增 ID,用于给每个线程分配唯一的编号
int num = 1;
// 全局互斥锁,用于保护全局变量 num 的线程安全访问
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

// 获取全局唯一编号的辅助函数
int GetNumber()
{
    pthread_mutex_lock(&lock); // 加锁,确保同一时间只有一个线程能修改 num
    int number = num++;        // 获取当前编号并自增
    pthread_mutex_unlock(&lock); // 解锁,释放临界资源

    return number;
}

// 消费者线程的回调函数
void *ConsumerRoutine(void *args)
{
    // 获取当前线程的唯一编号,并设置线程名称(方便调试和观察输出)
    int nunmber = GetNumber();
    std::string name = "Consumer-" + std::to_string(nunmber);
    pthread_setname_np(pthread_self(), name.c_str());

    // 将传入的 void* 参数强制转换为阻塞队列的指针
    BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(args);
    
    while (1)
    {
        sleep(1); // 模拟消费者处理业务逻辑耗时,每秒消费一次
        int data;
        bq->Pop(&data); // 从阻塞队列中取出数据。若队列为空,线程将在此处自动阻塞等待
        std::cout << name << "消费:" << data << std::endl; // 打印消费结果
    }
}

// 生产者线程的回调函数
void *ProductorROutine(void *args)
{
    // 获取当前线程的唯一编号,并设置线程名称
    int nunmber = GetNumber();
    std::string name = "Productor-" + std::to_string(nunmber);
    pthread_setname_np(pthread_self(), name.c_str());

    // 将传入的 void* 参数强制转换为阻塞队列的指针
    BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(args);
    
    int data = 10; // 生产者初始生产的数据
    while (1)
    {
        bq->Enqueue(data); // 将数据压入阻塞队列。若队列已满,线程将在此处自动阻塞等待
        std::cout << name << "生产:" << data++ << std::endl; // 打印生产结果,并将数据自增
    }
}

int main()
{
    // 在堆区创建一个默认容量(5)的阻塞队列实例
    // 因为每个消费者和生产者在访问队列时,都会先竞争队列内部的互斥锁,
    // 锁机制天然保证了临界区的互斥访问,所以该阻塞队列完美支持多生产多消费模型!
    BlockQueue<int> *bq = new BlockQueue<int>();

    // 定义线程数组:3个消费者线程,2个生产者线程
    pthread_t c[3], p[2];
    
    // 批量创建 3 个消费者线程,并将阻塞队列的指针作为参数传递
    pthread_create(c, nullptr, ConsumerRoutine, bq);
    pthread_create(c + 1, nullptr, ConsumerRoutine, bq);
    pthread_create(c + 2, nullptr, ConsumerRoutine, bq);

    // 批量创建 2 个生产者线程,同样将阻塞队列的指针作为参数传递
    pthread_create(p, nullptr, ProductorROutine, bq);
    pthread_create(p + 1, nullptr, ProductorROutine, 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;
}

通过创建 2 个生产者线程和 3 个消费者线程,共同操作同一个阻塞队列,完美演示了阻塞队列在多生产者-多消费者模型下的线程安全与同步机制:生产者线程会不断生成递增的整数并压入队列,当队列满时会自动阻塞等待;而消费者线程则每秒从队列中取出一个数据并打印,当队列空时同样会自动阻塞休眠,整个系统在多线程并发环境下实现了数据的有序生产与消费。

2.6 条件变量的封装

在分装前 使用了以前封装的 mutex 具体代码在 本文的 1.4部分

cpp 复制代码
     pthread_mutex_t* Ptr()
    {
        return &_lock;
    } 
cpp 复制代码
#ifndef _COND_HPP
#define _COND_HPP

#include <pthread.h>
#include "Mutex.hpp" // 引入我们之前封装好的互斥锁类

class Cond
{
public:
    // 构造函数:初始化底层的 POSIX 条件变量
    // 第二个参数传 nullptr 表示使用默认属性
    Cond()
    {
        pthread_cond_init(&_cond, nullptr);
    }
    
    // 条件等待函数
    // 参数 mutex:传入一个 Mutex 对象的引用。调用者必须在调用此函数前已经持有该锁。
    void Wait(Mutex &mutex)
    {
        // 调用底层 API 进行等待。
        // 核心机制:pthread_cond_wait 内部会原子性地执行以下两步操作:
        // 1. 自动释放 mutex.Ptr() 指向的互斥锁,让其他线程有机会获取锁并修改条件。
        // 2. 将当前线程挂起,进入阻塞休眠状态,直到收到信号。
        // 当线程被唤醒时,该函数会在返回前重新自动获取该互斥锁。
        int n = pthread_cond_wait(&_cond, mutex.Ptr());
        
        // 在工程实践中,我们通常会检查系统调用的返回值。
        // 这里使用 (void)n 是为了显式地忽略返回值,防止编译器报"未使用变量"的警告。
        (void)n;
    }
    
    // 唤醒单个等待线程
    // 如果有多个线程在等待,操作系统会唤醒其中一个(具体唤醒哪一个由调度器决定)。
    void Signal()
    {
        // 发送信号唤醒一个等待在条件变量上的线程
        int n = pthread_cond_signal(&_cond);
        // 实际生产中建议对 n 进行判断,若 n != 0 则说明唤醒失败(如传入非法指针)
        (void)n;
    }

    // 广播唤醒所有等待线程
    // 适用于需要唤醒所有等待者重新检查条件的场景(如资源池销毁、全局状态变更)。
    void Broadcast()
    {
        // 唤醒所有阻塞在该条件变量上的线程
        int n = pthread_cond_broadcast(&_cond); 
        (void)n;
    }

    // 析构函数:销毁底层的条件变量,防止资源泄漏
    ~Cond()
    {
        pthread_cond_destroy(&_cond);
    }

private:
    pthread_cond_t _cond; // 底层实际的 POSIX 条件变量
};

#endif

2.7 线程相关接口对阻塞队列的二次升级(并以任务的形式看待生产者消费者模型)

1. 代码升级

接下来 我将会基于1.42.6部分的代码对2.5.2的封装代码进行二次升级!!!我将完成"C 语言风格向 C++ RAII 风格的重构"。

cpp 复制代码
#ifndef _BLOCK_QUEUE_H
#define _BLOCK_QUEUE_H

#include <iostream>
#include <pthread.h>
#include <queue>
#include <unistd.h>
#include "Mutex.hpp"  // 引入 RAII 风格的互斥锁封装
#include "Cond.hpp"   // 引入条件变量封装

const int defaultcap = 5;

template <typename T>
class BlockQueue
{
public:
    // 构造函数:初始化队列容量,成员变量(Mutex, Cond)会自动调用各自的构造函数
    BlockQueue(int cap = defaultcap) : _cap(cap)
    {
        // 初始化休眠线程计数器(用于优化唤醒策略)
        sleep_consumer_num = 0;
        sleep_productor_num = 0;
    }

    // 入队操作(生产者调用)
    void Enqueue(T &in)
    {
        // 【核心重构点】使用额外的花括号 {} 限制 LockGuard 的作用域
        {
            // 1. 构造 LockGuard 对象,构造函数内部自动调用 _mutex.Lock() 加锁
            LockGuard lockguard(_mutex);
            
            // 2. 使用 while 循环防御"伪唤醒",当队列满时进入等待
            while (_bq.size() == _cap)
            {
                sleep_productor_num++; // 记录休眠的生产者数量
                _producter_cond.Wait(_mutex); // 自动释放锁并休眠,唤醒后自动重新获取锁
                sleep_productor_num--;
            }

            // 3. 此时锁在手,且队列不满,安全地将数据推入队列
            _bq.push(in);
            
            // 4. 性能优化:只有当有消费者在休眠时,才发送唤醒信号
            if (sleep_consumer_num > 0)
                _consumer_cond.Signal();
        } 
        // 【核心重构点】代码执行到这里,lockguard 离开作用域,自动调用析构函数解锁 (_mutex.Unlock())
        // 锁的粒度被严格控制,后续如果有其他耗时操作,也不会阻塞其他线程
    }

    // 出队操作(消费者调用,使用输出型参数带回数据)
    void Pop(T *out) 
    {
        // 同样使用花括号 {} 严格控制锁的作用域
        {
            // 1. 构造 LockGuard 对象,自动加锁
            LockGuard lockguard(_mutex);
            
            // 2. 防御性编程:使用 while 循环防止伪唤醒,当队列为空时进入等待
            while (_bq.empty())
            {
                sleep_consumer_num++; // 记录休眠的消费者数量
                _consumer_cond.Wait(_mutex); // 自动释放锁并休眠
                sleep_consumer_num--;
            }
            
            // 3. 此时锁在手,且队列非空,安全地取出队头元素
            *out = _bq.front();
            _bq.pop();
            
            // 4. 性能优化:只有当有生产者在休眠时,才发送唤醒信号
            if (sleep_productor_num > 0)
                _producter_cond.Signal();
        }
        // 离开作用域,lockguard 析构,自动解锁
    }

    // 析构函数:无需手动销毁锁和条件变量
    // 因为 _mutex, _consumer_cond, _producter_cond 都是成员对象,
    // 当 BlockQueue 销毁时,它们会自动调用各自的析构函数完成资源释放。
    ~BlockQueue()
    {
    }

private:
    std::queue<T> _bq;      // 底层实际存储数据的队列
    int _cap;               // 队列的最大容量

    Mutex _mutex;           // C++ 封装的互斥锁
    Cond _consumer_cond;    // C++ 封装的消费者条件变量
    Cond _producter_cond;   // C++ 封装的生产者条件变量

    int sleep_productor_num; // 当前休眠的生产者线程数
    int sleep_consumer_num;  // 当前休眠的消费者线程数
};

#endif

2. 任务

cpp 复制代码
#ifndef __TASK_HPP
#define __TASK_HPP

#include <iostream>
#include <string>
#include <functional> // 引入 std::function,为后续使用函数对象做铺垫

// 【基础任务类】:封装了一个具体的业务逻辑(这里以加法为例)
// 在实际的线程池中,任务类通常需要支持拷贝,以便放入队列中
class Task
{
public:
    Task()
    {
    }

    // 构造函数:初始化任务的输入数据
    Task(int x, int y)
        : _x(x), _y(y)
    {
    }

    // 核心业务逻辑执行函数
    void Execute()
    {
        _result = _x + _y; // 执行具体的计算任务
    }

    // 重载函数调用运算符 ()
    // 【设计意图】:让 Task 对象变成"函数对象(仿函数)"。
    // 这样在线程池中,我们可以直接通过 task() 的方式来执行任务,
    // 极大地简化了线程回调函数的调用逻辑。
    void operator()()
    {
        Execute();
    }

    // 获取任务的执行结果
    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;      // 输入数据1
    int _y;      // 输入数据2
    int _result; // 任务执行后的结果
};


// 【任务扩展与多态】
// 通过继承基础 Task 类,我们可以轻松定义各种不同类型的任务。
// 在实际工程中,可以在这些子类中重写 Execute() 方法,实现不同的业务逻辑。
class CalTask : public Task{};    // 计算类任务
class StorageTask : public Task{}; // 存储/IO类任务
class NetTask : public Task{};    // 网络通信类任务


// 【架构演进方向:从"类"到"函数对象"】
// 上面的 Task 是一个具体的类,但在实际的高并发服务器(如线程池)开发中,
// 我们往往希望"派发任意任务",而不局限于某种特定的类。

// 1. 我们可以使用 C++11 的 std::function<void()> 来定义一个通用的任务类型别名。
//    它可以容纳:普通函数、函数对象(如上面的 Task)、Lambda 表达式、std::bind 绑定对象等。
// using task_t = std::function<void()>;

// 2. 例如,我们可以直接定义一个普通函数作为任务:
// void Print()
// {
//     std::cout << "我是一个任务...." << std::endl;
// }

// 3. 在线程池中,我们只需要维护一个 std::queue<task_t> 任务队列。
//    无论是 new 出来的 Task 对象,还是普通的 Print 函数,都可以统一打包塞进队列,
//    工作线程取出后直接执行 task() 即可。这就是"任务派发"的终极形态!

#endif

代码展示了线程池或任务调度系统中"任务(Task)"的最基础形态。它通过封装数据和逻辑,将"做什么"和"怎么做"打包成一个对象,为后续实现"任务派发"打下了基础!

代码末尾注释掉的 std::function<void()> 是点睛之笔。在实际的线程池开发中,我们不会死板地只用 Task 类,而是会将 std::function 作为任务队列的元素类型。这样,你的线程池就变成了一个通用的任务调度器,任何符合 void() 签名的逻辑都可以被派发执行,实现了极高的解耦和扩展性。

3. 测试代码和效果展示

cpp 复制代码
#include "BlockQueue.hpp" // 引入我们封装的 C++ RAII 风格阻塞队列
#include "Thread.hpp"     // 引入封装好的线程类
#include "Task.hpp"       // 引入任务类
#include <memory>         // 引入智能指针,用于自动管理阻塞队列的生命周期
#include <ctime>
#include <cstdlib>

// 全局自增 ID 和锁(虽然当前 main 中未直接使用,但在复杂的多线程场景中常用于给线程或任务打唯一标签)
int num = 1;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

using namespace ThreadModule; // 引入线程模块的命名空间

int main()
{
    // 1. 初始化随机数种子
    // 使用当前时间和进程 ID 进行异或,确保每次运行生成的随机数序列都不同
    srand(time(nullptr) ^ getpid());

    // 2. 在堆区创建一个专门存放 Task 对象的阻塞队列,并交给智能指针管理
    // 队列满时生产者阻塞,队列空时消费者阻塞,天然实现了线程同步
    std::unique_ptr<BlockQueue<Task>> bq = std::make_unique<BlockQueue<Task>>();
    
    // 3. 创建消费者线程(使用 C++ Lambda 表达式作为线程回调)
    // [&bq]() 表示以引用捕获的方式,让线程内部能访问外部的阻塞队列
    Thread consumer([&bq]()
                    {
        while (1)
        {
            sleep(1); // 模拟消费者处理业务逻辑的耗时,每秒处理一次
            
            Task t;   // 创建一个空的任务对象作为容器
            // 【核心动作1】从阻塞队列中取出任务
            // 如果队列为空,当前线程会自动在 Pop 内部阻塞休眠,直到被生产者唤醒
            bq->Pop(&t);
            
            // 【核心动作2】执行取出的任务(调用 Task 内部的 Execute 进行加法计算)
            t.Execute();
            
            // 打印任务执行后的结果,例如:消费:3+7=10
            std::cout << "消费:" << t.getResult() << std::endl;
        } 
    });
    
    // 4. 创建生产者线程(同样使用 Lambda 表达式)
    Thread productor([&bq]()
                     { 
        while (1)
        {
            // 随机生成两个 1~10 之间的整数作为任务参数
            int datax = rand() % 10 + 1;
            usleep(rand() % 1000); // 模拟生产者生成数据的微小耗时
            int datay = rand() % 10 + 1;
            
            // 将数据打包成一个具体的 Task 对象
            Task t(datax, datay);
            
            // 【核心动作3】将任务压入阻塞队列
            // 如果队列已满(默认容量为5),当前线程会自动在 Enqueue 内部阻塞,直到被消费者唤醒
            bq->Enqueue(t);
            
            // 打印生产出的任务题目,例如:生产:3+7=?
            std::cout << "生产:" << t.Question() << std::endl;
        } 
    });

    // 5. 启动两个线程,操作系统开始并发调度它们
    consumer.Start();
    productor.Start();

    // 6. 主线程阻塞等待子线程结束
    consumer.Join();
    productor.Join();

    return 0;
}

代码优势:

  1. C++ Lambda 与线程封装的完美结合:
    在创建 Thread 对象时,我们直接传入了 Lambda 表达式([&bq](){ ... })。这得益于之前对
    Thread 类的优秀封装(内部使用 std::function
    接收回调)。这使得我们无需再定义繁琐的全局回调函数,业务逻辑直接内联在 main 函数中,代码可读性和内聚性极高。
  2. 智能指针(unique_ptr)保障资源安全:
    阻塞队列 bq 是通过 std::make_unique 在堆上创建的。这意味着无论程序如何退出,BlockQueue
    的内存都会被自动释放。而在 BlockQueue 析构时,其内部的 MutexCond 对象又会通过 RAII
    机制自动销毁底层的 pthread_mutexpthread_cond,彻底杜绝了资源泄漏。
  3. 任务与执行的彻底解耦:
    生产者只负责"制造任务(数据)",消费者只负责"执行任务(逻辑)",两者通过 BlockQueue<Task>
    这个中间件进行通信。如果未来业务变更(比如将加法改为乘法,或者增加网络请求任务),我们只需要修改 Task
    类或新增任务类型,而完全不需要改动线程的创建、阻塞队列的同步逻辑。这就是高并发服务器开发中"高内聚、低耦合"设计哲学的完美体现。

序启动后,生产者线程会源源不断地随机生成"加法算式"并打包成 Task 对象塞入阻塞队列;消费者线程则每秒从队列中取出一个算式进行计算并打印结果。当队列塞满5个任务时,生产者会自动"踩刹车"休眠;当队列被掏空时,消费者会自动"挂起"等待,两者在多线程环境下实现了完美的自动流控与同步。

2.8 重谈生产消费者模型

所谓串形 是一整套流程上的 给一个任务给生产者,生产者获取完任务后,放入阻塞队列,然后消费者拿到任务并处理 串形的是放入和取出的这个流程!

但为什么 说这么做是效率高的呢?因为 消费者和生产者 处理和生产任务的 过程 二者是并行的!高效是体现在这两头是并发的!

问题: 为什么任何时刻,都只有一个线程在访问阻塞队列啊?

因为我们把阻塞队列当成了一个整体在使用,即临界资源被我们当作了一个完整的资源,要么不用,要用,就必须互斥的用!

2.9 POSIX信号量

POSIX信号量和system V 的信号量从概念上其实大差不差

感兴趣的可以阅读我 以前写的文章 【点击转跳】

给几个结论:

  1. 信号量:本质是一个描述临界资源,资源数目的计数器!
  2. 信号量的本质:对资源的预定机制
  3. 不过这些都有个前提条件:临界资源是多份的,可以被多个执行流分别访问!
  4. 当资源只有一份的时候,我们将这种信号量称为二元信号量 ,在核心思想上mutex就等同于二元信号量!

我们可以把信号量理解成这样

cpp 复制代码
struct sem
{
int count;
mutex_t lock;
thread_queue;
}

我们所谓的p操作可以这么理解

cpp 复制代码
p:
lock();//加锁访问sem
if(count>0) 
{
   count--;
   unlock();
   return;
}
else
{
挂起进程 将当前进程加入该信号量的等待队列
unlock();
goto lock();
}

所谓的v操作可以这么理解

cpp 复制代码
v:
lock();        // 加锁访问sem,保证原子性
count++;       // 释放一个资源,计数器加1
if(有进程在等待队列中) 
{
   唤醒等待队列中的一个进程; // 将某个挂起的进程从"阻塞态"变为"就绪态"
}
unlock();      
return;      

2.9.1 信号量的接口

初始化信号量

cpp 复制代码
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
pshared:0表⽰线程间共享,⾮零表⽰进程间共享
value:信号量初始值(>=0)
返回值:成功返回0,失败返回-1

销毁信号量

cpp 复制代码
int sem_destroy(sem_t *sem);
返回值:成功返回0,失败返回-1

等待信号量

cpp 复制代码
功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem); //P()

与条件变量的对比

特性 sem_wait(信号量) pthread_cond_wait(条件变量)
阻塞队列 内核 futex 等待队列 内核 futex 等待队列
数据载体 带有计数器 无计数器,只管理等待线程
配套锁 不需要额外互斥锁 必须绑定互斥锁mutex
唤醒动作 sem_post 只唤醒1个线程 signal唤醒1个,broadcast唤醒全部(惊群)
唤醒源头 计数器+1,自动唤醒等待者 手动调用signal/broadcast
竞态防护 原子计数+内核二次校验,无窗口漏洞 依靠mutex关闭"条件判断→进入阻塞"之间的竞态窗口(while)
虚假唤醒 极少,内核严格校验,业务一般不用while 一定会出现虚假唤醒,必须while循环检查条件
状态记忆 有计数,post多次会累计资源 无记忆,没线程等待时,signal直接无效
典型用途 资源计数限流、生产者消费者计数 等待某个布尔条件成立

二者都存在内核阻塞队列;信号量靠计数器实现状态记忆(不需要额外加锁,靠计数器保证原子性,所以使用的时候不需要while),条件变量只负责排队,状态需要程序员自己用变量保存并配合锁保护。

底层:


发布信号量

cpp 复制代码
功能:发布信号量,表⽰资源使⽤完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);//V()

2.9.2 基于环形队列的⽣产消费模型

特点:将资源拆成多份来进行访问

环形队列为空:head == taill,计数器为0
环形队列为满:head == taill,计数器为1
其他情况:head != tail

关于实现:可以用链表也可以直接用数组(这里为了方便,直接用数组!)

这样我们就可以使用多线程,基于环形队列进行数据交换,实现多线程并发访问环形队列!


先对该队列进行 规则建模:

  1. 为空,taillhead在同一个位置,taill(生产者)先动
  2. 为满,在同一个位置,得先让head(消费者)先动
  3. 不为空,不为满,生产者和消费者向不同的位置,并发生产和消费
  4. 生产者不能把消费者 "套圈"!
  5. 消费者也不能超过生产者。

生产者与消费者眼中的资源:

生产者角度:空格子是资源。

消费者角度:数据是资源。

所以 我们需要两个信号量分别表示二者对应资源:

cpp 复制代码
sem_t blank_sem = N;
sem_t data_sem = 0;

那么二者的具体操作是什么?

对生产者来说:

cpp 复制代码
P(blank_sem);//申请信号量

//生产数据
ring[tail++] =data;
tail%N; 

V(data_sem);//释放信号量 data_sem

对消费者来说:

cpp 复制代码
P(data_sem);

//消费数据
out = ring(head++);
head% = N;

V(block_sem);

只有在空和满的时候 同步和互斥才会体现出来,也就是说 如果不为空不为满的时候,生产者和消费者二者不就可以并发进行了嘛!!

以上就是 基于环形队列的生产者与消费者模型!

2.9.3 信号量的封装

cpp 复制代码
#ifndef _SEM_HPP
#define _SEM_HPP

#include <iostream>
#include <semaphore.h> // 引入 POSIX 信号量头文件

// 【信号量封装类】
// 将底层的 sem_t 及其操作封装为 C++ 类,实现 RAII 管理
class Sem
{
public:
    // 构造函数:初始化信号量
    // init_val: 信号量的初始值。
    //               若用于互斥,通常设为 1;
    //               若用于同步(如生产者消费者),通常设为 0(表示初始无资源)。
    Sem(int init_val)
    {
        if (init_val >= 0)
        {
            // sem_init 参数说明:
            // 1. &_sem: 要初始化的信号量对象
            // 2. 0:    pshared 参数。0 表示该信号量仅在当前进程的多个线程间共享(进程内信号量)。
            // 3. init_val: 信号量的初始计数值
            int n = sem_init(&_sem, 0, init_val);
            (void)n; // 强制忽略返回值,消除编译器关于"未使用变量"的警告
        }
    }

    // P 操作(Wait / Down / 减一操作)
    // 尝试获取资源。如果信号量值 > 0,则减 1 并立即返回;
    // 如果信号量值 == 0,则当前线程会被阻塞(挂起),直到其他线程执行 V 操作。
    void P()
    {
        int n = sem_wait(&_sem);
        (void)n;
    }

    // V 操作(Post / Up / 加一操作)
    // 释放资源。将信号量值加 1。
    // 如果有其他线程因为执行 P 操作而被阻塞,V 操作会唤醒其中一个线程。
    void V()
    {
        int n = sem_post(&_sem);
        (void)n;
    }

    // 析构函数:销毁信号量
    // 当 Sem 对象离开作用域时,自动调用 sem_destroy 释放底层资源
    ~Sem()
    {
        int n = sem_destroy(&_sem);
        (void)n;
    }

private:
    sem_t _sem; // 底层 POSIX 信号量结构体
};

#endif

2.9.4 基于环形队列的⽣产消费模型代码实现

1. 思路与代码

与普通的阻塞队列不同,它巧妙地利用了两个信号量两把互斥锁的配合,将"资源同步"与"临界区保护"分离开来。这种设计极大地减少了线程在持有锁时的等待时间,从而提升了高并发场景下的性能。


在这段代码的 Enqueue 函数中,有一段非常有意思的注释逻辑:

"让生产者先竞争锁?还是让生产者先预定资源?很明显先买票再竞争锁效率更高!!"

这是本代码最大的亮点。

  • 传统做法(低效):先加锁 -> 发现队列满了 -> 解锁 -> 阻塞等待 -> 醒来 -> 再加锁 -> 生产。这会导致频繁的加锁/解锁操作。
  • 本代码做法(高效):先申请信号量(买票)-> 只有拿到票了才去加锁(入场)-> 生产 -> 解锁 -> 通知消费者。

这种 "粗粒度同步(信号量) + 细粒度互斥(互斥锁)" 的模式,是高性能服务器开发的标配。

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <vector>
#include <pthread.h>
#include "Sem.hpp"   // 引入封装好的信号量类
#include "Mutex.hpp" // 引入封装好的互斥锁及RAII锁管理类

// 默认环形队列容量为 5
int defaultcap = 5;

template <typename T>
class RingQueue
{
public:
    // 构造函数
    // @param cap: 队列最大容量
    RingQueue(int cap = defaultcap) : _cap(cap),
                                      _rq(cap),       // 预分配内存空间
                                      _consumer_step(0),
                                      _productor_step(0),
                                      _data_sem(0),     // 数据信号量初始化为0(表示初始没有数据可消费)
                                      _blank_sem(cap)   // 空格信号量初始化为cap(表示初始有cap个空位可生产)
    {
    }

    // 【入队操作】由生产者调用
    void Enqueue(T &in)
    {
        // 1. 预定资源 (P操作)
        // 检查是否有空闲格子。如果没有空位(_blank_sem为0),生产者会在这里阻塞等待。
        // 这一步不需要加锁,因为信号量本身是线程安全的原子操作。
        _blank_sem.P();

        {
            // 2. 进入临界区进行写入
            // 只有在确认有空位后,才获取互斥锁。这避免了"拿着锁等空位"的低效行为。
            LockGuard lockguard(_pmutx);

            // 将数据放入当前生产位置
            _rq[_productor_step] = in;
            // 移动生产指针,利用取模运算实现环形回绕
            _productor_step++;
            _productor_step %= _cap;
        } // 3. 离开作用域,LockGuard 自动释放互斥锁 (_pmutx)

        // 4. 释放数据资源 (V操作)
        // 告诉消费者:"这里有一个新数据了,你可以来取了"。
        // 如果有消费者正在 _data_sem.P() 处等待,此时会被唤醒。
        _data_sem.V();
    }

    // 【出队操作】由消费者调用
    void Pop(T *out)
    {
        // 1. 预定数据 (P操作)
        // 检查是否有数据。如果队列为空(_data_sem为0),消费者会在这里阻塞等待。
        _data_sem.P();

        {
            // 2. 进入临界区进行读取
            // 同样,确认有数据后才加锁,保证多线程消费时的数据安全。
            LockGuard lockguard(_cmutx);

            // 从当前消费位置取出数据
            *out = _rq[_consumer_step];
            // 移动消费指针,利用取模运算实现环形回绕
            _consumer_step++;
            _consumer_step %= _cap;
        } // 3. 离开作用域,LockGuard 自动释放互斥锁 (_cmutx)

        // 4. 释放空格资源 (V操作)
        // 告诉生产者:"我拿走一个数据了,现在多出来一个空位,你可以来生产了"。
        _blank_sem.V();
    }

    ~RingQueue()
    {
        // 析构函数,依靠成员变量的 RAII 特性自动销毁信号量和互斥锁
    }

private:
    int _cap;             // 环形队列的总容量

    std::vector<T> _rq;   // 底层存储容器,使用 vector 模拟环形数组

    int _consumer_step;   // 消费者下标(读指针)
    int _productor_step;  // 生产者下标(写指针)

    // --- 同步原语(负责协调生产和消费的步调) ---
    Sem _blank_sem; // 空格信号量:记录当前有多少个空位。生产者关心这个,初始值为 cap。
    Sem _data_sem;  // 数据信号量:记录当前有多少个有效数据。消费者关心这个,初始值为 0。

    // --- 互斥原语(负责保护临界区安全) ---
    // 注意:这里使用了两把锁分别保护生产和消费过程。
    // 为什么不用一把大锁?
    // 因为在环形队列中,只要生产者和消费者不操作同一个下标(即队列未满且未空),
    // 他们的读写操作其实是互不干扰的。
    // 使用两把锁可以允许"一边生产、一边消费"并行执行,进一步提升并发度。
    Mutex _cmutex; // 保护消费者临界区(读操作)
    Mutex _pmutx;  // 保护生产者临界区(写操作)
};

2. 关键技术点总结

  1. 双信号量模型
    • _blank_sem_data_sem 互为补充。生产者的 V 操作对应消费者的 P 操作,反之亦然。这完美解决了"生产者太快撑爆队列"和"消费者太快读空队列"的问题。
  2. 锁的粒度优化
    • 代码中使用了 _pmutx(生产者锁)和 _cmutex(消费者锁)。
    • 如果只用一把锁,那么当生产者正在写入时,消费者即使想读另一个位置的数据也必须等待。
    • 使用两把锁后,只要 _productor_step != _consumer_step(即没有发生读写冲突),生产和消费就可以真正并行进行。
  3. 环形数组的实现
    • 通过 _step % _cap 取模运算,让下标在 0cap-1 之间循环跳动,实现了物理上的线性存储、逻辑上的环形结构。
  4. RAII 守卫
    • LockGuard 的使用确保了即使在处理数据过程中发生异常,互斥锁也能被正确释放,防止死锁。

3. 测试代码

cpp 复制代码
#include "RingQueue.hpp" // 引入我们封装的基于信号量和双锁的环形队列
#include <unistd.h>      // 引入 sleep() 等系统调用

// --- 全局资源保护锁 ---
Mutex cnt_lock;    // 专门用于保护全局变量 data 的互斥锁(保证数据唯一性)
Mutex screen_lock; // 专门用于保护 std::cout 的互斥锁(保证屏幕打印不乱码)

// --- 全局共享数据 ---
int data = 1; // 模拟一个全局递增的流水号或任务ID

// 获取下一个全局唯一数据(线程安全)
int GetData()
{
    cnt_lock.Lock();      // 进入临界区,防止多线程同时读取到相同的 data
    int result = data++;  // 读取当前值并自增
    cnt_lock.Unlock();    // 离开临界区
    return result;        // 返回获取到的唯一数据
}

// 线程安全的屏幕打印函数
void Print(const std::string &name, const std::string &info)
{
    screen_lock.Lock(); // 加锁,防止多个线程的输出内容交错重叠
    std::cout << name << " : " << info << std::endl;
    screen_lock.Unlock(); // 解锁
}

// --- 线程参数封装类 ---
// 因为 pthread_create 只能传递一个 void* 参数,所以需要将队列指针和线程名打包
class ThreadData
{
public:
    // 构造函数初始化列表
    ThreadData(RingQueue<int> * r, const std::string &n) : rq(r), name(n)
    {}
    ~ThreadData() {}

public:
    std::string name;         // 线程的名字(用于日志打印和调试)
    RingQueue<int> *rq;       // 指向共享环形队列的指针
};

// --- 生产者线程回调函数 ---
void *ProductorRoutine(void *args)
{
    // 将传入的 void* 强制转换回 ThreadData*
    ThreadData *td = static_cast<ThreadData *>(args);
    // 设置当前线程在系统中的名字,方便使用 top -H 等工具观察
    pthread_setname_np(pthread_self(), td->name.c_str());
    
    while (true)
    {
        sleep(3); // 模拟生产者生成数据的耗时,每隔3秒生产一次
        
        int data = GetData();       // 获取一个全局唯一的数据
        td->rq->Enqueue(data);      // 将数据放入环形队列(若队列满会自动阻塞)
        
        // 打印生产日志(内部已加锁保护)
        Print(td->name, "生产数据:" + std::to_string(data));
    }
}

// --- 消费者线程回调函数 ---
void *ConsumerRoutine(void *args)
{
    ThreadData *td = static_cast<ThreadData *>(args);
    pthread_setname_np(pthread_self(), td->name.c_str());
    
    int data = 0; // 用于接收从队列中取出的数据
    while (true)
    {
        // 从环形队列中取出数据(若队列为空,内部信号量会自动让线程阻塞休眠)
        td->rq->Pop(&data);
        
        // 打印消费日志
        Print(td->name, "消费数据:" + std::to_string(data));
    }
}

// --- 主线程:负责资源的创建与线程的调度 ---
int main()
{
    // 1. 在堆区创建一个共享的环形队列
    RingQueue<int> *rq = new RingQueue<int>();

    // 定义线程数组:2个消费者,3个生产者
    pthread_t c[2], p[3];

    // 2. 在堆上创建线程参数对象(必须用 new,防止局部变量销毁导致子线程访问野指针)
    ThreadData *td0 = new ThreadData(rq, "product-1");
    pthread_create(p, nullptr, ProductorRoutine, td0); // 创建第1个生产者

    ThreadData *td1 = new ThreadData(rq, "product-2");
    pthread_create(p+1, nullptr, ProductorRoutine, td1); // 创建第2个生产者

    ThreadData *td2 = new ThreadData(rq, "product-3");
    pthread_create(p+2, nullptr, ProductorRoutine, td2); // 创建第3个生产者

    ThreadData *td3 = new ThreadData(rq, "consumer-1");
    pthread_create(c, nullptr, ConsumerRoutine, td3); // 创建第1个消费者

    ThreadData *td4 = new ThreadData(rq, "consumer-2");
    pthread_create(c+1, nullptr, ConsumerRoutine, td4); // 创建第2个消费者

    // 3. 主线程阻塞等待,回收所有子线程资源(由于子线程是死循环,这里会一直等待)
    pthread_join(c[0], nullptr);
    pthread_join(c[1], nullptr);
    pthread_join(p[0], nullptr);
    pthread_join(p[1], nullptr);
    pthread_join(p[2], nullptr);
    
    return 0;
}

主线程创建了一个共享的环形队列,并启动了5个子线程;3个生产者线程每隔3秒生成一个全局唯一的递增数字并塞入队列,2个消费者线程则不断从队列中取出数据;代码通过独立的互斥锁保证了全局计数器和屏幕输出的线程安全,而环形队列内部则通过信号量与锁的配合,完美实现了多生产多消费场景下的自动同步与流控。

⚠️:这里也可以把代码里面加入类似2.7部分的task!!这里就不写了!!