Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

互斥锁

复习

  • 进程间通信:进程之间靠管道、消息、信号或共享内存交换数据
  • 程序竞争:多条执行线同时读写共享数据,可能互相干扰
  • 临界区与原子操作:哪些代码必须表现得像不可分割的一步

TL;DR

  • 互斥锁:进入临界区前加锁,离开时解锁
  • 加锁失败就等待,直到持有者释放
  • 它把“互斥”这件麻烦事封装成一个简单接口
  • 用锁要小心:忘记解锁,别人就永远进不来

正文

  上一章的临界区和原子操作,原理都对,可用起来还是麻烦。现实中,没人愿意在每个临界区门口手写一套“测试并设置”。于是人们把它封装成一个接口——互斥锁(mutex)。

一个简单的接口

  互斥锁只提供两个动作:

  • lock:尝试加锁。成功就进入临界区;失败就等待
  • unlock:解锁,让出临界区

  用法非常直白:

lock()
    // 临界区:访问共享数据
unlock()

  锁只有“被持有”和“空闲”两种状态。谁先 lock 成功,谁就拿着锁;其余的人一律在 lock 处排队,直到持有者 unlock

锁替我们做了什么

  别看接口简单,它背后替我们处理了一堆麻烦:

  • 用原子操作抢锁,保证不会两个人同时进入
  • 拿不到时如何等待、如何被唤醒
  • 释放时如何把锁交给下一个等待者

  我们只要记得“进临界区就加锁、出临界区就解锁”,其余细节都被锁藏起来了。这又是加一层:把容易写错的同步细节,包装成一个不会用错的按钮。

锁的粒度

  锁保护的范围,叫粒度。粒度要拿捏:

  • 锁得太宽(把很多不相关的代码都圈进去),大家挤在一把锁上,并发度下降,等于白瞎了多核
  • 锁得太窄(只锁住其中一小部分),可能漏保护了共享数据,错误照旧

  一个常见毛病叫粗粒度锁:图省事,用一把大锁保护几乎所有共享数据。程序是安全了,但多个线程几乎串行,性能很差。好的做法是把大锁拆成若干小锁,各管一摊——不过这又容易引出新的麻烦,后面讲死锁时会看到。

忘记解锁会怎样

  锁用起来简单,但有一个致命陷阱:忘记解锁

  一旦某个线程拿了锁却忘了还,所有等在 lock 处的线程就永远等下去,整个程序卡死。所以,实际编程里通常用一套“成对出现”的写法:加了锁,就必须保证在任何路径下都会解锁,哪怕中途出错。

思考题

  如果一把锁保护的范围里,又调用了另一个也需要同一把锁的函数,会发生什么?这和“忘记解锁”有什么共同点?

小结

知识点

  • 互斥锁提供 lockunlock
  • 锁把原子操作、等待和唤醒封装起来
  • 锁的粒度:太宽降低并发,太窄保护不全
  • 忘记解锁会导致其他线程永远等待

参考资料

  1. Wikipedia(zh):互斥锁:mutex
  2. Wikipedia(zh):锁:锁的概念

思考题答案(仅供参考)

  如果同一把锁在已经持有的情况下又被请求,就形成“自己等自己”:持有者等一个只有它自己能释放的锁,于是永远等下去。这和“忘记解锁”的共同点是——锁都不会再被放开,等待者全都卡住。它们的区别只是:忘记解锁是根本没还;重复加锁是还想再借一次,却把门从里面反锁了。不少锁提供了“可重入”版本来缓解后者,但设计上仍应尽量避免这种嵌套。

协议

  本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

封面图

设计师 | 南国微雪