公平锁

锁有两种模式:

正常模式和饥饿模式。

在正常模式下,所有的等待锁的goroutine都会存在一个先进先出的队列中(轮流被唤醒)
但是一个被唤醒的goroutine并不是直接获得锁,而是仍然需要和那些新请求锁的(new arrivial)的goroutine竞争,而这其实是不公平的,因为新请求锁的goroutine有一个优势——它们正在CPU上运行,并且数量可能会很多。所以一个被唤醒的goroutine拿到锁的概率是很小的。在这种情况下,这个被唤醒的goroutine会加入到队列的头部。如果一个等待的goroutine有超过1ms(写死在代码中) 都没获取到锁,那么就会把锁转变为饥饿模式。

在饥饿模式中,锁的所有权会直接从释放锁(unlock)的goroutine转交给队列头的goroutine,新请求锁的goroutine就算锁是空闲状态也不会去获取锁,并且也不会尝试自旋。它们只是排到队列的尾部。如果一个goroutine获取到了锁之后,它会判断以下两种情况:

  1. 它是队列中最后一个goroutine;
  2. 它拿到锁所花的时间小于1ms;

以上只要有一个成立,它就会把锁转变回正常模式。

正常模式会有比较好的性能,因为即使有很多阻塞的等待锁的goroutine,一个goroutine也可以尝试请求多次锁。饥饿模式对于防止尾部延迟来说非常的重要。

锁自旋

canSpin

接下来我们来看看上文提到的canSpin条件如何:

  1. // runtime/proc.go
  2. const (
  3. active_spin = 4
  4. )
  5. // Active spinning for sync.Mutex.
  6. //go:linkname sync_runtime_canSpin sync.runtime_canSpin
  7. //go:nosplit
  8. func sync_runtime_canSpin(i int) bool {
  9. // 这里的active_spin是个常量,值为4
  10. // 简单来说,sync.Mutex是有可能被多个goroutine竞争的,所以不应该大量自旋(消耗CPU)
  11. // 自旋的条件如下:
  12. // 1. 自旋次数小于active_spin(这里是4)次;
  13. // 2. 在多核机器上;
  14. // 3. GOMAXPROCS > 1并且至少有一个其它的处于运行状态的P;
  15. // 4. 当前P没有其它等待运行的G;
  16. // 满足以上四个条件才可以进行自旋。
  17. if i >= active_spin || ncpu <= 1 || gomaxprocs <= int32(sched.npidle+sched.nmspinning)+1 {
  18. return false
  19. }
  20. if p := getg().m.p.ptr(); !runqempty(p) {
  21. return false
  22. }
  23. return true
  24. }

所以可以看出来,并不是一直无限自旋下去的,当自旋次数到达4次或者其它条件不符合的时候,就改为信号量拿锁了。

doSpin

然后我们来看看doSpin的实现(其实也没啥好看的):

  1. //go:linkname sync_runtime_doSpin sync.runtime_doSpin
  2. //go:nosplit
  3. func sync_runtime_doSpin() {
  4. procyield(active_spin_cnt)
  5. }

这是一个汇编实现的函数,简单看两眼amd64上的实现:

  1. TEXT runtime·procyield(SB),NOSPLIT,$0-0
  2. MOVL cycles+0(FP), AX
  3. again:
  4. PAUSE
  5. SUBL $1, AX
  6. JNZ again
  7. RET

代码分析:https://juejin.cn/post/6844903910541361160

根据以上代码的分析,可以看出,sync.Mutex这把锁在你的工作负载(所需时间)比较低,比如只是对某个关键变量赋值的时候,性能还是比较好的,但是如果说对于临界资源的操作耗时很长(特别是单个操作就大于1ms)的话,实际上性能上会有一定的问题,这也就是我们经常看到“的锁一直处于饥饿状态”的问题,对于这种情况,可能就需要另寻他法了。

参考文章