【Linux线程】Linux系统多线程(六):<线程同步与互斥>线程同步(上)

🎬 个人主页艾莉丝努力练剑
专栏传送门 :《C语言》《数据结构与算法》《C/C++干货分享&学习过程记录
Linux操作系统编程详解》《笔试/面试常见算法:从基础到进阶》《Python干货分享

⭐️为天地立心,为生民立命,为往圣继绝学,为万世开太平


🎬 艾莉丝的简介:


文章目录

  • [1 ~> 理论](#1 ~> 理论)
  • [2 ~> 生产者消费者模型:并发高效性的底层逻辑](#2 ~> 生产者消费者模型:并发高效性的底层逻辑)
    • [2.1 计算与 IO 的并发重叠](#2.1 计算与 IO 的并发重叠)
    • [2.2 执行流解耦与调度优化](#2.2 执行流解耦与调度优化)
    • [2.3 生产者消费者模型:从逻辑到代码的映射](#2.3 生产者消费者模型:从逻辑到代码的映射)
  • [3 ~> 条件变量](#3 ~> 条件变量)
    • [3.1 pthread_cond_wait](#3.1 pthread_cond_wait)
      • [3.1.1 为什么pthread_cond_wait需要互斥量?](#3.1.1 为什么pthread_cond_wait需要互斥量?)
      • [3.1.2 条件变量的使用规范](#3.1.2 条件变量的使用规范)
      • [3.1.3 条件变量的封装](#3.1.3 条件变量的封装)
    • [3.2 传入锁的原子性深度剖析](#3.2 传入锁的原子性深度剖析)
      • [3.2.1 原子性操作序列](#3.2.1 原子性操作序列)
      • [3.2.2 防止信号丢失(Lost Wakeup)](#3.2.2 防止信号丢失(Lost Wakeup))
  • [4 ~> 环形队列的空满区分与信号量控制实现](#4 ~> 环形队列的空满区分与信号量控制实现)
    • [4.1 底层判空判满逻辑](#4.1 底层判空判满逻辑)
    • [4.2 POSIX 信号量:信号量(Semaphore)的 P / V 操作逻辑](#4.2 POSIX 信号量:信号量(Semaphore)的 P / V 操作逻辑)
  • [5 ~> 核心代码演示:基于 C++11 与 POSIX 混合风格](#5 ~> 核心代码演示:基于 C++11 与 POSIX 混合风格)
  • [6 ~> 死锁与预防](#6 ~> 死锁与预防)
    • [6.1 用C++queue模拟阻塞队列的生产消费模型](#6.1 用C++queue模拟阻塞队列的生产消费模型)
      • [6.1.1 核心预防:对"请求与保持"条件的破坏](#6.1.1 核心预防:对“请求与保持”条件的破坏)
      • [6.1.2 针对"伪唤醒"的预防:while 循环重定向](#6.1.2 针对“伪唤醒”的预防:while 循环重定向)
      • [6.1.3 单一锁策略:规避"环路等待"](#6.1.3 单一锁策略:规避“环路等待”)
      • [6.1.4 条件变量的解耦预防](#6.1.4 条件变量的解耦预防)
      • [6.1.5 总结与评价](#6.1.5 总结与评价)
    • [6.2 RAII 风格的锁管理(防止忘记释放锁):](#6.2 RAII 风格的锁管理(防止忘记释放锁):)
    • [6.3 死锁预防的汇编级视角:](#6.3 死锁预防的汇编级视角:)
    • [6.4 死锁问题如何规避?](#6.4 死锁问题如何规避?)
  • 结尾

1 ~> 理论

VIP自习室被互斥的保护起来,这几个小人就是线程。

在安全的情况下,为了让线程的协同更加合理和高效,就有了同步的过程,哪怕顺序不是严格的排队也都可以是同步。

  • 线程同步是什么?
    线程同步:让线程访问临界资源具有一定的顺序性!
  • 为什么要有线程同步?
    线程同步:多线程协同更加合理!比如说更加高效,更加有预测性,不会存在不公平或者饥饿问题等!

2 ~> 生产者消费者模型:并发高效性的底层逻辑

在"321原则"(3种关系、2个角色、1个场所)中,存取数据虽在锁保护下,但模型的高效性源于非临界区代码的并行化

2.1 计算与 IO 的并发重叠

生产者在获取锁之前,可能正在进行耗时的网络 IO 或复杂序列化;消费者在释放锁之后,正在进行繁重的业务逻辑处理。

  • 硬核解释: 互斥锁(Mutex)仅保护指针偏移或内存拷贝(memcpy)等极短的操作。若没有缓冲区,生产者必须等待消费者处理完数据才能产出下一条,导致 CPU 核心处于 idle 状态。

2.2 执行流解耦与调度优化

通过缓冲区,系统将"生产"与"消费"从时间上解耦。当缓冲区不满/不空时,生产者与消费者在操作系统内核调度器看来是两个独立的就绪态任务,可以同时被调度到不同的 CPU 核心上运行。

2.3 生产者消费者模型:从逻辑到代码的映射

总结一下"321原则"和"阻塞队列"这两个生产者消费者模型的核心。


3 ~> 条件变量

3.1 pthread_cond_wait

3.1.1 为什么pthread_cond_wait需要互斥量?

如下图所示:

3.1.2 条件变量的使用规范

3.1.3 条件变量的封装

基于上面的基本认识,我们已经知道条件变量如何使用,虽然细节需要后面再来进行解释,但这里

可以做一下基本的封装,以备后面使用。

cpp 复制代码
// 为什么#deifne、#ifndef后面的COND_HPP前面要加__?
// 即C/C++预处理宏定义中,为什么要在名字前后加双下划线__COND_HPP
// 主要目的是避免宏名冲突:
// 1.头文件可能被多个库或代码包含
// 2.如果只写#defineCOND_HPP,别人也写了COND_HPP,就会冲突,导致宏被重复定义
#ifndef __COND_HPP
#define __COND_HPP

#include <pthread.h>
#include "Mutex.hpp"

class Cond
{
public:
    Cond()
    {
        pthread_cond_init(&_cond,nullptr);
    }

    // 下面的代码中:cond是条件变量,mutex 是互斥锁
    void Wait(Mutex &mutex) // 条件变量等待,用于线程同步
    {
    // 作用是:在等待某个条件成立时,先释放锁并阻塞线程:当被唤醒时,重新获取锁并返回。
    // 这通常用于实现生产者-消费者模式中的等待/通知机制。
        pthread_cond_wait(&_cond,mutex.Origin());
        // pthread_cond_wait是PosIX线程库中用于条件变量的等待函数 --> (见下一行)
        // pthread_cond_wait会让当前线程阻塞,直到另一个线程调用pthread_cond_signal或pthread_cond_broadcast唤醒它
    }

    void NotifyOne()
    {
        pthread_cond_signal(&_cond);
    }

    void NotifyAll()
    {
        pthread_cond_broadcast(&_cond);
    }

    ~Cond()
    {
        pthread_cond_destroy(&_cond);
    }
private:
    pthread_cond_t _cond;
};

#endif

这里插个题外话:为什么#deifne、#ifndef后面的COND_HPP前面要加__?

这个问题的意思就是:C/C++预处理宏定义中,为什么要在名字前后加双下划线__COND_HPP

主要目的是避免宏名冲突:

  • 头文件可能被多个库或代码包含;
  • 如果只写#define COND_HPP,别人也写了COND_HPP,就会冲突,导致宏被重复定义。

3.2 传入锁的原子性深度剖析

此处的底层逻辑涉及内核态与用户态的切换,以及对"竞态条件"的绝对封堵。

3.2.1 原子性操作序列

pthread_cond_wait 内部并非简单的挂起,而是包含以下三个核心步骤,且前两步必须是原子执行的:

1、解锁 : 释放传入的 mutex

2、挂起: 将当前线程放入条件变量的等待队列。

3、重新加锁 : 被唤醒后,尝试获取 mutex

3.2.2 防止信号丢失(Lost Wakeup)

如果不传入锁,以下时序会导致死锁:

  • Step A : 线程 1 检查条件 while(count == 0),发现不满足。

  • Step B : 线程 1 准备调用 wait

  • Step C : 此时 CPU 切换到线程 2,线程 2 生产了数据,并发出 signal

  • Step D : 线程 1 恢复运行并进入 wait,但 signal 已经错过。

由于 wait 内部会自动解锁并挂起,这保证了在"挂起"的一瞬间,没有任何人能修改条件,从而确保信号被捕捉。


4 ~> 环形队列的空满区分与信号量控制实现

在基于数组的环形队列中,判断空满是系统编程的重点。

Tail == head,通常表示空 || 满

说起环形队列,其实我们也不是完全没有接触过,比如我们学习C语言的时候大概率会做过这样一道题目:

4.1 底层判空判满逻辑

  • 空状态head == tail

  • 满状态(head + 1) % capacity == tail(此方案通过牺牲一个存储单元来区分)

怎么区分环形队列是空还是满?

1、给环形队列维护一个计数器;

2、我们来使用这样的形式,head在tail前面则是满。

4.2 POSIX 信号量:信号量(Semaphore)的 P / V 操作逻辑

资源计数器的本质。

相比于 BlockQueue 使用的条件变量,信号量的逻辑更偏向资源预订。

信号量的本质是一个内核计数器,用于表示资源的剩余数量。

  • sem_wait(P):计数器大于 0 则递减并返回;等于 0 则阻塞。

  • sem_post(V):计数器递增并唤醒等待的线程。

通过这一套逻辑,环形队列实现了比普通 BlockQueue 更细粒度的并发控制。


5 ~> 核心代码演示:基于 C++11 与 POSIX 混合风格

以下代码展示了如何利用信号量和互斥锁实现一个硬核的环形缓冲区。

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

template <typename T>
class RingBuffer {
public:
    RingBuffer(int cap) : _cap(cap), _head(0), _tail(0) {
        // (1)初始化空间信号量,初始值为容量
        sem_init(&_sem_space, 0, _cap);
        // (2)初始化数据信号量,初始值为0
        sem_init(&_sem_data, 0, 0);
        pthread_mutex_init(&_lock, nullptr);
        _buffer.resize(_cap);
    }

    void Push(const T& in) {
        sem_wait(&_sem_space);      // P 操作:申请空间
        pthread_mutex_lock(&_lock); // 互斥保护下标移动
        
        _buffer[_head] = in;
        _head = (_head + 1) % _cap;
        
        pthread_mutex_unlock(&_lock);
        sem_post(&_sem_data);       // V 操作:发布数据
    }

    void Pop(T* out) {
        sem_wait(&_sem_data);       // P 操作:等待数据
        pthread_mutex_lock(&_lock);
        
        *out = _buffer[_tail];
        _tail = (_tail + 1) % _cap;
        
        pthread_mutex_unlock(&_lock);
        sem_post(&_sem_space);      // V 操作:归还空间
    }

    ~RingBuffer() {
        sem_destroy(&_sem_space);
        sem_destroy(&_sem_data);
        pthread_mutex_destroy(&_lock);
    }

private:
    std::vector<T> _buffer;
    int _cap;
    int _head; // 生产者下标
    int _tail; // 消费者下标
    sem_t _sem_space;
    sem_t _sem_data;
    pthread_mutex_t _lock;
};

6 ~> 死锁与预防

6.1 用C++queue模拟阻塞队列的生产消费模型

用C++queue模拟阻塞队列的生产消费模型,这是BlockQueue.hpp文件------

cpp 复制代码
#ifndef __BLOCK_QUEUE_HPP__
#define __BLOCK_QUEUE_HPP__
#include <iostream>
#include <string>
#include <queue>
#include <pthread.h>
template <typename T>
class BlockQueue
{
private:
    bool IsFull()
    {
        return _block_queue.size() == _cap;
    }
    bool IsEmpty()
    {
        return _block_queue.empty();
    }

public:
    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 (IsFull()) // 保证代码的健壮性
        {
            // ⽣产线程去等待,是在临界区中休眠的!你现在还持有锁呢!!!
            // 1. pthread_cond_wait调⽤是: a. 让调⽤线程等待 b. ⾃动释放曾经持有的
            _mutex锁 c.当条件满⾜,线程唤醒,pthread_cond_wait要求线性
                // 必须重新竞争_mutex锁,竞争成功,⽅可返回!!!
                // 之前:安全
                _productor_wait_num++;
            pthread_cond_wait(&_product_cond, &_mutex); // 只要等待,必定会有
            唤醒,唤醒的时候,就要继续从这个位置向下运⾏!!
            _productor_wait_num--;
            // 之后:安全
        }
        // 进⾏⽣产
        // _block_queue.push(std::move(in));
        // std::cout << in << std::endl;
        _block_queue.push(in);
        // 通知消费者来消费
        if (_consumer_wait_num > 0)
            pthread_cond_signal(&_consum_cond); // pthread_cond_broadcast
        pthread_mutex_unlock(&_mutex);
    }
    void Pop(T *out) // 消费者⽤的接⼝ --- 5个消费者
    {
        pthread_mutex_lock(&_mutex);
        while (IsEmpty()) // 保证代码的健壮性
        {
            // 消费线程去等待,是在临界区中休眠的!你现在还持有锁呢!!!
            // 1. 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);
        // pthread_cond_signal(&_product_cond);
    }
    ~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;
};

这段代码体现了死锁的预防机制,通过 条件变量(Condition Variable) 解决了在临界区内非法挂起导致的"死锁逻辑陷阱"。

6.1.1 核心预防:对"请求与保持"条件的破坏

在互斥锁的逻辑中,如果一个线程持有锁进入临界区后直接阻塞(挂起),且不释放锁,就会导致死锁(其他线程无法进入临界区修改条件,导致持有锁的线程永远等不到信号)。

(1)代码体现

cpp 复制代码
pthread_cond_wait(&_product_cond, &_mutex);

(2)硬核原理 : 该接口在底层设计上就是为了预防死锁 。它接收 _mutex 作为参数,并在线程进入等待队列的一瞬间,自动、原子地释放该锁

  • 逻辑后果 : 锁被释放了,其他执行流(如消费者)才能获取 _mutex 进入临界区执行 Pop 操作,从而修改 IsFull() 的判断条件并发出 signal

  • 恢复阶段 : 当线程被唤醒返回前,它会重新竞争并持有锁 ,保证了后续 _block_queue.push(in) 操作依然处于互斥保护下。

6.1.2 针对"伪唤醒"的预防:while 循环重定向

代码中使用了 while(IsFull())while(IsEmpty()) 而非 if,这是预防逻辑死锁的关键。

(1)风险场景 : 假设有多个生产者在等待。当一个消费者消费了一个数据并发出 signal 时,系统可能唤醒了多个生产者(伪唤醒或广播唤醒)。

(2)预防机制 : 如果使用 if,线程唤醒后会直接向下执行 push,此时队列可能已被另一个抢先的生产者填满,导致缓冲区溢出。使用 while 强制线程在唤醒后重新检测条件。如果不满足,则继续挂起。这保证了逻辑的绝对安全性,避免了因数据状态不一致引发的系统性阻塞。

6.1.3 单一锁策略:规避"环路等待"

与您之前上传的 access_shared_resources(多锁竞争)不同,这段代码采用了单一锁策略(Single Lock Strategy)

(1)设计逻辑 : 无论是生产者还是消费者,整个 BlockQueue 只受 _mutex 这一把锁保护。

(2)预防效果: 因为只有一个锁资源,所以永远不可能出现"线程 A 拿锁 1 等锁 2,线程 B 拿锁 2 等锁 1"的环路等待。这是从架构设计上彻底根除了多锁死锁的可能性。

6.1.4 条件变量的解耦预防

代码中定义了两个条件变量:_product_cond_consum_cond

(1)技术考量: 这种"定向通知"机制预防了无效唤醒导致的系统低效。

(2)硬核实现 : * 生产者只在 _product_cond 上等,消费者只在 _consum_cond 上等。

  • 生产者生产完后,只通知(signal)在 _consum_cond 上等的消费者。
    这种精准通知避免了所有执行流在同一个条件变量上乱抢资源,降低了锁竞争的剧烈程度,间接提高了调度效率。

6.1.5 总结与评价

这段代码是典型的生产消费模型标准实现 ,它在底层通过 pthread_cond_wait 的原子释放特性,完美绕过了"持有锁时挂起"这一死锁禁区。

(1)优点 : 严谨使用了 while 判定和 RAII 思想(虽然是手动初始化,但逻辑闭环)。

(2)底层映射 : * 原子性: push / pop 在锁内。

  • 同步性: 靠两个信号通知机制。

  • 死锁预防 : 靠 wait 接口内部的"解锁-挂起-加锁"原子序列。

6.2 RAII 风格的锁管理(防止忘记释放锁):

在 C++11 中,应优先使用 std::lock_guardstd::unique_lock。在底层实现中,这利用了栈对象的析构函数自动调用pthread_mutex_unlock,即使代码在临界区发生异常(Exception)或提前 return,也能保证锁被释放。

6.3 死锁预防的汇编级视角:

如果存在多个锁(A 和 B),线程 1 锁 A 等 B,线程 2 锁 B 等 A,会造成 task_struct 状态永久置为 TASK_INTERRUPTIBLE

  • 解决方案 : 强制所有线程按地址序(Address Order)申请锁。比较 &mutex_A&mutex_B 的大小,始终先锁较小的地址,从根本上破坏循环等待条件。

6.4 死锁问题如何规避?


结尾

uu们,本文的内容到这里就全部结束了,艾莉丝在这里再次感谢您的阅读!

|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ### 艾莉丝努力练剑 C/C++ & Linux 底层探索者 | 一个正在努力练剑的技术博主 *** ** * ** *** 👀 【关注】 跟随我一起深耕技术领域,见证每一次成长。 ❤️ 【点赞】 让优质内容被更多人看见,让知识传递更有力量。 ⭐ 【收藏】 把核心知识点存好,在需要时随时查、随时用。 💬 【评论】 分享你的经验或疑问,评论区一起交流避坑! 不要忘记给博主"一键四连"哦! "今日练剑达成!" "技术之路难免有困惑,但同行的人会让前进更有方向。" |

结语:希望对学习Linux相关内容的uu有所帮助,不要忘记给博主"一键四连"哦!

往期回顾

【Linux线程】Linux系统多线程(五):<线程同步与互斥>线程互斥

🗡博主在这里放了一只小狗,大家看完了摸摸小狗放松一下吧!🗡 ૮₍ ˶ ˊ ᴥ ˋ˶₎ა

相关推荐
好家伙VCC2 小时前
# BERT在中文文本分类中的实战优化:从基础模型到高效部署BERT(Bi
java·人工智能·python·分类·bert
姚不倒2 小时前
构建高可用可观测性平台:VictoriaMetrics 集群 + VictoriaLogs 统一接入实践
运维·docker·微服务·云原生·架构
brave_zhao2 小时前
什么是增值税
学习
身如柳絮随风扬2 小时前
什么是缓存预热
java·spring·缓存
feng_you_ying_li2 小时前
C++11可变模板参数,包扩展,emplace系列和push系列的区别
前端·c++·算法
tankeven2 小时前
HJ177 可匹配子段计数
c++·算法
herinspace2 小时前
管家婆实用帖-如何使用ping命令检测网络环境
网络·数据库·人工智能·学习·excel·语音识别
Gofarlic_OMS2 小时前
中小企业控制方法:中小型制造企业Creo许可证成本控制
java·大数据·运维·算法·matlab·制造
XiYang-DING2 小时前
【Java】Lambda表达式
java·开发语言·python