本文给做锁应用模型的软体设计师介绍一下怎么为锁的使用建模(写设计)。请注意,本文讨论的是「使用」,不是锁本身的设计。

需要讨论这个问题,是因为很多缺乏设计经验的工程师很容易认为锁的设计是一个编码问题,不知道要抽象它的什么模型,就算写设计文档也就是写成代码。这种行为,一旦遇到非常复杂的锁设计,很容易就晕菜,在最终的代码中引入很多Race Condition问题。

锁的本质是排队。当你有多个执行执行线程的时候,本来这些线程是并行执行,互相河水不犯井水的,但你这些线程要修改一组数据,这组数据互相有关系,那么一个没有修改完,其他人就只能等著,这就是排队。

这里说的「线程」,是个泛化的概念。它可以理解为进程、pthread,kthread,中断向量,Linux的bottom half,Posix的signal钩子,都可以是这里的线程。

针对不同的线程的排队,你会需要不同的锁,比如读写锁,spinlock,mutex,condition等,包括部分不称为锁的东西,比如RCU,关中断,关bottom half等。只要它们能把线程停下来排队,就是锁。针对不同的线程选择合适的锁,这是一个独立的问题,不在本文的讨论范围内。笔者认为,这种选择,大部分时候也不值得在设计文档中做出选择,它仅仅是个编码问题。但每种锁的特性和行为特征,那是必须很清楚的,这里不谈不表示他们不重要,只是说他们属于编码维度,不属于设计建模维度。如果你是在Linux内核编程,最好都看看Linux的Unreliable Guide To Locking。就算不是,这个文档也很值得看,因为它基本上把锁的各种形态都做了介绍了。

理解锁的本质是排队,对我们的建模非常重要,因为单个线程的本质其实就是排队。既然都是排队,那么只要我们能把线程减少到只有一个,那么锁就是不需要的。我看过不止一次了,有人辛辛苦苦做了很复杂的锁设计,最后发现大部分执行都是串列的,既然如此,你当初何必开那么多线程

所以,锁应用设计的本质是线程族的设计。你要做锁的设计,首先把你有哪些线程挖出来。看你是否需要这种并行。当然,这有时是被迫的,比如你做了一个库,别人可以用多个线程上下文来调用你,你又有多个非堆栈变数需要使用,这种情况,你的线程族就是所有可以调用你的线程。如果对方还可以从中断向量中调用你,那你的线程族中就又包括中断向量了。把这些上下文先找出来,这是锁应用设计的第一步。

顺便说一句,我个人是比较讨厌在公共的演算法库中加锁的,因为锁和线程库相关,一旦你选定了锁,就选定了线程库,降低了公共演算法库的自由度。这毫无意义。我要封装,我自己会加封装层,犯不著你自作聪明。

第二步是找出引起排队的数据结构。先按最粗的粒度,把他们看做是一体的再说,比如你管理一组用户,那么就先考虑用一把锁,把任何访问这组数据的行为都锁起来再说。然后看看是否影响你的线程发挥性能了(当然包括你未来的变数),没有就到此为止,别过度设计了。

然后我们就可以看优化了。还是拿前面这个例子来说事,你有一组用户,每个用户的数据是一个必须排队的数据结构,整个用户池是另一个必须排队的数据结构。如果多个线程会操作在不同的用户上面,那么我们可以独立使用两把锁,一个锁用户,一个锁用户池。这样大部分时候,我们的线程相互作用在不同的用户上面,他们没有排队问题。

但有两把(或者更多)锁是非常危险的事情,简单学过自己操作系统原理都知道这种死锁模型(无非就是圆桌拿筷子那一套)。我这里不谈这个东西的技术原理。我要谈的是:这个东西没有银弹,一旦你开始进入这个模式了,你要设计这里的原则,保证你编码的时候不会掉入这个陷阱。

比如你有3个操作,op1作用在用户上,op2把用户加入用户池,op3把用户删除出用户池。op1你只锁用户锁;op2你只锁用户池锁;op3你先锁这个用户锁,然后锁用户池,让用户离开用户池后,释放用户池锁,断开这个用户在op1上的访问通道,然后放开用户锁。

看起来这个设计自恰了——其实没有——你打算什么时候释放这个用户锁?可能op1已经进来了,还在锁上等著呢,你根本断不开它。

所以,很可能你需要更复杂的设计,比如一种方法是在用户池上加一个引用计数,只要op1的通道打开了,拒绝掉所有的op3。这是Linux大部分公共框架用的方法。

但这种方法只能用于介面通道全部受控的情况,如果每个用户可以被signal或者中断这种线程所左右,你这个关闭就不一定能做到了,这时你得为每个用户设计一个可见的队列,把释放操作放在这个队列中(例如评论中有人提到的使用引用计数),让op1已经没有操作作用在这个队列上,你才能释放它。——是不是烦死了?——我前面说过了,没事最好就不要弄什么多线程,弄了多线程最好也不要弄什么多个锁,这就是个马蜂窝。

但如果你没得选,你就只能做这个设计。锁应用设计就是需要设计一个保证在线程任何组合的情况下都能自恰的逻辑。它通常包含:

  1. 定义你面对什么线程环境
  2. 定义你所要保护的需要原子处理的数据
  3. 定义包括用的锁,以及锁的使用原则。这个原则最难的,通常就是死锁规避和由此引起的资源释放陷阱

但你不需要定义具体的每个执行流程,那个可以留给编码。


推荐阅读:
相关文章