【Linux线程】Linux系统多线程(六):<线程同步与互斥>线程同步(上)
个人主页艾莉丝努力练剑❄专栏传送门《C语言》《数据结构与算法》《C/C干货分享学习过程记录》《Linux操作系统编程详解》《笔试/面试常见算法从基础到进阶》《Python干货分享》⭐️为天地立心为生民立命为往圣继绝学为万世开太平 艾莉丝的简介文章目录1 ~ 理论2 ~ 生产者消费者模型并发高效性的底层逻辑2.1 计算与 IO 的并发重叠2.2 执行流解耦与调度优化2.3 生产者消费者模型从逻辑到代码的映射3 ~ 条件变量3.1 pthread_cond_wait3.1.1 为什么pthread_cond_wait需要互斥量?3.1.2 条件变量的使用规范3.1.3 条件变量的封装3.2 传入锁的原子性深度剖析3.2.1 原子性操作序列3.2.2 防止信号丢失Lost Wakeup4 ~ 环形队列的空满区分与信号量控制实现4.1 底层判空判满逻辑4.2 POSIX 信号量信号量Semaphore的 P / V 操作逻辑5 ~ 核心代码演示基于 C11 与 POSIX 混合风格6 ~ 死锁与预防6.1 用Cqueue模拟阻塞队列的生产消费模型6.1.1 核心预防对“请求与保持”条件的破坏6.1.2 针对“伪唤醒”的预防while 循环重定向6.1.3 单一锁策略规避“环路等待”6.1.4 条件变量的解耦预防6.1.5 总结与评价6.2 RAII 风格的锁管理防止忘记释放锁6.3 死锁预防的汇编级视角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_wait3.1.1 为什么pthread_cond_wait需要互斥量?如下图所示3.1.2 条件变量的使用规范3.1.3 条件变量的封装基于上面的基本认识我们已经知道条件变量如何使用虽然细节需要后面再来进行解释但这里可以做一下基本的封装以备后面使用。// 为什么#deifne、#ifndef后面的COND_HPP前面要加__// 即C/C预处理宏定义中为什么要在名字前后加双下划线__COND_HPP// 主要目的是避免宏名冲突// 1.头文件可能被多个库或代码包含// 2.如果只写#defineCOND_HPP别人也写了COND_HPP就会冲突导致宏被重复定义#ifndef__COND_HPP#define__COND_HPP#includepthread.h#includeMutex.hppclassCond{public:Cond(){pthread_cond_init(_cond,nullptr);}// 下面的代码中cond是条件变量mutex 是互斥锁voidWait(Mutexmutex)// 条件变量等待用于线程同步{// 作用是在等待某个条件成立时先释放锁并阻塞线程当被唤醒时重新获取锁并返回。// 这通常用于实现生产者-消费者模式中的等待/通知机制。pthread_cond_wait(_cond,mutex.Origin());// pthread_cond_wait是PosIX线程库中用于条件变量的等待函数 -- (见下一行)// pthread_cond_wait会让当前线程阻塞直到另一个线程调用pthread_cond_signal或pthread_cond_broadcast唤醒它}voidNotifyOne(){pthread_cond_signal(_cond);}voidNotifyAll(){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 ~ 核心代码演示基于 C11 与 POSIX 混合风格以下代码展示了如何利用信号量和互斥锁实现一个硬核的环形缓冲区。#includeiostream#includevector#includepthread.h#includesemaphore.htemplatetypenameTclassRingBuffer{public:RingBuffer(intcap):_cap(cap),_head(0),_tail(0){// 1初始化空间信号量初始值为容量sem_init(_sem_space,0,_cap);// 2初始化数据信号量初始值为0sem_init(_sem_data,0,0);pthread_mutex_init(_lock,nullptr);_buffer.resize(_cap);}voidPush(constTin){sem_wait(_sem_space);// P 操作申请空间pthread_mutex_lock(_lock);// 互斥保护下标移动_buffer[_head]in;_head(_head1)%_cap;pthread_mutex_unlock(_lock);sem_post(_sem_data);// V 操作发布数据}voidPop(T*out){sem_wait(_sem_data);// P 操作等待数据pthread_mutex_lock(_lock);*out_buffer[_tail];_tail(_tail1)%_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::vectorT_buffer;int_cap;int_head;// 生产者下标int_tail;// 消费者下标sem_t _sem_space;sem_t _sem_data;pthread_mutex_t _lock;};6 ~ 死锁与预防6.1 用Cqueue模拟阻塞队列的生产消费模型用Cqueue模拟阻塞队列的生产消费模型这是BlockQueue.hpp文件——#ifndef__BLOCK_QUEUE_HPP__#define__BLOCK_QUEUE_HPP__#includeiostream#includestring#includequeue#includepthread.htemplatetypenameTclassBlockQueue{private:boolIsFull(){return_block_queue.size()_cap;}boolIsEmpty(){return_block_queue.empty();}public:BlockQueue(intcap):_cap(cap){_productor_wait_num0;_consumer_wait_num0;pthread_mutex_init(_mutex,nullptr);pthread_cond_init(_product_cond,nullptr);pthread_cond_init(_consum_cond,nullptr);}voidEnqueue(Tin)// ⽣产者⽤的接⼝{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_num0)pthread_cond_signal(_consum_cond);// pthread_cond_broadcastpthread_mutex_unlock(_mutex);}voidPop(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_num0)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::queueT_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代码体现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 风格的锁管理防止忘记释放锁在 C11 中应优先使用std::lock_guard或std::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系统多线程五线程同步与互斥线程互斥博主在这里放了一只小狗大家看完了摸摸小狗放松一下吧૮₍ ˶ ˊ ᴥ ˋ˶₎ა