Go-Mutex(互斥锁)解读

互斥锁是并发程序中对共享资源进行访问控制的主要手段,Go提供了Mutex(互斥锁)结构体类型

并且使用简单:对外暴露两个方法 Lock()Unlock() 分别用于加锁和解锁

1.Mutex使用

开启10个Goroutine来计算count的结果

package main

import (
    "fmt"
    "sync"
)

// 开启10个协程  计算10个(1+2+3+...+10 = 55) 综合count 应该是550
var syncMut sync.Mutex

var wg sync.WaitGroup

func main() {
    wg.Add(10)
    count := 0
    for i := 0; i < 10; i++ {
        fmt.Println("i==", i)
        go func() {
            defer wg.Done()
            for j := 1; j <= 10; j++ {
                syncMut.Lock()
                count += j
                syncMut.Unlock()

            }
        }()
    }
    wg.Wait()
    fmt.Println(count)
    /*
       i== 0
       i== 1
       i== 2
       i== 3
       i== 4
       i== 5
       i== 6
       i== 7
       i== 8
       i== 9
       550

    */
}

sync.Mutex匿名嵌入到结构体

可以使用匿名嵌入字段的方式,将 sync.Mutex 直接嵌入到 Counter 结构体中,然后在需要进行并发控制的方法中使用 Lock() 和 Unlock() 方法进行锁操作。这样可以使代码更加简洁,同时也可以保证并发安全。这个方法的代码示例如下:

type Counter struct {
    sync.Mutex
    count uint64
}

func (c *Counter) Incr() {
    c.Lock()
    c.count++
    c.Unlock()
}

func (c *Counter) Count() uint64 {
    c.Lock()
    defer c.Unlock()
    return c.count
}

sync.Mutex命名进行调用

(推荐使用上面匿名方式)

type Counter struct {
    mu    sync.Mutex
    count uint64
}

func (c *Counter) Incr() {
    c.mu.Lock()
    c.count++
    c.mu.Unlock()
}

func (c *Counter) Count() uint64 {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

2.Mutex源码解读

Mutex设计核心是通过「位压缩状态」+「双模式切换」平衡性能与公平性

核心逻辑:“快速路径→自旋→阻塞→唤醒”

关键字段

  • Mutex 的核心是 state 字段(状态标记)和 sema 字段(信号量,用于阻塞 / 唤醒 goroutine)

  • state 字段的位压缩设计(用 32 位整数存储多维度状态,减少内存开销)

type Mutex struct {
    state int32  // 锁的核心状态(32位整数,不同位段表示不同含义)
    sema  uint32 // 信号量,用于 goroutine 的阻塞/唤醒(依赖 runtime 调度)
}

state 是一个 32 位整数,不同位段表示不同状态,从低到高依次为:

  • 第 0 位(mutexLocked:锁定标记(1 = 被锁定,0 = 未锁定)。

  • 第 1 位(mutexWoken:唤醒标记(1 = 有等待者已被唤醒,避免重复唤醒)。

  • 第 2 位(mutexStarving:饥饿模式标记(1 = 处于饥饿模式,0 = 正常模式)。

  • 第 3~31 位:等待队列长度(记录阻塞在该锁上的 goroutine 数量)。

const (
    mutexLocked = 1 << iota  // 0b0001(锁定位)
    mutexWoken               // 0b0010(唤醒位)
    mutexStarving            // 0b0100(饥饿位)
    mutexWaiterShift = iota  // 3(等待者数量的位移,即从第3位开始)
)

我们看到Mutex.state是32位的整型变量,内部实现时把该变量分成四份,用于记录Mutex的四种状态。

下图展示Mutex的内存布局:

  • Locked: 表示该Mutex是否已被锁定,0:没有锁定 1:已被锁定。

  • Woken: 表示是否有协程已被唤醒,0:没有协程唤醒 1:已有协程唤醒,正在加锁过程中。

  • Starving:表示该Mutex是否处理饥饿状态, 0:没有饥饿 1:饥饿状态,说明有协程阻塞了超过1ms。

  • Waiter: 表示阻塞等待锁的协程个数,协程解锁时根据此值来判断是否需要释放信号量。

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

加锁 / 解锁的核心流程

每个 Goroutine(G)加锁 / 解锁的核心流程

  • 快速尝试:先通过 CAS 抢锁(无竞争时直接成功,最高效);

  • 自旋探测:抢不到锁时,先自旋 4 次(短期等待,避免频繁阻塞),期间持续探测 locked 位是否为 0;

  • 阻塞排队:自旋后仍抢不到(locked 还是 1),就进入等待队列,让出 CPU;

  • 释放唤醒:持有锁的 G 解锁时,会更新锁状态(把 locked 设为 0),并唤醒等待队列里的 G,让它再尝试抢锁。

3.加锁流程(Lock() 方法)

  • Lock() 的逻辑分为快速路径(无竞争时直接获取锁)和慢速路径(有竞争时进入 lockSlow() 处理),核心是通过 CAS 操作修改 state,并在需要时阻塞等待。

  • 加锁分「快速路径」(无竞争,低开销)和「慢速路径」(有竞争,复杂处理)

  • 整体是「CAS 尝试 → 自旋优化 → 阻塞等待 → 唤醒获取」的流程

1. 快速路径(无竞争场景)

直接通过 CAS 尝试获取锁,成功则立即返回(仅 1 次 CAS 操作,开销极小):

func (m *Mutex) Lock() {
    // 尝试通过CAS直接获取锁:如果state为0(未锁定),则设置锁定位为1
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        return // 成功获取锁,直接返回
    }
    // 有竞争,进入慢速路径
    m.lockSlow()
}

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

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

2. 慢速路径(lockSlow()核心逻辑)

处理「锁被占用」「有等待者」等复杂场景,核心逻辑分 4 步:

2.1自旋优化(正常模式专属)

自旋条件(缺一不可,避免浪费 CPU):

  • 锁处于「正常模式」(非饥饿)且已被锁定(old&(mutexLocked|mutexStarving) == mutexLocked);

  • CPU 核心数 > 1(单核心自旋无意义,只会浪费时间);

  • 自旋次数较少(默认最多 4 次,避免长期空转)。

自旋目的:短期持有锁的场景下,通过空循环等待锁释放,避免「阻塞 - 唤醒」的高开销(阻塞需切换内核态,成本比自旋高)。

2.2计算新状态(状态变更逻辑)

根据当前状态(old)计算要更新的新状态(new):

  • 非饥饿模式:尝试设置「锁定位」(new |= mutexLocked),争取获取锁;

  • 有竞争(锁被占 / 饥饿模式):增加「等待者数量」(new += 1 << mutexWaiterShift);

  • 当前 goroutine 等待超 1ms:标记「饥饿位」(new |= mutexStarving),触发公平模式。

2.3CAS 更新状态 + 阻塞等待

通过 CAS 原子更新 state

  • 若 CAS 成功:若锁空闲则直接获取;否则通过 runtime_SemacquireMutex(&m.sema) 阻塞(加入等待队列,释放 CPU)。

  • 若 CAS 失败:重新读取 state,循环重试。

2.4唤醒后处理(饥饿模式专属)

被唤醒后,若处于「饥饿模式」:

  • 锁直接传递给当前 goroutine(无需 CAS,保证公平性,避免新 goroutine 插队);

  • 若当前是最后一个等待者:清除「饥饿位」,切回正常模式。

当快速路径失败(锁已被占用或有竞争),会进入 lockSlow(),处理以下场景:

  • 锁被其他 goroutine 持有(正常模式或饥饿模式)。

  • 等待队列中有其他 goroutine 在排队。

核心流程拆解:

func (m *Mutex) lockSlow() {
    var waitStartTime int64 // 记录等待开始时间(用于判断是否进入饥饿模式)
    starving := false       // 当前goroutine是否处于饥饿状态
    awoke := false          // 当前goroutine是否被唤醒
    iter := 0               // 自旋次数(正常模式下尝试自旋获取锁)
    old := m.state          // 记录当前锁状态

    for {
        // 情况1:锁处于正常模式(非饥饿)且被锁定,尝试自旋(spin)获取锁
        // 自旋条件:CPU核心数>1、当前自旋次数较少(避免浪费CPU)、有其他线程在运行
        if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
            // 尝试设置唤醒位(避免被Unlock唤醒其他等待者)
            if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
                atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
                awoke = true
            }
            runtime_doSpin() // 执行自旋(空循环,消耗CPU时间片)
            iter++
            old = m.state // 重新读取状态
            continue
        }

        // 情况2:处理锁状态(计算新状态)
        new := old
        // 非饥饿模式下,尝试获取锁(设置锁定位)
        if old&mutexStarving == 0 {
            new |= mutexLocked
        }
        // 如果锁已被锁定或处于饥饿模式,增加等待者数量
        if old&(mutexLocked|mutexStarving) != 0 {
            new += 1 << mutexWaiterShift
        }

        // 情况3:当前goroutine处于饥饿状态,且锁被锁定,则将锁标记为饥饿模式
        if starving && old&mutexLocked != 0 {
            new |= mutexStarving
        }

        // 情况4:如果当前goroutine已被唤醒,清除唤醒位
        if awoke {
            if new&mutexWoken != 0 {
                panic("sync: inconsistent mutex state")
            }
            new &^= mutexWoken // 清除唤醒位
        }

        // 通过CAS更新状态,如果成功则跳出循环
        if atomic.CompareAndSwapInt32(&m.state, old, new) {
            // 锁未被占用且非饥饿模式,成功获取锁
            if old&(mutexLocked|mutexStarving) == 0 {
                break
            }
            // 处理等待逻辑(之前已在等待队列,现在重新排队)
            queueLifo := waitStartTime != 0
            if waitStartTime == 0 {
                waitStartTime = runtime_nanotime()
            }
            // 阻塞等待信号量(通过sema等待,进入等待队列)
            runtime_SemacquireMutex(&m.sema, queueLifo, 1)
            // 被唤醒后,检查是否需要进入饥饿模式(等待超过1ms)
            starving = starving || runtime_nanotime()-waitStartTime > 1e6
            old = m.state
            // 如果锁处于饥饿模式,直接获取锁(无需CAS,饥饿模式下锁直接传递)
            if old&mutexStarving != 0 {
                if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
                    panic("sync: inconsistent mutex state")
                }
                // 减少等待者数量并获取锁
                delta := int32(mutexLocked - 1<<mutexWaiterShift)
                if !starving || old>>mutexWaiterShift == 1 {
                    delta -= mutexStarving // 最后一个等待者,退出饥饿模式
                }
                atomic.AddInt32(&m.state, delta)
                break
            }
            awoke = true
            iter = 0
        } else {
            old = m.state // CAS失败,重新读取状态
        }
    }
}

核心逻辑要点

  • 自旋优化:正常模式下,锁被短暂持有时,当前 goroutine 会自旋几次(空循环),避免立即阻塞(阻塞 / 唤醒开销比自旋大)。

  • 饥饿模式:当一个 goroutine 等待锁超过 1ms,会触发饥饿模式,此时锁会直接传递给等待时间最长的 goroutine,避免被新到来的 goroutine “插队”(解决公平性问题)。

  • 等待队列:通过 sema 信号量管理等待的 goroutine,runtime_SemacquireMutex 会将 goroutine 加入等待队列并阻塞。

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

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

4.解锁流程(Unlock()方法)

  • Unlock() 负责释放锁,并根据状态唤醒等待的 goroutine

  • 解锁分「快速路径」(无等待者)和「慢速路径」(有等待者),核心是「释放锁 + 唤醒等待者」

1. 快速路径(无等待者)

直接原子清除「锁定位」,若结果为 0(无等待者、无其他状态),则解锁完成:

func (m *Mutex) Unlock() {
    // 快速释放锁:清除锁定位
    new := atomic.AddInt32(&m.state, -mutexLocked)
    if new != 0 {
        // 有等待者或状态异常,进入慢速路径
        m.unlockSlow(new)
    }
}

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

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

2. 慢速路径(unlockSlow()

2.1 重复解锁检查(容错)

若解锁一个未锁定的锁((new+mutexLocked)&mutexLocked == 0),直接 panic(避免错误使用)。

2.2 分模式唤醒等待者

  • 正常模式:优先性能,唤醒一个等待者(减少等待者数量,设置「唤醒位」避免重复唤醒),调用 runtime_Semrelease(&m.sema) 唤醒;

  • 饥饿模式:优先公平,直接唤醒等待队列的第一个 goroutine(锁所有权直接传递,不允许新 goroutine 插队,解决「等待者饿死」问题)。

当释放锁后仍有等待者或状态异常时,进入 unlockSlow() 处理:

func (m *Mutex) unlockSlow(new int32) {
    if (new+mutexLocked)&mutexLocked == 0 {
        panic("sync: unlock of unlocked mutex") // 重复解锁,触发panic
    }

    if new&mutexStarving == 0 { // 正常模式
        old := new
        for {
            // 无等待者,直接返回
            if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
                return
            }
            // 唤醒一个等待者(减少等待者数量,设置唤醒位)
            new := (old - 1<<mutexWaiterShift) | mutexWoken
            if atomic.CompareAndSwapInt32(&m.state, old, new) {
                runtime_Semrelease(&m.sema, false, 1) // 唤醒等待的goroutine
                return
            }
            old = m.state
        }
    } else { // 饥饿模式
        // 直接唤醒等待队列的第一个goroutine(锁所有权直接传递)
        runtime_Semrelease(&m.sema, true, 1)
    }
}

核心逻辑要点

  • 正常模式:释放锁后,若有等待者,唤醒其中一个(通过 runtime_Semrelease),并设置唤醒位避免重复唤醒。

  • 饥饿模式:锁直接传递给等待队列的第一个 goroutine(不允许新 goroutine 插队),确保公平性。

  • 重复解锁检查:通过状态校验,若解锁一个未锁定的锁,直接 panic(避免错误使用)。

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

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

5.CAS

CAS 是 Compare And Swap(比较并交换) 的缩写,是 CPU 提供的原子操作指令,也是实现无锁编程(如 Go 的 atomic 包、sync.Mutex)的核心技术。

核心价值

  • 无锁安全:无需加互斥锁,就能保证多 Goroutine 操作共享变量的线程安全(避免锁的开销);

  • 轻量高效:是 CPU 级指令,操作开销远小于 Goroutine 阻塞 / 唤醒(这也是 Mutex 自旋优化的基础)。

核心原理(3 步原子执行)

CAS 操作针对一个「内存地址」,需传入 3 个参数:

  • 旧值(Old Value):预期内存地址中当前存储的值;

  • 新值(New Value):若内存值与旧值一致,要写入的新值;

  • 内存地址(Addr):要操作的目标内存位置。

执行逻辑(原子不可打断)

  1. 读取 Addr 地址中的「当前值」;

  2. 比较「当前值」与「旧值」是否相等;

  3. 若相等:将「新值」写入 Addr,返回 true(操作成功);若不相等:不做任何修改,返回 false(操作失败)。

举个实际例子(结合 Go Mutex)

sync.Mutex 加锁的「快速尝试」步骤中,就用到了 CAS:

  • Mutex 用一个 int32 存储状态(低 1 位是 locked 标志,0 = 未锁,1 = 已锁);

  • 加锁时,先读取该 int32 的「当前值」(假设为 0,即未锁);

  • 执行 CAS:旧值 = 0,新值 = 1,目标地址是状态变量的内存地址;

  • 若 CAS 成功(返回 true):说明没人抢锁,直接加锁成功;

  • 若 CAS 失败(返回 false):说明锁已被持有,进入后续自旋 / 排队逻辑。

ABA问题

  • 什么是 ABA:内存值先从 A 变成 B,再变回 A。CAS 比较时会认为「值没变化」,但实际中间发生过修改(可能引发逻辑错误,如链表节点复用场景)。

  • 解决方案:用「值 + 版本号」组合代替纯值(如 atomic.Value 内部逻辑),CAS 时同时比较「值 + 版本号」,版本号每次修改都递增,避免误判。

局限性

  • 只能操作单个变量(无法原子操作多个变量,需用其他方案如 sync.Mutex);

  • 可能出现「自旋循环」(如 CAS 一直失败,会反复尝试,消耗 CPU;需设置合理重试次数或退出逻辑)。

6.自旋

自旋过程

加锁时,如果当前Locked位为1,说明该锁当前由其他协程持有,尝试加锁的协程并不是马上转入阻塞,而是会持续 的探测Locked位是否变为0,这个过程即为自旋过程。 自旋时间很短,但如果在自旋过程中发现锁已被释放,那么协程可以立即获取锁。此时即便有协程被唤醒也无法获取 锁,只能再次阻塞。 自旋的好处是,当加锁失败时不必立即转入阻塞,有一定机会获取到锁,这样可以避免协程的切换。

什么是自旋

自旋对应于CPU的”PAUSE”指令,CPU对该指令什么都不做,相当于CPU空转,对程序而言相当于sleep了一小段时 间,时间非常短,当前实现是30个时钟周期。 自旋过程中会持续探测Locked是否变为0,连续两次探测间隔就是执行这些PAUSE指令,它不同于sleep,不需要将 协程转为睡眠状态。

Go 中控制自旋次数上限的逻辑在 runtime/proc.go 源码中,通过 active_spin 常量定义了默认最大自旋次数为 4 次。以下是具体位置和代码解析:

1. 源码位置(以 Go 1.25 为例)

  • 文件路径:src/runtime/proc.go

  • 核心常量与函数:active_spin(自旋次数上限)和 runtime_canSpin(判断是否允许自旋)。

// src/runtime/proc.go

// 最大主动自旋次数(默认 4 次)
const active_spin = 4

// 最大被动自旋次数(通常不影响 Mutex 场景)
const active_spin_cnt = 30

// runtime_canSpin 决定当前是否可以进行自旋
// iter:当前已自旋的次数
func runtime_canSpin(iter int) bool {
    // 条件 1:自旋次数未超过上限(iter < active_spin → 最多 4 次)
    // 条件 2:CPU 核心数 > 1(单核心自旋无意义)
    // 条件 3:当前 P 的本地队列有其他 G 在运行(避免独占 CPU)
    // 条件 4:当前机器处于多线程模式(非单线程)
    return iter < active_spin &&
        runtime_ncpu() > 1 &&
        gomaxprocs > 1 &&
        sched.nmspinning.Load() < uint32(gomaxprocs-1) &&
        atomic.Load(&sched.npidle) == 0
}
  • active_spin = 4:直接定义了最大自旋次数为 4 次,这是 Mutex 自旋的核心限制。

  • runtime_canSpin 函数:Mutex 的 lockSlow() 中会调用此函数判断是否继续自旋。当 iter(当前自旋次数)小于 active_spin(4),且满足其他 CPU 条件时,才允许继续自旋。

  • 为什么是 4 次?:这是 Go 团队基于性能测试的经验值 —— 短期自旋(4 次)足以覆盖大多数 “锁持有时间极短” 的场景(如几纳秒级操作),既能减少阻塞开销,又不会因长期空转浪费 CPU。

7.关键设计点

  1. 双模式切换(正常 vs 饥饿)

    • 正常模式:优先性能,允许新 goroutine 自旋插队(短期锁场景高效);

    • 饥饿模式:等待超 1ms 触发,优先公平,锁直接传递给等待最久的 goroutine(避免饿死);

    • 平衡了「高并发性能」和「长期等待公平性」。

  2. 快速路径优化 无竞争场景下,加锁 / 解锁仅需 1-2 次 CAS 操作(用户态操作,无内核态切换),开销极低。

  3. 自旋 + 阻塞结合 短期锁用自旋减少阻塞开销,长期锁用阻塞避免 CPU 空转,兼顾效率与资源利用率。

  4. 位压缩状态 用 1 个 32 位整数存储「锁状态 + 唤醒标记 + 饥饿标记 + 等待者数量」,减少内存占用(Mutex 仅 8 字节),提升缓存命中率。

8.注意点

  1. Mutex 为什么设计双模式? 正常模式优先性能(允许插队),饥饿模式优先公平(避免等待者饿死),平衡不同场景需求。

  2. 自旋的条件是什么?为什么要自旋? 条件:正常模式、锁被持有、CPU>1、自旋次数少;目的:避免短期锁的「阻塞 - 唤醒」高开销。

  3. 如何判断 Mutex 被重复解锁? 解锁时原子减 mutexLocked 后,若 (new+mutexLocked)&mutexLocked == 0(说明锁原本就未锁定),触发 panic。

  4. 饥饿模式的触发条件? goroutine 等待锁的时间超过 1ms,会标记「饥饿位」,切换到饥饿模式。

  5. 加锁后立即使用defer对其解锁,可以有效的避免死锁。
  6. 加锁和解锁最好出现在同一个层次的代码块中,比如同一个函数。 重复解锁会引起panic,应避免这种操作的可能性。

9.Mutex模式

前面分析加锁和解锁过程中只关注了Waiter和Locked位的变化,现在我们看一下Starving位的作用。 每个Mutex都有两个模式,称为Normal和Starving。下面分别说明这两个模式。

normal模式

默认情况下,Mutex的模式为normal。 该模式下,协程如果加锁不成功不会立即转入阻塞排队,而是判断是否满足自旋的条件,如果满足则会启动自旋过程,尝试抢锁。

starvation模式

自旋过程中能抢到锁,一定意味着同一时刻有协程释放了锁,我们知道释放锁时如果发现有阻塞等待的协程,还会释 放一个信号量来唤醒一个等待协程,被唤醒的协程得到CPU后开始运行,此时发现锁已被抢占了,自己只好再次阻塞, 不过阻塞前会判断自上次阻塞到本次阻塞经过了多长时间,如果超过1ms的话,会将Mutex标记为”饥饿”模式,然后 再阻塞。 处于饥饿模式下,不会启动自旋过程,也即一旦有协程释放了锁,那么一定会唤醒协程,被唤醒的协程将会成功获取 锁,同时也会把等待计数减1。

Woken状态

Woken状态用于加锁和解锁过程的通信,举个例子,同一时刻,两个协程一个在加锁,一个在解锁,在加锁的协程可 能在自旋过程中,此时把Woken标记为1,用于通知解锁协程不必释放信号量了,好比在说:你只管解锁好了,不必释 放信号量,我马上就拿到锁了。

 

 

 

 

 

posted @ 2023-03-21 15:00  GJH-  阅读(110)  评论(0)    收藏  举报