互斥锁
复习
- 进程间通信:进程之间靠管道、消息、信号或共享内存交换数据
- 程序竞争:多条执行线同时读写共享数据,可能互相干扰
- 临界区与原子操作:哪些代码必须表现得像不可分割的一步
TL;DR
- 互斥锁:进入临界区前加锁,离开时解锁
- 加锁失败就等待,直到持有者释放
- 它把“互斥”这件麻烦事封装成一个简单接口
- 用锁要小心:忘记解锁,别人就永远进不来
正文
上一章的临界区和原子操作,原理都对,可用起来还是麻烦。现实中,没人愿意在每个临界区门口手写一套“测试并设置”。于是人们把它封装成一个接口——互斥锁(mutex)。
一个简单的接口
互斥锁只提供两个动作:
lock:尝试加锁。成功就进入临界区;失败就等待unlock:解锁,让出临界区
用法非常直白:
lock()
// 临界区:访问共享数据
unlock()
锁只有“被持有”和“空闲”两种状态。谁先 lock 成功,谁就拿着锁;其余的人一律在 lock 处排队,直到持有者 unlock。
锁替我们做了什么
别看接口简单,它背后替我们处理了一堆麻烦:
- 用原子操作抢锁,保证不会两个人同时进入
- 拿不到时如何等待、如何被唤醒
- 释放时如何把锁交给下一个等待者
我们只要记得“进临界区就加锁、出临界区就解锁”,其余细节都被锁藏起来了。这又是加一层:把容易写错的同步细节,包装成一个不会用错的按钮。
锁的粒度
锁保护的范围,叫粒度。粒度要拿捏:
- 锁得太宽(把很多不相关的代码都圈进去),大家挤在一把锁上,并发度下降,等于白瞎了多核
- 锁得太窄(只锁住其中一小部分),可能漏保护了共享数据,错误照旧
一个常见毛病叫粗粒度锁:图省事,用一把大锁保护几乎所有共享数据。程序是安全了,但多个线程几乎串行,性能很差。好的做法是把大锁拆成若干小锁,各管一摊——不过这又容易引出新的麻烦,后面讲死锁时会看到。
忘记解锁会怎样
锁用起来简单,但有一个致命陷阱:忘记解锁。
一旦某个线程拿了锁却忘了还,所有等在 lock 处的线程就永远等下去,整个程序卡死。所以,实际编程里通常用一套“成对出现”的写法:加了锁,就必须保证在任何路径下都会解锁,哪怕中途出错。
思考题
如果一把锁保护的范围里,又调用了另一个也需要同一把锁的函数,会发生什么?这和“忘记解锁”有什么共同点?
小结
知识点
- 互斥锁提供
lock和unlock - 锁把原子操作、等待和唤醒封装起来
- 锁的粒度:太宽降低并发,太窄保护不全
- 忘记解锁会导致其他线程永远等待
参考资料
- Wikipedia(zh):互斥锁:mutex
- Wikipedia(zh):锁:锁的概念
思考题答案(仅供参考)
如果同一把锁在已经持有的情况下又被请求,就形成“自己等自己”:持有者等一个只有它自己能释放的锁,于是永远等下去。这和“忘记解锁”的共同点是——锁都不会再被放开,等待者全都卡住。它们的区别只是:忘记解锁是根本没还;重复加锁是还想再借一次,却把门从里面反锁了。不少锁提供了“可重入”版本来缓解后者,但设计上仍应尽量避免这种嵌套。
协议
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。
封面图
设计师 | 南国微雪