简介

sync.Mutex 是 go 标准库中使用的排它锁。当一个 goroutine 获取锁后,其它 goroutine 则无法获取锁而被阻塞,直到锁被释放而成功抢到锁。

本文是对 sync.Mutex 源码的总结,不会涉及到具体代码逻辑。

两方面的取舍

sync.Mutex 的实现主要涉及两方面的取舍

抢锁策略

排它锁是独占资源,因此会存在两个 goroutine 同时抢同一个锁的情况,不同的抢锁策略会导致不同的 goroutine 抢到锁。有如下两类抢锁策略

自旋

当进程 A 在获取锁时,若锁已经被抢占,则进程 A 将不断循环、判断锁是否能够被成功获取,直到成功抢到锁才退出循环。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func getLock() {
	for {
		ret = cas(&lock.status, unlocked, locked)
		// 同以下逻辑
		// if lock.status == unlocked {
		// 	lock.status = locked
		// }
    if ret {
      // 加锁成功
      return
    }
	}
}

信号量阻塞

进程抢不到锁,则通过信号量操作被系统挂起。待锁释放时,系统根据进程挂起的先后顺序,FIFO 唤醒进程去抢锁。

1
2
3
4
5
6
func getLock() {
  if lock.status == locked {
    semaAcquireMutex(&sema)
  }
  lock.status = locked
}

两种抢锁策略各有优劣,如下表

优点 缺点
自旋 轮询抢锁,抢锁速度快 长时间轮询,对 CPU 资源消耗较大
信号量阻塞 对 CPU 资源的消耗较少;FIFO 的抢锁顺序,较为公平 系统唤醒进程去抢锁,唤醒有一定成本;唤醒需要一定时间,抢锁速度较慢

公平性

sync.Mutex 中,为了充分利用自旋信号量阻塞两种抢锁策略的优点,采用了 “先自旋一定次数,后信号量阻塞” 的方式来抢锁,伪代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const maxSpinTimes = 4  // 最大自旋次数

func (m *Mutex) Lock() {
  i := 0

  // 自旋抢锁
  for {
    if i >= maxSpinTimes {
      break
    }
    atomic.Add(&i, 1)

    if getLock() {
      // 抢锁成功
      m.status = locked // 设置锁状态
      return
    }
  }

  // 信号量抢锁
  semaAcquireMutex(&m.sema) // 此处阻塞等待系统唤醒
  m.status = locked // 设置锁状态
}

但这种抢锁方式会造成 goroutine 饿死,例如这个 case

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func main() {
	var mu sync.Mutex
	
	go f(&mu) // goroutine A
	go f(&mu) // goroutine B
	
	time.Sleep(time.Second * 10)
}

func f(mu *sync.Mutex) {
	for {
		mu.Lock()
		time.Sleep(time.Microsecond * 100)
		mu.Unlock()
	}
}

goroutine A、B 在争抢一把锁。开始时两者均使用自旋的方式抢锁,假设 A 先抢到了锁,那么 B 自旋次数用完后就使用信号量阻塞抢锁,并被阻塞而进入挂起状态。

当 goroutine A 释放锁后,协程 B 被唤醒,此时 A 也接着开始抢锁。由于进程(协程对应的)被唤醒需要一定时间,因此有很大几率是晚于 A 开始抢锁的,因此很大概率上还是 A 抢到锁。

长此以往,协程 B 一直抢不到锁,就是我们说的协程 B 被 “饿死” 了。

image-20220123114241937

因此,为避免”饿死“情况的出现,保证每个协程都能尽量公平地拿到锁,go 在 1.9 版本后加入了 “饥饿模式” 来避免饿死的情况出现。在不同的模式中抢锁步骤略有不同,如下表

抢锁步骤 优缺点
正常模式 先自旋指定次数,然后信号量阻塞排队抢锁 兼顾性能,能较快地抢到锁;但是可能出现”饿死“情况
饥饿模式 信号量阻塞排队抢锁 抢锁速度较慢;但能较好地保证公平性,不会有“饿死”的情况

Go 中对抢锁耗费的时间进行了记录,若抢锁耗费的时间超过 1ms,则将当前模式从 “正常模式” 切换到 “饥饿模式” ;当饥饿协程获取锁后,才切换回 “正常模式”

总结

了解了 sync.Mutex 库的基本逻辑,这些问题也就不难回答了

  1. 使用全局一个大锁 or 多个小锁,哪个更好
  2. 为什么 sync.Mutex 结构不能被复制使用

参考资料

https://morioh.com/p/0a103ab09f46

https://blog.csdn.net/qq_37102984/article/details/115322706

https://mp.weixin.qq.com/s/BZvfNn_Vre7o2T8BZ4LLMw