互斥锁(Mutex)是一种关键的并发控制机制,用于保护共享资源免受多个Goroutine的并发访问。

互斥锁(sync.Mutex)的基本操作包括加锁(Lock)和解锁(Unlock)。

1
2
3
4
5
6
7
8
var mu sync.Mutex

func A() {
    mu.Lock()
    defer mu.Unlock() 

    // 操作锁住一些共享资源
}

互斥锁的实现原理

  1. 互斥锁的零值是未加锁状态,即初始状态下没有任何Goroutine拥有锁。
  2. 当一个Goroutine尝试获取锁时,如果锁处于未加锁状态,它会立即获得锁,将锁状态置为已加锁,并继续执行。
  3. 如果锁已经被其他Goroutine持有,那么当前Goroutine将被阻塞,直到锁被释放。
  4. 当一个Goroutine释放锁时,锁的状态将被设置为未加锁,此时等待的Goroutine中的一个将被唤醒并获得锁。

加锁时如何发现锁已被抢占或锁已被释放,能用的有两个针对不同场景的解决方案:

  • 阻塞/唤醒:将当前 goroutine 阻塞挂起,直到锁被释放后,以回调的方式将阻塞 goroutine 重新唤醒,进行锁争夺;

  • 自旋 + CAS:基于自旋结合 CAS (atomic.CompareAndSwapInt32) 的方式,重复校验锁的状态并尝试获取锁,始终把主动权握在手中.

两种方案各有优劣,且有其适用的场景:

锁竞争方案 优势 劣势 适用场景
阻塞/唤醒 精准打击,不浪费 CPU 时间片 需要挂起协程,进行上下文切换,操作较重 并发竞争激烈的场景
自旋+CAS 无需阻塞协程,短期来看操作较轻 长时间争而不得,会浪费 CPU 时间片 并发竞争强度低的场景

sync.Mutex 结合两种方案的使用场景,制定了一个锁升级的过程,反映了面对并发环境通过持续试探逐渐由乐观逐渐转为悲观的态度,具体方案如下:

  • 首先保持乐观,goroutine 采用自旋 + CAS 的策略争夺锁;

  • 尝试持续受挫达到一定条件后,判定当前过于激烈,则由自旋转为 阻塞/挂起模式.

上面谈及到的由自旋模式转为阻塞模式的具体条件拆解如下:

  • 自旋累计达到 4 次仍未取得战果;

  • CPU 单核或仅有单个 P 调度器;(此时自旋,其他 goroutine 根本没机会释放锁,自旋纯属空转);

  • 当前 P 的执行队列中仍有待执行的 G. (避免因自旋影响到 GMP 调度效率).

饥饿模式见Golang 单机锁实现原理

互斥锁对应的是底层结构是sync.Mutex结构体,,位于 src/sync/mutex.go

1
2
3
4
type Mutex struct {  
  state int32  
  sema  uint32
 }
  • state表示锁的状态,有锁定、被唤醒、饥饿模式等,并且是用state的二进制位来标识的,不同模式下会有不同的处理方式,不同 bit 位分别存储了 mutexLocked(是否上锁)、mutexWoken(是否有 goroutine 从阻塞队列中被唤醒)、mutexStarving(是否处于饥饿模式)的信息

  • sema表示信号量,mutex阻塞队列的定位是通过这个变量来实现的,从而实现goroutine的阻塞和唤醒

加锁:通过原子操作cas加锁,如果加锁不成功,根据不同的场景选择自旋重试加锁或者阻塞等待被唤醒后加锁

解锁:通过原子操作add解锁,如果仍有goroutine在等待,唤醒等待的goroutine

互斥锁的注意事项

  • 在 Lock() 之前使用 Unlock() 会导致 panic 异常
  • 使用 Lock() 加锁后,再次 Lock() 会导致死锁(不支持重入),需Unlock()解锁后才能再加锁
  • 锁定状态与 goroutine 没有关联,一个 goroutine 可以 Lock,另一个 goroutine 可以 Unlock
  • 在高度竞争的情况下,多个Goroutine争夺锁可能导致性能下降。为了提高性能,可以考虑使用更轻量级的同步原语,如读写锁(sync.RWMutex)或通道(chan),以根据需求进行读或写的并发控制。
  • 互斥锁不适合用于允许多个线程同时读取共享资源的情况。如果您的应用程序需要支持多个线程并发读取但在写入时仍然需要互斥访问,可以考虑使用读写锁

参考