《Linux系统编程》Linux 系统多线程(六):<线程同步与互斥>线程同步(下):POSIX 信号量与环形队列生产者消费者模型详解

🔥小叶-duck:个人主页

❄️个人专栏:《Data-Structure-Learning》《C++入门到进阶&自我学习过程记录》

《Linux操作系统从入门到实践》《Qt从入门到实践》

《算法题讲解指南》--优选算法

《算法题讲解指南》--递归、搜索与回溯算法

《算法题讲解指南》--动态规划算法

✨未择之路,不须回头
已择之路,纵是荆棘遍野,亦作花海遨游


目录

前言

[一、POSIX 信号量](#一、POSIX 信号量)

[1.1 信号量的本质:临界资源的原子计数器](#1.1 信号量的本质:临界资源的原子计数器)

[1.2 信号量与互斥锁、条件变量的核心区别](#1.2 信号量与互斥锁、条件变量的核心区别)

[1.3 POSIX 信号量核心API](#1.3 POSIX 信号量核心API)

[1.3.1 初始化信号量](#1.3.1 初始化信号量)

[1.3.2 销毁信号量](#1.3.2 销毁信号量)

[1.3.3 P 操作:申请 / 等待资源](#1.3.3 P 操作:申请 / 等待资源)

[1.3.4 V 操作:发布 / 释放资源](#1.3.4 V 操作:发布 / 释放资源)

[1.4 C++ 简单封装信号量](#1.4 C++ 简单封装信号量)

[二、 基于环形队列的生产者消费者模型核心原理](#二、 基于环形队列的生产者消费者模型核心原理)

[2.1 环形队列的基础特性](#2.1 环形队列的基础特性)

[2.2 模型的四大核心执行规则](#2.2 模型的四大核心执行规则)

[2.3 信号量与环形队列的天然适配](#2.3 信号量与环形队列的天然适配)

[2.4 多生产多消费的互斥处理](#2.4 多生产多消费的互斥处理)

三、环形队列生产消费模型完整实现

[3.1 单生产单消费](#3.1 单生产单消费)

[3.2 多生产多消费](#3.2 多生产多消费)

[3.3 核心代码细节深度解读](#3.3 核心代码细节深度解读)

[3.3.1 为什么先执行 P 操作,再加锁?](#3.3.1 为什么先执行 P 操作,再加锁?)

[3.3.2 为什么无需 while 循环判断条件](#3.3.2 为什么无需 while 循环判断条件)

[3.3.3 单生产 / 消费场景的锁优化](#3.3.3 单生产 / 消费场景的锁优化)

[3.4 阻塞队列 VS 环形队列:两种生产消费模型对比](#3.4 阻塞队列 VS 环形队列:两种生产消费模型对比)

四、面试核心考点总结

结束语


前言

生产者消费者模型是 Linux 多线程并发开发里最经典的同步模型。在上篇文章我们借助互斥锁 + 条件变量 ,依托阻塞队列完成模型实现,解决了线程之间互斥访问、执行时序同步的需求。但这套方案存在明显短板:由于整个生产消费者模型只有一个锁,导致生产者与消费者争抢同一把全局互斥锁,即便队列处于非空、非满的理想状态,生产逻辑和消费逻辑依旧无法并行运行,高并发场景下极易产生性能瓶颈。

POSIX 信号量恰好弥补了这一缺陷。信号量依托资源计数器的思想,提供粒度更精细的同步控制手段,结合环形队列结构,可以支持生产者、消费者在多数场景下并行运行,有效提升并发吞吐能力。本文将从 POSIX 信号量底层原理入手,讲解相关 API 用法、代码封装思路,基于环形队列的生产者消费者模型,带你吃透这套 Linux 高并发编程经典方案。

一、POSIX 信号量

1.1 信号量的本质:临界资源的原子计数器

POSIX 信号量本质上是一个实现了原子操作的资源计数器,用来衡量临界资源的可用数量, 是对临界资源的「预定机制」。

我们用生活中的例子就能轻松理解:

电影院有 100 个座位,这就是 100 份可用的临界资源。信号量的初始值就设为 100,每有一个观众买票入场,信号量执行 P 操作(-1);每有一个观众离场,信号量执行 V 操作(+1)。当信号量的值为 0 时,所有再想入场的观众就会被阻塞,直到有人离场释放座位。

对应到计算机领域:

  • 信号量的值 > 0:表示当前有可用的临界资源,线程可以正常访问;
  • 信号量的值 = 0:表示当前无可用临界资源,线程会进入阻塞等待状态,直到资源被释放;

信号量提供了两个核心原子操作:

  • P 操作:申请资源,原子性地将传入的信号量值 - 1,若值 < 0 则线程阻塞;
  • V 操作:释放资源,原子性地将传入的信号量值 + 1,若有线程在等待,则唤醒其中一个。

而我们之前常用的互斥锁,本质上就是一个二元信号量(初始值为 1) ,它只保证**「资源要么可用,要么不可用」** 两种状态;而信号量 可以支持多线程同时访问多份临界资源,灵活性远高于互斥锁。

1.2 信号量与互斥锁、条件变量的核心区别

同步工具 核心能力 适用场景 并发粒度
互斥锁 保证临界资源的互斥访问,同一时间仅一个线程进入临界区 保护共享资源的原子性修改 粗粒度,全临界区互斥
条件变量 实现线程间的等待 - 通知机制,配合互斥锁使用 线程间的协同执行,由自己设定的条件下唤醒线程 依赖互斥锁,仍需全局竞争
POSIX 信号量 原子性的资源计数,同时实现互斥与同步,无需配合其他工具 多份临界资源的并发访问,细粒度同步控制 细粒度,可实现生产消费完全并行

1.3 POSIX 信号量核心API

信号量的所有接口都定义在**<semaphore.h> 头文件** 中,编译时需要链接 -lpthread库,核心 API 分为 4 类,完全对应我们的核心操作:

1.3.1 初始化信号量

cpp 复制代码
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);

参数解析:

  • **sem:**要初始化的信号量指针;
  • pshared: 共享方式:0 表示线程间共享 ,非 0 表示进程间共享;
  • value: 信号量的初始值,即可用临界资源的数量。

1.3.2 销毁信号量

cpp 复制代码
int sem_destroy(sem_t *sem);

用于销毁信号量,释放其占用的资源,注意必须确保没有线程在该信号量上等待时再执行销毁。

1.3.3 P 操作:申请 / 等待资源

cpp 复制代码
// 核心P操作:申请资源,sem信号量-1,无资源(为0)则阻塞
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 ,它会原子性 地完成**「资源判断 - 计数修改(信号量值 - 1) - 线程阻塞」**全流程,不会出现并发安全问题。

1.3.4 V 操作:发布 / 释放资源

cpp 复制代码
// 核心V操作:释放资源,信号量+1,有等待线程则唤醒
int sem_post(sem_t *sem);

该函数会原子性 地将信号量值 + 1 ,若此时有线程因sem_wait 阻塞 ,会唤醒其中一个线程。

1.4 C++ 简单封装信号量

为了后续代码的易用性和安全性,我们参考文档中的实现,对原生信号量 API 进行 RAII 风格的封装,屏蔽底层细节:

cpp 复制代码
#ifndef SEM_HPP
#define SEM_HPP

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

const int default_value = 1;

class Sem
{
public:
    Sem(unsigned int value = default_value)
    {
        // int sem_init(sem_t *sem, int pshared, unsigned int value);
        sem_init(&_sem, 0, value);
    }

    // P操作:申请资源,信号量--,若资源数 <=0 则阻塞等待
    void P()
    {
        // int sem_wait(sem_t *sem); //原子的
        int n = sem_wait(&_sem);
        if (n != 0)
        {
            std::cerr << "Failed to sem_wait" << std::endl;
        }
    }

    // V操作:释放资源,信号量++,唤醒可能等待的线程
    void V()
    {
        // int sem_post(sem_t *sem); //原子的
        int n = sem_post(&_sem);
        if (n != 0)
        {
            std::cerr << "Failed to sem_post" << std::endl;
        }
    }

    ~Sem()
    {
        // int sem_destroy(sem_t *sem);
        sem_destroy(&_sem);
    }

private:
    sem_t _sem; // 原生POSIX信号量
};

#endif

封装设计要点:

  • 利用 RAII 机制,构造时初始化信号量,析构时自动销毁,避免资源泄漏;
  • 屏蔽了原生 API 的参数细节,仅暴露核心的 P/V 操作,使用更简洁;
  • 完全保留了原生信号量的原子性特性,无性能损耗。

二、 基于环形队列的生产者消费者模型核心原理

有了信号量这个工具,我们就可以实现比阻塞队列更高效的生产者消费者模型,而环形队列就是这个模型的最佳载体。

2.1 环形队列的基础特性

环形队列 本质上是用数组 + 模运算 模拟的环形数据结构,相比普通链表队列 ,它无需频繁申请释放内存 ,访问效率更高,且天然适配信号量的资源计数机制。

它的核心特性:

  • 用一维数组存储数据,通过head(生产者写入下标) 和 **tail(消费者读取下标)**标识操作位置;
  • 通过 下标 % 队列 容量的模运算,实现数组首尾相接的环形效果;
  • 仅当head == tail 时,队列会出现**「空」** 或**「满」**两种状态,其余所有状态下,生产者和消费者的操作位置完全分离。

这也是环形队列能实现高并发的核心:只要队列非空非满,也就说明生产者和消费者永远不会访问同一个数组下标位置,天然支持并行执行。

2.2 模型的四大核心执行规则

我们用「圆桌上盘子放苹果」的例子,理解模型的执行规则:

  • 圆桌有 N 个盘子,对应环形队列的 N 个存储位置;
  • 生产者负责往空盘子里放苹果,消费者负责从有苹果的盘子里拿苹果;
  • 生产者和消费者围着圆桌顺时针移动,每次操作一个盘子。

由此衍生出四大不可违背的执行规则,也是模型同步逻辑的核心:

  • 队列为空时,一定只能是生产者先执行:此时没有数据可供消费者消费,两者在同一个位置,消费者必须阻塞等待生产者生产;
  • 队列为满时,一定只能是消费者先执行:此时没有空闲位置可供生产者生产,两者在同一个位置,生产者必须阻塞等待消费者消费。
  • 生产者不能套消费者超过一圈 :否则会覆盖消费者还没消费的数据,造成数据丢失;
  • 消费者不能超过生产者 :否则会读取到无效的空数据 ,造成程序异常;

2.3 信号量与环形队列的天然适配

看了上面的内容可能就会有人问了:当队列非空非满时说明生产者和消费者不在同一个数组下标位置访问资源,那如果在同一个下标位置同时对资源进行访问那不就出问题了吗?是不是当两者下标相同的时候额外判断一下是谁来访问这个下标位置?其实是不需要的!

在这个模型中,我们只需要两个信号量,就能完美实现上述规则的同步控制,无需额外的条件判断:

  • 空间资源信号量(_blank_sem) :生产者核心关心的资源,初始值为队列容量 cap ,表示队列初始有 cap 个空闲位置,没有数据资源;
    • 生产者 每次生产前,必须先执行 P 操作申请空间资源信号量,申请成功才能写入数据;
    • 消费者 每次消费完成后,必须执行 V 操作释放空间 ,唤醒阻塞的生产者。
  • 数据资源信号量(_data_sem) :消费者核心关心的资源,初始值为 0 ,表示队列初始无可用数据;
    • 消费者 每次消费前,必须先执行 P 操作申请数据资源信号量,申请成功才能读取数据;
    • 生产者 每次生产完成后,执行 V 操作释放数据,唤醒阻塞的消费者。

再结合对上面 POSIX 信号量核心API 的学习我们就会知道当生产者和消费者处于下标相同的位置时,是不可能存在两者同时访问当前位置的数据。

因为两者处于同一个位置时,只会存在两种情况:(1)队列为空;(2)队列为满。

当队列为空的时候,即**_data_sem为0** ,_blank_sem为满值(cap) 。此时当消费者再次进行消费时,首先会执行 P 操作申请数据资源信号量(_data_sem) ,而此时信号量为0,消费者就会被阻塞等待,只有当生产者生产了一个数据后执行 V 操作释放空间 ,让**_data_sem+1** ,并且唤醒阻塞中的消费者,消费者才能继续消费,但是此时生产者生产了一个数据后下标已经+1,所以也就不在同一位置 了允许两者并行执行。 同理,当队列为满的时候,即**_blank_sem为0,_data_sem为满值(cap)** 。此时当生产者再次进行生产时,首先会执行 P 操作申请空间资源信号量(_blank_sem) ,而此时信号量为0,生产者就会被阻塞等待,只有当消费者消费了一个数据后执行 V 操作释放空间 ,让**_blank_sem+1** ,并且唤醒阻塞中的生产者,生产者才能继续生产,但是此时消费者消费了一个数据后下标已经+1,所以也就不在同一位置 了允许两者并行执行。

所以我们会发现当借助两个信号量之后,生产者和消费者处于同一位置时通过 P 操作中的 sem_wait 对信号量值的判断+阻塞等待 使得两者就被天然隔离了无法同时访问,而只有两者处于不同下标位置时才可并行执行。

核心优势:信号量的 P 操作已经隐形完成了「队列空 / 满」的条件判断,只要 P 操作返回成功,就一定有对应的资源可用,无需像条件变量那样在临界区内做二次判断,代码更简洁,执行效率更高。

2.4 多生产多消费的互斥处理

上述逻辑完美适配单生产者单消费者场景,而在多生产者、多消费者场景下,我们只需要解决两个额外的互斥问题:

  • 多个生产者之间 ,会竞争写入下标**_productor_step** ,因此需要一把生产者专属互斥锁,保证同一时间只有一个生产者修改写入下标,安全的访问资源;
  • 多个消费者之间 ,会竞争读取下标 _consumer_step ,因此需要一把消费者专属互斥锁,保证同一时间只有一个消费者修改读取下标,安全的访问资源。

这里的设计精髓在于:生产者和消费者不再竞争同一把全局锁,生产者之间竞争自己的锁,消费者之间竞争自己的锁。 在绝大多数场景下,生产者和消费者可以完全并行执行,只有同角色的线程之间才有锁竞争,并发性能相比阻塞队列模型有质的提升。

三、环形队列生产消费模型完整实现

我们基于上述原理,实现一个完整的、支持单 / 多生产消费、模板化的环形队列,逐行拆解代码设计与细节。

这里我们直接使用前面文章所封装的互斥锁以及刚刚封装的信号量来实现环形队列,因为我们的封装都是直接使用系统非常简单,所以清楚了封装互斥锁和信号量实现环形队列,对于系统的直接实现也就不难了。

  • Mutex.hpp:
cpp 复制代码
#ifndef MUTEX_HPP
#define MUTEX_HPP

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

class Mutex
{
public:
    Mutex()
    {
        pthread_mutex_init(&_mutex, nullptr);
        std::cout << "mutex init success" << std::endl;
    }

    void Lock()
    {
        int n = pthread_mutex_lock(&_mutex);
        if (n != 0)
        {
            std::cerr << "pthread_mutex_lock false" << std::endl;
        }
    }

    void Unlock()
    {
        int n = pthread_mutex_unlock(&_mutex);
        if (n != 0)
        {
            std::cerr << "pthread_mutex_unlock false" << std::endl;
        }
    }

    ~Mutex()
    {
        int n = pthread_mutex_destroy(&_mutex);
        if (n != 0)
        {
            std::cerr << "pthread_mutex_destroy false" << std::endl;
        }
        else
        {
            std::cout << "mutex destroy success" << std::endl;
        }
    }

    pthread_mutex_t *GetMutex()
    {
        return &_mutex;
    }

private:
    pthread_mutex_t _mutex;
};

// RAII风格的互斥锁的封装(实现自动解锁)
class LockGuard
{
public:
    LockGuard(Mutex &mutex) : _mutex(mutex)
    {
        _mutex.Lock();
    }

    ~LockGuard()
    {
        _mutex.Unlock();
    }

private:
    Mutex &_mutex;
};

#endif

3.1 单生产单消费

  • RingQueue.hpp(核心代码):
cpp 复制代码
#ifndef RINGQUEUE_HPP
#define RINGQUEUE_HPP

#include <iostream>
#include <vector>
#include "Sem.hpp"
#include "Mutex.hpp"

const int g_cap = 5;

template <class T>
class RingQueue
{
public:
    RingQueue(const int cap = g_cap)
        : _rq(cap), _cap(cap), _blank_sem(cap), _p_step(0), _data_sem(0), _c_step(0)
    {
    }

    void Equeue(const T &in)
    {
        // 生产者
        // 1、申请信号量->(空位置信号量--)
        // 如果当前_blank_sem值为0,则生产者再次调用sem_wait会被阻塞,
        // 直到信号量的值变为正数(由消费者使用_blank_sem调用V()增加)
        _blank_sem.P();

        // 2、生产
        _rq[_p_step] = in;
        // 3、更新下标
        _p_step++;
        // 4、维持环形特性(取余)
        _p_step %= _cap;
        // 5、通知消费者->(资源信号量++: 若存在一个阻塞的消费者则唤醒)

        _data_sem.V();
        // sem_post不会阻塞线程。只是原子地增加信号量的值,
        // 并唤醒一个对应信号量(_data_sem)因sem_wait而阻塞的线程->消费者(如果有)
    }

    void Top(T *out)
    {
        // 消费者
        // 1、申请信号量->(资源信号量--)
        // 如果当前_data_sem值为0,则消费者再次调用sem_wait会被阻塞,
        // 直到信号量的值变为正数(由生产者使用_data_sem调用V()增加)
        _data_sem.P();
        // 2、消费
        *out = _rq[_c_step];
        // 3、更新下标
        _c_step++;
        // 4、维持环形特性(取余)
        _c_step %= _cap;
        // 5、通知生产者->(空位置信号量++: 若存在一个阻塞的生产者则唤醒)
        _blank_sem.V();
        // sem_post不会阻塞线程。只是原子地增加信号量的值,
        // 并唤醒一个对应信号量(_blank_sem)因sem_wait而阻塞的线程->生产者(如果有)
    }

    ~RingQueue()
    {
    }

private:
    std::vector<T> _rq; // 使用vector数组模拟实现环形队列
    int _cap;           // 环形队列的容量

    // 生产者的信号量
    Sem _blank_sem; // 生产者关注的是空位置的信号量(即空位置的个数)
    // 生产者所在位置(下标)
    int _p_step;

    // 消费者的信号量
    Sem _data_sem; // 消费者关注的是资源的信号量(即资源的个数)
    // 消费者所在位置(下标)
    int _c_step;
};

#endif
cpp 复制代码
#include "RingQueue.hpp"
#include <unistd.h>

void *consumer(void *args)
{
    RingQueue<int> *rq = static_cast<RingQueue<int> *>(args);
    while (true)
    {
        sleep(1);
        int data = 0;
        rq->Top(&data);
        std::cout << "消费者消费了一个数据: " << data << std::endl;
    }
}

void *productor(void *args)
{
    RingQueue<int> *rq = static_cast<RingQueue<int> *>(args);
    int data = 1;
    while (true)
    {
        // sleep(1);
        std::cout << "生产了一个数据: " << data << std::endl;
        rq->Equeue(data);
        data++;
    }
}

int main()
{
    RingQueue<int> *rq = new RingQueue<int>();

    // 构建生产者和消费者
    pthread_t c;
    pthread_t p;

    pthread_create(&c, nullptr, consumer, rq);
    pthread_create(&p, nullptr, productor, rq);

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

场景一: 消费者消费的慢一点,现象应该是生产者会瞬间把容量为 5 的队列打满,之后消费者每消费一个数据,生产者才会生产一个新数据,完美实现了同步控制,符合我们的四大执行规则。

场景二:我们让生产者慢一点,这个就会是严格的生产一个消费一个

3.2 多生产多消费

  • RingQueue.hpp(LockGuard优化前:手动加锁解锁):
cpp 复制代码
#ifndef RINGQUEUE_HPP
#define RINGQUEUE_HPP

#include <iostream>
#include <vector>
#include "Sem.hpp"
#include "Mutex.hpp"

const int g_cap = 5;

template <class T>
class RingQueue
{
public:
    RingQueue(const int cap = g_cap)
        : _rq(cap), _cap(cap), _blank_sem(cap), _p_step(0), _data_sem(0), _c_step(0)
    {
    }

    void Equeue(const T &in)
    {
        // _p_mutex.Lock(); //这里加锁有问题吗?没问题,但不是最好

        // 生产者
        // 1、申请信号量->(空位置信号量--)
        // 如果当前_blank_sem值为0,则生产者再次调用sem_wait会被阻塞,
        // 直到信号量的值变为正数(由消费者使用_blank_sem调用V()增加)
        _blank_sem.P();

        _p_mutex.Lock(); //加锁

        // 2、生产
        _rq[_p_step] = in;
        // 3、更新下标
        _p_step++;
        // 4、维持环形特性(取余)
        _p_step %= _cap;
        // 5、通知消费者->(资源信号量++: 若存在一个阻塞的消费者则唤醒)

        _p_mutex.Unlock(); // 解锁

        _data_sem.V();
        // sem_post不会阻塞线程。只是原子地增加信号量的值,
        // 并唤醒一个对应信号量(_data_sem)因sem_wait而阻塞的线程->消费者(如果有)

        // _p_mutex.Unlock();
        // 解锁的操作放到V的前面还是后面影响不大,都可以,主要是对加锁的讲解
    }

    void Top(T *out)
    {
        // 消费者
        // 1、申请信号量->(资源信号量--)
        // 如果当前_data_sem值为0,则消费者再次调用sem_wait会被阻塞,
        // 直到信号量的值变为正数(由生产者使用_data_sem调用V()增加)
        _data_sem.P();

        _c_mutex.Lock(); //加锁

        // 2、消费
        *out = _rq[_c_step];
        // 3、更新下标
        _c_step++;
        // 4、维持环形特性(取余)
        _c_step %= _cap;
        // 5、通知生产者->(空位置信号量++: 若存在一个阻塞的生产者则唤醒)

        _c_mutex.Unlock(); // 解锁

        _blank_sem.V();
        // sem_post不会阻塞线程。只是原子地增加信号量的值,
        // 并唤醒一个对应信号量(_blank_sem)因sem_wait而阻塞的线程->生产者(如果有)
    }

    ~RingQueue()
    {
    }

private:
    std::vector<T> _rq; // 使用vector数组模拟实现环形队列
    int _cap;           // 环形队列的容量

    // 生产者的信号量
    Sem _blank_sem; // 生产者关注的是空位置的信号量(即空位置的个数)
    // 生产者所在位置(下标)
    int _p_step;

    // 消费者的信号量
    Sem _data_sem; // 消费者关注的是资源的信号量(即资源的个数)
    // 消费者所在位置(下标)
    int _c_step;

    // 维护多生产、多消费
    Mutex _c_mutex; // 维护消费者之间的互斥关系
    Mutex _p_mutex; // 维护生产者之间的互斥关系
};

#endif
  • RingQueue.hpp(LockGuard优化:自动解锁):
cpp 复制代码
#ifndef RINGQUEUE_HPP
#define RINGQUEUE_HPP

#include <iostream>
#include <vector>
#include "Sem.hpp"
#include "Mutex.hpp"

const int g_cap = 5;

template <class T>
class RingQueue
{
public:
    RingQueue(const int cap = g_cap)
        : _rq(cap), _cap(cap), _blank_sem(cap), _p_step(0), _data_sem(0), _c_step(0)
    {
    }

    // 优化:LockGuard
    void Equeue(const T &in)
    {
        // 生产者
        // 1、申请信号量->(空位置信号量--)
        // 如果当前_blank_sem值为0,则生产者再次调用sem_wait会被阻塞,
        // 直到信号量的值变为正数(由消费者使用_blank_sem调用V()增加)
        _blank_sem.P();
        {
            // _p_mutex.Lock();
            LockGuard lockguard(_p_mutex);
            // 2、生产
            _rq[_p_step] = in;
            // 3、更新下标
            _p_step++;
            // 4、维持环形特性(取余)
            _p_step %= _cap;
            // 5、通知消费者->(资源信号量++: 若存在一个阻塞的消费者则唤醒)
        }
        // 临界区代码块执行结束,lockguard自动销毁调用析构进行解锁
        //  _p_mutex.Unlock(); // 解锁
        _data_sem.V();
    }

    void Top(T *out)
    {
        // 消费者
        // 1、申请信号量->(资源信号量--)
        // 如果当前_data_sem值为0,则消费者再次调用sem_wait会被阻塞,
        // 直到信号量的值变为正数(由生产者使用_data_sem调用V()增加)
        _data_sem.P();
        {
            LockGuard lockguard(_c_mutex);
            // 2、消费
            *out = _rq[_c_step];
            // 3、更新下标
            _c_step++;
            // 4、维持环形特性(取余)
            _c_step %= _cap;
            // 5、通知生产者->(空位置信号量++: 若存在一个阻塞的生产者则唤醒)
        }
        _blank_sem.V();
        // sem_post不会阻塞线程。只是原子地增加信号量的值,
        // 并唤醒一个对应信号量(_blank_sem)因sem_wait而阻塞的线程->生产者(如果有)
    }

    ~RingQueue()
    {
    }

private:
    std::vector<T> _rq; // 使用vector数组模拟实现环形队列
    int _cap;           // 环形队列的容量

    // 生产者的信号量
    Sem _blank_sem; // 生产者关注的是空位置的信号量(即空位置的个数)
    // 生产者所在位置(下标)
    int _p_step;

    // 消费者的信号量
    Sem _data_sem; // 消费者关注的是资源的信号量(即资源的个数)
    // 消费者所在位置(下标)
    int _c_step;

    // 维护多生产、多消费
    Mutex _c_mutex; // 维护消费者之间的互斥关系
    Mutex _p_mutex; // 维护生产者之间的互斥关系
};

#endif
cpp 复制代码
#include "RingQueue.hpp"
#include <unistd.h>
#include <functional>

// Task -- 面向过程任务
// 任务类型:void() 函数类型
using task_t = std::function<void()>;

// 示例任务:打印执行该任务的线程名称
void Task()
{
    char name[64];
    pthread_getname_np(pthread_self(), name, sizeof(name));
    std::cout << "我是一个任务, 处理我的是: " << name << std::endl;
}

struct thread_data
{
    RingQueue<task_t> *_rq;
    std::string _name;
};

void *consumer(void *args)
{
    // RingQueue<task_t> *rq = static_cast<RingQueue<task_t> *>(args);
    thread_data *td = static_cast<thread_data *>(args);
    pthread_setname_np(pthread_self(), td->_name.c_str());
    while (true)
    {
        sleep(2);
        // 1、消费任务
        task_t t;
        td->_rq->Top(&t);
        // 2、处理任务
        t();
    }
}

void *productor(void *args)
{
    // RingQueue<task_t> *rq = static_cast<RingQueue<task_t> *>(args);
    thread_data *td = static_cast<thread_data *>(args);
    while (true)
    {
        // sleep(1);
        // 1、获取任务
        // std::cout << td->_name << " 生产了一个任务: " << std::endl;
        // 2、生产任务
        td->_rq->Equeue(Task);
    }
}

// 多生产多消费
int main()
{
    RingQueue<task_t> *rq = new RingQueue<task_t>();

    // 构建生产者和消费者
    // pthread_t c1;
    // pthread_t c2;
    // pthread_t p1;
    // pthread_t p2;
    // pthread_t p3;

    // pthread_create(&c1, nullptr, consumer, rq);
    // pthread_create(&c2, nullptr, consumer, rq);
    // pthread_create(&p1, nullptr, productor, rq);
    // pthread_create(&p2, nullptr, productor, rq);
    // pthread_create(&p3, nullptr, productor, rq);

    // pthread_join(c1, nullptr);
    // pthread_join(c2, nullptr);
    // pthread_join(p1, nullptr);
    // pthread_join(p2, nullptr);
    // pthread_join(p3, nullptr);

    pthread_t c[2], p[3];
    for (int i = 0; i < 2; i++)
    {
        thread_data *td = new thread_data();
        td->_rq = rq;
        char *name = new char[64];
        snprintf(name, 64, "c_thread-%d", i);
        td->_name = name;
        pthread_create(c + i, nullptr, consumer, td);
    }

    for (int i = 0; i < 3; i++)
    {
        thread_data *td = new thread_data();
        td->_rq = rq;
        char *name = new char[64];
        snprintf(name, 64, "p_thread-%d", i);
        td->_name = name;
        pthread_create(p + i, nullptr, productor, td);
    }

    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;
}

3.3 核心代码细节深度解读

3.3.1 为什么先执行 P 操作,再加锁?

这是面试高频考点,也是代码性能优化的核心:

  • 信号量的 P/V 操作是原子的系统调用,本身是线程安全的,无需加锁保护;
  • 先申请资源,再加锁,能大幅缩小锁的粒度,让锁仅保护「下标修改」这一极短的临界区;
  • 现实中的类比:先网上买票(P 操作预定资源 ),再去影院排队检票(加锁访问资源 ),而不是先排队再买票,大幅提升了并发效率。

因为我们是巧妙的借助了两个互斥锁将生产者之间和消费者之间进行隔离不影响对方的访问,所以其实加锁的位置是在 P 操作之前还是 P 操作之后只是影响了并发执行的效率但不会造成什么严重后果。

但是如果我们让生产者和消费者都共用一把锁 呢?这样生产者和消费者同一时间就只能存在一方执行代码,虽然这样设计本来就存在缺陷 因为我们希望生产者和消费者之间不要串行执行,不然就又变成上篇所讲解的阻塞队列那种情况了。但假设我们就是这样设计的,如果还选择先加锁,再执行 P 操作,不同于两个互斥锁,就会导致一个非常严重的问题------死锁。

所以这也是为什么我们选择先执行 P 操作再加锁的原因,因为不管是怎样设计,先执行 P 操作再加锁都不会存在问题。

3.3.2 为什么无需 while 循环判断条件

在条件变量 的实现中,我们必须用 while 循环判断队列空 / 满 ,防止伪唤醒问题;而在信号量实现中,完全不需要:

  • 信号量的 P 操作是严格原子的,只有当资源真正可用时,才会返回;
  • 不存在「伪唤醒」的情况,只要 P 操作成功返回,就一定存在有对应的资源可用,无需二次判断;
  • 这也是信号量相比条件变量的一大优势,代码更简洁,逻辑更安全。

3.3.3 单生产 / 消费场景的锁优化

在单生产者、单消费者场景下,_productor_mutex 和 _consumer_mutex两把锁可以完全省略:

  • 单生产者场景下,只有一个线程会修改 _productor_step,不存在竞争,无需加锁;
  • 单消费者场景下,只有一个线程会修改 _consumer_step,不存在竞争,无需加锁。

省略锁之后,单生产单消费场景下,代码完全无锁竞争,生产者和消费者可以 100% 并行执行,性能达到极致。

3.4 阻塞队列 VS 环形队列:两种生产消费模型对比

特性 阻塞队列(互斥锁 + 条件变量) 环形队列(POSIX 信号量)
并发性能 中等,生产消费必须竞争同一把全局锁,无法真正并行 极高,生产消费仅同角色竞争锁,绝大多数场景完全并行
代码复杂度 中等,需要处理条件判断、伪唤醒,while 循环校验 简洁,信号量天然处理条件判断 ,无需额外校验
内存使用 动态分配 ,队列长度可动态变化(push),内存占用灵活 固定容量 ,预分配数组内存,无频繁内存申请释放(直接数组上修改)
适用场景 任务量波动大、队列长度不固定的通用场景 高并发、低延迟要求的固定容量场景,如服务器异步任务处理、音视频帧缓存
多线程适配 天然支持多生产多消费,无需额外修改 支持多生产多消费,需增加两把同角色互斥锁

四、面试核心考点总结

  • POSIX 信号量和 SystemV 信号量的区别?

    • POSIX 信号量 更轻量,支持线程间和进程间同步,接口更简洁易用;
    • SystemV 信号量 是内核级对象,生命周期随内核,更适合跨进程的复杂同步场景;
    • 线程间同步优先使用 POSIX 信号量,这也是行业通用规范。
  • 信号量的 P/V 操作是原子的吗?为什么?

    • P/V 操作是完全原子的,由操作系统内核保证;
    • 因为信号量的修改涉及到多线程的竞争,内核会在执行P/V 操作时屏蔽中断 ,保证操作不会被线程调度打断,只有完成和未完成两种状态,不会出现中间态。
  • 基于环形队列的生产消费模型,为什么先 P 操作再加锁,而不是先加锁再 P 操作?

    • 先 P 操作再加锁,能大幅缩小锁的临界区,锁仅保护下标修改,提升并发性能;

    • 先加锁再 P 操作,会导致线程持有锁时阻塞,同角色其他线程完全无法执行,并发性能严重下降;

    • P 操作本身是原子的,无需加锁保护,先申请资源再访问资源,也符合现实中的资源使用逻辑。

  • 信号量实现的模型中,为什么不会出现伪唤醒问题?

    • 条件变量的唤醒是「无差别」的,也就是说可能由于代码问题导致无条件改变就直接进行唤醒,则唤醒后就必须重新判断条件是否满足,否则会出现伪唤醒;
    • 信号量的P/V 操作是和资源计数强绑定的,只有当资源真正可用时,P 操作才会返回,不存在无差别唤醒,因此不会出现伪唤醒。
  • 互斥锁可以用二元信号量实现,那二元信号量和互斥锁完全等价吗?

    • 不完全等价,核心区别在于所有权:
    • 互斥锁有严格的所有权,哪个线程加锁,就必须由哪个线程解锁;
    • 二元信号量没有所有权,一个线程执行 P 操作,另一个线程可以执行 V 操作释放。

结束语

POSIX 信号量是 Linux 线程同步中最强大的工具之一,它以资源计数器的形式,实现了比互斥锁 + 条件变量更细粒度、更高性能的同步控制。而基于环形队列的生产者消费者模型,更是把信号量的优势发挥到了极致,让生产和消费真正实现了并行执行,是高并发服务器开发中不可或缺的核心组件。

相关推荐
杨云龙UP19 分钟前
TDengine 3.4.2.8 Community 三节点三副本生产集群部署实战(DNode/MNode/taosAdapter/Explorer)
大数据·linux·运维·数据库·tdengine·时序库
向成科技1 小时前
XC3576H工控主板|深度适配Ubuntu 26.04 LTS,释放边缘AI与工业开发新潜能
linux·人工智能·ubuntu·机器人·硬件·主板·边缘ai
Fcy6482 小时前
Linux下 进程间关系与守护进程
linux·运维·服务器·守护进程
码农小韩2 小时前
Linux驱动理论(二)——Linux字符设备驱动
linux·嵌入式软件开发·linux操作系统·linux应用开发·linux驱动理论
well06122 小时前
Linux粘滞位与Makefile机制深度解析
linux·运维·服务器
再写一行代码就下班2 小时前
linux sh脚本在windows修改导致无法使用解决方式
java·linux·centos
额额额对了3 小时前
SPI通信
linux·c语言·汇编·单片机·嵌入式硬件·arm
子木HAPPY阳VIP3 小时前
Ubuntu 关闭防火墙操作步骤
linux·运维·ubuntu
王振超wzc3 小时前
嵌入式开发环境搭建--WM软件安装,Ubuntu操作系统安装
linux·运维·ubuntu
GeW5 小时前
制造强国底座:Linux与数据库驱动的新型工业基础设施
linux