从抢票事故到锁的原理:互斥、条件变量与生产消费模型(8-16 ~ 8-19)
我的github:(https://github.com/xcx55/ubuntu-linux-project)
感谢各位大佬参观我的github!!
多线程共享地址空间的红利吃到之后,代价立刻就来了:多个执行流同时改同一份数据,不出事才怪。这几天的笔记沿着"出事 → 加锁 → 锁怎么实现 → 光锁不够 → 同步"一路推下来,正好一篇讲完。
一、事故现场:抢票为什么会出问题(8-16)
场景很简单:多个线程对同一个全局票数 --。笔记里的推演:
当一个线程条件判断进来了,后面切走、还没有减去;再来一个线程,再切走,也没有减去!这样一直来------直到放进来了很多个线程的
--,但票数没有得到保护!
本质原因:"判断 + 修改"是两步,线程可以在两步之间被切走。寄存器里的值还是旧票数,另一个线程已经把内存里的票减掉了------数据不一致就此发生。
结论:访问公共资源会出问题 → 必须把共享资源升级为临界资源 → 同一时刻只允许一个执行流进入。这就是互斥锁的动机。
加锁前先想清楚的三个原则问题
笔记里列的加锁原则,全是实战坑:
- Mutex 是共享资源,它保护别人,谁保护它? ------答案:lock/unlock 本身被设计为原子的(后面看实现);
- 一些线程加锁、一些不加锁,可以吗?------不行,不加锁的线程等于绕过了规则,锁形同虚设;
- 申请不成功怎么办?------阻塞等待,这也是加锁把"并行"变"串行"的代价。
锁的粒度因此成为核心权衡:加锁范围内的代码串行执行,效率会降------所以临界区要尽量小,"串行的时间变小",加锁的同时必须考虑时间效率。
二、锁的原理:一条交换指令定胜负(8-17)
1. 硬件方案:先看看但别用
硬件实现锁的思路简单粗暴:执行临界区代码的 PCB 不允许时钟切换 ------关闭时钟中断,自然没人能打断你。但关中断太危险(中断全关,系统响应瘫痪),一般不用。
2. 软件方案:xchgb 交换指令
软件实现的互斥锁,胜负手是一条汇编指令:
谁先拿到执行 xchgb 汇编,谁就拿到了锁!拿锁就一条汇编指令,没有三条指令!
为什么一条指令就够?回忆之前信号章的结论:CPU 的底层是一个汇编指令集,一条汇编语句是原子的 ------要么完整执行,要么不执行,不存在执行到一半被切走。而"检测锁变量 + 占有锁"这两个动作,被 xchgb(交换寄存器和内存的值)合成了一条指令:
- 交换回来的是 0 → 别人已持有,继续等待;
- 交换回来的是 1 → 锁到手,进临界区。
解锁更简单:直接硬编码把锁的值写回去------也是一条汇编指令。
3. 两个重要推论
- 加锁之后可以随便切换调度!只要拿到锁的线程不执行"还锁"的函数、不把锁换回去,别的线程在锁上等待即可------锁保护的是临界区,不是禁用调度;
- 把互斥锁理解为信号量 :互斥锁本质是对资源的预定机制------这句话为下一篇的 POSIX 信号量埋好了伏笔。
4. 全局锁用宏、局部锁用函数的原因
- 全局(静态)分配的锁:用宏
PTHREAD_MUTEX_INITIALIZER初始化; - 栈上开辟的锁:必须用
pthread_mutex_init函数初始化。
笔记点破:因为那个宏不是普通的宏------它要在编译期就把锁置成"未持有"的确定状态,只有全局变量能保证;栈上变量的地址运行期才确定,只能走函数。
三、光有锁还不够:忙轮询的浪费(8-17 ~ 8-19)
1. 只有互斥会发生什么
生产消费场景里,消费者拿到锁 → 发现没数据 → 释放锁回到就绪队列 → 马上又参与竞争 → 又拿到 → 又没数据......
当前申请锁失败就会回到就绪队列忙轮询,没有等待队列的生成。你放了苹果,每次还要检测------大量的检测和获取(写入)只维护了互斥关系,效率降低,而且不合理!
8-19 那篇总结得最直白:
要是不要条件变量,线程就一直待在就绪队列等被调度,CPU 资源白白消耗 。不如让它等待------排好队、有序进行,这才是 CPU 算力的优化。
2. 同步的说人话定义
同步:在保证共享资源安全的情况下,让"拿钥匙的顺序"具有一定的顺序性。
在同步发明之前,互斥失败就回就绪队列------没有等待队列 。于是会出现"抢钥匙很强的线程无限次抢到钥匙,其他线程饿死"。同步就是用阻塞等待替代忙轮询:一个一个串行拿钥匙执行。
四、条件变量:铃铛 + 等待队列(8-18)
1. 模型
条件变量 = 一个等待队列 + 唤醒机制:
- pthread_cond_wait(cond, mutex):线程挂入条件变量的等待队列(返回 0 成功、非 0 失败);
- pthread_cond_signal :敲一下铃铛,唤醒队列里一个等待者;
- pthread_cond_broadcast:广播,唤醒全部。
可以配两个铃铛------"队列空了等消费者敲、队列满了等生产者敲"(单同步 vs 多同步)。
2. pthread_cond_wait 为什么必须传锁
笔记里连续问了两次"为什么 wait 要传递锁",答案是两层:
- cond 的等待队列本身是共享资源------操作它必须持锁,所以 wait 必须在加锁之后调用;
- wait 内部要"换锁" :挂入等待队列的同时把持有的锁释放掉 ,被唤醒后再重新申请锁------要不然全部线程都要等待这个休眠的线程(你睡着了还抱着锁,别人永远进不来)。
3. if 改 while
被唤醒后条件可能又被别人抢了(广播唤醒一堆人、或唤醒后条件被消费),所以判断要用 while 再查一遍,不能用 if 只查一次。这是条件变量代码的第一铁律。
4. 321 原则:生产者消费者模型
- 3 种关系:生产者×生产者(互斥)、消费者×消费者(互斥)、生产者×消费者(互斥 + 同步);
- 2 种角色:生产者线程、消费者线程;
- 1 个交易场所:阻塞队列("超市")。
模型的价值:解耦 (生产消费互不等待)、支持并发 (投递和拿取很快)、提升效率 ------运行任务不阻塞投递和拿取。笔记还点了一句:这个阻塞队列模型越看越像进程间通信里的管道------只是这次的阻塞是线程自己用条件变量实现的。
五、小结
| 问题 | 答案 |
|---|---|
| 为什么会数据不一致 | "判断+修改"两步之间可被切走 |
| 锁的实现 | xchgb 一条交换指令完成检测+占有,解锁一条指令写回 |
| 加锁后能切走吗 | 能;锁保护临界区,不禁用调度 |
| 全局锁宏 vs 局部锁函数 | 宏要求编译期确定的地址,只有全局变量满足 |
| 互斥 vs 同步 | 互斥管"一次一个",同步管"顺序合理" |
| 条件变量本质 | 等待队列 + 铃铛(signal/broadcast) |
| wait 为什么传锁 | 等待队列是共享资源;wait 内部要放锁再睡 |
一句话:互斥解决"安全",同步解决"有序";锁是一条交换指令的原子性,条件变量是把忙轮询换成带铃铛的等待队列------CPU 的算力从"反复试探"变成了"精准唤醒"。
下一篇把资源从"整体使用"拆成"多份使用"------POSIX 信号量、环形队列与线程池。