# Go Mutex 原理


Go mutex 面试要点整理。覆盖：基本用法、不可重入与误用后果、饥饿模式、TryLock、RWMutex、内存模型、常见模式与陷阱。

## 基本用法

```go
var mu sync.Mutex // 零值可用，不需要 new

mu.Lock()   // 加锁；已被别人持有时阻塞等待
// 临界区：同一时刻只有一个 goroutine 能进
mu.Unlock() // 解锁
```

关键性质：

- 零值就是一个可用的、未锁定的 mutex，声明即可用，不用初始化。
- 加锁和解锁不要求同一个 goroutine：`sync.Mutex` 不关联持有者，A goroutine 加锁、B goroutine 解锁是合法的。很多语言的锁语义是"谁加锁谁解锁"，Go 不是。
- 锁不关联持有者，也就无法判断"我是否持有这把锁"——所以没有重入（reentrant）能力，这是下面死锁案例的根源。
- 临界区要短：mutex 保护的是共享数据的读写，锁外别留多余操作。

正确姿势是 `defer` 解锁，保证任何 return 路径都释放：

```go
mu.Lock()
defer mu.Unlock() // 函数返回时解锁，包括 panic 时
```

`defer` 的代价：在循环里大量加解锁时，defer 有少量开销，可以改成手动 Unlock，但多数场景不值得优化。

## 不可重入

同一 goroutine 对已持有的 mutex 再次 Lock，会永久阻塞，死锁：

```go
var mu sync.Mutex
mu.Lock()
mu.Lock() // 永久阻塞：mutex 不知道锁是自己持有的
```

这是设计取舍：记录持有者需要额外状态和检查（每次 Lock/Unlock 都要查 goroutine 身份），Go 选择不做。后果是临界区里不能再碰需要同一把锁的代码，包括间接调用——锁内调用函数时，要确认那个函数不会反手再 Lock 同一把锁。

类似的死锁还有 RWMutex 的递归读锁，见 RWMutex 一节。

## 误用都是 fatal error，不是 panic

| 操作                     | 结果                                                      |
| ------------------------ | --------------------------------------------------------- |
| Unlock 未锁定的 Mutex    | `fatal error: sync: unlock of unlocked mutex`             |
| RUnlock 未锁定的 RWMutex | `fatal error: sync: RUnlock of unlocked RWMutex`          |
| 只持有读锁时调 Unlock    | `fatal error: sync: Unlock of unlocked RWMutex`           |
| 已持有时再 Lock          | 永久阻塞，死锁（`all goroutines are asleep - deadlock!`） |

`fatal error` 和 `panic` 不一样：panic 可以用 recover 接住，fatal error 直接打印栈信息崩溃退出，接不住。所以锁的配对错误是运行时不可恢复的，写代码时就要保证配对正确。

复制已使用的 mutex 是另一类错误。文档规定 `A Mutex must not be copied after first use`，复制会把锁状态一起带走，两个副本各管各的，锁形同虚设。go vet 的 copylocks 检查能抓：

```go
mu2 := mu // go vet 报错：assignment copies lock value to mu2: sync.Mutex
```

结构体里嵌了 mutex 的，传参一律用指针，别做值拷贝——值拷贝会把锁状态一起带走。

## 饥饿模式

mutex 有两种运行模式：正常模式和饥饿模式（starvation mode），Go 1.9 引入，源码在 sync 包的状态机注释里有完整说明。

正常模式下，阻塞的等待者按 FIFO 排队。但锁释放时，被唤醒的等待者不直接拿到锁，而是和新到的 goroutine 竞争：新来的 goroutine 已经在 CPU 上跑着，占优势，等待者经常抢输，抢输了排回队首继续等。单个 goroutine 可能连续多次抢到锁，即使后面排着长队——吞吐好，但极端情况下等待者可能一直抢不到，饿死。

所以有个切换机制：等待者连续抢锁失败超过 1ms，mutex 切成饥饿模式。饥饿模式下规则反过来了：

- 锁释放时直接交接给队首等待者，新来的不参与竞争。
- 新到的 goroutine 看到饥饿标志，不自旋也不抢，直接排到队尾。
- 拿到锁的等待者如果发现自己是队里最后一个，或者这次等待时间不足 1ms，切回正常模式。

用大白话记：正常模式是"先来排队，但后到的可以插队"；饥饿模式是"严格先来后到"。防止的就是尾部延迟：极端情况下等待者长时间抢不到，个别请求延迟飙升。

顺带一提自旋（spin）：正常模式下锁被持有、又是多核时，新来的 goroutine 会先自旋转几圈（空转检查锁状态）而不是直接挂起，短临界区时自旋比线程挂起唤醒便宜得多。饥饿模式禁止自旋，因为锁注定要交给队首。

## 底层实现

源码在 sync 包（Go 1.26 起实现迁到了 src/internal/sync/mutex.go）。Mutex 只有两个字段：

```go
type Mutex struct {
	state int32 // 位域，一个字段存四样信息
	sema  uint32 // 等待队列信号量
}
```

`state` 的位含义：

- 第 0 位 `locked`：锁是否被持有。
- 第 1 位 `woken`：是否已有 goroutine 在抢锁的路上（自旋中或已被唤醒）。
- 第 2 位 `starving`：是否处于饥饿模式。
- 剩余高位：等待者（waiter）计数。

### CAS 是什么

疑问：CAS 是什么，为什么在这里被使用？

答案：CAS（compare-and-swap，比较并交换）是一条原子指令。它做三件事：

1. 读出内存里的当前值。
2. 和预期值比较。
3. 相等就写入新值，不相等就什么都不做。

整个"比较 + 交换"不可分割，别的 goroutine 插不进来。Go 里对应 sync/atomic 的函数：

```go
// addr 指向的值等于 old，就换成 new，返回 true
// 不等，什么都不做，返回 false
atomic.CompareAndSwapInt32(addr, old, new)
```

mutex 用它有三个原因：

- 多个 goroutine 同时改 state，必须原子。两个 goroutine 同时读到"未锁定"、同时写"已锁定"，锁就失效了。
- state 不能用另一把锁保护，那成了鸡生蛋：锁自己需要一种不加锁的原子操作来初始化。
- 加锁是最高频的路径。CAS 一条 CPU 指令完成，比"拿锁再检查"快一个数量级。

所以：CAS 是不加锁的原子更新，mutex 靠它在无竞争时一条指令完成加锁。

### Lock 快路径

无竞争时就是一次 CAS：`CAS(&state, 0, locked)`。没人持锁，一次原子操作直接抢到。无竞争场景下，加锁的全部开销就这一条指令。

### Lock 慢路径

竞争时走 lockSlow，四步：

1. 多核且有空闲 P（processor，GMP 调度里的逻辑处理器）时，自旋最多 4 次（active_spin = 4）。短临界区里自旋比挂起便宜。
2. 自旋还抢不到，waiter 计数加一。
3. 通过 `sema` 把自己挂起，睡进等待队列（下一节讲 sema）。Linux 上底层基于 futex（内核的睡眠唤醒原语）。
4. 被唤醒后重新竞争。抢输的等待者排回队首（源码注释：queue at the front of the queue），已经等过一次，再输继续插在队首。

### sema 的作用

疑问：sema 是做什么的？

答案：sema 是睡眠/唤醒原语的锚点。它本身只是一个 uint32，值没有意义（永远是 0），有意义的是它的内存地址。runtime 按地址把同一把锁的等待者挂到同一个队列，不同锁的地址不同，队列就隔离了。

两个关键操作：

- Lock 抢不到时：`runtime_SemacquireMutex(&m.sema, ...)`，当前 goroutine 挂到 sema 地址对应的等待队列，睡下去。
- Unlock 该唤醒时：`runtime_Semrelease(&m.sema, ...)`，从队列里唤醒一个 goroutine。

别把它当成计数信号量。runtime 源码注释原话：别把这些当成信号量，把它们看成实现睡眠和唤醒的方式，每次睡眠都配对一次唤醒。计数是 state 里 waiter 字段的事，sema 只管排队和叫醒。它和 futex 是同一类东西——futex 也是内核里的睡眠唤醒原语，runtime 的 sema 在 Linux 上就基于它实现。

### Unlock

快路径：`AddInt32(&state, -locked)` 清掉 locked 位。清完没有其他位要处理，直接返回。

慢路径看等待者，三种情况不唤醒：

- 没有等待者。
- 已有 woken 标记。
- 锁又被别人抢走了。

`woken` 位就是干这个的：避免白唤醒。唤醒一个等待者，它跑过来又抢不过新来的，这次唤醒就浪费了。该唤醒时，waiter 计数减一、置 woken 位，`semrelease` 唤醒队首一个。

### 饥饿模式的实现

Unlock 时 starving 位已置，直接交接：

- 不置 locked 位。
- 让出时间片（Gosched），队首等待者立刻跑起来。
- 等待者醒来后自己把 locked 位补上。

恢复正常模式的判断在拿到锁时：自己是最后一个等待者，或者这次等待不足 1ms。源码注释解释了为什么在这里判断：饥饿模式低效到两个 goroutine 一旦切换就可能无限 lock-step 下去（lock-step：齐步走，A 拿锁时 B 等、B 拿锁时 A 等，循环交替），能退出就尽快退出。

TryLock 也看状态位：锁被持有，或者处于饥饿模式，直接返回 false，不排队。

## TryLock

Go 1.18 加入的非阻塞尝试加锁：

```go
if mu.TryLock() { // 成功返回 true 并持有锁
	defer mu.Unlock()
	// ...
} else {
	// 锁被占用，不等待，走这里
}
```

语义：成功等价于 Lock；失败不建立任何同步关系（内存模型上，失败的 TryLock 可以当作什么都没发生）。同样的还有 RWMutex 的 TryRLock。

官方文档对 TryLock 的态度是泼冷水的：正确的用法很少见，用了往往是更深层问题的信号。典型反模式是"抢不到就重试"的循环——这是饥饿模式的温床。用它一般是"抢到就干，抢不到就放弃走另一条路"这种一次性决策，不要拿它当乐观锁。

### 什么是乐观锁？

疑问：什么是乐观锁？

答案分两派。悲观锁（mutex 就是）：默认有人抢，先锁上再干活，干完释放，别人全程碰不了。乐观锁相反：默认没人抢，不加锁直接干活，提交那一刻验证"我读到的值还是不是现在的值"，是就写入，不是就说明期间被别人改过，重试或放弃。

Go 里最常见的乐观锁是 sync/atomic 的 CAS（compare-and-swap，比较并交换）。以模拟扣款为例：

```go
var balance int32 = 100

for {
	old := atomic.LoadInt32(&balance)   // 读快照
	next := old - 30                    // 干活（纯计算，不碰共享变量）
	if atomic.CompareAndSwapInt32(&balance, old, next) {
		// CAS 成功：期间没人改过，提交完成
		return
	}
	// CAS 失败：值已经不是 old，重试
}
```

`CompareAndSwapInt32(&balance, old, next)` 原子地完成"比较 + 交换"：balance 还等于 old 就换成 next 并返回 true，不等就什么都不做返回 false。不冲突时全程无锁，随便并发；冲突了就循环重试或放弃。数据库的乐观锁同理：表里加 version 字段，`UPDATE ... SET amount = ?, version = version+1 WHERE id = ? AND version = ?`，影响行数为 0 就是冲突。

TryLock 不算乐观锁。它还是拿锁：抢到锁才干活，冲突发生在干活之前；乐观锁的冲突发生在干活之后，而且没碰锁。拿 TryLock 当乐观锁用（抢不到就重试），等于拿锁的机制干验证的活，既没有互斥保证，也没有 CAS 的原子提交，只会制造饥饿。

所以：冲突率低用乐观锁省锁开销；冲突率高用悲观锁，重试本身比锁还贵。

## RWMutex

读写锁：读锁可以多个 goroutine 同时持有，写锁独占，读写互斥。

```go
var rw sync.RWMutex

rw.RLock() // 读锁：可并发
// 读共享数据
rw.RUnlock()

rw.Lock() // 写锁：独占
// 写共享数据
rw.Unlock()
```

两条硬规则：

- 写优先（writer-preference）：有写者在等待时，新来的 RLock 一律阻塞，等写者拿到并释放锁之后才放行。这是源码注释明确写的行为，目的就是保证写者最终能拿到锁，不会因为读者源源不断而饿死。实现上，写者等待时内部会翻转 readerCount 标记。
- 禁止递归读锁：`RLock` 里再 `RLock`，如果中间有写者插进来，会死锁——第二个 RLock 排在写者后面，写者又等第一个读者释放。没有写者时嵌套读锁没事（读计数只是加一），但写者一出现就挂。所以别写嵌套 RLock 的代码，别赌"这次没有写者"。

也不要把读锁升级成写锁（RLock 持有时调 Lock 死锁），写锁降级成读锁同样不允许。

适用场景：读多写少，且临界区足够大，RWMutex 才有意义。临界区只是读几个字段就结束的话，RWMutex 的原子计数开销比 Mutex 还贵，直接用 Mutex。判断标准是基准测试，别默认读写锁更快。

RWMutex 的内部结构值得一提：一个内嵌 Mutex 管写者互斥，readerCount 管读者计数，两个信号量分别等读者清空和等写者释放。读者进来就是原子加一，写者要等计数归零——所以读者路径轻、写者路径重，这也解释了为什么它适合读多写少。

## 内存模型

happens-before 的基础概念见 [go-channel 笔记](/posts/go-channel/) 的内存模型一节，这里只讲 mutex 的规则。先补一个术语：synchronized before（同步先于）。

synchronized before 是同步操作之间直接建立的关系。Lock 观察到上一次 Unlock，就说这次 Unlock synchronized before 这次 Lock。happens-before 是它的超集：程序顺序加上 synchronized before，再取传递闭包。普通读写不是同步操作，谈不上 synchronized before，它们靠程序顺序接上同步边。面试不用严格区分这两个词——锁的规则用 synchronized before 表述，只是因为 Lock/Unlock 本身就是同步操作。

官方文档（go.dev/ref/mem 的 Locks 章节）的规则：

- 对于任何 `sync.Mutex` 或 `sync.RWMutex` 变量 l，n < m 时，第 n 次 `l.Unlock()` synchronized before 第 m 次 `l.Lock()` 返回。

翻译成人话：每次解锁都会为后续的加锁建立一条同步边。你加锁时，能看到上一次解锁之前的所有写入——不管写的人是谁。这就是「锁除了互斥，还负责可见性」：临界区里读到的共享数据不会是陈旧值。

官方例子，换成实际代码验证过（-race 无告警，必打印 hello, world）：

```go
var l sync.Mutex
var a string

f := func() {
	a = "hello, world"
	l.Unlock() // 第 1 次 Unlock
}

l.Lock()
go f()
l.Lock()      // 第 2 次 Lock：能看到 f 里对 a 的写入
fmt.Println(a) // 一定是 "hello, world"
```

链条：a 的写入在 f 里排在 Unlock 之前（程序顺序），第 1 次 Unlock synchronized before 第 2 次 Lock 返回，print 在第 2 次 Lock 之后。所以没有数据竞争，结果确定。

RWMutex 还有一条配套规则：每次 RLock 调用，都存在某个 n，使得第 n 次 Unlock synchronized before 这次 RLock 返回，而这次 RLock 对应的 RUnlock synchronized before 第 n+1 次 Lock 返回。效果是读锁同样能看到最近一次写锁释放前的一切写入，而写锁能看到所有在读锁期间完成的写入——读写锁的两侧都被同步边覆盖。

TryLock 的补充：成功等价于 Lock，失败什么都不是——内存模型上失败的 TryLock 连"锁当时是解锁的"都不保证，可能被视为永远返回 false。所以 TryLock 失败后直接放弃，别基于失败做任何关于锁状态的推断。

最后是 sync.Once，它内部就是 mutex 加标志位，内存模型有一条专门规则：`once.Do(f)` 里 f 的完成 synchronized before 任何一次 `once.Do(f)` 调用返回。也就是说 f 里初始化的一切，所有调用方在 Do 返回后都可见——单例初始化的正确性靠的就是这条。

## 常见模式

保护共享数据，标准写法：

```go
type Counter struct {
	mu sync.Mutex
	n  int
}

func (c *Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}
```

注意这里的 `sync.Mutex` 是嵌入字段，值接收者方法会复制结构体——所以这类方法必须用指针接收者，否则每次调用复制一份带锁状态的结构体，vet 会报警，行为也错。

sync.Once 做单例初始化，替代"双重检查加锁"（double-checked locking）——后者在 Go 里不成立，go.dev/ref/mem 明确举例了双重检查在 Go 中会读到空字符串，因为观察 done 标志不等于观察 a 的写入：

```go
var once sync.Once
var cfg *Config

func GetConfig() *Config {
	once.Do(func() { cfg = loadConfig() })
	return cfg
}
```

多把锁的死锁预防：多个 goroutine 按不同顺序拿多把锁，就可能循环等待。规矩是全局统一加锁顺序——要么都从小到大拿，要么按约定好的层次拿。还有"锁内不要调用可能加锁的外部函数"这条，间接调用是隐蔽的死锁来源。

channel 与 mutex 的选择（和 go-channel 笔记的结论对应）：传递数据所有权、协作调度用 channel；保护共享数据结构的临界区用 mutex。go.dev 对 sync 包的原话是：除了 Once 和 WaitGroup，sync 包的类型主要是给底层库用的，高层同步优先用 channel 和通信。

## 陷阱清单

- 忘了 Unlock，或某个分支提前 return 没走到 Unlock → 用 defer 一次解决。
- 大循环里 defer Unlock：defer 到函数返回才执行，循环百万次会积累大量待执行 defer。把循环体包进匿名函数，锁在匿名函数内加和解。
- 复制 mutex：值传递结构体、slice/map 重新赋值都会复制，vet 的 copylocks 能抓。
- 重入：锁内直接或间接再 Lock 同一把锁，死锁。Go 的锁不可重入，这是特性不是 bug。
- 锁顺序不一致：多锁场景循环等待。
- 锁的范围过大：整个函数加锁，把不相关的慢操作也锁住，并发全废。锁只包住共享数据的读写。

## 面试题

### Go 的 Mutex 可重入吗？

答：不可重入。Mutex 不记录持有者，也没有重入计数，同一 goroutine 对已持有的锁再 Lock 一次，会永久阻塞，死锁。这是设计取舍：记录持有者需要额外状态和检查。锁内不要再碰需要同一把锁的代码，包括间接调用。

### Unlock 一个未锁定的 Mutex 会发生什么？

答：`fatal error: sync: unlock of unlocked mutex`，程序直接崩溃。它是 fatal error 不是 panic，recover 接不住。锁的配对错误在运行时不可恢复，写代码时就要保证配对正确。

### 为什么有饥饿模式？

答：正常模式下等待者按 FIFO 排队，但新到的 goroutine 可以插队抢锁，等待者可能长时间抢不到。等待超过 1ms 就切饥饿模式：锁直接交接给队首等待者，新来的排队，保证先来后到。防止的是尾部延迟：个别请求延迟飙升。拿到锁的等待者若是最后一个、或这次等待不足 1ms，切回正常模式。

### RWMutex 的写优先是什么？

答：有写者在等待时，新来的 RLock 一律阻塞，等写者拿到并释放锁之后才放行。保证写者不会因读者源源不断而饿死。副作用是禁止递归读锁：RLock 里再 RLock，中间有写者插进来就会死锁。

### Mutex 底层是怎么实现的？

答：两个字段：state（位域：locked/woken/starving + waiter 计数）和 sema（睡眠唤醒原语的锚点）。Lock 快路径是 `CAS(0 → locked)` 一条指令；竞争时走慢路径：多核且有 P 时自旋最多 4 次，再不行 waiter 计数加一、通过 sema 挂起（Linux 上基于 futex）。Unlock 对称：清 locked 位，有等待者时唤醒一个，woken 位防止重复唤醒。饥饿模式下 Unlock 直接交接给队首，并让出时间片。

### TryLock 失败有同步效果吗？

答：没有。成功的 TryLock 等价于 Lock，失败在内存模型上什么都没发生，甚至不保证锁当时是解锁的。别拿 TryLock 当乐观锁：它是拿锁的机制，冲突发生在干活之前；乐观锁的冲突发生在提交时。

### 什么时候用 Mutex，什么时候用 channel？

答：保护共享数据结构的临界区用 Mutex；传递数据所有权、协作调度（任务分发、退出通知）用 channel。Mutex 管"别同时碰"，channel 管"把东西交给谁"。


---

> 作者: Nite  
> URL: https://www.nite07.com/zh-cn/posts/go-mutex/  

