经常脚踩多条船的朋友都知道,为了不翻船,必须时刻确保同一时间只能与一位女友约会。
这个情境就像 Go 中,多个女友就好比是多个 goroutine,而我则是共享资源。为了避免冲突,互斥锁(sync.Mutex)用于保证 goroutine 对临界资源的互斥访问,也就是说,同一时间只能有一个女友独占我,其他女友无权约我(狗头保命)。

那么,Go 的互斥锁是如何实现的呢?以下结合源码进行分析(基于 Go 1.23.3 版本)。

互斥锁sync.Mutex的结构
type Mutex struct {
    state int32
    sema  uint32
}

state : 是锁的状态,按位被划分不同含义

  • 最低位 (第 0 位): 表示锁定状态:0 - 未锁定,1 (mutexLocked) - 锁定
  • 次最低位 (第 1 位): 表示唤醒状态:0 - 非唤醒,1 (mutexWoken) - 唤醒,用于区分公平和非公平锁
  • 再次最低位 (第 2 位): 表示饥饿模式:0 - 非饥饿,1 (mutexStarving) - 饥饿
  • 高位 (剩余位): 表示当前有多少个 goroutine 正在等待这个锁,用到的常量 mutexWaiterShift

1737083937381.png
sema : 这是一个信号量,用于协调 goroutine 在竞争互斥锁时的阻塞与唤醒操作

饥饿模式 : 所谓的饥饿指的是长时间等待goroutine,而无法获取锁的情况

加锁

加锁是调用 sync.Mutex.Lock

func (m *Mutex) Lock() {
    // Fast path: grab unlocked mutex.
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        if race.Enabled {
            race.Acquire(unsafe.Pointer(m))
        }
        return
    }
    // Slow path (outlined so that the fast path can be inlined)
    m.lockSlow()
}
💡 提示race 是Go 内置的竞态条件检测工具,它可以有效地帮助我们检测并发程序的正确性。只需在 go 命令加上 -race 选项即可,这里无需理会

首先通过快速路径 CAS 原子操作 CompareAndSwapInt32 ,设置m.state= mutexLocked(1),
CAS 只有旧值和预期值相等才能更新成功,也就是说能加锁成功代表旧值是 state = 0,即无锁状态,无等待等其他标志
失败会进入慢速路径:

func (m *Mutex) lockSlow() {
    var waitStartTime int64
    starving := false
    awoke := false
    iter := 0
    old := m.state
    for {
        //自旋
        if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
            // Active spinning makes sense.
            // Try to set mutexWoken flag to inform Unlock
            // to not wake other blocked goroutines.
            if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
                atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
                awoke = true
            }
            runtime_doSpin()
            iter++
            old = m.state
            continue
        }
        new := old
        // Don't try to acquire starving mutex, new arriving goroutines must queue.
        if old&mutexStarving == 0 {
            new |= mutexLocked
        }
        if old&(mutexLocked|mutexStarving) != 0 {
            new += 1 << mutexWaiterShift
        }
        // The current goroutine switches mutex to starvation mode.
        // But if the mutex is currently unlocked, don't do the switch.
        // Unlock expects that starving mutex has waiters, which will not
        // be true in this case.
        if starving && old&mutexLocked != 0 {
            new |= mutexStarving
        }
        if awoke {
            // The goroutine has been woken from sleep,
            // so we need to reset the flag in either case.
            if new&mutexWoken == 0 {
                throw("sync: inconsistent mutex state")
            }
            new &^= mutexWoken
        }
        if atomic.CompareAndSwapInt32(&m.state, old, new) {
            if old&(mutexLocked|mutexStarving) == 0 {
                break // locked the mutex with CAS
            }
            // If we were already waiting before, queue at the front of the queue.
            queueLifo := waitStartTime != 0
            if waitStartTime == 0 {
                waitStartTime = runtime_nanotime()
            }
            runtime_SemacquireMutex(&m.sema, queueLifo, 1)
            starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
            old = m.state
            if old&mutexStarving != 0 {
                // If this goroutine was woken and mutex is in starvation mode,
                // ownership was handed off to us but mutex is in somewhat
                // inconsistent state: mutexLocked is not set and we are still
                // accounted as waiter. Fix that.
                if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
                    throw("sync: inconsistent mutex state")
                }
                delta := int32(mutexLocked - 1<<mutexWaiterShift)
                if !starving || old>>mutexWaiterShift == 1 {
                    // Exit starvation mode.
                    // Critical to do it here and consider wait time.
                    // Starvation mode is so inefficient, that two goroutines
                    // can go lock-step infinitely once they switch mutex
                    // to starvation mode.
                    delta -= mutexStarving
                }
                atomic.AddInt32(&m.state, delta)
                break
            }
            awoke = true
            iter = 0
        } else {
            old = m.state
        }
    }

    if race.Enabled {
        race.Acquire(unsafe.Pointer(m))
    }
}

lockSlow 低速路径,里面是个大循环,主要做的是:

  • 判断是否自旋
  • 计算最新的状态
  • 更新状态

想必我们都听说过加锁是很费资源的操作,这个主要是因为等待过程中会阻塞,需要上下文切换。自旋是一种不阻塞,不释放CPU,只是空做无意义的事,避免睡眠。好的自旋能带来优越的性能,但使用不当性能反而下降,所以自旋的条件很严格,自旋部分的源码如下:

if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
            // Active spinning makes sense.
            // Try to set mutexWoken flag to inform Unlock
            // to not wake other blocked goroutines.
            if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
                atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
                awoke = true
            }
            runtime_doSpin()
            iter++
            old = m.state
            continue
    }
判断是否自旋

old&(mutexLocked|mutexStarving) == mutexLocked 是判断锁是否锁住状态,同时没有进入饥饿模式
runtime_canSpin(iter) 判断自旋条件是否成立,具体对应 runtime.sync_runtime_canSpin

func sync_runtime_canSpin(i int) bool {
    if i >= active_spin || ncpu <= 1 || gomaxprocs <= sched.npidle.Load()+sched.nmspinning.Load()+1 {
        return false
    }
    if p := getg().m.p.ptr(); !runqempty(p) {
        return false
    }
    return true
}

i >= active_spin : 自旋次数检查,不能超过阈值 4
ncpu <= 1 || gomaxprocs <= sched.npidle.Load()+sched.nmspinning.Load()+1 , 硬件检查:

  • cpu 内核数要大于1,不然单核自旋无意义
  • 当前活跃的 P(Processor) 数目加上闲置和自旋中的 P 数目已经大于或等于 GOMAXPROCS

p := getg().m.p.ptr(); !runqempty(p) : 当前运行的 goroutine 所在的 P(Processor) 本地无等待任务

在自旋前检查一下是否被唤醒,唤醒是由之前的持有锁的 goroutine ,释放锁 Unlock 触发的。

if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
                atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
                awoke = true
}

!awoke : 防止在循环自旋过程中重复操作
old&mutexWoken == 0 && old>>mutexWaiterShift != 0 : 当前锁状态不是唤醒状态,且有等待的goroutine
atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) 状态追加唤醒状态

自旋操作 runtime_doSpin 对应 runtime·procyield

func sync_runtime_doSpin() {
    procyield(active_spin_cnt)
}

procyield 不同架构有不同的编译,amd64 的如下:

TEXT runtime·procyield(SB),NOSPLIT,$0-0
    MOVL    cycles+0(FP), AX
again:
    PAUSE
    SUBL    $1, AX
    JNZ    again
    RET

可以看出是在重复执行 active_spin_cnt = 30PAUSE 指令,这是现代 CPU 的一个低功耗空转指令

更新状态

自旋说白了就是不阻塞,加锁的过程实际上是在一个循环中更新状态,直到成功地将 m.state 设置为 mutexLocked,表示当前 goroutine 成功获得了锁并退出循环。下面是详细的状态变化过程:

💡 提示& 运算符是位与操作,求的是 交集| 运算符是位或操作,求的是 并集

正常模式更新: 如果锁未处于饥饿模式,则尝试增加锁住标志:

new := old
// Don't try to acquire starving mutex, new arriving goroutines must queue.
if old&mutexStarving == 0 {
    new |= mutexLocked
}

此时并未真正抢到锁,只是准备尝试更新状态,最终是否成功还需要依赖后续的原子操作。

等待计数更新:

if old&(mutexLocked|mutexStarving) != 0 {
    new += 1 << mutexWaiterShift
}

如果是锁住或者处于饥饿状态,等待数量加 1

进入饥饿模式:

if starving && old&mutexLocked != 0 {
    new |= mutexStarving
}

...
// 进入时间条件
if waitStartTime == 0 {
    waitStartTime = runtime_nanotime()
}
starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs

这里的逻辑是:如果等待超过了预定义的时间阈值 starvationThresholdNs,则将 starving 置为 true

清理唤醒标志: 如果当前线程是被唤醒的,则需要清理唤醒标志

if awoke {
  // The goroutine has been woken from sleep,
  // so we need to reset the flag in either case.
  if new&mutexWoken == 0 {
      throw("sync: inconsistent mutex state")
  }
  new &^= mutexWoken
}

上面都是计算最新状态,然后通过原子 atomic.CompareAndSwapInt32(&m.state, old, new) 操作更新,
如果失败,即被其他 goroutine 抢先执行了根本更改了 old 期望值,只能从新循环开始重走一遍了

if atomic.CompareAndSwapInt32(&m.state, old, new) {
    if old&(mutexLocked|mutexStarving) == 0 {
        break // 成功获取锁,退出循环
    }
    // ...
    runtime_SemacquireMutex(&m.sema, queueLifo, 1) // 阻塞等待信号量
}

如果状态成功更新,处理一下不同状态需要做对应的事:

成功抢到锁 :这个表示抢到锁了,可以退出了

if old&(mutexLocked|mutexStarving) == 0 {
    break // locked the mutex with CAS
}

否则会调用 runtime_SemacquireMutex(&m.sema, queueLifo, 1) ,陷入阻塞,等待被唤起,具体内容就不展开来讲了
runtime_SemacquireMutex 唤醒后 ,正常模式是需要重新循环和其他 goroutine 公平竞争的,但饥饿模式会特殊处理,直接获取锁的使用权

饥饿模式:

  1. 当锁处于饥饿模式时,锁的所有权会直接传递给等待队列中的某个协程,而不会公平地分配
  2. 饥饿模式通常是在锁的竞争非常激烈时触发的,用于减少因公平调度造成的开销,但会降低效率

检查锁的状态是否一致

if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
      throw("sync: inconsistent mutex state")
}

这是设置锁定标志和等待数量减 1

delta := int32(mutexLocked - 1<<mutexWaiterShift)

判断自己处理完后是否退出饥饿模式

if !starving || old>>mutexWaiterShift == 1 {
    delta -= mutexStarving
}

atomic.AddInt32(&m.state, delta) 更新锁状态,这里获取到锁并退出

解锁

解锁调用的是 sync.Mutex.Unlock

func (m *Mutex) Unlock() {
    // Fast path: drop lock bit.
    new := atomic.AddInt32(&m.state, -mutexLocked)
    if new != 0 {
        // Outlined slow path to allow inlining the fast path.
        // To hide unlockSlow during tracing we skip one extra frame when tracing GoUnblock.
        m.unlockSlow(new)
    }
}

解锁的过程比较简单,atomic.AddInt32(&m.state, -mutexLocked) 就是释放了
不过释放了还不一定完成,可能还得做一些收尾工作
如果 new > 0 表示还有其它 goroutine 在等待或者其它标志需要处理清理,进入 unlockSlow

func (m *Mutex) unlockSlow(new int32) {
    if (new+mutexLocked)&mutexLocked == 0 {
        fatal("sync: unlock of unlocked mutex")
    }
    if new&mutexStarving == 0 {
        old := new
        for {
            // 如果没有等待的 goroutine,或者锁已经被唤醒或被锁定,直接返回。
            if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
                return
            }
            // 如果有等待的 goroutine,更新状态并唤醒一个 goroutine。
            new = (old - 1<<mutexWaiterShift) | mutexWoken
            if atomic.CompareAndSwapInt32(&m.state, old, new) {
                runtime_Semrelease(&m.sema, false, 1)
                return
            }
            old = m.state
        }
    } else {
        // 饥饿模式,直接将锁的所有权交给下一个等待的 goroutine
        runtime_Semrelease(&m.sema, true, 1)
    }
}
  1. 如果是饥饿模式,直接调用 runtime_Semrelease(&m.sema, true, 1) 来唤醒等待 goroutine
  2. 如果是非饥饿模式new&mutexStarving == 0 :
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
    return
}

如果等待数量为 0 或者 被锁定、被唤醒过、处于饥饿模式不需要处理

new = (old - 1<<mutexWaiterShift) | mutexWoken
if atomic.CompareAndSwapInt32(&m.state, old, new) {
  runtime_Semrelease(&m.sema, false, 1)
  return
}

等待数量减 1,并设置成唤起状态,更新状态成功后调用 runtime_Semrelease(&m.sema, false, 1) 来唤醒等待 goroutine

以上就是 Go 的互斥锁加锁和解锁的过程,现在可以放心的去和你的女友们约会了,
你学废了吗

参考文档:https://draveness.me/golang/docs/part3-runtime/ch06-concurren...


一夕烟云
1 声望1 粉丝