首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >【Linux系统编程】(四十四)线程同步下篇:条件变量深度解析与 POSIX 信号量实战

【Linux系统编程】(四十四)线程同步下篇:条件变量深度解析与 POSIX 信号量实战

作者头像
_OP_CHEN
发布2026-03-11 08:43:37
发布2026-03-11 08:43:37
450
举报
文章被收录于专栏:C++C++

目录

前言

一、灵魂拷问:pthread_cond_wait 为何必须绑定互斥量?

1.1 条件变量的核心作用:线程间的状态通知

1.2 无互斥量的致命问题:错过信号与永久阻塞

1.3 互斥量的核心价值:让 “解锁 + 等待” 成为原子操作

1.4 再挖一层:条件判断为何必须保护?

二、避坑指南:条件变量的标准化使用规范

2.1 等待条件的规范写法:while 判断,而非 if 判断

错误写法:if 判断条件

正确写法:while 循环判断条件

2.2 核心原因:处理 “伪唤醒” 问题

2.3 等待条件的完整标准化模板

2.4 发送信号的标准化模板

2.5 信号选择:signal 还是 broadcast?

三、优雅封装:C++ 实现可复用的条件变量类

3.1 前置准备:已封装的互斥量类 Lock.hpp

3.2 条件变量封装:Cond.hpp

3.3 封装的核心亮点解析

3.4 封装后的使用示例

四、另一种同步方案:POSIX 信号量详解与实战

4.1 POSIX 信号量的核心概念

4.2 POSIX 信号量的核心 API

4.2.1 初始化信号量:sem_init

4.2.2 销毁信号量:sem_destroy

4.2.3 P 操作(等待):sem_wai

4.2.4 V 操作(发布):sem_post

4.3 C++ 封装 POSIX 信号量:简洁易用的 Sem 类

4.4 实战:基于 POSIX 信号量实现环形队列的生产者消费者模型

4.4.1 环形队列封装:RingQueue.hpp

4.4.2 生产者消费者测试代码:ringqueue_test.cpp

4.4.3 编译运行与结果分析

五、条件变量与 POSIX 信号量的对比与选型建议

5.1 核心对比

5.2 选型建议

总结


哈喽,各位 C/C++ 开发者小伙伴们!上篇我们聊了线程互斥和条件变量的基础使用,相信大家已经对线程同步有了初步的认知。但实际开发中,光会用 API 远远不够,为什么 pthread_cond_wait 必须搭配互斥量条件变量的使用有哪些避坑规范如何优雅封装条件变量POSIX 信号量又该如何实现高效的线程同步?这些都是大家在实际开发中一定会遇到的核心问题。 今天这篇线程同步下篇,就带大家把这些问题彻底吃透,从原理层面刨根问底,再到实战层面封装实现,最后结合 POSIX 信号量实现环形队列的生产消费模型,全程干货满满,建议收藏反复研读!下面就让我们正式开始吧!


一、灵魂拷问:pthread_cond_wait 为何必须绑定互斥量?

用过条件变量的小伙伴都知道,pthread_cond_wait的函数声明是int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);,第二个参数强制要求传入互斥量。很多人一开始只是机械使用,却不知道背后的底层逻辑,这就很容易写出 bug 代码。今天我们就把这个问题讲透,从线程同步的本质竞态条件的规避两个角度分析。

1.1 条件变量的核心作用:线程间的状态通知

条件变量是实现线程同步的核心机制之一,同步的本质是在保证数据安全的前提下,让线程按特定顺序访问临界资源。简单来说,条件变量解决的是 “线程等待某个条件满足,直到其他线程通知它条件成立” 的问题。

比如生产者消费者模型中,消费者线程发现队列为空时,需要等待生产者生产数据;生产者生产完数据后,需要通知消费者来消费。这里的 “队列非空” 就是条件,条件变量就是实现这种 “等待 - 通知” 机制的载体。

但问题来了:条件的判断和等待操作,并不是原子的,而条件的变化必然牵扯到共享资源(比如队列的元素个数)的修改,这就需要互斥量来保护共享资源。

1.2 无互斥量的致命问题:错过信号与永久阻塞

我们不妨大胆假设一下:如果pthread_cond_wait不绑定互斥量,我们会怎么写代码?大概率会是这样的错误写法:

代码语言:javascript
复制
// 错误示范:无互斥量的条件等待
pthread_mutex_lock(&mutex);
while (queue.empty()) { // 判断条件:队列为空
    pthread_mutex_unlock(&mutex); // 解锁后等待
    pthread_cond_wait(&cond, NULL); // 假设可以不传互斥量
    pthread_mutex_lock(&mutex); // 被唤醒后重新加锁
}
// 处理队列数据
pthread_mutex_unlock(&mutex);

看似合理的代码,却隐藏着致命的竞态条件解锁操作和等待操作之间,存在时间窗口

具体来说,当消费者线程执行完pthread_mutex_unlock(&mutex)后,还没来得及执行pthread_cond_wait(&cond, NULL)时,操作系统发生线程调度,切换到生产者线程。生产者线程生产了数据,调用pthread_cond_signal(&cond)发送通知信号。但此时消费者线程还没有进入等待状态,这个信号就被永久错过了

当消费者线程再次被调度,执行pthread_cond_wait时,会一直等待永远不会到来的信号,最终导致线程永久阻塞,这是多线程开发中典型的 “信号丢失” 问题。

1.3 互斥量的核心价值:让 “解锁 + 等待” 成为原子操作

pthread_cond_wait设计时强制绑定互斥量,本质是为了将 “释放互斥量” 和 “进入等待状态” 封装成一个原子操作,从根本上规避上述的竞态条件。

当调用pthread_cond_wait(cond, mutex)时,函数内部会自动完成三个核心操作(原子执行):

  1. 释放传入的互斥量mutex
  2. 将当前线程挂起,加入条件变量cond的等待队列;
  3. 当线程被pthread_cond_signal/broadcast唤醒后,自动重新竞争互斥量,竞争成功后函数才会返回。

这个原子操作的过程,保证了线程要么持有互斥量并判断条件,要么处于等待状态并释放互斥量,不会出现 “解锁后、等待前” 的时间窗口,彻底避免了信号丢失的问题。

1.4 再挖一层:条件判断为何必须保护?

除了避免信号丢失,互斥量的另一个核心作用是保护条件判断的共享资源

条件变量的等待条件,本质是对共享资源状态的判断(比如队列是否为空、计数器是否为 0)。这些共享资源是多个线程共同操作的,必须通过互斥量保证访问的原子性。如果没有互斥量保护,多个线程同时判断和修改共享资源,会导致条件判断的结果失真。

比如多个消费者线程同时判断queue.empty(),如果没有互斥量,可能出现多个线程都判断队列为空并进入等待,而生产者只生产了一个数据,却唤醒了多个线程,导致后续的队列操作出现越界等问题。

总结一下:pthread_cond_wait 绑定互斥量,既是为了让 “解锁 + 等待” 原子化,避免信号丢失,也是为了保护条件判断的共享资源,保证条件判断的准确性。这两个原因,决定了互斥量是条件变量的 “标配”,缺一不可。

二、避坑指南:条件变量的标准化使用规范

理解了pthread_cond_wait与互斥量的绑定关系后,接下来的关键是如何正确使用条件变量。实际开发中,很多小伙伴因为使用不规范,写出了存在伪唤醒条件判断失效的 bug 代码。接下来就给大家总结条件变量的标准化使用规范,记住这些规则,能避开 99% 的坑!

2.1 等待条件的规范写法:while 判断,而非 if 判断

这是条件变量使用中最重要、最容易踩坑的点:判断等待条件时,必须使用 while 循环,而不能使用 if 语句

错误写法:if 判断条件
代码语言:javascript
复制
// 错误:if判断,存在伪唤醒风险
pthread_mutex_lock(&mutex);
if (queue.empty()) { // 单次判断
    pthread_cond_wait(&cond, &mutex);
}
// 处理数据
pthread_mutex_unlock(&mutex);
正确写法:while 循环判断条件
代码语言:javascript
复制
// 正确:while循环,重新判断条件
pthread_mutex_lock(&mutex);
while (queue.empty()) { // 循环判断
    pthread_cond_wait(&cond, &mutex);
}
// 处理数据
pthread_mutex_unlock(&mutex);

2.2 核心原因:处理 “伪唤醒” 问题

所谓伪唤醒(Spurious Wakeup),是指线程在没有被pthread_cond_signal/broadcast显式唤醒的情况下,从pthread_cond_wait中返回。这种情况在 Linux 等系统中是允许的,原因可能是操作系统的调度策略、信号中断等。

如果使用if单次判断,线程被伪唤醒后,会直接跳过条件判断,执行后续的临界区代码。但此时条件其实并没有满足(比如队列还是空的),直接操作共享资源会导致数据越界、逻辑错误等严重问题。

而使用while循环判断,线程被唤醒后(无论是显式唤醒还是伪唤醒),会重新检查条件是否满足:如果条件不满足,会再次调用pthread_cond_wait进入等待状态;只有条件满足时,才会退出循环执行后续代码。这就从根本上解决了伪唤醒的问题。

2.3 等待条件的完整标准化模板

结合互斥量的使用和 while 循环判断,等待条件的标准化代码模板如下,大家可以直接套用:

代码语言:javascript
复制
// 等待条件的标准模板
pthread_mutex_lock(&mutex); // 1. 加锁保护共享资源
while (条件为假) { // 2. 循环判断条件,处理伪唤醒
    pthread_cond_wait(&cond, &mutex); // 3. 原子解锁+等待
}
// 4. 条件为真,操作临界资源
// ... 处理共享资源的代码 ...
pthread_mutex_unlock(&mutex); // 5. 解锁

这个模板的核心是 “加锁 - 循环判断 - 等待 - 操作 - 解锁”,环环相扣,缺一不可。

2.4 发送信号的标准化模板

除了等待条件,发送信号(pthread_cond_signal/broadcast)的使用也有规范,核心原则是:修改共享资源后,在互斥量的保护下发送信号

发送信号的标准化模板:

代码语言:javascript
复制
// 发送信号的标准模板
pthread_mutex_lock(&mutex); // 1. 加锁保护共享资源
// 2. 修改共享资源,使等待条件变为真
// ... 修改共享资源的代码 ...
pthread_cond_signal(&cond); // 3. 发送信号,唤醒等待线程
// 也可以用pthread_cond_broadcast(&cond)唤醒所有等待线程
pthread_mutex_unlock(&mutex); // 4. 解锁

为什么要在互斥量保护下发送信号?原因是保证共享资源的修改信号的发送是有序的。如果先解锁再发送信号,可能出现解锁后其他线程抢先修改共享资源,导致信号发送的时机滞后,进而出现线程唤醒后条件又变为假的情况。在互斥量保护下发送信号,能保证线程被唤醒时,共享资源的状态已经被正确修改。

2.5 信号选择:signal 还是 broadcast?

实际开发中,我们需要根据场景选择pthread_cond_signal(唤醒一个等待线程)还是pthread_cond_broadcast(唤醒所有等待线程):

  1. pthread_cond_signal:适用于只有一个线程的条件会被满足的场景,比如单生产者单消费者模型。优点是开销小,不会唤醒多余的线程;
  2. pthread_cond_broadcast:适用于多个线程的条件可能被满足的场景,比如多生产者多消费者模型、线程池中的任务通知。缺点是可能唤醒多余的线程,但通过 while 循环判断条件,多余的线程会重新进入等待,不影响逻辑正确性。

总结:如果不确定用哪种,优先使用pthread_cond_broadcast,虽然开销稍大,但能保证逻辑的正确性,避免因漏唤醒导致的线程阻塞问题。

三、优雅封装:C++ 实现可复用的条件变量类

在 C++ 开发中,我们不会一直直接使用 POSIX 的 C 语言 API,而是会将互斥量条件变量进行面向对象的封装,实现可复用、易维护的工具类。这样做的好处是:

  1. 避免手动管理锁的创建和销毁,减少内存泄漏;
  2. 利用 RAII 机制,自动加锁解锁,避免忘记解锁导致的死锁;
  3. 封装后接口更简洁,降低使用成本。

接下来,我们就基于之前封装的互斥量类,实现一个通用、安全、可复用的条件变量类,全程使用 C++ 实现,兼容 C++11 及以上版本。

3.1 前置准备:已封装的互斥量类 Lock.hpp

首先,我们需要一个已经封装好的互斥量类,包含锁的创建、销毁、加锁、解锁功能,并且禁用拷贝和赋值(避免多个对象操作同一个锁)。这里使用我们之前封装的Mutex类和 RAII 风格的LockGuard类,代码如下:

代码语言:javascript
复制
// Lock.hpp 互斥量封装
#pragma once
#include <iostream>
#include <pthread.h>

namespace LockModule
{
    // 互斥量基础类
    class Mutex
    {
    public:
        // 禁用拷贝和赋值
        Mutex(const Mutex &) = delete;
        const Mutex &operator=(const Mutex &) = delete;

        // 构造函数:初始化互斥量
        Mutex()
        {
            int ret = pthread_mutex_init(&_mutex, nullptr);
            if (ret != 0)
            {
                std::cerr << "pthread_mutex_init error!" << std::endl;
                exit(EXIT_FAILURE);
            }
        }

        // 加锁
        void Lock()
        {
            int ret = pthread_mutex_lock(&_mutex);
            if (ret != 0)
            {
                std::cerr << "pthread_mutex_lock error!" << std::endl;
                exit(EXIT_FAILURE);
            }
        }

        // 解锁
        void Unlock()
        {
            int ret = pthread_mutex_unlock(&_mutex);
            if (ret != 0)
            {
                std::cerr << "pthread_mutex_unlock error!" << std::endl;
                exit(EXIT_FAILURE);
            }
        }

        // 获取原生互斥量指针,供条件变量使用
        pthread_mutex_t *GetMutexOriginal()
        {
            return &_mutex;
        }

        // 析构函数:销毁互斥量
        ~Mutex()
        {
            pthread_mutex_destroy(&_mutex);
        }

    private:
        pthread_mutex_t _mutex; // 原生POSIX互斥量
    };

    // RAII风格的锁守卫:自动加锁解锁
    class LockGuard
    {
    public:
        // 构造函数:加锁
        explicit LockGuard(Mutex &mutex) : _mutex(mutex)
        {
            _mutex.Lock();
        }

        // 析构函数:解锁
        ~LockGuard()
        {
            _mutex.Unlock();
        }

        // 禁用拷贝和赋值
        LockGuard(const LockGuard &) = delete;
        const LockGuard &operator=(const LockGuard &) = delete;

    private:
        Mutex &_mutex; // 引用互斥量,避免拷贝
    };
}

这个互斥量封装的核心点:

  1. 构造函数初始化互斥量,析构函数销毁互斥量,实现资源的自动管理;
  2. 禁用拷贝和赋值,避免多个Mutex对象操作同一个底层互斥量;
  3. LockGuard利用 RAII 机制,构造加锁、析构解锁,彻底避免忘记解锁的问题;
  4. 提供GetMutexOriginal方法,返回原生互斥量指针,供条件变量绑定使用。

3.2 条件变量封装:Cond.hpp

基于上面的Mutex类,我们封装条件变量类Cond,核心要求:

  1. 封装 POSIX 条件变量的创建、销毁、等待、唤醒接口;
  2. 等待接口需要接收Mutex对象,实现与互斥量的绑定;
  3. 提供Wait(等待)、Notify(唤醒一个)、NotifyAll(唤醒所有)三个核心接口;
  4. 禁用拷贝和赋值,保证对象的唯一性。

完整的Cond.hpp代码如下:

代码语言:javascript
复制
// Cond.hpp 条件变量封装
#pragma once
#include <iostream>
#include <pthread.h>
#include "Lock.hpp" // 引入封装的互斥量类

namespace CondModule
{
    // 引入互斥量的命名空间
    using namespace LockModule;

    // 条件变量类
    class Cond
    {
    public:
        // 禁用拷贝和赋值
        Cond(const Cond &) = delete;
        const Cond &operator=(const Cond &) = delete;

        // 构造函数:初始化条件变量
        Cond()
        {
            int ret = pthread_cond_init(&_cond, nullptr);
            if (ret != 0)
            {
                std::cerr << "pthread_cond_init error!" << std::endl;
                exit(EXIT_FAILURE);
            }
        }

        // 等待接口:绑定互斥量,原子解锁+等待
        void Wait(Mutex &mutex)
        {
            // 传入原生互斥量指针
            int ret = pthread_cond_wait(&_cond, mutex.GetMutexOriginal());
            if (ret != 0)
            {
                std::cerr << "pthread_cond_wait error!" << std::endl;
                exit(EXIT_FAILURE);
            }
        }

        // 唤醒一个等待线程
        void Notify()
        {
            int ret = pthread_cond_signal(&_cond);
            if (ret != 0)
            {
                std::cerr << "pthread_cond_signal error!" << std::endl;
                exit(EXIT_FAILURE);
            }
        }

        // 唤醒所有等待线程
        void NotifyAll()
        {
            int ret = pthread_cond_broadcast(&_cond);
            if (ret != 0)
            {
                std::cerr << "pthread_cond_broadcast error!" << std::endl;
                exit(EXIT_FAILURE);
            }
        }

        // 析构函数:销毁条件变量
        ~Cond()
        {
            pthread_cond_destroy(&_cond);
        }

    private:
        pthread_cond_t _cond; // 原生POSIX条件变量
    };
}

3.3 封装的核心亮点解析

  1. 解耦设计Cond类内部不持有Mutex对象,而是通过Wait接口的参数接收Mutex对象。这样做的好处是一个条件变量可以绑定多个互斥量,也可以一个互斥量绑定多个条件变量,提高了类的通用性,避免了代码耦合。
  2. 异常处理:对 POSIX API 的返回值进行判断,若调用失败则直接退出程序,避免后续的错误传播。实际开发中,也可以根据需求修改为抛异常,适配不同的错误处理策略。
  3. 接口简洁:对外只暴露WaitNotifyNotifyAll三个核心接口,隐藏了底层的 POSIX API 细节,使用者无需关心底层实现,降低了使用成本。
  4. 资源自动管理:构造函数初始化条件变量,析构函数销毁条件变量,实现了RAII 风格的资源管理,避免了手动管理导致的资源泄漏。

3.4 封装后的使用示例

封装后的条件变量使用起来非常简洁,结合MutexLockGuard,实现一个简单的 “线程等待 - 通知” 示例,代码如下:

代码语言:javascript
复制
// cond_test.cpp 封装后的条件变量使用示例
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include "Lock.hpp"
#include "Cond.hpp"

// 命名空间
using namespace LockModule;
using namespace CondModule;

// 全局的互斥量和条件变量
Mutex g_mutex;
Cond g_cond;

// 线程函数:等待条件变量通知
void *thread_func(void *arg)
{
    std::string thread_name = (char *)arg;
    while (true)
    {
        LockGuard lock(g_mutex); // RAII自动加锁
        // 循环等待条件(这里简化为永久等待,实际开发中替换为业务条件)
        g_cond.Wait(g_mutex);
        // 被唤醒后执行业务逻辑
        std::cout << thread_name << " 被唤醒,执行业务逻辑..." << std::endl;
    }
    return nullptr;
}

int main()
{
    // 创建两个线程
    pthread_t t1, t2;
    pthread_create(&t1, nullptr, thread_func, (void *)"Thread-1");
    pthread_create(&t2, nullptr, thread_func, (void *)"Thread-2");

    // 主线程每隔1秒发送一次通知
    int cnt = 5;
    while (cnt--)
    {
        sleep(1);
        LockGuard lock(g_mutex); // 加锁保护
        std::cout << "主线程发送通知,剩余次数:" << cnt << std::endl;
        g_cond.NotifyAll(); // 唤醒所有等待线程
    }

    // 等待线程退出
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    return 0;
}

编译运行

代码语言:javascript
复制
# 编译:链接pthread库
g++ cond_test.cpp -o cond_test -lpthread
# 运行
./cond_test

运行结果

代码语言:javascript
复制
主线程发送通知,剩余次数:4
Thread-1 被唤醒,执行业务逻辑...
Thread-2 被唤醒,执行业务逻辑...
主线程发送通知,剩余次数:3
Thread-1 被唤醒,执行业务逻辑...
Thread-2 被唤醒,执行业务逻辑...
主线程发送通知,剩余次数:2
Thread-1 被唤醒,执行业务逻辑...
Thread-2 被唤醒,执行业务逻辑...
主线程发送通知,剩余次数:1
Thread-1 被唤醒,执行业务逻辑...
Thread-2 被唤醒,执行业务逻辑...
主线程发送通知,剩余次数:0
Thread-1 被唤醒,执行业务逻辑...
Thread-2 被唤醒,执行业务逻辑...

可以看到,封装后的条件变量使用非常简洁,结合LockGuard实现了自动加锁解锁,彻底避免了手动管理锁的繁琐和错误。

四、另一种同步方案:POSIX 信号量详解与实战

除了条件变量,POSIX 还提供了信号量(Semaphore) 作为线程同步的机制。信号量本质是一个计数器,通过对计数器的原子操作,实现对共享资源的访问控制。相比条件变量,信号量的使用更简洁,尤其适合生产者消费者模型有限资源的访问控制等场景。

4.1 POSIX 信号量的核心概念

POSIX 信号量分为无名信号量有名信号量,我们这里主要讲解无名信号量(适用于线程间同步),核心概念:

  1. 计数器:信号量的核心是一个非负整数计数器,代表可用的共享资源数量
  2. P 操作(等待)sem_wait,将计数器减 1。如果计数器减 1 后小于 0,当前线程会被阻塞,直到其他线程执行 V 操作;
  3. V 操作(发布)sem_post,将计数器加 1。如果计数器加 1 后大于等于 0,会唤醒一个阻塞在该信号量上的线程;
  4. 原子性:P 操作和 V 操作都是原子操作,由操作系统保证,无需额外的互斥量保护(底层已实现)。

信号量的同步逻辑非常简单:当共享资源可用时,计数器大于 0,线程可以执行 P 操作获取资源;当共享资源不可用时,计数器为 0,线程执行 P 操作会被阻塞,直到其他线程执行 V 操作释放资源

4.2 POSIX 信号量的核心 API

POSIX 信号量的头文件是<semaphore.h>,核心 API 包括初始化销毁P 操作V 操作,所有 API 的返回值都是:成功返回 0,失败返回 - 1 并设置errno

4.2.1 初始化信号量:sem_init
代码语言:javascript
复制
int sem_init(sem_t *sem, int pshared, unsigned int value);

参数说明

  • sem:指向要初始化的信号量对象的指针;
  • pshared:共享属性,0 表示线程间共享,非 0 表示进程间共享(本篇只关注线程间同步,设为 0 即可);
  • value:信号量的初始计数器值,代表初始的可用资源数量。
4.2.2 销毁信号量:sem_destroy
代码语言:javascript
复制
int sem_destroy(sem_t *sem);

功能:销毁已初始化的信号量,释放相关资源。注意:不能销毁一个正在被线程等待的信号量。

4.2.3 P 操作(等待):sem_wai
代码语言:javascript
复制
int sem_wait(sem_t *sem);

功能:原子地将信号量计数器减 1。如果计数器减 1 后小于 0,当前线程阻塞,直到其他线程执行sem_post

4.2.4 V 操作(发布):sem_post
代码语言:javascript
复制
int sem_post(sem_t *sem);

功能:原子地将信号量计数器加 1。如果计数器加 1 后大于等于 0,唤醒一个阻塞在该信号量上的线程。

4.3 C++ 封装 POSIX 信号量:简洁易用的 Sem 类

和互斥量、条件变量一样,我们对 POSIX 信号量进行 C++ 面向对象封装,实现资源自动管理接口简洁化,代码如下:

代码语言:javascript
复制
// Sem.hpp POSIX信号量封装
#pragma once
#include <iostream>
#include <semaphore.h>
#include <cstdlib>

class Sem
{
public:
    // 禁用拷贝和赋值
    Sem(const Sem &) = delete;
    const Sem &operator=(const Sem &) = delete;

    // 构造函数:初始化信号量,指定初始计数器值
    explicit Sem(unsigned int value = 0)
    {
        int ret = sem_init(&_sem, 0, value);
        if (ret != 0)
        {
            std::cerr << "sem_init error!" << std::endl;
            exit(EXIT_FAILURE);
        }
    }

    // P操作:等待,计数器减1
    void P()
    {
        int ret = sem_wait(&_sem);
        if (ret != 0)
        {
            std::cerr << "sem_wait error!" << std::endl;
            exit(EXIT_FAILURE);
        }
    }

    // V操作:发布,计数器加1
    void V()
    {
        int ret = sem_post(&_sem);
        if (ret != 0)
        {
            std::cerr << "sem_post error!" << std::endl;
            exit(EXIT_FAILURE);
        }
    }

    // 析构函数:销毁信号量
    ~Sem()
    {
        sem_destroy(&_sem);
    }

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

这个封装的核心亮点和之前的互斥量、条件变量一致:RAII 资源管理禁用拷贝赋值接口简洁异常处理,使用者只需调用P()V()即可实现信号量的等待和发布。

4.4 实战:基于 POSIX 信号量实现环形队列的生产者消费者模型

信号量最经典的应用场景就是生产者消费者模型,相比条件变量实现的阻塞队列,信号量实现的环形队列更高效、更简洁。接下来我们就实现一个多生产者多消费者的环形队列模型,核心设计:

  1. 环形队列:用std::vector模拟,固定容量,通过下标取模实现环形特性;
  2. 两个信号量
    • 空间信号量_room_sem:计数器为环形队列的空闲空间数,初始值为队列容量,生产者关注
    • 数据信号量_data_sem:计数器为环形队列的有效数据数,初始值为 0,消费者关注
  3. 两个互斥量
    • 生产者互斥量_productor_mutex:保证多个生产者的入队操作互斥;
    • 消费者互斥量_consumer_mutex:保证多个消费者的出队操作互斥;
  4. 下标指针_productor_step(生产者入队下标)、_consumer_step(消费者出队下标),通过取模实现环形移动。
4.4.1 环形队列封装:RingQueue.hpp
代码语言:javascript
复制
// RingQueue.hpp 基于信号量的环形队列
#pragma once
#include <iostream>
#include <vector>
#include <pthread.h>
#include "Sem.hpp"

// 模板类,支持任意类型的任务
template <typename T>
class RingQueue
{
public:
    // 构造函数:初始化环形队列,指定容量
    explicit RingQueue(int cap) : _cap(cap),
                                  _ring_queue(cap),
                                  _room_sem(cap),   // 空间信号量初始值为容量
                                  _data_sem(0),     // 数据信号量初始值为0
                                  _productor_step(0),
                                  _consumer_step(0)
    {
        // 初始化互斥量
        pthread_mutex_init(&_productor_mutex, nullptr);
        pthread_mutex_init(&_consumer_mutex, nullptr);
    }

    // 入队操作:生产者调用
    void Enqueue(const T &in)
    {
        // 1. P操作获取空闲空间,无空间则阻塞
        _room_sem.P();
        // 2. 生产者互斥,保证入队操作原子性
        pthread_mutex_lock(&_productor_mutex);
        // 3. 入队:下标取模实现环形
        _ring_queue[_productor_step++] = in;
        _productor_step %= _cap;
        // 4. 解锁
        pthread_mutex_unlock(&_productor_mutex);
        // 5. V操作发布数据,通知消费者
        _data_sem.V();
    }

    // 出队操作:消费者调用
    void Pop(T *out)
    {
        // 1. P操作获取有效数据,无数据则阻塞
        _data_sem.P();
        // 2. 消费者互斥,保证出队操作原子性
        pthread_mutex_lock(&_consumer_mutex);
        // 3. 出队:下标取模实现环形
        *out = _ring_queue[_consumer_step++];
        _consumer_step %= _cap;
        // 4. 解锁
        pthread_mutex_unlock(&_consumer_mutex);
        // 5. V操作发布空间,通知生产者
        _room_sem.V();
    }

    // 析构函数:销毁互斥量
    ~RingQueue()
    {
        pthread_mutex_destroy(&_productor_mutex);
        pthread_mutex_destroy(&_consumer_mutex);
    }

    // 禁用拷贝和赋值
    RingQueue(const RingQueue &) = delete;
    const RingQueue &operator=(const RingQueue &) = delete;

private:
    std::vector<T> _ring_queue; // 环形队列的底层容器
    int _cap;                   // 环形队列的容量
    Sem _room_sem;              // 空间信号量:生产者关注
    Sem _data_sem;              // 数据信号量:消费者关注
    int _productor_step;        // 生产者入队下标
    int _consumer_step;         // 消费者出队下标
    pthread_mutex_t _productor_mutex; // 生产者互斥锁
    pthread_mutex_t _consumer_mutex;  // 消费者互斥锁
};
4.4.2 生产者消费者测试代码:ringqueue_test.cpp
代码语言:javascript
复制
// ringqueue_test.cpp 环形队列的生产者消费者测试
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <random>
#include "RingQueue.hpp"

// 定义环形队列容量
const int QUEUE_CAP = 5;
// 全局环形队列对象,存储int类型数据
RingQueue<int> g_rq(QUEUE_CAP);

// 生产者线程函数:不断生产随机数入队
void *productor_func(void *arg)
{
    std::string productor_name = (char *)arg;
    // 随机数生成器
    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_int_distribution<> dis(1, 100);

    while (true)
    {
        int data = dis(gen);
        g_rq.Enqueue(data);
        std::cout << productor_name << " 生产数据:" << data << std::endl;
        // 随机休眠,模拟生产耗时
        usleep(dis(gen) * 1000);
    }
    return nullptr;
}

// 消费者线程函数:不断出队并打印数据
void *consumer_func(void *arg)
{
    std::string consumer_name = (char *)arg;
    // 随机数生成器
    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_int_distribution<> dis(1, 100);

    while (true)
    {
        int data;
        g_rq.Pop(&data);
        std::cout << consumer_name << " 消费数据:" << data << std::endl;
        // 随机休眠,模拟消费耗时
        usleep(dis(gen) * 1000);
    }
    return nullptr;
}

int main()
{
    // 创建2个生产者线程,3个消费者线程
    pthread_t p1, p2, c1, c2, c3;
    pthread_create(&p1, nullptr, productor_func, (void *)"Productor-1");
    pthread_create(&p2, nullptr, productor_func, (void *)"Productor-2");
    pthread_create(&c1, nullptr, consumer_func, (void *)"Consumer-1");
    pthread_create(&c2, nullptr, consumer_func, (void *)"Consumer-2");
    pthread_create(&c3, nullptr, consumer_func, (void *)"Consumer-3");

    // 等待线程退出(实际会一直运行)
    pthread_join(p1, nullptr);
    pthread_join(p2, nullptr);
    pthread_join(c1, nullptr);
    pthread_join(c2, nullptr);
    pthread_join(c3, nullptr);

    return 0;
}
4.4.3 编译运行与结果分析

编译

代码语言:javascript
复制
g++ ringqueue_test.cpp -o ringqueue_test -lpthread -std=c++11

运行

代码语言:javascript
复制
./ringqueue_test

运行结果(节选)

代码语言:javascript
复制
Productor-1 生产数据:45
Productor-2 生产数据:78
Consumer-1 消费数据:45
Consumer-2 消费数据:78
Productor-1 生产数据:23
Productor-2 生产数据:91
Consumer-3 消费数据:23
Consumer-1 消费数据:91
Productor-1 生产数据:56
Productor-2 生产数据:88
Consumer-2 消费数据:56
Consumer-3 消费数据:88

结果分析

  1. 生产者生产数据后,消费者能及时消费,实现了同步
  2. 多个生产者和多个消费者同时运行,无数据错乱,实现了互斥
  3. 当环形队列满时,生产者会被阻塞;当队列空时,消费者会被阻塞,实现了阻塞等待

整个模型的核心是两个信号量的协同工作:空间信号量控制生产者的生产速度,数据信号量控制消费者的消费速度,互斥量保证多个生产者 / 消费者的操作互斥,最终实现了高效、安全的生产者消费者模型。

五、条件变量与 POSIX 信号量的对比与选型建议

学到这里,大家肯定会有疑问:条件变量和 POSIX 信号量都是线程同步机制,该如何选择? 接下来我们从底层实现使用场景优缺点三个维度进行对比,给出明确的选型建议。

5.1 核心对比

特性

条件变量

POSIX 信号量(无名)

核心原理

基于 “等待 - 通知” 机制,依赖互斥量保护共享资源

基于计数器的原子操作,P/V 操作实现阻塞和唤醒

资源依赖

必须绑定互斥量使用

无需额外互斥量(底层已实现原子性)

条件判断

需手动判断共享资源状态,必须用 while 循环处理伪唤醒

无需手动判断,计数器直接表示资源状态,无伪唤醒

使用复杂度

较高,需关注互斥量、条件判断、伪唤醒

较低,只需调用 P/V 操作,逻辑简单

灵活性

高,可实现复杂的同步逻辑(如多条件等待)

较低,主要适用于简单的资源计数和同步

适用场景

复杂的线程同步场景,如线程池、多条件的生产者消费者模型

简单的资源访问控制、固定容量的生产者消费者模型、有限资源的并发访问

5.2 选型建议

  1. 优先使用 POSIX 信号量:如果你的场景是有限资源的访问控制固定容量的生产者消费者模型(如环形队列),建议优先使用 POSIX 信号量,因为其使用更简洁,无需处理伪唤醒,开发效率更高,bug 率更低。
  2. 使用条件变量:如果你的场景是复杂的线程同步,比如多条件等待(一个线程需要等待多个条件满足)、线程池的任务通知自定义的阻塞队列,建议使用条件变量,因为其灵活性更高,能实现更复杂的同步逻辑。
  3. 混合使用:在一些复杂的系统中,也可以将条件变量和信号量混合使用,比如用信号量控制资源数量,用条件变量实现更精细的状态通知。

总结

线程同步是多线程开发的核心和难点,也是面试的高频考点。希望大家通过本篇的学习,不仅能知其然,更能知其所以然,从原理层面理解同步机制,从实战层面掌握封装和使用技巧。后续我会继续更新多线程开发的进阶内容,关注我,不迷路! 最后,大家如果有任何问题,欢迎在评论区留言讨论,一起学习,一起进步!💪

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2026-03-04,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 目录
  • 一、灵魂拷问:pthread_cond_wait 为何必须绑定互斥量?
    • 1.1 条件变量的核心作用:线程间的状态通知
    • 1.2 无互斥量的致命问题:错过信号与永久阻塞
    • 1.3 互斥量的核心价值:让 “解锁 + 等待” 成为原子操作
    • 1.4 再挖一层:条件判断为何必须保护?
  • 二、避坑指南:条件变量的标准化使用规范
    • 2.1 等待条件的规范写法:while 判断,而非 if 判断
      • 错误写法:if 判断条件
      • 正确写法:while 循环判断条件
    • 2.2 核心原因:处理 “伪唤醒” 问题
    • 2.3 等待条件的完整标准化模板
    • 2.4 发送信号的标准化模板
    • 2.5 信号选择:signal 还是 broadcast?
  • 三、优雅封装:C++ 实现可复用的条件变量类
    • 3.1 前置准备:已封装的互斥量类 Lock.hpp
    • 3.2 条件变量封装:Cond.hpp
    • 3.3 封装的核心亮点解析
    • 3.4 封装后的使用示例
  • 四、另一种同步方案:POSIX 信号量详解与实战
    • 4.1 POSIX 信号量的核心概念
    • 4.2 POSIX 信号量的核心 API
      • 4.2.1 初始化信号量:sem_init
      • 4.2.2 销毁信号量:sem_destroy
      • 4.2.3 P 操作(等待):sem_wai
      • 4.2.4 V 操作(发布):sem_post
    • 4.3 C++ 封装 POSIX 信号量:简洁易用的 Sem 类
    • 4.4 实战:基于 POSIX 信号量实现环形队列的生产者消费者模型
      • 4.4.1 环形队列封装:RingQueue.hpp
      • 4.4.2 生产者消费者测试代码:ringqueue_test.cpp
      • 4.4.3 编译运行与结果分析
  • 五、条件变量与 POSIX 信号量的对比与选型建议
    • 5.1 核心对比
    • 5.2 选型建议
  • 总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档