互斥锁是并发程序中对共享资源进行访问控制的主要手段,对此Go语言提供了非常简单易用的Mutex,Mutex为一结构体类型,对外暴露两个方法Lock()和Unlock()分别用于加锁和解锁。

Mutex 结构体

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

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

加解锁过程

简单加锁

假定当前只有一个协程在加锁,没有其他协程干扰,那么过程如下图所示:

加锁过程会去判断Locked标志位是否为0,如果是0则把Locked位置1,代表加锁成功。从上图可见,加锁成功后,只是Locked位置1,其他状态位没发生变化。

加锁阻塞

假定加锁时,锁已被其他协程占用了,此时加锁过程如下图所示:

从上图可看到,当协程B对一个已被占用的锁再次加锁时,Waiter计数器增加了1,此时协程B将被阻塞,直到Locked值变为0后才会被唤醒。

简单解锁

假定解锁时,没有其他协程阻塞,此时解锁过程如下图所示:

由于没有其他协程阻塞等待加锁,所以此时解锁时只需要把Locked位置为0即可,不需要释放信号量。

解锁并唤醒协程

假定解锁时,有1个或多个协程阻塞,此时解锁过程如下图所示:

协程A解锁过程分为两个步骤,一是把Locked位置0,二是查看到Waiter>0,所以释放一个信号量,唤醒一个阻塞的协程,被唤醒的协程B把Locked位置1,于是协程B获得锁。

自旋

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

自旋时间很短,但如果在自旋过程中发现锁已被释放,那么协程可以立即获取锁。此时即便有协程被唤醒也无法获取锁,只能再次阻塞。

优势

自旋更充分的利用了CPU,避免了协程切换。因为当前申请加锁的协程获得了CPU,如果通过短时间自旋就可以获得锁,那么就可以直接运行,而不用阻塞并切换协程。

条件

  • 运行在多 CPU 的机器上
  • 当前 Goroutine 为了获取该锁进入自旋的次数小于四次
  • 当前机器上至少存在一个正在运行的处理器 P 并且处理的运行队列为空
  • 互斥锁只有在普通模式才能进入自旋
func sync_runtime_canSpin(i int) bool {
	// sync.Mutex is cooperative, so we are conservative with spinning.
	// Spin only few times and only if running on a multicore machine and
	// GOMAXPROCS>1 and there is at least one other running P and local runq is empty.
	// As opposed to runtime mutex we don't do passive spinning here,
	// because there can be work on global runq or on other Ps.
	if i >= active_spin || ncpu <= 1 || gomaxprocs <= int32(sched.npidle+sched.nmspinning)+1 {
		return false
	}
	if p := getg().m.p.ptr(); !runqempty(p) {
		return false
	}
	return true
}

问题

如果自旋过程中获得了锁,那么之前阻塞的协程就无法获得锁。如果等待加锁的协程特别多,而都在自旋过程中获得了锁,那么之前阻塞的协程就将一直阻塞。

为了解决这个问题,在1.8的版本之后增加了一个状态 Starving。在这个状态下不会自旋,一定会有一个协程被唤醒并加锁

Mutex 模式

Normal模式

默认情况下都是 Normal 模式
当一个协程加锁失败时,不会立即转入等待状态,而是判断是否满足自旋条件,如果满足,则自旋来等待锁

Starving模式

自旋模式抢到锁,表示有协程释放了锁,我们知道释放锁时,如果 waiter > 0,即有阻塞等待的协程,会释放信号量来唤醒协程,当协程被唤醒后,发现 Locked = 1,锁又被抢占,则又会阻塞,但在阻塞前会判断自上次阻塞到本次阻塞经历了多长时间,如果超过 1ms 的话,会将 Mutex 标记为"饥饿"模式,然后再阻塞。
在饥饿模式下,不会启动自旋,如果有协程释放锁,那么一定会唤醒一个协程,被唤醒的协程会获得锁,同时会把 waiter 减 1.

Woken状态

Woken 状态作用于加锁和解锁的过程中,如果一个协程正在解锁,另一个协程在自旋等待加锁,那么会把 Woken 状态置为1,通知解锁的协程不用释放信号量。