mutex是golang提供的基础并发原语,可以帮助我们处理多goruntine并发访问共享资源的问题。每个goruntine都要再获取到锁之后才能操作共享资源,完成操作释放锁,保证了共享资源的读写安全性。

但这种方式也可能带来一些问题:一些悲惨的goruntine一直获取不到锁,导致业务逻辑不能继续完整执行,这种问题被称为"饥饿问题"

饥饿问题
饥饿模式和正常模式

正常模式

  1. 当前的mutex只有一个goruntine来获取,那么没有竞争,直接返回。
  2. 新的goruntine进来,如果当前mutex已经被获取了,则该goruntine进入一个先入先出的waiter队列,在mutex被释放后,waiter按照先进先出的方式获取锁。该goruntine会处于自旋状态(不挂起,继续占有cpu)。
  3. 新的goruntine进来,mutex处于空闲状态,将参与竞争。新来的 goroutine 有先天的优势,它们正在 CPU 中运行,可能它们的数量还不少,所以,在高并发情况下,被唤醒的 waiter 可能比较悲剧地获取不到锁,这时,它会被插入到队列的前面。如果 waiter 获取不到锁的时间超过阈值 1 毫秒,那么,这个 Mutex 就进入到了饥饿模式。

饥饿模式

在饥饿模式下,Mutex 的拥有者将直接把锁交给队列最前面的 waiter。新来的 goroutine 不会尝试获取锁,即使看起来锁没有被持有,它也不会去抢,也不会 spin(自旋),它会乖乖地加入到等待队列的尾部。
如果拥有 Mutex 的 waiter 发现下面两种情况的其中之一,它就会把这个 Mutex 转换成正常模式:

  1. 此 waiter 已经是队列中的最后一个 waiter 了,没有其它的等待锁的 goroutine 了;
  2. 此 waiter 的等待时间小于 1 毫秒。

正常模式下goruntine进入自旋行为解读

在正常模式下,新的goruntine进入想要获得互斥锁,而当前mutex已经被获取,该gorunitne会进入到一个先进先出的队列,等待获取锁。同时该goruntine会进入一个自旋行为(自旋就是只当前MP组合上运行的G是阻塞的,但没有进行切换或者线程复用,参考产生自旋的场景6)

自旋是自旋锁的行为,它通过忙等待,让线程在某段时间内一直保持执行,从而避免线程上下文的调度开销。自旋锁对于线程只会阻塞很短时间的场景是非常合适的。很显然,单核CPU是不适合使用自旋锁的,因为,在同一时间只有一个线程是处于运行状态,假设运行线程A发现无法获取锁,只能等待解锁,但因为A自身不挂起,所以那个持有锁的线程B没有办法进入运行状态,只能等到操作系统分给A的时间片用完,才能有机会被调度。这种情况下使用自旋锁的代价很高。

在本场景中,之所以想让当前goroutine进入自旋行为的依据是,我们乐观地认为:当前正在持有锁的goroutine能在较短的时间内归还锁。

runtime_canSpin()函数的实现如下:

1//go:linkname sync_runtime_canSpin sync.runtime_canSpin
 2func sync_runtime_canSpin(i int) bool {
 3  // active_spin = 4
 4    if i >= active_spin || ncpu <= 1 || gomaxprocs <= int32(sched.npidle+sched.nmspinning)+1 {
 5        return false
 6    }
 7    if p := getg().m.p.ptr(); !runqempty(p) {
 8        return false
 9    }
10    return true
11}

由于自旋本身是空转CPU的,所以如果使用不当,反倒会降低程序运行性能。结合函数中的判断逻辑,这里总结出来goroutine能进入自旋的条件如下

  1. 当前互斥锁处于正常模式
  2. 当前运行的机器是多核CPU,且GOMAXPROCS>1
  3. 至少存在一个其他正在运行的处理器P,并且它的本地运行队列(local runq)为空
  4. 当前goroutine进行自旋的次数小于4

总结

正常模式拥有更好的性能,因为即使有等待抢锁的 waiter,goroutine 也可以连续多次获取到锁。
饥饿模式是对公平性和性能的一种平衡,它避免了某些 goroutine 长时间的等待锁。在饥饿模式下,优先对待的是那些一直在等待的 waiter。

模式切换

  • 如果当前 goroutine 等待锁的时间超过了 1ms,互斥锁就会切换到饥饿模式。
  • 如果当前 goroutine 是互斥锁最后一个waiter,或者等待的时间小于 1ms,互斥锁切换回正常模式。

加锁

  1. 如果锁是完全空闲状态,则通过CAS直接加锁。
  2. 如果锁处于正常模式,则会尝试自旋,通过持有CPU等待锁的释放。
  3. 如果当前goroutine不再满足自旋条件,则会计算锁的期望状态,并尝试更新锁状态。
  4. 在更新锁状态成功后,会判断当前goroutine是否能获取到锁,能获取锁则直接退出。
  5. 当前goroutine不能获取到锁时,则会由sleep原语SemacquireMutex陷入睡眠,等待解锁的goroutine发出信号进行唤醒。
  6. 唤醒之后的goroutine发现锁处于饥饿模式,则能直接拿到锁,否则重置自旋迭代次数并标记唤醒位,重新进入步骤2中。

解锁

  1. 如果通过原子操作AddInt32后,锁变为完全空闲状态,则直接解锁
  2. 如果解锁一个没有上锁的锁,则直接抛出异常。
  3. 如果锁处于正常模式,且没有goroutine等待锁释放,或者锁被其他goroutine设置为了锁定状态、唤醒状态、饥饿模式中的任一种(非空闲状态),则会直接退出;否则,会通过wakeup原语Semrelease唤醒waiter。
  4. 如果锁处于饥饿模式,会直接将锁的所有权交给等待队列队头waiter,唤醒的waiter会负责设置Locked标志位。
Go的互斥锁带有自旋的设计
mu.Lock()
defer mu.Unlock()

写如上的代码,是很爽。但是,你有想过这会带来没必要的性能损耗吗?