互斥锁锁是并发程序中对共享资源进行访问控制的主要手段,对此GO语言提供了Mutex。对外暴露两个方法Lock()和Unlock()分别用于加锁和解锁。

Mutex数据结构

src/sync/mutex.go:Mutex定义了互斥锁的结构:

type Mutex struct {
	state int32
	sema uint32
}
  • state表示互斥锁的状态,比如是否被锁定等。
  • sema表示信号量,协程阻塞等待此信号量,解锁的协程释放信号量从而唤醒等待信号量的协程。

state变量虽然是一个32位的整形变量,但是在使用的过程中是分成4部分使用的,用于记录Mutex的四种状态: 这四种状态分别是:

  • Locked:表示协程是否已被锁定。0:没有锁定,1:已被锁定。
  • Woken:表示是否有协程已被唤醒,0:没有协程唤醒,1:已有协程唤醒正在加锁过程中。
  • Starving:表示该Mutex是否处理饥饿状态。0:没有饥饿,1:饥饿状态,说明有协程阻塞超过了1ms。
  • Waiter:表示阻塞等待锁的协程个数,协程解锁时根据此值来判断是否需要释放信号量。

协程之间抢锁就是抢给Locked赋值的权利,能够给Locked域置为1就说明抢锁成功。抢不到的话就阻塞等待sema信号量,一旦持有锁的协程解锁,等待的协程会一次被唤醒。

Woken和Starving主要用于控制协程间的抢锁过程。

加锁过程

简单加锁

假定当前只有一个协程在加锁,那么过程就简单了:

先判断Locked标志位是否位0,如果是0则把Locked位置1,代表加锁成功。这种场景下,只变动了Locked标志位,如图:

加锁被阻塞

如果加锁的时候,锁已经被其他协程占用了。此时Locked域值为1,对被占用的锁再次加锁时,将Waiter计数器加1,并且阻塞等待Locked值变为0后被唤醒。 如下图协程B的加锁过程:

解锁过程

简单解锁

解锁的时候,没有其他协程阻塞。此时直接将Locked域置为0即可,不需要释放信号量。

解锁并唤醒协程

解锁的时候有其他协程在等待锁,此时解锁的过程分为两步:

  1. 把Locked位置0
  2. 查看到Waiter>0,所以释放一个信号量,唤醒一个阻塞的协程,被唤醒的协程再将Locked位置0,获得锁。

自旋过程

加锁的时候,如果当前Locked位位1,说明其他协程持有锁。但是尝试加锁的协程并不会立即转为阻塞,而是会持续探测Locked位是否变为0,这个过程成为自旋过程。

自旋的好处是:自旋的时间很短,如果在自旋的过程中发现锁被释放,协程可以立即获取锁。当加锁失败的时候不必立即转入阻塞,有一定机会获取到锁,可以避免协程的切换

什么是自旋

自旋对应于CPU的“PAUSE”指令,相当于CPU空转,对程序而言相当于sleep了一段时间,时间非常短暂,当前是30个时钟周期。

自旋过程持续探测Locked位是否变为0,连续两次探测检测就是执行这些PAUSE指令,不同于sleep,不需要将协程转为睡眠状态。

自旋条件

加锁的时候程序会自动判断是否可以自旋,无限制地自旋会给CPU带来巨大压力,所以判断是否进行自旋就非常重要了。

自旋必须满足下面的条件:

  • 自旋次数要足够小,通常为4,即最多自旋4次。
  • CPU核数要大于1,否则自旋没有意义,因为此时不可能有其他协程释放锁。
  • 协程调度机制中的Process数量要大于1,比如使用GOMAXPROCS()将处理器设置为1就不能启用自旋。
  • 协程调度机制中的可运行队列必须为空,否则会延迟协程调用。 所以自旋的条件是非常严格的,就是不忙的时候才会启用自旋。

自旋的优势

自旋是为了更加充分利用CPU,尽量避免协程切换。当前协程申请加锁,如果经过短时间的自旋可以获得锁,当前协程就可以继续执行,而不用进入阻塞状态了。

自旋的问题

如果自旋过程获得锁,那么之前进入阻塞状态的协程可能将无法获得锁,进入饥饿状态。

为了避免协程长时间无法获取锁,1.8就增加了一个状态,就是Starving状态,在这个状态下不会自旋,一旦有协程释放锁,那么一定会唤醒一个协程并成功加锁。

Mutex的模式

每个Mutex都有两个模式,称为Normal和Starving。

Normal模式

该模式下,协程如果加锁不成功不会立即转入阻塞排队,而是判断是否满足自旋的条件,如果满足则会自动启动自旋过程

就是Starving标志位不为1的场景。也是默认情况下。

Starvation模式

与之对应的,starving为1的场景就是Starvation模式。

自旋过程能够抢到锁,一定意味着同一时刻有协程释放了锁 释放锁的时候如果发现有阻塞等待的协程,还会释放一个信号量来唤醒一个等待协程,被唤醒的协程得到CPU后开始执行,此时锁已经被自锁过程的协程获取了,所以被唤醒的协程就会继续阻塞。 不过阻塞前会判断自上次阻塞到本次阻塞经过了多长时间,如果超过1ms,那么就会将Mutex标记为饥饿模式,然后再阻塞。

处于饥饿模式下,就不会启动自旋过程,也就是一旦协程释放了锁,就一定会唤醒一个阻塞的协程并成功加锁。

woken状态

woken状态用于加锁和解锁的过程。就是同一时刻,两个协程一个在加锁,一个在解锁,在加锁的协程可能正在自旋,此时把woken标记为1,用于通知解锁协程不必释放信号量了。

重复解锁panic

如果多次执行Unlock不painic的话,那么多次解锁,可能每次都释放一个信号量,唤醒多个协程,多个协程唤醒后会继续在Lock的逻辑里抢锁,会增加Lock实现的复杂度,也会引起不必要的协程切换。

总结

  • 使用defer避免死锁。加锁后立即使用defer解锁,可以有效避免死锁。
  • 加锁和解锁要成对出现。