一句话结论

Go sync.Mutex 是CAS + 信号量实现的互斥锁,有正常模式和饥饿模式两种状态。正常模式下等待者自旋+排队,饥饿模式下按 FIFO 直接交接,防止尾端 Goroutine 饿死。

核心原理

底层结构

type Mutex struct {
    state int32  // 低 3 位: locked(1) + woken(1) + starving(1)
                 // 高 29 位: 等待者数量
    sema  uint32 // 信号量(runtime_Semacquire/Semrelease 阻塞/唤醒)
}

状态位

位

含义

值

bit 0

mutexLocked

1 = 已锁

bit 1

mutexWoken

1 = 已唤醒(有 Goroutine 在自旋)

bit 2

mutexStarving

1 = 饥饿模式

bit 3-31

mutexWaiterShift

等待者数量

正常模式(Normal Mode)

尝试加锁
  ├── CAS(0 → locked): 成功 → 获得锁
  ├── 自旋: 锁被持有但 holder 可能在另一个 P 上运行
  │   └── 自旋条件: 多核 + GOMAXPROCS>1 + P 本地队列空 + 自旋次数<4
  ├── 自旋后仍未获得 → 等待者+1 → Semacquire 阻塞
  └── 被唤醒 → 重新竞争锁

正常模式的特点: 被唤醒的 Goroutine 不直接获得锁,需要和新到达的 Goroutine 竞争——新来者在 CPU 上运行有优势,可能抢走锁。

饥饿模式(Starvation Mode)

触发条件:
  等待者等待超过 1ms

进入饥饿模式:
  mutexStarving = 1

锁释放时:
  直接通过信号量交接给 FIFO 队列头部的等待者(不和新来者竞争)

退出饥饿模式:
  当前等待者是队列最后一个,或等待时间 < 1ms

自旋条件

同时满足以下全部条件才自旋:
1. 多核 CPU
2. GOMAXPROCS > 1
3. 当前 P 的本地运行队列为空
4. 自旋次数 < 4

自旋时: 占用 CPU 但不做有用工作(busy-waiting)

项目中的应用

在 电商交易系统 中,秒杀库存的本地缓存用 Mutex 保护:

type StockCache struct {
    mu    sync.RWMutex
    cache map[string]int
}

func (sc *StockCache) Get(productID string) (int, bool) {
    sc.mu.RLock()
    defer sc.mu.RUnlock()
    stock, ok := sc.cache[productID]
    return stock, ok
}

// 读多写少 → RWMutex,比 Mutex 并发度更高

优点与缺点

优点

缺点

自旋减少上下文切换

正常模式下新来者可能插队

饥饿模式防止尾端饿死

饥饿模式降低整体吞吐

零值可用(不用初始化)

不可重入(同 Goroutine 二次 Lock → 死锁)

异常与边界

不可重入

mu := sync.Mutex{}
mu.Lock()
mu.Lock()  // ❌ 死锁!Go 的 Mutex 不是可重入的
// 如果需要可重入锁,需要自己实现(记录持有者 Goroutine ID)

复制 Mutex

var mu sync.Mutex
mu.Lock()
mu2 := mu  // ❌ 复制了锁的内部状态(state, sema)
mu2.Unlock()  // 未定义行为
// go vet 会检测: "call of mu2.Unlock copies lock value"

WaitGroup 误用

var wg sync.WaitGroup
for i := 0; i < 10; i++ {
    wg.Add(1)
    go func(i int) {
        defer wg.Done()
        // work
    }(i)  // ✅ i 作为参数传入
}
wg.Wait()

// ❌ 常见错误:
// 1. wg.Add 写在 goroutine 内部(可能在 Wait 之后执行)
// 2. wg.Add 的总数 < wg.Done 的次数 → 负数 panic
// 3. wg 被复制传递

高频面试问题

Q: Mutex 正常模式和饥饿模式的区别?

30 秒回答: 正常模式下被唤醒的 Goroutine 要和新来的竞争锁——新来者在 CPU 上有优势,可能抢走。饥饿模式下释放锁直接交给等待最久的 Goroutine(FIFO),防止尾端无限等待。等待超过 1ms 触发饥饿模式。

继续追问:

  • "饥饿模式什么时候退出?" → 当前拿到锁的 Goroutine 是队列最后一个,或它的等待时间 < 1ms。

Q: 自旋有什么代价?

30 秒回答: 自旋占用 CPU 但不做有用工作。好处是避免了 Goroutine 挂起/唤醒的上下文切换开销。Go 限制自旋次数 ≤ 4 且只在多核空闲 P 时才自旋,平衡收益和代价。

最小实验

// race detector 检测数据竞争
// go run -race main.go
func main() {
    var count int
    var wg sync.WaitGroup
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            count++  // ❌ 数据竞争
        }()
    }
    wg.Wait()
}
// 输出: WARNING: DATA RACE

速记

Mutex = CAS + 信号量。正常模式=竞争,饥饿模式=FIFO 防饿死。自旋 ≤ 4 次 + 多核空闲 P。不可重入。不可复制。RWMutex 读多写少更优。