一步一步设计 go sync.mutex

219 阅读6分钟

假如让你设计 go 的锁,你怎么设计?

首先我们知道 go 有 park 方法可以阻塞 g ,有调度方法可以重新调度 g ,所以我们可以用这两个方法来做 g 的加锁和解锁。其次,我们怎么知道什么时候该阻塞 g,也就是说,我们怎么知道发生了竞争,这里我们用状态 state 来标识是否发生了竞争。

既然有了状态,我们先定一下状态的枚举,首先 locked 表示锁存在竞争,也就是说,假如有 g 抢到锁了,锁的 state 得设置为 locked 状态。那只有一个 locked 就够了吗?应该是不够,对吧。但是没关系,先只用 locked 状态,看看会遇到什么问题。

我们来模拟一下竞态情况,现在有两个 g ,A和B 同时来抢锁,其中 A CAS 操作把锁的状态改为了 locked,那就说明 A 抢到了锁,A 可以执行它的逻辑,直到解锁。B 发现锁已经抢走了,B 现在应该怎么办?当然应该阻塞了对吧,除了 B 有可能会有 C、D 等等其他的 g 在 A 解锁之前来抢锁,所以我们需要一个队列来存储等待的 g (其实是 sudog)。现在把 B 的状态改为等待然后 park 住,并且放到阻塞队列里,B 只能等待被唤醒了。

来说说 A ,A 执行完它的逻辑,然后开始执行解锁逻辑,A 把锁的状态设置为初始值,然后唤醒 B 再重新调度就可以了。问题是怎么唤醒 B。首先,把 B 从锁的等待队里拿出来,把 B 的状态改为可执行,然后放到 P 的 runnext (下一个执行的 g) 的位置,执行调度,这样就会很快的执行 B 了。之所以不是立即执行 B ,因为当前 g 为 A,得 A 执行完,或者调度切换执行 g 的时候才会执行到 B。

B 被唤醒了并且被执行了,那现在 B 应该做什么?立刻执行加锁和解锁之间的逻辑吗?不行,为什么?因为得获取到锁才能执行自己的逻辑。那就再去获取锁,这样的话其实和一开始是同一个逻辑,也就是说这是一个 for 循环,直到获取到锁才能跳出这个循环。

现在锁大体的逻辑设计完成了,我们再来试着优化一下。

优化之前我们先来看看上面的锁设计有什么问题,我们一边解决问题,一边优化。

第一个问题,A 在解锁时,会先把锁状态设置为初始值,然后才去唤醒 B ,假如在唤醒 B 之前,有另外的 C 来抢锁,因为锁现在的状态是初始值,那么 C 肯定能抢到锁。B 醒了之后发现锁已经被抢了,只能继续再阻塞了。这里的问题是,B 是先来的,假如 B 每次都被 C 这种投机倒把分子给抢先,那等到 B 抢到锁执行自己的逻辑很可能会过了很长时间。

因为 C 是正常的抢锁操作,我们不能把 C 给屏蔽或者降低优先级。那怎么办?

可以让 B 和 C 处在同一个机会下,让 B 和 C 一起竞争抢锁,谁能抢到谁执行,这样的话相对公平。我们可以给锁多加一个状态:woken(唤醒状态),A 在解锁时,把状态从 locked 设置为 woken ,这样 C 就不能立刻抢到锁,需要进到 for 循环里抢锁,同样的 B 被唤醒时,发现锁的状态是 woken ,跟 C 抢锁。

还是上面那个问题,即使现在有了 woken 状态,B 仍然一直没抢到锁,一直没法执行,这个时候怎么办?

我们现在来定义一个时间变量 waitTime,假如 B 从第一次抢锁,到被唤醒的时间超过 waitTime(因为只有被唤醒才能继续操作), 我们可以认为 B 当前处在饥饿状态-starving,如果 B 处在 starving 状态,但是立刻抢到锁了,那我们就不管了,因为 B 已经抢到锁执行了。假如 B 处在 starving 状态,但是发现锁的状态为 locked 状态,也就是说锁已经被抢了,这个时候我们给锁新增加一个状态,也叫 starving ,并且把锁的状态从 locked 改为 starving,然后因为锁已经被抢了, B 只能继续阻塞。

现在锁又新增了一个状态-starving,我们来定义一下锁处在这个状态下的行为逻辑。首先这个状态的设置是因为 B 太长时间没被执行,所以假如锁处在这个状态下的话,我们要保证尽快的执行 B。这个时候就不能让 B 和其他的 g 再竞争抢锁了,而是需要顺序的唤醒锁的阻塞队列中的 g,并且新来的抢锁的 g 都放到后面唤醒。

锁的 starving 状态我们称为饥饿模式,饥饿模式是低效的,为什么这么说呢,因为当锁处在饥饿模式的时候,所有的 g 都必须被阻塞,然后等待被依次唤醒。加锁解锁和阻塞调度本身就是耗时的操作,而有的 g 在抢锁时完全没必要被阻塞,直接抢到锁执行就行。

所以当锁处在饥饿模式的时候,我们希望尽快的解除饥饿模式。一旦有 g 从第一次抢锁到被唤醒的时间小于 waitTime ,我们就认为饥饿模式可以解除,然后更新锁的状态。

当锁处在饥饿模式的时候,还有另外一种情况可以解除饥饿模式。当锁的阻塞队列的长度为0时,也就是没有g需要被唤醒时,可以取消饥饿模式,此时的饥饿模式已经没有意义了。

两个问题都已经解决了,锁还有别的优化空间吗?

有的,我们一直在说加锁,阻塞,解锁,但是有一种方式非常轻量级,某种情况下也能实现抢锁的功能,那就是自旋了。如果当前锁已经被抢了(非饥饿模式),可以让当前的 g 自旋等待几次。假如抢锁的 g 很快的完成自身的逻辑,并且解锁时,自旋其实是很高效的抢锁操作。