并发编程是 Go 的灵魂,而 Mutex(互斥锁) 是并发控制的第一道防线。这篇文章带你从"为什么需要锁"开始,一路走到 Mutex 的演进原理、常见坑点和读写锁 RWMutex,争取一文把 Mutex 讲透。

一、没有锁的世界有多危险?

先看一个经典的并发计数器问题:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var count int
	var wg sync.WaitGroup

	for i := 0; i < 10000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			count++ // 非原子操作:读取 → 加1 → 写回
		}()
	}

	wg.Wait()
	fmt.Println("count:", count) // 几乎不可能是 10000
}

多次运行,结果可能是 9831、9917、9756……每次都不一样。这就是典型的 数据竞争(Data Race)

count++ 看起来是一行代码,实际上是三步操作:

┌─ goroutine A ─┐     ┌─ goroutine B ─┐
│  读取 count=0  │     │               │
│               │     │  读取 count=0  │
│  count=0+1=1  │     │               │
│  写回 count=1  │     │  count=0+1=1  │
│               │     │  写回 count=1  │  ← 两次 +1,结果只增加了 1
└───────────────┘     └───────────────┘

两个 goroutine 同时读到了旧值,各自加 1 后写回,最终只加了 1。这就是 临界区 没有被保护的后果。

临界区:程序中访问共享资源的那段代码。多个 goroutine 同时执行临界区代码,就会产生竞争。

二、Mutex 基本用法

解决方案很简单——用互斥锁把临界区"锁"起来:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var count int
	var mu sync.Mutex
	var wg sync.WaitGroup

	for i := 0; i < 10000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()   // 进入临界区
			count++
			mu.Unlock() // 离开临界区
		}()
	}

	wg.Wait()
	fmt.Println("count:", count) // 稳定输出 10000
}

Mutex 的 API 极简,只有两个方法:

方法说明
Lock()获取锁,获取不到则阻塞等待
Unlock()释放锁,允许其他 goroutine 获取

工作原理用一张图表示:

goroutine A          Mutex           goroutine B
    │                  │                  │
    │── Lock() ───────→│                  │
    │   (获取成功)      │                  │
    │                  │←── Lock() ───────│
    │                  │   (阻塞等待...)   │
    │  [执行临界区]     │                  │
    │                  │                  │
    │── Unlock() ─────→│                  │
    │                  │──→ (唤醒,获取)   │
    │                  │   [执行临界区]    │
    │                  │←── Unlock() ─────│

三、Mutex 的演进之路

Go 的 Mutex 并非一成不变,它经历了 四个阶段 的演进,每一步都是在性能和公平性之间做权衡:

阶段年份特点说明
v1 初版2008一个 flag,CAS 抢锁简单粗暴
v2 给新人机会2011新 goroutine 也能直接竞争不必排队
v3 多给些机会2015自旋等待减少上下文切换,提升吞吐量
v4 解决饥饿2018正常模式 + 饥饿模式兼顾吞吐和公平

第一阶段:初版(2008)

最早的 Mutex 非常简洁——用一个 flag 字段表示是否被持有,通过 CAS 原子操作抢锁:

// 伪代码,展示核心思路
type Mutex struct {
    key  int32 // 0=未锁, 1=已锁
    sema uint32
}

func (m *Mutex) Lock() {
    if atomic.AddInt32(&m.key, 1) == 1 {
        return // CAS 成功,直接获取锁
    }
    runtime.Semacquire(&m.sema) // 获取不到,信号量等待
}

func (m *Mutex) Unlock() {
    if atomic.AddInt32(&m.key, -1) == 0 {
        return // 没有等待者
    }
    runtime.Semrelease(&m.sema) // 唤醒一个等待者
}

问题:所有抢不到锁的 goroutine 都进入等待队列,即使锁刚释放,新来的 goroutine 也得排队。这导致了不必要的上下文切换。

第二阶段:给新人机会(2011)

改进思路:新到达的 goroutine 在锁刚释放时,可以直接和被唤醒的 goroutine 竞争。新 goroutine 正在 CPU 上运行,如果让它直接拿锁,可以避免一次上下文切换,吞吐量更高。

第三阶段:多给些机会(2015)

引入 自旋(Spinning):当锁被持有时,新来的 goroutine 不立即进入等待队列,而是短暂自旋几次。如果这期间锁被释放了,就能直接获取,避免了昂贵的上下文切换。

新 goroutine 到达
    ├─ 锁空闲?─── 是 ──→ 直接获取
    └─ 锁被占用
        ├─ 自旋几次 ──→ 锁释放了?─── 是 ──→ 获取锁
        │                    │
        │                    └── 否
        └─ 进入等待队列,休眠

问题:新来的 goroutine 一直有优势,排队等待的老 goroutine 可能长期拿不到锁——这就是 饥饿问题

第四阶段:解决饥饿(2018,当前版本)

引入 两种模式

正常模式(Normal)

  • 等待队列中被唤醒的 goroutine 和新到达的一起竞争
  • 新到达的有优势(已经在 CPU 上运行)
  • 吞吐量高

饥饿模式(Starving)

  • 触发条件:某个 goroutine 等待超过 1ms
  • 锁直接交给等待队列头部的 goroutine,新来的必须排队
  • 保证公平性,避免尾延迟过高
  • 退出条件:拿到锁的是队列最后一个 或 等待时间 < 1ms

这个设计兼顾了吞吐量和公平性——大多数时候运行在高吞吐的正常模式,只有在检测到饥饿时才切换到公平模式。

四、使用 Mutex 最常踩的 4 个坑

坑 1:Lock/Unlock 不配对

忘记 Unlock——最常见,也最致命:

func doSomething(mu *sync.Mutex) {
    mu.Lock()
    if err := process(); err != nil {
        return // ❌ 忘记 Unlock,锁永远不会释放!
    }
    mu.Unlock()
}

最佳实践:始终用 defer

func doSomething(mu *sync.Mutex) {
    mu.Lock()
    defer mu.Unlock() // ✅ 无论如何都会释放

    if err := process(); err != nil {
        return // defer 保证 Unlock 一定执行
    }
}

误删 Lock——直接 Unlock 一个未加锁的 Mutex 会 panic:

func foo() {
    var mu sync.Mutex
    // mu.Lock() 被误删
    defer mu.Unlock() // panic: sync: unlock of unlocked mutex
    fmt.Println("hello")
}

坑 2:复制已使用的 Mutex

Mutex 是 值类型,复制一个已使用的 Mutex 会把当前的锁状态也复制过去:

type Counter struct {
    mu    sync.Mutex
    count int
}

func printCount(c Counter) { // ❌ 值传递,复制了 Mutex
    c.mu.Lock()
    defer c.mu.Unlock()
    fmt.Println(c.count)
}

func printCount(c *Counter) { // ✅ 传指针,共享同一把锁
    c.mu.Lock()
    defer c.mu.Unlock()
    fmt.Println(c.count)
}

小技巧:用 go vet 可以检测 Mutex 的复制问题。在 CI 中加入 go vet ./... 是一个好习惯。

坑 3:重入(Reentrant)

Go 的 Mutex 不支持重入。同一个 goroutine 对同一把锁调用两次 Lock,直接死锁:

func foo(mu *sync.Mutex) {
    mu.Lock()
    bar(mu)    // 💀 死锁!
    mu.Unlock()
}

func bar(mu *sync.Mutex) {
    mu.Lock()  // 同一个 goroutine 再次 Lock,永远等不到释放
    // ...
    mu.Unlock()
}

解决方案:重构代码,把需要锁保护的逻辑抽取到内部函数,只在最外层加锁:

func foo(mu *sync.Mutex) {
    mu.Lock()
    defer mu.Unlock()
    doWork() // 内部函数不再加锁
}

func bar(mu *sync.Mutex) {
    mu.Lock()
    defer mu.Unlock()
    doWork() // 同一个无锁的内部函数
}

func doWork() {
    // 实际业务逻辑,不操作锁
}

坑 4:死锁

多把锁交叉获取,经典的 ABBA 死锁:

// goroutine 1          // goroutine 2
mu1.Lock()              mu2.Lock()
mu2.Lock() // 等待 B    mu1.Lock() // 等待 A
// 💀 互相等待,永远无法继续
goroutine 1              goroutine 2
    │                        │
    │── 持有 mu1 ──┐   ┌── 持有 mu2 ──│
    │              │   │              │
    │── 等待 mu2 ──┘   └── 等待 mu1 ──│
    │         ↑                ↑      │
    │         └── 循环等待 ────┘      │
    │            💀 DEADLOCK           │

解决方案:始终按固定顺序获取多把锁:

// 所有 goroutine 都先锁 mu1,再锁 mu2
mu1.Lock()
mu2.Lock()
// ... 业务逻辑
mu2.Unlock()
mu1.Unlock()

五、读多写少?用 RWMutex

如果你的场景是 读多写少,所有读操作都用 Mutex 串行化就太浪费了。sync.RWMutex 允许多个读操作并发执行:

type Cache struct {
    mu   sync.RWMutex
    data map[string]string
}

// 读操作:可以多个 goroutine 同时读
func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    val, ok := c.data[key]
    return val, ok
}

// 写操作:独占,阻塞所有读和写
func (c *Cache) Set(key string, value string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[key] = value
}

RWMutex 的规则:

当前状态新的 RLock新的 Lock
无锁✅ 获取✅ 获取
被 Reader 持有✅ 并发读❌ 阻塞等所有读完
被 Writer 持有❌ 阻塞等写完❌ 阻塞等写完

什么时候用 RWMutex? 简单的经验法则:

  • 读写比 > 10:1,RWMutex 明显优于 Mutex
  • 读写比接近 1:1,直接用 Mutex,RWMutex 反而有额外开销
  • 不确定的话,用 benchmark 测

六、实战建议

最后总结几条实战中的经验:

  1. 锁的粒度要小——只锁必须锁的代码,不要把 I/O 操作放在锁里面
  2. 善用 defer mu.Unlock()——防止忘记释放,尤其是多 return 分支
  3. 不要复制 Mutex——包含 Mutex 的结构体传指针
  4. go vet 做静态检查——自动检测锁复制等问题
  5. -race 检测数据竞争——go test -race ./... 应该是 CI 标配
  6. 考虑是否真的需要锁——有时候 atomicchannelsync.Map 是更好的选择
# 两个应该加入 CI 的命令
go vet ./...
go test -race ./...

互斥锁看起来简单,用好不容易。理解它的演进思路,知道它的常见陷阱,才能在并发编程中写出既正确又高效的代码。