Linux线程同步与互斥(三):线程同步深度解析之POSIX 信号量与环形队列生产者消费者模型,从原理到源码彻底吃透

一. 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操作:申请资源,信号量-1,无资源则阻塞
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.3.4 V 操作:发布 / 释放资源
cpp 复制代码
// 核心V操作:释放资源,信号量+1,有等待线程则唤醒
int sem_post(sem_t *sem);

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

1.4 信号量的 C++ 极简封装

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

cpp 复制代码
// Sem.hpp
#pragma once

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

// 信号量封装类:提供P/V操作的简单接口,封装POSIX信号量
class Sem
{
public:
    // 构造函数:初始化信号量,默认线程间共享
    // num: 信号量初始值(例如0表示无资源,正整数表示可用资源数)
    Sem(int num):_initNum(num)
    {
        sem_init(&sem, 0, _initNum);  // 第二个参数0表示在线程间共享(而非进程间)
    }
    
    // P操作:申请资源,若资源数<=0则阻塞等待
    void P()
    {
        sem_wait(&sem);
    }
    
    // V操作:释放资源,唤醒可能等待的线程
    void V()
    {
        sem_post(&sem);
    }
    
    // 析构函数:销毁信号量,释放内核资源
    ~Sem()
    {
        sem_destroy(&sem);
    }
private:
    int _initNum;   // 保存初始值(当前未使用,可用于调试)
    sem_t sem;      // 原生POSIX信号量
};

封装设计要点

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

二. 基于环形队列的生产者消费者模型核心原理(补充上篇博客的)

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

2.1 环形队列的基础特性

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

它的核心特性:

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

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

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

我们用「圆桌放苹果」的例子,就能轻松理解模型的执行规则:

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

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

  • 生产者不能套消费者超过一圈:否则会覆盖消费者还没消费的数据,造成数据丢失;
  • 消费者不能超过生产者:否则会读取到无效的空数据,造成程序异常;
  • 队列为空时,生产者先执行:此时没有数据可供消费,消费者必须阻塞等待生产者生产;
  • 队列为满时,消费者先执行:此时没有空闲位置可供生产,生产者必须阻塞等待消费者消费。
场景 状态判断条件 谁在等待? 核心原因
队列为空 Head == Tail 消费者等待 缓冲区中没有数据可读
队列为满 Tail + 1 == Head(逻辑上) 生产者等待 缓冲区中没有空位可写
中间状态 Head != Tail 并发执行 读写位置不同,无需互斥阻塞

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

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

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

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

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

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

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

✅️ 这里的设计精髓在于:生产者和消费者不再竞争同一把全局锁,生产者之间竞争自己的锁,消费者之间竞争自己的锁。在绝大多数场景下,生产者和消费者可以完全并行执行,只有同角色的线程之间才有锁竞争,并发性能相比阻塞队列模型有质的提升。
❓️还有一个先加锁还是先申请信号量的问题,我们放在后面写代码的时候再来更详细的看看吧!

三. 代码深度 解析:环形队列生产消费模型完整实现

我们基于上述原理,实现一个完整的、支持单 / 多生产消费、模板化的环形队列,逐行拆解代码设计与细节。我们要是只想看最终版本的可以看看v2和v3就行,两个都是同时支持单单和多多的,一个是用的系统的一个是用的我们自己封装的

3.1 单生产单消费(RingQueue_v1)

我们先引入我们自己之前封装的 Thread.hpp,Makefile我就不在这里展示了,后面的版本也不会展示,这个东西的话前面的博客中经常在写,大家自己搞定就好,或者这种简单的任务交给opencode来完成是没问题的,我们需要的是通过写关键代码理解知识点。

  • Thread.hpp
cpp 复制代码
#ifndef __THREAD_HPP
#define __THREAD_HPP

#include <iostream>
#include <string>
#include <functional>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/syscall.h>

// 线程执行的任务函数类型:void()
using func_t = std::function<void()>;

// 线程状态枚举
enum class TSTAYUS
{
    THREAD_NEW,      // 新建,尚未启动
    THREAD_RUNNING,  // 运行中
    THREAD_STOPPED,  // 已停止(被取消)
};

// 静态计数器,用于生成默认线程名,每次递增
// 这个是有点bug的
static int gunm = 1;

// 线程封装类
class Thread
{
private:
    // 获取进程PID,存入 _pid
    void get_pid()
    {
        _pid = getpid();
    }

    // 获取轻量级进程ID(LWP,即内核线程ID),存入 _lwid
    void get_lwid()
    {
        _lwid = syscall(SYS_gettid);
    }

    // 静态成员函数,作为pthread_create的线程入口函数
    // 参数 args 实际指向 Thread 对象自身
    static void* routine(void* args)
    {
        Thread* ts = static_cast<Thread*>(args);
        ts->get_pid();                // 获取进程PID
        ts->get_lwid();               // 获取线程LWP ID
        pthread_setname_np(pthread_self(), ts->Name().c_str()); // 设置线程名称
        ts->_func();                  // 执行用户任务函数
        return nullptr;
    }

public:
    // 构造函数:接受一个任务函数,初始化线程为 NEW 状态且可 join
    Thread(func_t f) : _func(f), _joinable(true), _status(TSTAYUS::THREAD_NEW)
    {
        _name = "thread-" + std::to_string(gunm++);
    }

    // 启动线程,创建内核线程执行 routine
    void start()
    {
        if(_status == TSTAYUS::THREAD_RUNNING)
        {
            std::cerr << "thread is already running" << std::endl;
            return;
        }
        int n = pthread_create(&_tid, nullptr, routine, this);
        if(n != 0)
        {
            std::cerr << "pthread_create failed" << std::endl;
        }
        _status = TSTAYUS::THREAD_RUNNING;
    }

    // 停止线程(取消线程)
    void stop()
    {
        if(_status == TSTAYUS::THREAD_RUNNING)
        {
            int n = pthread_cancel(_tid);
            if(n != 0)
            {
                std::cerr << "pthread_cancel failed" << std::endl;
            }
            _status = TSTAYUS::THREAD_STOPPED;
        }
        else 
        {
            std::cerr << "thread status is : THREAD_STOPPED or THREAD_NEW" << std::endl;
            return;
        }
    }

    // 等待线程结束,回收资源(仅当 joinable 时有效)
    void join()
    {
        if(_joinable)
        {
            int n = pthread_join(_tid, nullptr);
            if(n != 0)
            {
                std::cerr << "pthread_join failed" << std::endl;
            }
            printf("lwp: %d, name: %s, join success\n", _lwid, _name.c_str());
        }
        else {
            printf("lwp: %d, name: %s, join failed, because thread is detached\n", _lwid, _name.c_str());
        }
    }

    // 分离线程,使其退出时自动释放资源,不可再 join
    void detach()
    {
        if(_joinable && _status == TSTAYUS::THREAD_RUNNING)
        {
            _joinable = false;
            int n = pthread_detach(_tid);
            if(n != 0)
            {
                std::cerr << "pthread_detach failed" << std::endl;
            }
        }
    }

    // 获取线程名称
    std::string Name()
    {
        return _name;
    }

    // 析构函数(当前为空,无特殊资源需释放)
    ~Thread()
    {}

private:
    pthread_t _tid;      // POSIX 线程ID
    pid_t _pid;          // 进程PID
    pid_t _lwid;         // 轻量级进程ID(内核线程ID)
    std::string _name;   // 线程名称
    func_t _func;        // 用户任务函数
    bool _joinable;      // 是否可 join(未被分离)
    TSTAYUS _status;     // 线程当前状态
};

#endif
  • RingQueue.hpp(核心代码)
cpp 复制代码
#pragma once

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

const static int gdefaultcap = 5;

// 环形队列:支持单生产者单消费者的线程安全队列,基于信号量实现无锁并发
template<class T>
class RingQueue
{
public:
    // 构造函数:初始化向量、容量、读写下标,并初始化数据信号量和空间信号量
    RingQueue(int cap = gdefaultcap):_ringqueue(cap), _cap(cap), _c_step(0), _p_step(0)
    {
        // 初始化两个信号量
        sem_init(&_data_sem, 0, 0);      // 数据信号量初始为0,表示一开始没有数据可消费
        sem_init(&_space_sem, 0, cap);   // 空间信号量初始为cap,表示一开始有cap个空位可供生产
    }

    // 生产者入队接口
    void EnQueue(const T &in)
    {
        // 生产者
        sem_wait(&_space_sem); // 空间P()  --;   // 申请一个空闲位置,若无空位则阻塞

        _ringqueue[_p_step] = in;   // 将数据放入当前生产下标位置
        _p_step++;                  // 生产下标后移
        _p_step %= _cap;            // 模运算实现环形回绕

        sem_post(&_data_sem); // 数据V()  ++;   // 增加一个可用数据,唤醒可能阻塞的消费者
    }

    // 消费者出队接口
    void Pop(T *out)
    {
        // 消费者
        sem_wait(&_data_sem); // 数据P()  --;   // 申请一个数据,若无数据则阻塞

        *out = _ringqueue[_c_step]; // 从当前消费下标取出数据
        _c_step++;                  // 消费下标后移
        _c_step %= _cap;            // 环形回绕

        sem_post(&_space_sem); // 空间V()  ++;   // 释放一个空闲位置,唤醒可能阻塞的生产者
    }

    // 析构函数:销毁两个信号量,释放资源
    ~RingQueue()
    {
        // 销毁两个信号量
        sem_destroy(&_data_sem);
        sem_destroy(&_space_sem);
    }

private:
    std::vector<T> _ringqueue;  // 底层环形存储结构
    int _cap;                   // 队列容量(最大元素个数)

    // 两个下标
    int _c_step;    // 消费者下标,指示下一次从哪个位置取数据
    int _p_step;    // 生产者下标,指示下一次在哪个位置放数据

    // 两个信号量, 消费者关心数据, 生产者关心空间
    sem_t _data_sem;   // 数据信号量:值为当前队列中元素个数
    sem_t _space_sem;  // 空间信号量:值为当前队列中空闲位置个数

    // 两个锁 -- 单生产单消费者模型用不上
    // pthread_mutex_t _p_mutex;
    // pthread_mutex_t _c_mutex;
};
  • Main.cc
cpp 复制代码
#include <functional>
#include <iostream>
#include <memory>
#include <pthread.h>
#include "RingQueue.hpp"
#include "Thread.hpp"

// int 类型 -- 测试下两个场景
int main()
{
    // 1. 使用RingQueue -- 智能指针
    // 创建环形队列实例,使用 unique_ptr 自动管理内存
    std::unique_ptr<RingQueue<int>> ringqueue = std::make_unique<RingQueue<int>>();

    // 2. 创建线程, 把Lambda表达式用起来
    // 消费者线程:不断从环形队列中取出数据并打印
    Thread c([&ringqueue]{
        while(true)
        {
        	// sleep(1); // 让消费者慢一点(可根据需要取消注释)
            int data = 0;
            ringqueue->Pop(&data);                // 从队列取出数据,若无数据则阻塞等待
            std::cout << "消费了数据: " << data << std::endl;
        }
    });

    // 生产者线程:每隔1秒生产一个递增的数据放入环形队列
    Thread p([&ringqueue]{
        int data = 0;
        while(true)
        {
            sleep(1);                             // 让生产者慢一点
            ringqueue->EnQueue(data);             // 将数据放入队列,若无空闲位置则阻塞等待
            std::cout << "生产了数据: " << data << std::endl;
            data++;
        }
    });

    // 3. 启动线程
    c.start();   // 启动消费者线程
    p.start();   // 启动生产者线程

    // 4. 回收线程
    c.join();    // 等待消费者线程结束(本例中线程无限循环,需外部终止)
    p.join();    // 等待生产者线程结束

    return 0;
}

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

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

场景三:如果是我们的任务数据呢 -- 这个阻塞队列的时候写过了Task任务类(面向对象),不多说了,今天换个玩法(面向过程)。

cpp 复制代码
#include <functional>
#include <iostream>
#include <memory>
#include <pthread.h>
#include "RingQueue.hpp"
#include "Thread.hpp"

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

void Task()
{
    char name[64];
    pthread_getname_np(pthread_self(), name, sizeof(name));
    std::cout << "我是一个任务, 处理我的是: " << name << std::endl;
}

int main()
{
    // 1. 使用RingQueue -- 智能指针
    std::unique_ptr<RingQueue<task_t>> ringqueue = std::make_unique<RingQueue<task_t>>();

    // 2. 创建线程, 把Lambda表达式用起来
    Thread c([&ringqueue]{
        while(true)
        {
            task_t t;
            ringqueue->Pop(&t);

            // 处理任务
            t();
        }
    });
    Thread p([&ringqueue]{
        while(true)
        {
            sleep(1);
            ringqueue->EnQueue(Task);
            std::cout << "生产了任务" << std::endl;
        }
    });

    // 3. 启动线程
    c.start();
    p.start();

    // 4. 回收线程
    c.join();
    p.join();
    return 0;
} 

3.2 多生产多消费(RingQueue_v2)

跟上面一样的Thread.hpp就不再带一遍了

  • RingQueue.hpp
cpp 复制代码
#pragma once

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

const static int gdefaultcap = 5;

// 环形队列:支持多生产者多消费者的线程安全队列,使用信号量 + 互斥锁实现
template<class T>
class RingQueue
{
public:
    // 构造函数:初始化容量、下标、信号量和互斥锁
    RingQueue(int cap = gdefaultcap):_ringqueue(cap), _cap(cap), _c_step(0), _p_step(0)
    {
        // 初始化两个信号量
        sem_init(&_data_sem, 0, 0);      // 数据信号量:当前队列中元素个数,初始为0
        sem_init(&_space_sem, 0, cap);   // 空间信号量:当前空闲位置个数,初始为cap

        // 初始化两个锁
        pthread_mutex_init(&_c_mutex, nullptr);  // 保护消费者下标的互斥锁
        pthread_mutex_init(&_p_mutex, nullptr);  // 保护生产者下标的互斥锁
    }

    // 生产者入队接口
    void EnQueue(const T &in)
    {
        // 思考一个问题,是先加锁还是先信号量? -- 先信号量
        // 申请信号量是对资源的预定机制,买票;申请锁就是买票时候排队的过程,那我难道每次看电影还得先排队再买票啊,效率太低了。
        // 我们现实生活中现在都是网上买票,我想去看电影再去排队。别让其他线程闲着啊,先预定了资源再说呗,后面再一个个进来,这样是不是效率高点啊。
        // 这个的前提其实是因为我们的信号量PV操作是原子的
        // 生产者
        sem_wait(&_space_sem); // 空间P()  --;   // 申请一个空闲位置,若无空位则阻塞

        pthread_mutex_lock(&_p_mutex);           // 加锁保护生产下标,避免多生产者竞争
        _ringqueue[_p_step] = in;                // 将数据放入当前生产下标位置
        _p_step++;                               // 生产下标后移
        _p_step %= _cap;                         // 模运算实现环形回绕
        pthread_mutex_unlock(&_p_mutex);         // 解锁

        sem_post(&_data_sem); // 数据V()  ++;    // 增加一个可用数据,唤醒可能阻塞的消费者
    }

    // 消费者出队接口
    void Pop(T *out)
    {
        // 消费者
        sem_wait(&_data_sem); // 数据P()  --;    // 申请一个数据,若无数据则阻塞

        pthread_mutex_lock(&_c_mutex);           // 加锁保护消费下标,避免多消费者竞争
        *out = _ringqueue[_c_step];              // 从当前消费下标取出数据
        _c_step++;                               // 消费下标后移
        _c_step %= _cap;                         // 环形回绕
        pthread_mutex_unlock(&_c_mutex);         // 解锁

        sem_post(&_space_sem); // 空间V()  ++;   // 释放一个空闲位置,唤醒可能阻塞的生产者
    }

    // 析构函数:销毁信号量和互斥锁,释放资源
    ~RingQueue()
    {
        // 销毁两个信号量
        sem_destroy(&_data_sem);
        sem_destroy(&_space_sem);

        // 销毁两个锁
        pthread_mutex_destroy(&_c_mutex);
        pthread_mutex_destroy(&_p_mutex);
    }

private:
    std::vector<T> _ringqueue;  // 底层环形存储结构
    int _cap;                   // 队列容量(最大元素个数)

    // 两个下标
    int _c_step;    // 消费者下标,指示下一次从哪个位置取数据
    int _p_step;    // 生产者下标,指示下一次在哪个位置放数据

    // 两个信号量, 消费者关心数据, 生产者关心空间
    sem_t _data_sem;   // 数据信号量:值为当前队列中元素个数
    sem_t _space_sem;  // 空间信号量:值为当前队列中空闲位置个数

    // 两个锁 -- 单生产单消费者模型用不上
    // 但是多生产者多消费者模型用的上
    pthread_mutex_t _p_mutex;  // 保护生产者下标 _p_step 的互斥锁
    pthread_mutex_t _c_mutex;  // 保护消费者下标 _c_step 的互斥锁
};
  • main.cc(在上面的基础上我们Lambda的形式变化了下,更好复用回调)
cpp 复制代码
#include <functional>
#include <iostream>
#include <memory>
#include <pthread.h>
#include <vector>
#include "RingQueue.hpp"
#include "Thread.hpp"

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

// 多生产者多消费者
int main()
{
    // 1. 使用RingQueue -- 智能指针
    // 创建环形队列,存储元素类型为 task_t(即任务函数)
    std::unique_ptr<RingQueue<task_t>> ringqueue = std::make_unique<RingQueue<task_t>>();

    // 消费者回调函数:不断从队列中取出任务并执行
    auto Consumer_cb = [&ringqueue]{
        while(true)
        {
            task_t t;
            ringqueue->Pop(&t);   // 阻塞等待任务到来

            // 处理任务
            t();                  // 执行取出的任务
        }
    };

    // 生产者回调函数:每隔1秒生产一个任务(Task)放入队列
    auto Productor_cb = [&ringqueue]{
        while(true)
        {
            sleep(1);                     // 控制生产速度
            ringqueue->EnQueue(Task);     // 将 Task 任务入队
            // 防止混乱,我们这里就不打印生产者的这个了
            // std::cout << "生产了任务" << std::endl;
        }
    };
    // 2. 创建多个线程
    // 创建3个消费者线程,每个线程执行相同的 Consumer_cb
    Thread c1(Consumer_cb);
    Thread c2(Consumer_cb);
    Thread c3(Consumer_cb);

    // 创建3个生产者线程,每个线程执行相同的 Productor_cb
    Thread p1(Productor_cb);
    Thread p2(Productor_cb);
    Thread p3(Productor_cb);

    // 3. 启动线程
    c1.start();
    c2.start();
    c3.start();
    p1.start();
    p2.start();
    p3.start();

    // 4. 回收线程
    // 等待所有线程结束(本例中线程无限循环,需外部终止)
    c1.join();
    c2.join();
    c3.join();
    p1.join();
    p2.join();
    p3.join();

    return 0;
}

核心业务价值

  • 任务的生产和消费完全解耦,生产者只需要发布任务,无需关心任务由谁执行、何时执行;
  • 真正的耗时业务逻辑(任务执行)在锁的临界区之外,完全并行执行,多核 CPU 的性能被充分利用;
  • 即使出现任务突增,也会被缓存在环形队列中,消费者线程平稳处理,避免了线程频繁创建销毁的开销,这也是线程池的核心设计思想。
  • 为了防止打印出来混乱,我们注释掉了生产者的打印

3.3 核心代码细节深度解读

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

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

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

如果先加锁再执行 P 操作,会导致线程在持有锁的情况下阻塞,其他同角色线程完全无法执行,并发性能会退化为和阻塞队列一致,完全失去了环形队列的优势。

3.3.2 为什么无需 while 循环判断条件(临界区为什么没有判断,站在更高视角去理解信号量,互斥锁和并发模型)?

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

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

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

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

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

3.4 利用自己封装信号量和互斥锁的来实现(RingQueue_v3)

除了Thread.hpp以外,我们还使用我们自己封装的Sem.hppMutex.hpp(Mutex.hpp这篇文章上文中没出现,我们就把这个也贴上,其他两个都出现过了自己看)

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

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

// 互斥锁封装类:提供加锁/解锁及获取原始锁的接口
class Mutex
{
public:
    // 构造函数:初始化互斥锁
    Mutex()
    {
        pthread_mutex_init(&_lock, nullptr);
    }
    // 析构函数:销毁互斥锁
    ~Mutex()
    {
        pthread_mutex_destroy(&_lock);
    }
    // 加锁操作
    void Lock()
    {
        pthread_mutex_lock(&_lock);
    }
    // 解锁操作
    void UnLock()
    {
        pthread_mutex_unlock(&_lock);
    }
    // 获取原始互斥锁指针,用于需要原生 pthread_mutex_t 的接口
    pthread_mutex_t* Origin()
    {
        return &_lock;
    }
private:
    pthread_mutex_t _lock;  // POSIX 互斥锁
};

// RAII 风格的锁守卫类:构造时加锁,析构时解锁,自动管理锁的生命周期
class LockGuard
{
public:
    // 构造函数:接收一个 Mutex 指针,并立即加锁
    LockGuard(Mutex* lockptr) : _lockptr(lockptr)
    {
        _lockptr->Lock();
    }
    // 析构函数:自动解锁
    ~LockGuard()
    {
        _lockptr->UnLock();
    }
private:
    Mutex* _lockptr;  // 指向被管理的互斥锁
};

#endif
  • RingQueue.hpp
cpp 复制代码
#pragma once

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

const static int gdefaultcap = 5;

// 环形队列:支持多生产者多消费者,使用信号量 + 互斥锁(RAII锁守卫)实现
template<class T>
class RingQueue
{
public:
    // 构造函数:初始化底层向量、容量、读写下标,并初始化两个信号量(数据信号量初始0,空间信号量初始为cap)
    RingQueue(int cap = gdefaultcap): _ringqueue(cap), _cap(cap), _c_step(0), _p_step(0)
                                    , _data_sem(0), _space_sem(cap) // 用我们自己的就得在这里初始化一下了,有个参数
    {

    }

    // 生产者入队接口
    void EnQueue(const T &in)
    {
        // 思考一个问题,是先加锁还是先信号量? -- 先信号量
        // 申请信号量是对资源的预定机制,买票;申请锁就是买票时候排队的过程,那我难道每次看电影还得先排队再买票啊,效率太低了。
        // 我们现实生活中现在都是网上买票,我想去看电影再去排队。别让其他线程闲着啊,先预定了资源再说呗,后面再一个个进来,这样是不是效率高点啊。
        // 这个的前提其实是因为我们的信号量PV操作是原子的
        // 生产者
        _space_sem.P(); // 空间P()  --;   // 申请一个空闲位置,若无空位则阻塞

        {
            LockGuard pLockGuard(&_p_mutex);  // RAII加锁,离开作用域自动解锁
            _ringqueue[_p_step] = in;         // 将数据放入当前生产下标位置
            _p_step++;                        // 生产下标后移
            _p_step %= _cap;                  // 模运算实现环形回绕
        }   // 此处 pLockGuard 析构,自动释放 _p_mutex

        _data_sem.V(); // 数据V()  ++;    // 增加一个可用数据,唤醒可能阻塞的消费者
    }

    // 消费者出队接口
    void Pop(T *out)
    {
        // 消费者
        _data_sem.P(); // 数据P()  --;    // 申请一个数据,若无数据则阻塞

       // 把这个区域直接括起来
       {
            LockGuard cLockGuard(&_c_mutex);  // RAII加锁,离开作用域自动解锁
            *out = _ringqueue[_c_step];       // 从当前消费下标取出数据
            _c_step++;                        // 消费下标后移
            _c_step %= _cap;                  // 环形回绕
       }   // 此处 cLockGuard 析构,自动释放 _c_mutex

        _space_sem.V(); // 空间V()  ++;    // 释放一个空闲位置,唤醒可能阻塞的生产者
    }

    // 析构函数:信号量和互斥锁的释放由 Sem 和 Mutex 的析构函数自动完成
    ~RingQueue()
    {}

private:
    std::vector<T> _ringqueue;  // 底层环形存储结构
    int _cap;                   // 队列容量(最大元素个数)

    // 两个下标
    int _c_step;    // 消费者下标,指示下一次从哪个位置取数据
    int _p_step;    // 生产者下标,指示下一次在哪个位置放数据

    // 两个信号量, 消费者关心数据, 生产者关心空间
    Sem _data_sem;   // 数据信号量:值为当前队列中元素个数
    Sem _space_sem;  // 空间信号量:值为当前队列中空闲位置个数

    // 两个锁 -- 单生产单消费者模型用不上
    // 但是多生产者多消费者模型用的上
    Mutex _p_mutex;  // 保护生产者下标 _p_step 的互斥锁
    Mutex _c_mutex;  // 保护消费者下标 _c_step 的互斥锁
};
  • Main.cc(在上面的基础上变成了利用循环创建和回收线程)
cpp 复制代码
#include <functional>
#include <iostream>
#include <memory>
#include <pthread.h>
#include <vector>
#include "RingQueue.hpp"
#include "Thread.hpp"

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

// 多生产者多消费者
int main()
{
    // 1. 使用RingQueue -- 智能指针
    // 创建存储 task_t 类型的环形队列
    std::unique_ptr<RingQueue<task_t>> ringqueue = std::make_unique<RingQueue<task_t>>();

    // 消费者回调函数:从队列中取出任务并执行
    auto Consumer_cb = [&ringqueue]{
        while(true)
        {
            task_t t;
            ringqueue->Pop(&t);   // 阻塞等待任务

            // 处理任务
            t();                  // 执行取出的任务
        }
    };

    // 生产者回调函数:每隔1秒生产一个 Task 任务放入队列
    auto Productor_cb = [&ringqueue]{
        while(true)
        {
            sleep(1);                     // 控制生产速度
            ringqueue->EnQueue(Task);     // 将 Task 任务入队
            // 防止混乱,我们这里就不打印生产者的这个了
            // std::cout << "生产了任务" << std::endl;
        }
    };

    // 2. 创建多个线程 -- 这里也优化一下
    const int c_count = 3;   // 消费者线程数量
    const int p_count = 3;   // 生产者线程数量
    std::vector<Thread> ct;   // 存放消费者线程对象
    std::vector<Thread> pt;   // 存放生产者线程对象

    // 创建消费者线程
    for(int i = 0; i < c_count; i++)
    {
        Thread c(Consumer_cb);
        ct.push_back(c);
    }
    // 创建生产者线程
    for(int i = 0; i < p_count; i++)
    {
        Thread p(Productor_cb);
        pt.push_back(p);
    }

    // 3. 启动线程
    // 启动所有消费者线程
    for(auto& c : ct)
    {
        c.start();
    }
    // 启动所有生产者线程
    for(auto& p : pt)
    {
        p.start();
    }

    // 4. 回收线程
    // 等待所有生产者线程结束(本例中无限循环,需外部终止)
    for(auto& p : pt)
    {
        p.join();
    }
    // 注意:下面本应是等待消费者线程结束,但误写为 pt,应改为 ct
    // 用户要求不修改代码,故保留原样并在此注释说明
    for(auto& p : pt)   // 此处应为 ct(消费者线程)
    {
        p.join();
    }

    return 0;
}

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

我们把本文的环形队列模型,和之前的条件变量阻塞队列模型做一个全面对比,帮你在开发中选择最合适的方案:

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

五. 面试核心考点提炼

这部分内容是 Linux 后端开发面试的高频考点,我帮你提炼出最核心的问答要点,面试前可以直接复习:

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

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

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

    • 先 P 操作再加锁,能大幅缩小锁的临界区,锁仅保护下标修改,提升并发性能;
    • 先加锁再 P 操作,会导致线程持有锁时阻塞,同角色其他线程完全无法执行,并发性能严重下降;
    • P 操作本身是原子的,无需加锁保护,先申请资源再访问资源,也符合现实中的资源使用逻辑。
  • 信号量实现的模型中,为什么不会出现伪唤醒问题?

    • 条件变量的唤醒是「无差别」的,唤醒后需要重新判断条件是否满足,否则会出现伪唤醒;
    • 信号量的 P/V 操作是和资源计数器绑定的,只有当资源真正可用时,P 操作才会返回,不存在无差别唤醒,因此不会出现伪唤醒。
  • 互斥锁可以用二元信号量实现,那二元信号量和互斥锁完全等价吗?

    • 不完全等价,核心区别在于所有权:
    • 互斥锁有严格的所有权,哪个线程加锁,就必须由哪个线程解锁;
    • 二元信号量没有所有权,一个线程执行 P 操作,另一个线程可以执行 V 操作释放。
相关推荐
2zcode2 小时前
基于MATLAB元胞自动机(CA)的AZ80A镁合金动态再结晶(DRX)过程模拟
开发语言·matlab·动态再结晶
iCxhust2 小时前
微机原理实践教程(C语言篇)---A001闪烁灯
c语言·开发语言·汇编·单片机·嵌入式硬件·51单片机·微机原理
MATLAB代码顾问3 小时前
Python实现蜂群算法优化TSP问题
开发语言·python·算法
jinanwuhuaguo3 小时前
(第三十三篇)五月的文明奠基:OpenClaw 2026.5.2版本的文明级解读
android·java·开发语言·人工智能·github·拓扑学·openclaw
xmjd msup3 小时前
spring security 超详细使用教程(接入springboot、前后端分离)
java·spring boot·spring
952364 小时前
SpringBoot统一功能处理
java·spring boot·后端
有一个好名字4 小时前
工具即双手 —— 从 Bash 到 Tool Dispatch Map
开发语言·chrome·bash
Lyyaoo.4 小时前
优惠券秒杀业务分析
java·开发语言
消失的旧时光-19434 小时前
统一并发模型:线程、Reactor、协程本质是一件事(从线程到协程 · 第6篇·终章)
java·python·算法