Linux之线程同步:条件变量和两种生产消费模型

1. 线程同步

线程饥饿问题:某一个线程频繁地申请、释放锁但没有进行任何有效的操作,导致其他线程没有办法拿到锁从而长期处于阻塞状态。

同步:在保证临界资源安全的前提下,让所有执行流访问临界资源时按照一定顺序进行访问。

2. 条件变量

条件变量是 Linux 线程同步的核心机制之一。与互斥锁不同,互斥锁解决的是"能不能进入临界区"的问题,而条件变量解决的是"临界区中有没有可用资源"的问题。

创建的方式与锁几乎一样:

c 复制代码
// 局部条件变量
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
// attr 通常传 nullptr 表示默认属性
int pthread_cond_destroy(pthread_cond_t *cond);

// 全局条件变量(静态初始化)
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

条件变量的本质是一个等待队列,用于阻塞和唤醒线程。

c 复制代码
// 让当前线程在 cond 队列下等待(原子性地释放 mutex 并进入等待)
int pthread_cond_wait(pthread_cond_t *restrict cond,
                      pthread_mutex_t *restrict mutex);

// 带超时的等待,超过 abstime 后线程自动醒来
int pthread_cond_timedwait(pthread_cond_t *restrict cond,
                           pthread_mutex_t *restrict mutex,
                           const struct timespec *restrict abstime);

// 唤醒在该条件变量下等待的一个线程
int pthread_cond_signal(pthread_cond_t *cond);

// 唤醒在该条件变量下等待的所有线程(广播)
int pthread_cond_broadcast(pthread_cond_t *cond);

关于 pthread_cond_wait 为什么需要传入 mutex:线程在调用该函数前必须已经持有 mutex(即在临界区内)。当条件不满足时,该函数会原子性地执行两个动作------将当前线程加入条件变量的等待队列,同时释放 mutex。当线程被唤醒后,它会自动重新获取 mutex 然后返回。这个"解锁+等待"的原子性正是条件变量正确工作的基础:如果分开执行,可能在解锁后、等待前发生线程切换,导致信号丢失。

2.1 互斥锁与条件变量之间的关系

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

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void *active(void *arg)
{
    std::string name = static_cast<const char *>(arg);
    while (true)
    {
        pthread_mutex_lock(&mutex);
        pthread_cond_wait(&cond, &mutex);
        std::cout << name << " 活动..." << std::endl;
        pthread_mutex_unlock(&mutex);
    }
}

int main(void)
{
    pthread_t t1, t2;
    pthread_create(&t1, NULL, active, (void *)"thread-1");
    pthread_create(&t2, NULL, active, (void *)"thread-2");

    sleep(3); // 确保两个线程已经在运行

    while (true)
    {
        // 对比测试
        // pthread_cond_signal(&cond);    // 唤醒一个线程
        pthread_cond_broadcast(&cond);    // 唤醒所有线程
        sleep(1);
    }

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    return 0;
}

使用条件变量的情况基本上是由于临界资源不足。由于要对临界资源进行判定,因此判断临界资源的条件变量本身要在临界区内部。唤醒一个在 cond 下休眠的线程需要由其他线程来唤醒,从而实现同步。

一个重要的编程实践:判断条件时必须使用 while 而非 if。这是因为存在虚假唤醒------线程可能在没有收到 signal 的情况下被意外唤醒(POSIX 标准允许这种情况)。使用 while 循环确保线程醒来后重新检查条件,条件仍不满足则继续等待。

3. 生产消费模型

3.1 "321" 原则

生产者、消费者和交易场所构成三种关系:生产者之间是竞争关系(互斥),消费者之间也是互斥关系,生产者与消费者之间既是互斥关系(生产者写时消费者不能读,从而保障数据安全)又是同步关系(生产者写完消费者才能读,消费者取走生产者才能继续写)。

两种角色:消费者和生产者。

一种交易场所:以特定结构构成的一种"内存空间"。

3.2 该模型的意义

(1) 生产者生产数据和消费者消费数据二者之间彼此独立互不影响。

(2) 交易场所具有的缓存能力确保了生产或消费任意一方能力下降时暂时不会造成整体效率降低。

(3) 解耦生产者和消费者,提高系统的可扩展性和效率。

3.3 基于 BlockQueue 的生产消费模型

该模型的核心特点是 BlockQueue 中的每个数据是独立完整的个体------生产者和消费者操作的是同一个队列,但等待在不同的条件变量上。

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

template <class T>
class BlockQueue
{
private:
    bool IsFull()
    {
        return _block_queue.size() == _cap;
    }
    bool IsEmpty()
    {
        return _block_queue.empty();
    }

public:
    // cap 是指定的 BlockQueue 中允许存在的数据个数
    BlockQueue(int cap)
        : _cap(cap),
          _productor_wait_num(0),
          _consumer_wait_num(0)
    {
        pthread_mutex_init(&_mutex, nullptr);
        pthread_cond_init(&_product_cond, nullptr);
        pthread_cond_init(&_consum_cond, nullptr);
    }

    // 生产者用的接口
    void Enqueue(T &in)
    {
        pthread_mutex_lock(&_mutex);
        // 使用 while 而非 if:防止虚假唤醒导致在队列仍满时继续生产
        while (IsFull())
        {
            // 记录阻塞的线程数
            _productor_wait_num++;
            // pthread_cond_wait 中形参的锁的意义:
            // 该锁是将要被阻塞的线程所持有的,
            // 得让该线程阻塞的同时释放该锁给其他线程用
            pthread_cond_wait(&_product_cond, &_mutex);
            // 只要等待,必定会有唤醒的时候,就要继续从这个位置向下运行
            _productor_wait_num--;
        }
        // 进行生产
        _block_queue.push(in);
        // 通知消费者来消费
        // 运行到这一行代码时,BlockQueue 中一定有数据,
        // 此处能确保一定有一个醒着的消费者来处理数据
        if (_consumer_wait_num > 0)
        {
            pthread_cond_signal(&_consum_cond);
        }
        pthread_mutex_unlock(&_mutex);
    }

    // 消费者用的接口,参数用于接收数据
    void Pop(T *out)
    {
        pthread_mutex_lock(&_mutex);
        // 与生产者同理,使用 while 保证代码的健壮性
        while (IsEmpty())
        {
            // 消费线程去等待,是在临界区中休眠的,现在还持有锁
            // pthread_cond_wait 调用是:
            //   a. 让调用进程等待
            //   b. 自动释放曾经持有的 _mutex 锁
            _consumer_wait_num++;
            pthread_cond_wait(&_consum_cond, &_mutex);
            _consumer_wait_num--;
        }
        // 进行消费
        *out = _block_queue.front();
        _block_queue.pop();
        // 通知生产者来生产
        if (_productor_wait_num > 0)
        {
            pthread_cond_signal(&_product_cond);
        }
        pthread_mutex_unlock(&_mutex);
    }

    ~BlockQueue()
    {
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_product_cond);
        pthread_cond_destroy(&_consum_cond);
    }

private:
    std::queue<T> _block_queue;      // 阻塞队列,是被整体使用的
    int _cap;                        // 总上限
    pthread_mutex_t _mutex;          // 保护 _block_queue 的锁
    pthread_cond_t _product_cond;    // 专门给生产者提供的条件变量
    pthread_cond_t _consum_cond;     // 专门给消费者提供的条件变量
    int _productor_wait_num;         // 生产者等待计数
    int _consumer_wait_num;          // 消费者等待计数
};

条件变量本身也要在锁内部使用,因为条件变量(本质就是一个阻塞队列)本身要被多个线程同时看到来阻塞和唤醒线程,但其不具备原子性。

进一步讲就是 pthread_cond_wait 本身不是原子性的:可能在读取到没有数据、对线程阻塞的这个过程中发生线程切换,生成了数据,但是这个被阻塞的线程并不知道,导致一直阻塞。pthread_cond_signal 也有类似的问题。因此在 cond 的阻塞或唤醒时必须持有 mutex 以保证原子性。

3.4 Cond 的封装

cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include <pthread.h>
#include "Lock.hpp"

namespace CondModule
{
using namespace LockModule;

class Cond
{
public:
    Cond()
    {
        int n = pthread_cond_init(&_cond, nullptr);
        (void)n; // 酌情加日志、加判断
    }

    // 阻塞等待(需要配合外部 Mutex 使用)
    void Wait(Mutex &mutex)
    {
        int n = pthread_cond_wait(&_cond, mutex.GetMutexOriginal());
        (void)n;
    }

    // 唤醒一个等待线程
    void Notify()
    {
        int n = pthread_cond_signal(&_cond);
        (void)n;
    }

    // 唤醒全部等待线程
    void NotifyAll()
    {
        int n = pthread_cond_broadcast(&_cond);
        (void)n;
    }

    ~Cond()
    {
        int n = pthread_cond_destroy(&_cond);
        (void)n; // 酌情加日志、加判断
    }

private:
    pthread_cond_t _cond;
};
} // namespace CondModule

此封装依赖 Lock.hpp 中定义的 Mutex 类(提供 GetMutexOriginal() 方法返回原始 pthread_mutex_t*)。封装后将 C 风格 API 转为面向对象接口,使业务代码更清晰。

4. 基于环形队列的生产消费模型

POSIX 信号量

使用和意义上与 System V 的信号量没有区别,只是 POSIX 信号量能对线程使用。多元信号量本身就是临界资源,但其 P/V 操作依旧是原子性的。

c 复制代码
// 初始化信号量
// sem:     信号量变量
// pshared: 0 表示线程间共享,非 0 表示进程间共享
// value:   信号量的初始值(本质就是一个计数器)
int sem_init(sem_t *sem, int pshared, unsigned int value);

// 销毁一个信号量
int sem_destroy(sem_t *sem);

// P 操作(proberen,荷兰语"测试"),对信号量值减 1
// 若信号量值为 0 则阻塞等待
int sem_wait(sem_t *sem);

// V 操作(verhogen,荷兰语"增加"),对信号量值加 1
int sem_post(sem_t *sem);
Sem 封装
cpp 复制代码
#pragma once
#include <iostream>
#include <semaphore.h>

class Sem
{
public:
    Sem(int n)
    {
        sem_init(&_sem, 0, n);
    }

    void P()
    {
        sem_wait(&_sem);
    }

    void V()
    {
        sem_post(&_sem);
    }

    ~Sem()
    {
        sem_destroy(&_sem);
    }

private:
    sem_t _sem;
};

约定

(1) 使用模运算 + 数组模拟固定大小的环形队列,以 consumer_step 为消费者下标,productor_step 为生产者下标。

(2) 队列为空时生产者先运行,队列为满时消费者先运行。空或满时 head 与 tail 指向同一个位置。

(3) 生产者不能套消费者一个圈以上,同时消费者不能超过生产者。

特点

(1) 只要我们不访问同一个位置,生产和消费就能同时运行。

(2) 只有为空或者为满,二者才会在同一个位置。也就是说只要不为空或者不为满,就可以同时访问------这比 BlockQueue 模型并发度更高。

环形队列的生产消费模型代码

注意:

(1) 多元信号量的 P/V 操作本身是原子性的,但队列中每个槽位的数据不是原子性的,因此访问槽位时要使用锁保护。

(2) 此处使用多元信号量进行资源的计数分配,因此生产者和消费者持有的信号量总值始终保持为 _cap。

(3) 一开始由生产者先运行,因此最开始生产者拥有所有的信号量(_room_sem = _cap,_data_sem = 0)。

(4) 信号量 P 操作在 mutex lock 之前执行------这是一个性能优化:先通过信号量"预定"一个槽位,再竞争 mutex 进行实际的读写。这样可以减少锁的持有时间和竞争。

cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <semaphore.h>
#include <pthread.h>

template <typename T>
class RingQueue
{
private:
    void Lock(pthread_mutex_t &mutex)
    {
        pthread_mutex_lock(&mutex);
    }

    void Unlock(pthread_mutex_t &mutex)
    {
        pthread_mutex_unlock(&mutex);
    }

public:
    // cap 是环形队列的大小
    RingQueue(int cap)
        : _ring_queue(cap),
          _cap(cap),
          // _room_sem 是生产者所拥有的初始信号量值,为 _cap 大小
          _room_sem(cap),
          // _data_sem 是消费者所拥有的初始信号量值,为 0 大小
          _data_sem(0),
          // 逻辑上的 tail,生产者下标
          _productor_step(0),
          // 逻辑上的 head,消费者下标
          _consumer_step(0)
    {
        // 生产者之间和消费者之间要互斥操作
        pthread_mutex_init(&_productor_mutex, nullptr);
        pthread_mutex_init(&_consumer_mutex, nullptr);
    }

    // 生产者生产
    void Enqueue(const T &in)
    {
        // 先 P 再 lock 的原因:先预定位置再生产,能提高效率
        _room_sem.P();
        Lock(_productor_mutex);
        // 到了这一步时是一定有空位置的
        _ring_queue[_productor_step++] = in;
        _productor_step %= _cap;
        Unlock(_productor_mutex);
        // 给对方信号量从而唤醒对方消费资源
        _data_sem.V();
    }

    // 消费者消费,行为与生产者几乎一致
    void Pop(T *out)
    {
        _data_sem.P();
        Lock(_consumer_mutex);
        *out = _ring_queue[_consumer_step++];
        _consumer_step %= _cap;
        Unlock(_consumer_mutex);
        _room_sem.V();
    }

    ~RingQueue()
    {
        pthread_mutex_destroy(&_productor_mutex);
        pthread_mutex_destroy(&_consumer_mutex);
    }

private:
    std::vector<T> _ring_queue;          // 环形队列
    int _cap;                            // 环形队列的容量上限
    int _productor_step;                 // 生产者下标
    int _consumer_step;                  // 消费者下标
    Sem _room_sem;                       // 生产者所拥有的信号量次数
    Sem _data_sem;                       // 消费者所拥有的信号量次数
    // 定义锁,维护多生产多消费之间的互斥关系
    // 因为 P/V 操作本身是原子性的,但内部的临界资源不是原子性的
    pthread_mutex_t _productor_mutex;
    pthread_mutex_t _consumer_mutex;
};

两种模型对比

维度 BlockQueue(阻塞队列) RingQueue(环形队列)
数据结构 std::queue(链表/FIFO) std::vector + 模运算(定长数组)
同步机制 互斥锁 + 两个条件变量 互斥锁 + 两个 POSIX 信号量
并发度 低,整个队列一把锁 高,生产者与消费者可同时操作不同槽位
适用场景 数据大小不固定、需要动态增长 数据大小固定、追求高吞吐量
空/满判断 empty() / size() == cap data_sem == 0 / room_sem == 0

5. 总结

(1) 互斥锁用于控制线程能不能进入临界区,条件变量用于判断临界区中有没有可用的临界资源,信号量则兼具计数和同步的能力。

(2) 信号量的底层就是一个计数器加等待队列,在访问临界资源之前,信号量就以原子性的形式对临界资源的存在或就绪等条件进行了判断(P 操作即"申请一个资源",V 操作即"释放一个资源")。

(3) BlockQueue 的实现就类似于工人们排队往一个桶中放数据,消费者排另一条队等桶中有数据时取出------生产者和消费者操作同一个容器,但等待时在不同的条件变量上排队。

(4) RingQueue 就是工人们排队在闭环流水线上生产物品,消费者在闭环流水线上取物品------两条不同的队,只有在队首相遇(队列空或满)时才需要等待。

相关推荐
tianyuanwo1 小时前
OS运维智能化落地抉择:构建故障诊断AI Skill VS 沉淀领域知识库,谁是核心先手?
运维·人工智能·知识库·skill
Dlrb12111 小时前
Linux系统编程-线程与多线程模块的封装
linux·线程·互斥锁·线程同步·线程互斥
拾贰_C1 小时前
【Ubuntu | VSCode | SSH | 远程连接 | Linux】VSCode 怎么实现ssh远程连接
linux·vscode·ubuntu
liulilittle1 小时前
用户态 TCP 端口转发:对 CUBIC 友好,对 BBR/KCC 收益不大
运维·网络·tcp/ip·计算机网络·信息与通信·tcp·通信
杨了个杨89822 小时前
HAproxy+Keepalive的简介及安装
运维·服务器
一叶知秋dong2 小时前
llama.cpp 启动脚本
linux·服务器·llama
桌面运维家2 小时前
校园机房vDisk IDV云桌面建设方案价格参考
linux·服务器·数据库
utf8mb4安全女神2 小时前
【shell函数】【shell脚本】定期自动检查服务器磁盘使用情况并发出告警
运维·服务器
憧憬成为java架构高手的小白2 小时前
计算机网络管理
服务器·网络·计算机网络