Linux架构:线程同步中的条件变量与基于阻塞队列的生产者消费者模型

一、条件变量

1.1 引言

在线程互斥章节我们描述并实现了一个简单的多线程抢票系统:

#include
#include
#include
#include
#include
int ticket=10000;
void* routine(void* args)
{
    char* name=static_cast(args);
    //一个模拟抢票的代码片段:
    usleep(10000);
    while(1)
    {
        if ( ticket > 0 ) {
            usleep(1000);
            printf("%s sells ticket:%d\n", name, ticket);
            ticket--;
        } else {
            break;
        }
    }
    return nullptr;
}
int main()
{
    std::vector arr;
    int num=10;
    for(int i=0;i

在这个最初的版本中我们并没有引入互斥锁的机制,因为对临界资源(票数)的操作不是原子操作所以导致了严重的数据不一致问题。多个线程由于竞态访问临界资源导致最后票数被抢到了负数,这在现实情况下是绝对不允许发生的。

为了解决多线程竞态访问临界资源导致的数据不一致问题,我们引入了互斥锁的来保证同一时间只有一个线程(或进程)能访问共享资源,从而避免并发操作导致的数据冲突或不一致问题。核心操作就是让每个线程在进行抢票之前都必须首先“拿到锁”,这是线程能否进入临界区操作临界资源的唯一凭证。在多线程中同一时间只有一个线程持有锁可以进入临界区抢票,其他线程则会被链入到与该锁相关的一个阻塞队列中。持有锁的线程操作完毕重新释放互斥锁时会随机唤醒阻塞队列中的一个(或多个)线程,被唤醒的线程会重新竞争锁,持有锁的线程会再次进入临界区访问临界资源。

多线程抢票系统(互斥锁版本):

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;  // 静态初始化锁
int ticket=1000;
void* routine(void* args)
{
    char* name=static_cast(args);
    //一个模拟抢票的代码片段:
    usleep(10000);
    while(1)
    {
        pthread_mutex_lock(&mutex);
        if ( ticket > 0 ) {
            usleep(1000);
            printf("%s sells ticket:%d\n", name, ticket);
            ticket--;
            pthread_mutex_unlock(&mutex);
        } else {
            pthread_mutex_unlock(&mutex);
            break;
        }
        usleep(123);
    }
    return nullptr;
}
int main()
{
    std::vector arr;
    int num=10;
    for(int i=0;i

到这里我们的简易抢票系统就可以安全的运行了,那我们增加一个功能:让多线程中的一部分线程负责增加票数,另一部分线程负责抢票呢?根据我们学到的互斥锁机制我们很容易就会想出下面的代码:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;  // 静态初始化锁
int ticket=1000;
void* consum(void* args)
{
    char* name=static_cast(args);
    //一个模拟抢票的代码片段:
    sleep(1);
    while(1)
    {
        pthread_mutex_lock(&mutex);
        if ( ticket > 0 ) {
            usleep(10000);
            printf("%s sells ticket:%d\n", name, --ticket);
            pthread_mutex_unlock(&mutex);
        } else {
            pthread_mutex_unlock(&mutex);
            break;
        }
        usleep(123);
    }
    return nullptr;
}
void* produce(void* args)
{
    char* name=static_cast(args);
    //一个模拟加票的代码片段:
    while(1)
    {
        pthread_mutex_lock(&mutex);
        if ( ticket <10 ) {
            usleep(10000);
            printf("%s add ticket:%d\n", name, ++ticket);
            pthread_mutex_unlock(&mutex);
        } else {
            pthread_mutex_unlock(&mutex);
        }
        usleep(123);
    }
    return nullptr;
}
int main()
{
    std::vector arr;
    int num=10;
    for(int i=0;i

这段代码对我们简易的抢票系统进行了一些升级,我们让10个线程中的一半线程调用produce负责增加票数,让另一半线程调用consum负责抢票,运行起来会是下面的结果(部分):

看似没有问题,加票与抢票的线程都在运行并没有导致错误的发生但是在底层互斥锁的竞争和运行效率层面有着很大的隐患,我们下面来详细分析一下:

在我们的代码实现的加票/抢票的过程中操作系统并不知道票数的具体数量,在加上锁的竞争本身具有随机性,试想一种场景:当票数等于0时,随机的一个线程拿到了锁但它是一个抢票的线程,此时线程判断票数为0时无事可做只能将锁释放,然后随机唤醒一个阻塞队列中的线程。如果第二个线程还是抢票线程的话会重复上述过程。如果在实际情况下,抢票线程的数量远大于加票线程的话抢票线程在锁的竞争中占据 “概率优势”—— 每次锁释放后,被唤醒或参与竞争的线程大概率还是抢票线程,会导致CPU一直在运行调度可是“抢票线程一直空转、加票线程无法介入” 的死循环。

反过来如果票数达到了上限而加票线程的数量远大于抢票线程的话,也会导致类似“加票线程一直空转、抢票线程无法介入” 的死循环。

导致这一情况的主要原因是什么呢?

细想一下,主要原因就在于抢/加票线程并不能知道票数的变化情况只能通过一遍又一遍地进入临界区进行确认。当抢票线程数量远多于加票线程时,每次锁释放后,参与竞争的线程中 “抢票线程占绝对多数,但是进入临界区后并不能操作临界资源”导致机会一直被浪费,而加票线程却无法持锁。

如果我们再次引入一个队列cond,当抢票线程持有锁发现票数为0释放锁后不会再次进入该锁的阻塞队列参与竞争而是进入新的阻塞队列cond。反复下去,所有在加票线程之前的抢票线程都会进入cond。此时就会使加票线程持有锁的几率越来越大知道拿到锁修改临界资源。当临界资源被修改也就是有票后我们通过唤醒机制,将cond中的抢票线程加入锁的阻塞队列中参与竞争。这样就确保了 “需要操作的线程(加票线程)最终能拿到锁”,避免了单纯互斥锁的 “盲目竞争” 导致的饥饿。

上述的阻塞队列cond与唤醒机制就是条件变量的功能,下面我们就来详细介绍一下条件变量和对应的操作接口:

1.2 核心概念

在多线程编程中,条件变量(Condition Variable)是一种用于线程间同步的机制,它允许线程在特定条件不满足时阻塞等待,当条件满足时被其他线程唤醒,从而高效地协调线程间的协作。

  • 作用:解决线程间因共享资源状态变化而需要等待 / 唤醒的场景。
  • 依赖互斥锁:条件变量必须与互斥锁(Mutex)配合使用,用于保护共享资源的访问,并确保条件判断的原子性。
  • 等待与唤醒
    • 线程在条件不满足时,通过条件变量释放互斥锁并进入阻塞状态(等待)。
    • 当条件满足时,其他线程通过条件变量唤醒一个或多个等待的线程,被唤醒的线程重新获取互斥锁并继续执行。

我们可以通过如下图来辅助了解,我们以放票与抢票的场景为例:

首先我们需要定义两个条件变量:

  • pcond:关联放票线程的条件变量
  • ccond:关联抢票线程的条件变量

当用户创建多个抢票、放票线程后,这些线程会依次竞争锁并进入临界区检查临界资源(票数)。抢票线程会检查票池中是否为0,如果为0则会直接进入ccond关联的等待队列,并释放锁后唤醒pcond等待队列中的一个或多个加票线程;如果不为0则会使票数-1后释放锁并进入ccond关联的等待队列,并唤醒pcond等待队列中的一个加票线程。

对于加票线程会检查票池中票数是否为满,如果为满则会直接进入pcond关联的等待队列,并释放锁后唤醒ccond等待队列中的一个或多个加票线程;如果不为满则会使票数+1后释放锁并进入ccond关联的等待队列,并唤醒ccond等待队列中的一个抢票线程。

无论是抢票线程还是加票线程,在条件变量pcond和ccond中被唤醒后都会进入与锁关联的阻塞队列重新竞争锁。

进入临界区中检查临界资源或修改(条件符合)并唤醒对方线程进入锁的阻塞队列后会第一时间释放锁最后才会去自己的条件变量等待队列中休眠,这样避免一个线程自始至终持有锁造成死锁。

1.3 POSIX库实现条件变量

Linux 中条件变量的操作接口定义在 <pthread.h> 头文件中,核心函数如下:

1.3.1 初始化条件变量

int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);

作用:初始化一个条件变量对象(pthread_cond_t)。

  • cond:指向未初始化的pthread_cond_t变量(输出参数)。
  • attr:条件变量的属性,控制条件变量的共享范围(进程内 / 进程间)等。若为NULL,使用默认属性(仅进程内线程可见)。

返回值:

  • 成功返回0
  • 失败返回非 0 错误码(如ENOMEM:内存不足)。

静态初始化:若无需自定义属性,可直接静态初始化(更简洁):

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

1.3.2 销毁条件变量

int pthread_cond_destroy(pthread_cond_t *cond);

作用释放条件变量占用的资源(如等待队列)。

注意事项

  • 必须销毁已初始化的条件变量,否则可能导致内存泄漏。
  • 若有线程正在等待该条件变量,销毁操作会导致未定义行为(通常程序崩溃)。

返回值:成功返回0;失败返回错误码(如EBUSY:条件变量正被使用)。

1.3.3 等待条件成立

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);

作用当前线程阻塞等待条件变量,并原子性释放互斥锁;被唤醒后重新获取互斥锁并继续执行。

参数

  • cond:等待的条件变量。
  • mutex:关联的互斥锁(必须已加锁,否则行为未定义)。

核心流程(原子操作)

将当前线程加入条件变量的等待队列并释放mutex(允许其他线程获取锁并修改共享资源)此时该线程进入阻塞状态(不占用 CPU)。

当被唤醒(signal/broadcast或虚假唤醒)时线程从条件变量的等待队列中移除并重新竞争mutex(若锁被其他线程持有,则阻塞等待锁)。如果竞争成功,函数返回,执行pthread_cond_wait的后续代码。

1.3.4 唤醒一个等待线程

int pthread_cond_broadcast(pthread_cond_t *cond);

作用从条件变量的等待队列中唤醒至少一个线程(具体唤醒哪个由操作系统调度器决定,通常是优先级最高或等待最久的)。

行为细节

  • 被唤醒的线程不会立即执行,而是需要重新竞争互斥锁(mutex),获取锁后才能继续。
  • 若等待队列中无线程,该操作无效果(不会报错)。

适用场景:条件变化仅影响一个线程的场景(如单消费者模型)。

1.3.5 唤醒所有等待线程

int pthread_cond_broadcast(pthread_cond_t *cond);

作用:唤醒条件变量等待队列中的所有线程

行为细节:所有被唤醒的线程会竞争互斥锁,逐个获取锁并检查条件(满足则执行,不满足可能再次阻塞)。

适用场景:条件变化影响多个线程的场景(如 “资源池新增资源” 时通知所有等待的消费者)。

在一般情况下,不会使用pthread_cond_broadcast一次性唤醒大量线程避免惊群效应,导致cpu负荷一时间大量增加

1.3.6 使用事项

锁的范围:互斥锁必须在检查条件、修改资源、调用wait/signal的整个过程中有效,确保操作的原子性。

唤醒时机:必须先修改共享资源使条件满足,再调用signal/broadcast,否则可能导致等待线程 “错过” 唤醒信号(永久阻塞)。

避免死锁:pthread_cond_wait必须在已加锁的状态下调用,且释放锁和进入等待是原子操作,避免 “唤醒丢失” 问题。

1.4 条件变量的封装

封装互斥锁:

#include 
#include 
class Mutex
{
public:
    // 去掉不要的赋值与拷贝
    Mutex(const Mutex &) = delete;
    const Mutex &operator=(const Mutex &) = delete;
    Mutex()
    {
        pthread_mutex_init(&tid, NULL);
    }
    ~Mutex()
    {
        pthread_mutex_destroy(&tid);
    }
    void lock()
    {
        pthread_mutex_lock(&tid);
    }
    void unlock()
    {
        pthread_mutex_unlock(&tid);
    }
    pthread_mutex_t *GetmMutexOriginal()
    {
        return &tid;
    }
private:
    pthread_mutex_t tid;
};
class LockGuard
{
public:
    LockGuard(Mutex &mutex) : _mutex(mutex)
    {
        _mutex.lock();
    }
    ~LockGuard()
    {
        _mutex.unlock();
    }
private:
    Mutex &_mutex;
};

封装条件变量:

#pragma once
#include
#include
#include"Lock.hpp"
class Cond
{
    public:
    Cond()
    {
        pthread_cond_init(&cond,nullptr);
    }
    void Wait(Mutex& mutex)
    {
        pthread_cond_wait(&cond,mutex.GetmMutexOriginal());
    }
    void Notify()
    {
        //唤醒当前条件变量下的一个线程
        //至于是哪个线程由调度器决定
        pthread_cond_signal(&cond);
    }
    void Notifyall()
    {
        pthread_cond_broadcast(&cond);
    }
    ~Cond()
    {
        pthread_cond_destroy(&cond);
    }
    private:
    pthread_cond_t cond;
};

二、生产者消费者模型

生产者 - 消费者模型是多线程编程中经典的同步协作模式,用于解决 “生产者线程生成数据” 与 “消费者线程消费数据” 之间的协作问题。其核心是通过共享缓冲区传递数据,并使用同步机制(如互斥锁、条件变量)保证数据访问的安全性和效率。

我们可以通过超市这一简单的例子来理解生产者、消费者与共享缓冲区的概念:

我们知道生活中一个厂家不会直接接触消费者并为其提供服务,而是将生产出来的物品交给超市。再由超市管理员将物品统一放置在架子上供消费者消费。一个货架上的商品可能来自多个不同的厂家,而在一个货架上挑选商品的消费者也不会只有一个。在这个场景中,厂家就是生产者,超市就是一个大的共享缓冲区(可以同时被生产者、消费者看到)

生产者 - 消费者模型是多线程 / 进程协作中极具价值的设计模式,其优势主要体现在解耦性、并发效率、资源平衡等多个方面,

1.解耦生产者与消费者

  • 核心逻辑:生产者和消费者通过共享缓冲区间接交互,而非直接通信。
  • 优势
    • 两者无需知道对方的存在,只需关注自身逻辑(生产者生成数据,消费者处理数据)。
    • 便于独立扩展或修改:例如,可增加生产者数量以提高数据生成速度,或替换消费者的处理逻辑(如从 “本地处理” 改为 “网络发送”),而无需修改另一方的代码。
    • 符合 “单一职责原则”:每个组件仅负责自己的核心任务,代码模块化更强,维护成本更低。

2. 平衡生产与消费速度

  • 核心逻辑:共享缓冲区作为 “缓冲地带”,可暂存生产者生成的数据,缓解两者速度不匹配的问题。
  • 优势
    • 若生产者速度快于消费者:缓冲区可暂存多余数据,避免生产者因 “消费者处理不及” 而阻塞(或频繁等待)。
    • 若消费者速度快于生产者:缓冲区可缓存历史数据,避免消费者因 “无数据可处理” 而频繁空闲。
    • 例如:在日志系统中,日志生成(生产者)可能突发高频写入,而日志持久化(消费者)速度较慢,缓冲区可暂存日志,避免生产端阻塞。

3. 提高系统并发效率

  • 核心逻辑:生产者和消费者可并行执行,通过同步机制(互斥锁、条件变量)协调对缓冲区的访问,而非串行等待。
  • 优势
    • 充分利用 CPU 资源:当生产者等待缓冲区空闲时,消费者可同时处理数据;反之,消费者等待数据时,生产者可继续生成数据。
    • 支持多生产者 / 多消费者协作:通过锁机制保护缓冲区,多个生产者可并发生成数据,多个消费者可并发处理数据,大幅提升整体吞吐量(如分布式消息队列 Kafka 的多分区并发读写)。

4.支持异步通信

  • 核心逻辑:生产者无需等待消费者处理完数据,只需将数据放入缓冲区即可返回,继续执行后续任务。
  • 优势
    • 减少等待时间,提升响应速度:例如,用户上传图片后,服务器(生产者)将图片放入处理队列,立即返回 “上传成功”,后台线程(消费者)异步处理图片压缩、存储等耗时操作,用户无需等待处理完成。

2.1 核心组成

生产者 - 消费者模型包含 3 个关键角色:

  1. 生产者(Producer):生成数据并放入共享缓冲区。
  2. 消费者(Consumer):从共享缓冲区取出数据并处理。
  3. 共享缓冲区(Buffer):用于暂存数据的中间区域(如队列、数组),通常有容量限制。

共享缓冲区就是一个临界区域,我们规定多个生产者 / 消费者不能同时操作缓冲区(避免数据混乱)及多个生产者之间是互斥的,多个消费者之间是互斥的。

当缓冲区满时,生产者需等待(不能继续放入数据)。缓冲区空时,消费者需等待(不能继续取出数据)。即生产者与消费者之间是同步的,我们可以用条件变量实现等待 / 唤醒机制。

2.2 基于阻塞队列的生产者消费者模型

基于阻塞队列的生产者 - 消费者模型是一种高效且易用的实现方式。阻塞队列本身集成了同步机制(互斥锁 + 条件变量),当队列满时自动阻塞生产者,当队列空时自动阻塞消费者,无需手动处理复杂的同步逻辑。

  1. 入队(Enqueue):当队列满时,生产者线程会被阻塞,直到队列有空闲空间。
  2. 出队(Dequeue):当队列空时,消费者线程会被阻塞,直到队列中有数据。

实现代码:

#pragma once
#include
#include
#include
template
class BlockQueue
{
    public:
    BlockQueue(int cap=10)
    :_cap(cap)
    {
        pthread_mutex_init(&_mutex,nullptr);
        pthread_cond_init(&_pcond,nullptr);
        pthread_cond_init(&_ccond,nullptr);
    }
    ~BlockQueue()
    {
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_pcond);
        pthread_cond_destroy(&_ccond);
    }
    bool Empty()
    {
        return _blockqueue.empty();
    }
    bool IsFull()
    {
        return _cap==_blockqueue.size();
    }
    //生产者调用
    void Equeue(T& in)
    {
        pthread_mutex_lock(&_mutex);
        loop_prstart:
        if(!IsFull())
        {
            _blockqueue.push(in);
            if(consum_slnum>0)
            {
                pthread_cond_signal(&_ccond);
            }
        }
        else
        {
            produce_slnum++;
            pthread_cond_wait(&_pcond, &_mutex);
            produce_slnum--;
            goto loop_prstart;
        }
        pthread_mutex_unlock(&_mutex);
    }
    //消费者调用
    void PopQueue(T* out)
    {
        pthread_mutex_lock(&_mutex);
        loop_crstart:
        if(!Empty())
        {
            *out=_blockqueue.front();
            _blockqueue.pop();
            if(produce_slnum>0)
            {
                pthread_cond_signal(&_pcond);
            }
        }
        else
        {
            consum_slnum++;
            pthread_cond_wait(&_ccond, &_mutex);
            consum_slnum--;
            goto loop_crstart;
        }
        pthread_mutex_unlock(&_mutex);
    }
    private:
    std::queue _blockqueue;
    //队列容量
    size_t _cap;
    //等待队列中消费者的数量
    size_t consum_slnum=0;
    //等待队列中生产者的数量
    size_t produce_slnum=0;
    //互斥锁保护临界资源
    pthread_mutex_t _mutex;
    //条件变量
    pthread_cond_t _pcond;
    pthread_cond_t _ccond;
};

测试代码:

#include
#include"queue.hpp"
#include
#include
void* produce(void* args)
{
    BlockQueue* bq=static_cast*>(args);
    int data=0;
    while(1)
    {
        bq->Equeue(data);
        std::cout<<"生产了一个数据"<* bq=static_cast*>(args);
    int value;
    while(1)
    {
        int* out=&value;
        bq->PopQueue(out);
        sleep(1);
        std::cout<<"消费了一个数据"<<*out<* blockqueue=new  BlockQueue();
    pthread_t l1;
    pthread_t l2;
    pthread_create(&l1,nullptr,produce,(void*)blockqueue);
    pthread_create(&l2,nullptr,consum,(void*)blockqueue);
    pthread_join(l1,nullptr);
    pthread_join(l2,nullptr);
    return 0;
}

运行结果:

posted @ 2025-12-18 21:35  clnchanpin  阅读(77)  评论(0)    收藏  举报