sync.Mutex 源码深度拆解:正常模式(自旋)与饥饿模式

sync.Mutex 源码深度拆解:正常模式(自旋)与饥饿模式

一、核心概念与架构设计

sync.Mutex 是 Go 并发体系里最基础、也是被调用次数最多的同步原语。一个 Lock()/Unlock() 只占几纳秒的快速路径,背后是一套为两种截然相反的目标做权衡的状态机:

  • 吞吐优先:锁释放的瞬间,让正在 CPU 上自旋的竞争者立刻接手,避免一次昂贵的 goroutine 挂起与唤醒。
  • 公平优先:当某个等待者已经被晾了太久,锁必须直接移交给队列队首,不能让"插队的新来者"无限拖延。

Go 1.9 之前的实现只有第一种目标,代价是极端争抢下会出现尾随延迟(tail latency):等待队列里的 goroutine 可能被反复"截胡",几十毫秒都拿不到锁。Go 1.9 引入的饥饿模式(starvation mode)用一个 1ms 的时间阈值把两种模式粘在了一起:大部分时间跑在吞吐优先的正常模式 ,一旦出现不公平迹象就临时切换到饥饿模式,交接完成后自动退回。

另一个值得知道的版本事实:从 Go 1.23 开始,sync.Mutex 的实现被搬到了 internal/sync 标准库内层包(internal/sync/mutex.go),sync 只是薄薄一层转发。所以在 Go 1.23+ 的死锁堆栈里,你会看到 internal/sync.(*Mutex).unlockSlow 这样的帧,这是正常的,不是什么第三方库。

二、深度原理与底层剖析

2.1 state:一个 int32 承载四种信息

Mutex 只有两个零值可用的字段:

go 复制代码
// 位于 internal/sync/mutex.go(Go 1.23+,此前在 sync/mutex.go)
type Mutex struct {
    state int32  // 复合状态字,见下方位掩码
    sema  uint32 // 信号量,挂起与唤醒等待者
}

const (
    mutexLocked      = 1 << 0 // 第 0 位:锁被持有
    mutexWoken       = 1 << 1 // 第 1 位:已有等待者被唤醒,正在转起来
    mutexStarving    = 1 << 2 // 第 2 位:饥饿模式开关
    mutexWaiterShift = 3      // 第 3 位起:等待者数量(高位整数)
)

const starvationThresholdNs = 1e6 // 1ms:切入饥饿模式的时间阈值

state 的 32 个位被切成两段:低 3 位是三个标志位,高位是等待者计数。一次 state 的原子读就能同时回答"锁在谁手里""有没有人刚被唤醒""是不是饥饿模式""队列里还有多少人"四个问题。这种位压缩让 Mutex 在 64 位机器上只占 8 字节,两个 int32 恰好对齐,也是它能被零值直接使用、可嵌入任意结构体的原因。

2.2 Lock 快速路径:一次 CAS 决胜负

go 复制代码
func (m *Mutex) Lock() {
    // 快速路径:state 从 0(完全空闲)CAS 成 mutexLocked,
    // 无竞争时只花一次原子指令 + 一次 CAS,约 10~20ns。
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        return
    }
    m.lockSlow() // 竞争失败,进入慢速路径
}

state == 0 意味着没人持锁、没人被唤醒、非饥饿、队列空。绝大多数无竞争的 Lock 都在这条路径上终结,这就是 Mutex "零值可用且几乎免费"的本质。

2.3 lockSlow:自旋与饥饿的完整状态机

慢速路径(节选并简化注释):

go 复制代码
func (m *Mutex) lockSlow() {
    var waitStartTime int64 // 本 goroutine 开始排队的时间
    var starving bool       // 是否已进入饥饿心态
    var awoke bool          // 是否是被唤醒的那一个
    iter := 0               // 自旋轮数计数

    for {
        old := atomic.LoadInt32(&m.state)

        // 分支一:锁被持有、非饥饿模式,尝试自旋。
        // 自旋有硬门槛:多核、GOMAXPROCS>1、至少还有一个 P 在跑、
        // 当前 P 的本地运行队列为空(自旋才划算)。最多自旋 4 次,
        // 每次执行 procyield(30)(x86 上的 PAUSE 指令,约 30 个周期)。
        if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
            // 自己是被唤醒的、且 woken 位还没人设置,就占住 woken 位,
            // 阻止 Unlock 再去唤醒别的 goroutine,减少无效唤醒。
            if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
                atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
                awoke = true
            }
            runtime_doSpin()
            iter++
            continue // 自旋结束后重新读 state 再战
        }

        new := old
        if old&mutexStarving == 0 {
            new |= mutexLocked // 正常模式:尝试把锁位占上
        }
        if old&(mutexLocked|mutexStarving) != 0 {
            new += 1 << mutexWaiterShift // 抢不到,排队人数 +1
        }
        if starving && old&mutexLocked != 0 {
            new |= mutexStarving // 等得太久,把饥饿标志写进全局状态
        }
        if awoke {
            new &^= mutexWoken // 转入正式流程,清掉 woken 位
        }

        if atomic.CompareAndSwapInt32(&m.state, old, new) {
            // 分支二:CAS 成功且此前的 state 是完全空闲 + 非饥饿,
            // 说明拿到了锁,收工。
            if old&(mutexLocked|mutexStarving) == 0 {
                break
            }

            // 分支三:排队路径。记录首次排队时间,随后信号量挂起。
            queueLifo := waitStartTime != 0
            if waitStartTime == 0 {
                waitStartTime = runtime_nanotime()
            }
            // 饥饿中传入 lifo=true:挂入信号量等待队列的队首
            runtime_SemacquireMutex(&m.sema, queueLifo, 1)

            // 醒来后重新评估:本 goroutine 的累计等待是否超过 1ms
            starving = waitStartTime != 0 && runtime_nanotime()-waitStartTime > starvationThresholdNs
            old = atomic.LoadInt32(&m.state)

            // 分支四:正常模式下被唤醒,但锁位/饥饿位又被别人占了,
            // 说明醒来晚了,回到循环顶部重新竞争。
            if old&(mutexLocked|mutexStarving) != 0 {
                old = 0
                continue
            }

            // 分支五:饥饿交接完成,锁的所有权已经属于本 goroutine,
            // 锁位尚未设置,由本 goroutine 自己置位并做账目结算:
            // 锁位 +1,等待者 -1。
            delta := int32(mutexLocked - 1<<mutexWaiterShift)
            if !starving || old>>mutexWaiterShift == 0 {
                // 自己已不饥饿、或自己是队列最后一个等待者:
                // 顺手清掉饥饿标志,状态机退回正常模式。
                delta -= mutexStarving
            }
            atomic.AddInt32(&m.state, delta)
            break
        }
        awoke = false
    }
}

三个关键设计读一遍源码就能看清:

自旋的本质是赌"锁马上就释放" 。临界区普遍在几十纳秒量级时,自旋 4 次(约百 ns)远比走一次信号量挂起(涉及 gopark、调度器切换,微秒级)便宜。runtime_canSpin 里的"P 本地队列为空"检查很精妙:如果 P 上还有别的 goroutine 可跑,自旋就是浪费别人的 CPU 时间片。

饥饿模式的判定权在等待者,执行权在 Unlock 。等待者在挂起醒来后检查自己等了多久,超过 1ms 就把 mutexStarving 写回 state。此后 Unlock 的行为发生根本变化:不再"唤醒一个竞争者让他回来重新抢",而是调用 runtime_Semrelease(&m.sema, true, 1),handoff=true 直接把信号量移交给等待队列队首,锁的所有权在唤醒的那一刻就完成了转移,新来者连自旋的机会都没有(lockSlow 分支一要求 old&mutexStarving == 0 才允许自旋)。

饥饿模式是自限的 。交接时如果发现自己是队列里最后一个等待者(或最后一段饥饿时间已耗尽),就在减等待者计数的同时清掉 mutexStarving,状态机退回正常模式。这保证饥饿模式只在拥塞尖峰时短暂存在,不会永久牺牲吞吐。

2.4 Unlock:正常唤醒 vs 饥饿移交

go 复制代码
func (m *Mutex) Unlock() {
    // 快速路径:state -= mutexLocked。若结果非 0,说明还有标志位或等待者。
    new := atomic.AddInt32(&m.state, -mutexLocked)
    if new != 0 {
        m.unlockSlow(new)
    }
}

func (m *Mutex) unlockSlow(new int32) {
    if new&mutexStarving != 0 {
        // 饥饿模式:锁的所有权直接移交队首等待者(handoff = true),
        // 被移交者醒来即持锁,不需要再竞争。
        runtime_Semrelease(&m.sema, true, 1)
        return
    }
    // 正常模式:
    if new>>mutexWaiterShift == 0 {
        return // 没有等待者,无事发生
    }
    for {
        old := atomic.LoadInt32(&m.state)
        // 已有人被唤醒 / 已有饥饿标志,交给他们处理
        if old&(mutexWoken|mutexStarving) != 0 { return }
        // 自己这轮 Unlock 已经置过 locked=0(Add 已完成),
        // 只需把一个等待者从队列减掉,并设置 woken 位
        new = (old - 1<<mutexWaiterShift) | mutexWoken
        if atomic.CompareAndSwapInt32(&m.state, old, new) {
            runtime_Semrelease(&m.sema, false, 1)
            return
        }
    }
}

注意 Unlock 里减去的是 mutexLocked 而不处理等待者计数,等待者计数的减少由被唤醒者在分支四完成。状态的所有变更都通过原子操作完成,没有任何一个字段需要互斥保护,这是它能在快速路径做到十几纳秒的根本原因。

三、完整可运行示例

下面这段程序可以在本机直接 go run,用两个场景分别复现正常模式的尾随延迟和饥饿模式的交接公平性:

go 复制代码
package main

import (
	"fmt"
	"runtime"
	"sync"
	"time"
)

// 场景一:一个"贪婪"的持有者在正常模式下反复抢锁,
// 观察等待者平均要等多久才能拿到锁。
func greedyHolder() {
	var mu sync.Mutex
	const waiters = 4

	mu.Lock() // 主 goroutine 先持有锁
	var wg sync.WaitGroup
	start := make(chan struct{})
	latency := make([]time.Duration, waiters)

	for i := 0; i < waiters; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			<-start // 所有等待者就位后再开始计时
			t0 := time.Now()
			mu.Lock()
			latency[i] = time.Since(t0) // 从调用 Lock 到真正获锁的耗时
			time.Sleep(50 * time.Microsecond)
			mu.Unlock()
		}(i)
	}

	close(start)
	// 持有者不放锁,而是不停地 Lock/Unlock 空转。
	// 正常模式下唤醒的等待者会与新来的 Lock 竞争,
	// 而自旋的新来者往往能抢赢刚被唤醒、还没来得及执行的等待者。
	for i := 0; i < 200; i++ {
		mu.Unlock()
		runtime.Gosched() // 让出 P,但并没有真正"让出锁"
		mu.Lock()
	}
	// 此刻锁上大概率已积压等待者,Unlock 会触发交接;
	// 若等待时间超过 1ms,sync 内部会切入饥饿模式,锁直接移交给队首。
	mu.Unlock()
	wg.Wait()

	var total time.Duration
	for i, d := range latency {
		fmt.Printf("  waiter#%d 获锁延迟: %v\n", i, d)
		total += d
	}
	fmt.Printf("  平均获锁延迟: %v\n\n", total/time.Duration(waiters))
}

// 场景二:受控场景,直接观察饥饿模式交接后的公平性。
func fairHandoff() {
	var mu sync.Mutex
	done := make(chan struct{})

	mu.Lock() // 主 goroutine 持锁,制造持续 5ms 的持有窗口

	// 后台线程持续争抢,模拟"新来的竞争者"
	go func() {
		for {
			select {
			case <-done:
				return
			default:
			}
			mu.Lock()
			mu.Unlock()
		}
	}()

	var wg sync.WaitGroup
	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			time.Sleep(time.Duration(i) * time.Millisecond) // 错峰入队
			mu.Lock()
			fmt.Printf("  waiter#%d 拿到锁(此刻处于饥饿交接或队列队首)\n", i)
			mu.Unlock()
		}(i)
	}
	// 持锁 5ms,远超 starvationThresholdNs = 1e6(1ms),
	// Unlock 时 sync 内部以 handoff=true 释放信号量,锁直接移交队首等待者。
	time.Sleep(5 * time.Millisecond)
	mu.Unlock()
	wg.Wait()
	close(done)
}

func main() {
	runtime.GOMAXPROCS(runtime.NumCPU())
	fmt.Println("== 场景一:正常模式下贪婪持有者造成的尾随延迟 ==")
	greedyHolder()
	fmt.Println("== 场景二:饥饿模式交接(handoff)的公平性 ==")
	fairHandoff()
}

逐段解读几个容易忽略的点:

  1. 场景一的 runtime.Gosched() 是关键陷阱还原。它只让出当前 P 的执行权,锁本身仍在持有者手里反复加解,等待者被唤醒后依然要和新 Lock 的自旋竞争,这正是尾随延迟的产生机制。实测中 4 个等待者的平均获锁延迟在几十到百余微秒之间波动,且不同轮次差异明显,这个"不可预测"本身就是结论。
  2. 场景二把持锁时间拉到 5ms,超过 1ms 阈值后 Unlock 走 handoff=true 路径,三个等待者按入队顺序依次拿到锁,看不到自旋插队。
  3. latency 统计的是"调用 Lock 到获锁"的墙钟时间。如果把 Sleep 从 50μs 调大,等待者会更快跨过 1ms 阈值进入饥饿路径,延迟反而更稳定,可以动手验证状态机切换的效果。

四、生产踩坑与调优建议

1. 临界区大于 1ms 时警惕饥饿抖动。 数据库序列化写、加密压缩这类长临界区,在高并发下会频繁触发模式切换。表现为 p99 剧烈抖动而 p50 正常。缓解方向是缩小临界区(把 IO 移出锁外、换更细粒度的锁分片),而不是调阈值,1ms 阈值没有暴露任何配置项,这是刻意为之。

2. Unlock 未持锁会直接 fatal,不是 panic。 unlock of unlocked mutex 是不可 recover 的运行时错误,进程直接退出。它通常由三种写法引起:条件分支里只有部分路径加了锁、双层函数各自 Unlock、以及把 Unlock 写在了错误的 defer 作用域。防御手段是固定使用 mu.Lock(); defer mu.Unlock() 模式,让加解锁在词法上配对。

3. 不要用 time.Sleep "等锁释放"。 Mutex 没有超时语义,TryLock(Go 1.18 引入)返回 false 就该立即放弃。需要"持锁一段时间不成就放弃"的场景,用 TryLock 加重试循环,或者换成带超时的 channel 信号量。

4. runtime 内部还有一套自己的锁,别和 sync.Mutex 混为一谈。 Go 1.24 给 runtime 内部的互斥量引入了 spinbit 优化(GOEXPERIMENT=nospinbitmutex 可关闭),在 GOMAXPROCS 较大的机器上降低了调度器内部锁的争用开销。它作用于 runtime.mutex,对你代码里的 sync.Mutex 没有影响,但分析调度器相关的性能问题时需要知道这条线。

5. 锁分片前先测自旋开销的真实占比。 正常模式下 Mutex 的无竞争成本约 15ns,8 核高争抢下可能恶化到微秒级。但在临界区极短、竞争模式随机的业务里,盲目分片反而增加代码复杂度。判断依据应来自 pprof 的 sync.Mutex.Lock 采样和 mutex profile(runtime.SetMutexProfileFraction),而不是感觉。

五、总结

Mutex 用一个 int32 位掩码 + 一个信号量,在"吞吐优先的自旋竞争"与"公平优先的队列交接"之间做了动态平衡:无竞争时一次 CAS、轻度竞争时最多 4 轮自旋、重度且长久的排队超过 1ms 后切换为饥饿交接并自动退回。整个状态机没有任何锁保护锁本身,全部由原子操作和信号量原语撑起。

相关推荐
阿瑞IT2 小时前
企业知识库为什么越建越难用?从知识沉淀到权限检索的五种实现路径
程序员
泡泡oO2 小时前
“如果你还在用Superpowers,那我不要和你说话”
前端·后端·全栈
掘金012 小时前
0 元把联想平板变成 MacBook 副屏:真扩展、支持触控、纯开源
程序员·产品
武子康2 小时前
LingBot-Video 怎么选推理路径?8 步 DMD 不等于小显存
人工智能·后端
打工仔折腾 AI2 小时前
Docker镜像分层与卷挂载到底怎么工作:一次文件系统层面的实测分析
运维·人工智能·后端·python·docker·容器·性能优化
程序员Sunday3 小时前
MCP stdio 与 Streamable HTTP 怎么选,流式消息传到哪里
后端
tntxia3 小时前
单点登录(SSO)完整技术实现方案
后端
一个有温度的技术博主3 小时前
凌晨三点的消息堆积:当 MQ 变成“停车场“
java·后端·场景
谢亮_vipxieliang3 小时前
Go WaitGroup与Once——并发同步的基石
开发语言·后端·golang