《Linux系统编程》Linux 系统多线程(五):<线程同步与互斥>线程互斥(上):从条件变量到生产者消费者模型详解

🔥小叶-duck个人主页

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

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

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

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

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

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


目录

前言

一、为什么有了互斥锁,还需要线程同步?

[1.1 互斥锁的局限性:安全但不合理](#1.1 互斥锁的局限性:安全但不合理)

[1.2 线程同步的核心定义](#1.2 线程同步的核心定义)

二、线程同步的核心工具:条件变量

[2.1 条件变量是什么?](#2.1 条件变量是什么?)

[2.2 条件变量核心 API](#2.2 条件变量核心 API)

[2.2.1 初始化条件变量](#2.2.1 初始化条件变量)

[2.2.2 销毁条件变量](#2.2.2 销毁条件变量)

[2.2.3 等待条件满足(核心 API)](#2.2.3 等待条件满足(核心 API))

[2.2.4 唤醒等待的线程](#2.2.4 唤醒等待的线程)

[2.2.5 条件变量的最基础demo(API 的初步使用)](#2.2.5 条件变量的最基础demo(API 的初步使用))

[2.3 为什么pthread_cond_wait需要互斥量?](#2.3 为什么pthread_cond_wait需要互斥量?)

[2.3.1 错误的设计:先解锁,再等待](#2.3.1 错误的设计:先解锁,再等待)

[2.3.2 正确的原子性保证](#2.3.2 正确的原子性保证)

[2.4 条件变量使用规范](#2.4 条件变量使用规范)

[2.4.1 为什么必须用 while 判断条件?](#2.4.1 为什么必须用 while 判断条件?)

[2.5 条件变量的 C++ 封装](#2.5 条件变量的 C++ 封装)

三、线程同步的经典范式:生产者消费者模型

[3.1 什么是生产者消费者模型?](#3.1 什么是生产者消费者模型?)

[3.2 核心记忆点:321 原则](#3.2 核心记忆点:321 原则)

[3.3 该模型的三大核心优势](#3.3 该模型的三大核心优势)

[3.4 基于阻塞队列 (BlockingQueue) 的模型实现](#3.4 基于阻塞队列 (BlockingQueue) 的模型实现)

[3.4.1 阻塞队列完整代码与注释解析](#3.4.1 阻塞队列完整代码与注释解析)

[3.4.1.1 BlockQueue.hpp](#3.4.1.1 BlockQueue.hpp)

[3.4.1.2 单生产单消费 Demo(int类型)](#3.4.1.2 单生产单消费 Demo(int类型))

[3.4.1.3 单生产单消费 Demo(任务类类型)](#3.4.1.3 单生产单消费 Demo(任务类类型))

[3.4.1.4 单生产单消费 Demo(可调用对象包装器)](#3.4.1.4 单生产单消费 Demo(可调用对象包装器))

[3.4.1.5 多生产多消费 Demo](#3.4.1.5 多生产多消费 Demo)

结束语


前言

在 Linux 多线程编程中,我们通过互斥锁解决了临界资源的并发安全问题。上篇文章我们已经对线程互斥和互斥锁进行了详细的讲解,基本保证了我们现在的线程不会出现并发访问临界资源的情况了。但是很多同学写的代码依然会出现问题:某个线程疯狂抢占锁 ,导致其他线程长期得不到执行造成饥饿问题 ;线程不断轮询判断临界资源状态,造成 CPU 资源的严重浪费;生产者和消费者强耦合,代码扩展性极差。造成这些问题的核心,就是没有对线程进行同步处理。互斥保证了临界资源的访问安全 ,而同步 则在安全的前提下,让多线程按照合理的顺序协同执行,既避免了饥饿,又最大化发挥了多线程的并发性能。本文从同步的核心概念、条件变量的底层原理,到生产者消费者模型的两种经典实现,结合代码逐行拆解,帮你彻底搞懂 Linux 线程同步。

一、为什么有了互斥锁,还需要线程同步?

在讲同步之前,我们先搞懂一个核心问题:互斥锁已经能保证临界资源的安全访问了,为什么还需要线程同步

1.1 互斥锁的局限性:安全但不合理

我们用一个VIP 自习室例子来理解:

  • VIP 自习室一次只能进一个人 ,门口只有一把钥匙,这就是互斥锁
  • 你凌晨抢到钥匙进了自习室,学完出门刚挂上钥匙,立刻又抢了回来继续学,如此反复;
  • 其他排队的同学永远抢不到钥匙,只能一直等,这就是线程饥饿问题

互斥锁只保证了「同一时间只有一个线程访问临界资源」,但完全不保证访问的公平性和顺序性 。在极端情况 下,一个线程可以反复抢占锁其他线程长期得不到 CPU 调度 ,代码虽然是线程安全的,但执行效率极低 ,完全浪费了多线程的并发优势

1.2 线程同步的核心定义

  • 同步:在保证数据安全(互斥)的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,让多线程协同更合理、更高效。
  • 正确的说法(什么是线程同步,为什么需要同步) :线程同步指的是线程间对数据资源进行获取,有可能在不满足访问资源条件的情况下访问资源 而造成程序逻辑混乱 ,因此通过进行条件判断 来决定线程在不能访问资源时休眠等待满足资源后唤醒等待的线程的方式实现对资源访问的合理性。(这几句话只看文字不是很好理解,后面我们同步的接口进行讲解再用代码实例讲解线程同步大家就会理解这几句话了)

简单来说

  • 互斥 :解决**「能不能访问」** 的问题,保证临界资源的安全
  • 同步 :解决**「什么时候访问」** 的问题,保证多线程的协同有序

二、线程同步的核心工具:条件变量

2.1 条件变量是什么?

我们还是用自习室的例子来理解:

  • 自习室门口新增了一个等待队列 ,没抢到钥匙的同学必须去队列挨个排队,不能反复抢锁
  • 自习室里的同学出来后,必须先通知队列里的第一个同学让他进去,而不是自己重新抢锁,并且自己也不能插队 必须到队尾重新排队等待
  • 这个**「排队 + 通知」** 的机制,就是条件变量

从技术角度来说,条件变量是 POSIX 线程库 提供的同步工具,它提供了线程等待线程唤醒两大核心能力:

  • 当线程访问临界资源的条件不满足时,就让线程进入阻塞等待状态,释放 CPU 资源;
  • 当其他线程将条件修改为满足时,主动唤醒等待的线程,让其继续执行。

2.2 条件变量核心 API

条件变量的操作接口 都在 <pthread.h> 头文件 中,编译时需要链接**-lpthread**库,核心 API 分为 4 类:

2.2.1 初始化条件变量

初始化有两种方式,和互斥锁完全对应:

cpp 复制代码
// 方式1:静态初始化(全局/静态变量适用)
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

// 方式2:动态初始化(局部变量适用)
int pthread_cond_init(pthread_cond_t *restrict cond,
                      const pthread_condattr_t *restrict attr);
  • **参数cond:**要初始化的条件变量指针;
  • **参数attr:**条件变量属性,填NULL表示使用默认属性;
  • **返回值:**成功返回 0,失败返回错误码。

2.2.2 销毁条件变量

cpp 复制代码
int pthread_cond_destroy(pthread_cond_t *cond);
  • 注意:PTHREAD_COND_INITIALIZER 静态初始化的条件变量,无需手动销毁
  • 必须确保没有线程在该条件变量上等待时,再执行销毁。

2.2.3 等待条件满足(核心 API)

cpp 复制代码
int pthread_cond_wait(pthread_cond_t *restrict cond,
                      pthread_mutex_t *restrict mutex);
  • **参数cond:**要等待的条件变量;
  • 参数mutex:保护临界资源的互斥锁;
  • 核心功能:让调用线程进入阻塞等待状态,原子性地释放持有的互斥锁 ;当线程被唤醒时,会自动重新竞争互斥锁,竞争成功后才会返回

2.2.4 唤醒等待的线程

cpp 复制代码
// 唤醒条件变量等待队列中的至少一个线程
int pthread_cond_signal(pthread_cond_t *cond);
// 唤醒条件变量等待队列中的所有线程
int pthread_cond_broadcast(pthread_cond_t *cond);

2.2.5 条件变量的最基础demo(API 的初步使用)

(1)主线程每次唤醒一个线程:

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

#define NUM 5
int cnt = 1000;

pthread_mutex_t glock = PTHREAD_MUTEX_INITIALIZER; // 定义锁,为什么一定需要要有锁?
pthread_cond_t gcond = PTHREAD_COND_INITIALIZER;   // 定义条件变量

// 证明:条件变量可以允许线程等待
//      条件变量可以允许一个线程唤醒在cond中等待的其他线程,实现同步过程
void *threadrun(void *args)
{
    std::string name = static_cast<const char *>(args);
    while (true)
    {
        pthread_mutex_lock(&glock);
        // 直接让对应的线程进行等待??临界资源不满足导致我们等待的!
        // 等待是需要等,什么条件才会等呢?等待之前,就要对资源的数量进行判定。
        // 判定本身就是访问临界资源!,判断一定是在临界区内部的.
        // 判定结果,也一定在临界资源内部。所以,条件不满足要休眠,一定是在临界区内休眠的!
        pthread_cond_wait(&gcond, &glock);
        std::cout << name << " 计算: " << cnt << std::endl;
        cnt++;
        pthread_mutex_unlock(&glock);
    }
}

int main()
{
    std::vector<pthread_t> threads;
    for (int i = 0; i < NUM; i++)
    {
        pthread_t tid;
        char *name = new char[64]; // 在堆上开辟空间
        snprintf(name, 64, "thread-%d", i);
        pthread_create(&tid, nullptr, threadrun, name);
        threads.push_back(tid); // 管理多线程
    }
    sleep(3);

    //主线程每隔1s唤醒一个线程
    while(true)
    {
        std::cout << "唤醒一个线程" << std::endl;
        pthread_cond_signal(&gcond);
        sleep(1);
    }

    for (auto &thread : threads)
    {
        pthread_join(thread, nullptr);
    }
    return 0;
}

(2)主线程每次唤醒所有线程:

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

#define NUM 5
int cnt = 1000;

pthread_mutex_t glock = PTHREAD_MUTEX_INITIALIZER; // 定义锁,为什么一定需要要有锁?
pthread_cond_t gcond = PTHREAD_COND_INITIALIZER;   // 定义条件变量

// 证明:条件变量可以允许线程等待
//      条件变量可以允许一个线程唤醒在cond中等待的其他线程,实现同步过程
void *threadrun(void *args)
{
    std::string name = static_cast<const char *>(args);
    while (true)
    {
        pthread_mutex_lock(&glock);
        // 直接让对应的线程进行等待??临界资源不满足导致我们等待的!
        // 等待是需要等,什么条件才会等呢?等待之前,就要对资源的数量进行判定。
        // 判定本身就是访问临界资源!,判断一定是在临界区内部的.
        // 判定结果,也一定在临界资源内部。所以,条件不满足要休眠,一定是在临界区内休眠的!
        pthread_cond_wait(&gcond, &glock);
        std::cout << name << " 计算: " << cnt << std::endl;
        cnt++;
        pthread_mutex_unlock(&glock);
    }
}

int main()
{
    std::vector<pthread_t> threads;
    for (int i = 0; i < NUM; i++)
    {
        pthread_t tid;
        char *name = new char[64]; // 在堆上开辟空间
        snprintf(name, 64, "thread-%d", i);
        pthread_create(&tid, nullptr, threadrun, name);
        threads.push_back(tid); // 管理多线程
    }
    sleep(3);

    // 主线程每隔1s唤醒所有线程
    while (true)
    {
        std::cout << "唤醒所有线程" << std::endl;
        pthread_cond_broadcast(&gcond);
        sleep(1);

        // 相当于全部线程竞争锁
    }

    for (auto &thread : threads)
    {
        pthread_join(thread, nullptr);
    }
    return 0;
}

代码核心解析

  • 线程函数中,调用 pthread_cond_wait 前必须先持有互斥锁,符合使用规范;
  • pthread_cond_wait 会自动释放锁 ,让其他线程有机会修改条件,唤醒后重新拿锁,保证了原子性;
  • pthread_cond_signal 只会唤醒等待队列中的一个线程,而 pthread_cond_broadcast 会唤醒所有等待的线程,根据业务场景选择使用。

2.3 为什么pthread_cond_wait需要互斥量?

上面讲了这么多,一直在谈 pthread_cond_wait 需要传入互斥锁,可是为什么呢?能不能不传呢?

pthread_cond_wait 之所以需要传入互斥锁,其本质并非为了给等待线程提供保护(它即将睡眠,持有锁毫无意义),而是为了在等待期间将锁释放,以便让其他线程能够安全地获取锁并进入临界区修改共享数据,从而改变条件并唤醒等待线程。换句话说,这把锁的真实用途是服务于"拯救者"而非"等待者"。

2.3.1 错误的设计:先解锁,再等待

很多同学会想:我先上锁判断条件不满足(其他线程拿不到了),先解锁,再等待不就行了?我们看这段错误代码:

cpp 复制代码
// 错误示范!!!
pthread_mutex_lock(&mutex);
while (condition_is_false) 
{
    pthread_mutex_unlock(&mutex);
    // 【致命窗口】解锁之后、等待之前,条件可能已经满足,信号被彻底错过!
    pthread_cond_wait(&cond, &mutex); // 这里的mutex只是占位,无实际意义
    pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);

致命问题 :解锁和等待不是原子操作。在解锁之后、进入等待之前,其他线程可能已经修改了条件,发出了唤醒信号,但当前线程还没进入等待队列这个信号就被永久错过了,线程会永远阻塞在 wait 调用中。

那这里就会有人提问了:这一次唤醒发出的信号被错过了,那等这个线程 wait 了之后我再唤醒发出信号不就行了?那我就提问了:这个线程因为被切换了还没进入 wait ,那什么时候被切回来呢?如果这个时间非常长,即使第二次发生唤醒信号也无济于事,你是不是还会说那就第三次发生唤醒信号?所以你根本不清楚什么时候你发出的唤醒信号才会有意义!这种靠"运气"来恢复错误是并发编程的一个大忌!

2.3.2 正确的原子性保证

pthread_cond_wait的核心设计,就是把**「释放互斥锁」** 和**「进入等待状态」**做成了原子操作:

  • 线程调用 wait 时,会原子性地释放持有的互斥锁,让其他线程可以修改临界资源;
  • 当线程被唤醒时,会自动重新竞争互斥锁,只有竞争成功,才会从 wait 函数返回,继续访问临界资源。
  • 这就彻底解决了信号丢失的问题,也保证了临界资源访问的安全性。

2.4 条件变量使用规范

2.4.1 为什么必须用 while 判断条件?

很多同学一开始在这里不解,为什么一定需要用 while 判断条件,用 if 判断条件是否满足,这是严重的错误,必须用 while 循环重新判断条件。那为什么呢?

核心原因:伪唤醒问题

  • pthread_cond_wait 可能在没有收到 pthread_cond_signal/pthread_cond_broadcast 的情况下异常返回 (比如系统信号中断、多核 CPU 竞争、广播唤醒后条件被其他线程抢先修改 ),这就是伪唤醒
  • 如果用 if 判断,伪唤醒 后线程会直接往下执行此时条件依然不满足 ,就会访问非法的临界资源,造成程序崩溃 ;而用 while 循环,线程被唤醒后会重新判断条件 ,若条件不满足就继续等待,直到条件真正满足了再往下执行,彻底规避了伪唤醒的风险

2.5 条件变量的 C++ 封装

我们把条件变量封装成一个通用的 Cond 类 ,配合之前封装的互斥锁使用,代码更简洁、更安全:要引入我们之前自己封装的 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* Origin()
    {
        return &_lock;
    }
private:
    pthread_mutex_t _lock;
};


class LockGuard
{
public:
    LockGuard(Mutex* lockptr) : _lockptr(lockptr)
    {
        _lockptr->Lock();
    }
    ~LockGuard()
    {
        _lockptr->UnLock();
    }
private:
    Mutex* _lockptr;
};
#endif
  • Cond.hpp:
cpp 复制代码
#ifndef COND_HPP
#define COND_HPP
#include <iostream>
#include <pthread.h>
#include "Mutex.hpp"

class Cond 
{
public:
    Cond()
    {
        pthread_cond_init(&cond, nullptr);
    }
    void Wait(Mutex &mutex)
    {
        pthread_cond_wait(&cond, mutex.Origin());
    }
    void NotifyOne()
    {
        pthread_cond_signal(&cond);
    }
    void NotifyAll()
    {
        pthread_cond_broadcast(&cond);
    }
    ~Cond()
    {
        pthread_cond_destroy(&cond);
    }
private:
    pthread_cond_t cond;
};
#endif

封装设计要点

  • 不把互斥锁内置到 Cond 类中,避免耦合,因为一个互斥锁可以对应多个条件变量;
  • 封装了原生 API,屏蔽了底层细节,使用更简洁;
  • 利用 RAII 机制,构造时初始化,析构时销毁,避免资源泄漏。

三、线程同步的经典范式:生产者消费者模型

讲解线程同步,就离不开生产者消费者模型生产者消费者模型是多线程编程中最经典、最常用的同步模型,也是面试非常重要的考点。

3.1 什么是生产者消费者模型?

我们用生活中的超市例子来理解:

  • 生产者:食品工厂,负责生产商品,不用关心谁来买、什么时候买;
  • 消费者:我们买东西的人,不用关心商品是谁生产的、怎么生产的;
  • 交易场所:超市,工厂把商品放到超市,我们从超市买商品。

对应到计算机领域:

  • **生产者线程:**负责生产数据 / 任务,写入缓冲区;
  • **消费者线程:**负责从缓冲区读取数据 / 任务,执行处理;
  • **交易场所:**一块带同步机制的缓冲区(阻塞队列、环形队列等)。

3.2 核心记忆点:321 原则

这个原则并不是所谓的官方原则,只是便于我们理解记忆生产消费者模型的。如果在面试的时候问到了和生产消费者模型相关话题,我们就可以心里想出 321 原则来便于整理我们所说的话,但在面试的时候不要直接回答这个原则。

数字 核心含义 详细说明
3 三种关系 1. 生产者之间:互斥关系 2. 消费者之间:互斥关系 3. 生产者与消费者之间:互斥 + 同步关系
2 两种角色 生产者线程、消费者线程(两种角色可以有多个线程)
1 一个交易场所 线程间共享的缓冲区(核心是对这个缓冲区的互斥与同步控制)

3.3 该模型的三大核心优势

  • 解耦:生产者和消费者不直接通信,通过缓冲区交互,一方代码修改不会影响另一方,彻底解决了强耦合问题。
  • 支持忙闲不均:生产者短时间生产大量数据时,缓冲区可以暂存数据,消费者慢慢处理;生产者空闲时,缓冲区里的缓存数据也能让消费者持续有活干,避免了线程频繁阻塞唤醒。
  • **提高效率(支持并发):**生产者生产完数据直接放入缓冲区,不用等待消费者处理,继续生产;消费者同时从缓冲区取数据处理,生产和消费真正并行执行,最大化利用多核 CPU。

对于生产消费者模型的三大核心优势啊,很多同学对于前两个优势都能理解为什么,但是对于第三个优势也就是提高了效率这一方面,有些学生不理解究竟是为什么就提高了效率,而且基于等会我们所讲的内容会发现,这个生产消费者模型是需要用到互斥锁的,所以那些同学就疑问了:互斥锁同一时刻都只能允许一个线程去访问临界资源,这怎么就能提高效率了?

我们先把原因放出来,把下面内容看完再回过头看这个原因就能非常清楚了。

3.4 基于阻塞队列 (BlockingQueue) 的模型实现

阻塞队列 是实现生产者消费者模型最常用的数据结构,它的核心特性是:

  • 队列为空 时,消费者 取数据的操作会被阻塞直到队列中有数据
  • 队列满 时,生产者 放数据的操作会被阻塞直到队列中有空闲位置

我们基于 C++ 模板实现一个通用的阻塞队列,完整兼容单生产 / 单消费、多生产 / 多消费场景。

3.4.1 阻塞队列完整代码与注释解析

3.4.1.1 BlockQueue.hpp
cpp 复制代码
#ifndef __BLOCKQUEUE_HPP
#define __BLOCKQUEUE_HPP

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

const int default_cap = 5;

// 类模板(实现多种类型的生产消费者模型)
template <class T>
class BlockQueue
{
public:
    BlockQueue(int cap = default_cap)
        : _cap(cap), _psleep_num(0), _csleep_num(0)
    {
        pthread_mutex_init(&_mutex, nullptr);
        pthread_cond_init(&_full_cond, nullptr);
        pthread_cond_init(&_empty_cond, nullptr);
    }

    void Equeue(const T &in)
    {
        pthread_mutex_lock(&_mutex);

        // 生产者调用
        // if (IsFull()) // 存在bug??
        while (IsFull())
        {
            // 队列数据满了->生产者不能再生产了->
            // 阻塞等待生产者(_psleep_num++),等到消费者消费后唤醒(_psleep_num--)

            // 重点1:pthread_cond_wait调用成功,在挂起当前线程前,会先自动释放锁!!
            // 为什么要释放锁呢?如果不释放锁会怎么样?->线程带着锁进行睡眠!
            // 就会导致其他所有线程都无法再得到锁了,但这还不是最严重的
            // 因为我们可以通过pthread_cond_signal来唤醒这个线程,也就是说唤醒操作可以在锁外进行(后面再讲)
            // 但是:我们要知道其他线程也只能执行pthread_cond_signal来唤醒这个线程(因为已经无法获取到锁进入临界区改变条件)
            // 可是此时唤醒生产者后还会继续生产->就会导致数据溢出!!
            // 为什么,让消费者消费一些数据不就行了?->怎么消费,没有锁啊!所以问题也就产生了
            // 这也就是为什么pthread_cond_wait需要第二个参数传入锁的原因了!

            // 重点2:当线程被唤醒的时候,前面讲了默认就是在临界区内唤醒!
            // 所以,pthread_cond_wait成功返回,当前唤醒的线程如果需要继续执行代码,就需要重新申请_mutex锁!
            // 重点3:那申请的时候还会不会存在其他线程抢到锁而申请失败呢?会!->当前线程就会到锁上阻塞等待了!
            _psleep_num++;

            // 问题1:pthread_cond_wait是一个函数->函数调用也存在调用失败->
            // 函数立即返回并且生产者线程没有成功挂起等待,生产者继续往下走
            // 问题2:pthread_cond_wait可能会因为,条件其实不满足,但是pthread_cond_wait"伪唤醒"了
            //(问题2的理解:举个例子,假设当前有非常多的生产者都被条件变量挂起(数据满了),
            // 一个消费者消费一个数据后并不是唤醒一个生产者,而是唤醒全部生产者!
            // 所以全部生产者就都要一个一个重新申请锁,此时锁还在生产者手上所以生产者会一个一个到锁上阻塞等待,
            // 但是,由于生产者非常多一直都在申请锁,一直到生产者解锁了还在申请!
            // 此时可能就存在一个被唤醒的生产者成功申请到锁而继续往下走,
            // 当生产者解锁后可能又有一个被唤醒的生产者成功申请到锁,就会导致一个消费者消费多个生产者生产的情况)

            // 这两个问题都会指向一个后果:因为是if语句->生产者成功申请到锁就会直接生产数据
            // 可是如果此时数据已经满了呢!问题一就会直接导致数据溢出;
            // 问题二因为导致了一个消费者消费多个生产者生产的情况,也会导致数据溢出

            // 本质的原因:就是因为唤醒后并没有二次检查条件是否满足(数据是否满)导致的
            // 所以解决方法:就是if语句改成while语句一直重复判断直到条件符合要求(数据不满)再生产数据!

            pthread_cond_wait(&_full_cond, &_mutex);
            _psleep_num--;
        }

        _q.push(in);

        // 临时方案v2
        // 已经生产了一个数据,消费者可以消费了
        // 一定要唤醒消费者吗?如果此时本来就没有消费者在等待(数据没有为空)则无需唤醒
        if (_csleep_num)
        {
            pthread_cond_signal(&_empty_cond);
            std::cout << "唤醒一个消费者..." << std::endl;
        }

        // pthread_cond_signal(&_empty_cond);
        // 可以锁内直接唤醒线程吗?可以。
        // 结果:因为当前线程仍然拿着锁导致唤醒的线程一定申请锁失败而到锁上阻塞等待
        // 但是一定不会还在条件变量下等待了,说明是成功唤醒了
        pthread_mutex_unlock(&_mutex);

        // pthread_cond_signal(&_empty_cond);
        // 可以在锁外直接唤醒线程吗?可以。
        // 结果:当前线程已经解锁,唤醒的线程可以申请锁成功而使用临界资源,但也可能存在其他线程抢到锁
        // 而导致依然申请锁失败到锁上阻塞等待。但不管怎么样一定不会还在条件变量下等待,说明也是成功唤醒了
        // 结论:所以唤醒的操作在锁内锁外都可以,一般而言我们是在锁内唤醒
    }

    T Top()
    {
        // 消费者调用
        pthread_mutex_lock(&_mutex);
        // if (IsEmpty()) // 存在bug??
        while (IsEmpty())
        {
            // 队列数据为空->消费者不能再消费了->
            // 阻塞等待消费者(_csleep_num++),等到生产者生产后唤醒(_csleep_num--)
            _csleep_num++;
            pthread_cond_wait(&_empty_cond, &_mutex);
            _csleep_num--;
        }

        T data = _q.front();
        _q.pop();

        // 临时方案v2
        // 已经消费了一个数据,生产者可以生产了
        // 一定要唤醒生产者吗?如果此时本来就没有生产者在等待(数据没有满)则无需唤醒
        if (_psleep_num)
        {
            pthread_cond_signal(&_full_cond);
            std::cout << "唤醒一个生产者..." << std::endl;
        }

        pthread_mutex_unlock(&_mutex);
        return data;
    }

    ~BlockQueue()
    {

        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_full_cond);
        pthread_cond_destroy(&_empty_cond);
    }

private:
    bool IsFull()
    {
        return _q.size() == _cap;
    }

    bool IsEmpty()
    {
        return _q.empty();
    }

private:
    std::queue<T> _q;

    pthread_mutex_t _mutex;     // 锁
    pthread_cond_t _full_cond;  // 用于队列满时将生产者阻塞等待的条件变量
    pthread_cond_t _empty_cond; // 用于队为空时将消费者阻塞等待的条件变量
    int _cap;                   // 统计队列数据的个数

    int _csleep_num; // 统计当前等待的消费者的个数,用于判断是否需要唤醒消费者
    int _psleep_num; // 统计当前等待的生产者的个数,用于判断是否需要唤醒生产者
};

#endif

核心设计解析

  1. 双条件变量设计:生产者和消费者各用一个条件变量,避免了广播唤醒所有线程造成的无效竞争,性能更高。
  2. 等待计数优化:只有当确实有线程在等待时,才发送唤醒信号,避免了无效的系统调用。
  3. 模板化设计:支持任意类型的数据,不仅可以放 int 等基础类型,还可以放函数对象、任务类等,实现任务的生产消费。
  4. 完全兼容多生产多消费:因为队列的访问被互斥锁完全保护,不管多少个生产者、消费者线程,都能安全访问,代码无需任何修改。
3.4.1.2 单生产单消费 Demo(int类型)

Main.cc

cpp 复制代码
//Main.cc
#include "BlockQueue.hpp"
#include <unistd.h>

void *consumer(void *args)
{
    BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(args);
    while (true)
    {
        sleep(2);
        int data = bq->Top();
        std::cout << "消费了一个数据: " << data << std::endl;
    }
}

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

int main()
{
    // 申请阻塞队列
    BlockQueue<int> *bq = new BlockQueue<int>();

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

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

    pthread_join(c, nullptr);
    pthread_join(p, nullptr);
    return 0;
}
3.4.1.3 单生产单消费 Demo(任务类类型)

Task.hpp:

cpp 复制代码
//Task.hpp
#ifndef __TASK_HPP
#define __TASK_HPP
#include <iostream>
#include <functional>

class Task
{
public:
    Task(int x, int y)
        : _x(x), _y(y)
    {
    }

    void Excute()
    {
        _result = _x + _y;
    }

    int X() { return _x; }
    int Y() { return _y; }

    int Result() { return _result; }

    ~Task()
    {
    }

private:
    int _x;
    int _y;
    int _result;
};

#endif

Main.cc

cpp 复制代码
//Main.cc
#include "BlockQueue.hpp"
#include <unistd.h>
#include "Task.hpp"

void *consumer(void *args)
{
    BlockQueue<Task> *bq = static_cast<BlockQueue<Task> *>(args);
    while (true)
    {
        sleep(2);
        Task t = bq->Top();
        t.Excute();
        std::cout << "消费了一个任务: " << t.X() << "+" << t.Y() << "=" << t.Result() <<std::endl;
    }
}

void *productor(void *args)
{
    BlockQueue<Task> *bq = static_cast<BlockQueue<Task> *>(args);
    int x = 1;
    int y = 1;
    while (true)
    {
        // sleep(1);
        std::cout << "生产了一个任务: " << x << "+" << y << "=?" << std::endl;
        bq->Equeue(Task(x, y));
        x++;
        y++;
    }
}

int main()
{
    //拓展认识:阻塞队列:可以放入任务吗?
    BlockQueue<Task> *bq = new BlockQueue<Task>();

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

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

    pthread_join(c, nullptr);
    pthread_join(p, nullptr);
    return 0;
}
3.4.1.4 单生产单消费 Demo(可调用对象包装器)

Task.hpp:

cpp 复制代码
//Task.hpp
#ifndef __TASK_HPP
#define __TASK_HPP
#include <iostream>
#include <functional>

//包装器
using task_t = std::function<void()>;

void DownLoad()
{
    std::cout << "我是一个下载任务..." << std::endl;
}

Main.cc

cpp 复制代码
void *consumer(void *args)
{
    BlockQueue<task_t> *bq = static_cast<BlockQueue<task_t> *>(args);
    while (true)
    {
        // sleep(2);
        // 1、消费任务
        task_t t = bq->Top();
        // 2、处理任务
        t();
    }
}

void *productor(void *args)
{
    BlockQueue<task_t> *bq = static_cast<BlockQueue<task_t> *>(args);
    int x = 1;
    int y = 1;
    while (true)
    {
        sleep(1);
        // 1、获取任务
        std::cout << "生产了一个任务: " << std::endl;
        // 2、生产任务
        bq->Equeue(DownLoad);
    }
}

//生产者消费者模型不仅仅是从生产任务到消费任务的过程,而是从获取任务到处理任务的过程
//生产者消费者模型"提高效率"的地方,并不是在"放入队列(生产任务)"和"取出队列(消费任务)"这两个串行操作上,
//而是在:生产者获取任务(可以并发进行); 消费者处理任务(可以并发进行)
//队列的串行操作只占总耗时的一小部分,因此对整个系统效率的影响微乎其微。
//真正的效率提升来源于:获取任务和处理任务的并发重叠执行!

int main()
{
    BlockQueue<task_t> *bq = new BlockQueue<task_t>();

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

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

    pthread_join(c, nullptr);
    pthread_join(p, nullptr);
    return 0;
}
3.4.1.5 多生产多消费 Demo

Main.cc

cpp 复制代码
void *consumer(void *args)
{
    BlockQueue<task_t> *bq = static_cast<BlockQueue<task_t> *>(args);
    while (true)
    {
        sleep(2);
        // 1、消费任务
        task_t t = bq->Top();
        // 2、处理任务
        t();
    }
}

void *productor(void *args)
{
    BlockQueue<task_t> *bq = static_cast<BlockQueue<task_t> *>(args);
    while (true)
    {
        // sleep(1);
        // 1、获取任务
        std::cout << "生产了一个任务: " << std::endl;
        // 2、生产任务
        bq->Equeue(DownLoad);
    }
}

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

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

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

    pthread_join(c1, nullptr);
    pthread_join(c2, nullptr);
    pthread_join(p1, nullptr);
    pthread_join(p2, nullptr);
    pthread_join(p3, nullptr);
    return 0;
}

结束语

本文从互斥锁的局限性出发,完整讲解了 Linux 线程同步的核心知识:线程同步的核心是在安全的前提下,让多线程有序协同,避免饥饿,提升效率;条件变量是实现同步的核心工具,重点掌握 wait 函数的原子性、while 循环防伪唤醒的使用规范;生产者消费者模型是同步的经典范式,用 321 原则就能彻底吃透,阻塞队列实现简单易用。线程同步是 Linux 多线程编程的核心,也是后端开发面试的重中之重。掌握了这些知识,你就能写出既安全又高效的多线程代码,不管是业务开发还是面试,都能游刃有余。

相关推荐
一叶龙洲3 小时前
向日葵远程Ubuntu,支持隐私屏
linux·运维·ubuntu
寒水馨4 小时前
Linux下载、安装llama.cpp-b10068(附安装包llama-b10068-bin-ubuntu-vulkan-x64.tar.gz)
linux·ubuntu·llm·llama·本地部署·llama.cpp·推理引擎
ziguo11227 小时前
C/C++ 错误处理全解:从 errno 到 C++ 异常
linux·c语言·c++·windows·visual studio
三8448 小时前
使用Samba/NFS实现文件共享/自动挂载共享目录/autofs自动挂载服务
linux·服务器·网络
高铭杰8 小时前
万物皆可KV(2)SurrealDB 存储布局分析
linux·服务器
无小道9 小时前
深入解析操作系统文件缓冲区:页缓存、基数树与f_pos的协同设计
linux·缓存·文件缓冲区
AOwhisky10 小时前
Linux(CentOS)系统管理入门笔记(第十二期)——系统管理工具与软件包管理(上篇):Cockpit 与 RPM 包管理
linux·运维·笔记·centos·云计算
寒水馨10 小时前
Linux下载、安装godot-4.7.1-stable(附安装包Godot_v4.7.1-stable_linux.x86_64.zip)
linux·游戏引擎·godot·游戏开发·2d游戏·3d游戏·godot engine
念恒1230610 小时前
Socket编程UDP(中)
linux·网络协议·udp