Go Mutex 原理
Go mutex 面试要点整理。覆盖:基本用法、不可重入与误用后果、饥饿模式、TryLock、RWMutex、内存模型、常见模式与陷阱。
基本用法
var mu sync.Mutex // 零值可用,不需要 new
mu.Lock() // 加锁;已被别人持有时阻塞等待
// 临界区:同一时刻只有一个 goroutine 能进
mu.Unlock() // 解锁关键性质:
- 零值就是一个可用的、未锁定的 mutex,声明即可用,不用初始化。
- 加锁和解锁不要求同一个 goroutine:
sync.Mutex不关联持有者,A goroutine 加锁、B goroutine 解锁是合法的。很多语言的锁语义是"谁加锁谁解锁",Go 不是。 - 锁不关联持有者,也就无法判断"我是否持有这把锁"——所以没有重入(reentrant)能力,这是下面死锁案例的根源。
- 临界区要短:mutex 保护的是共享数据的读写,锁外别留多余操作。
正确姿势是 defer 解锁,保证任何 return 路径都释放:
mu.Lock()
defer mu.Unlock() // 函数返回时解锁,包括 panic 时defer 的代价:在循环里大量加解锁时,defer 有少量开销,可以改成手动 Unlock,但多数场景不值得优化。
不可重入
同一 goroutine 对已持有的 mutex 再次 Lock,会永久阻塞,死锁:
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 检查能抓:
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 只有两个字段:
type Mutex struct {
state int32 // 位域,一个字段存四样信息
sema uint32 // 等待队列信号量
}state 的位含义:
- 第 0 位
locked:锁是否被持有。 - 第 1 位
woken:是否已有 goroutine 在抢锁的路上(自旋中或已被唤醒)。 - 第 2 位
starving:是否处于饥饿模式。 - 剩余高位:等待者(waiter)计数。
CAS 是什么
疑问:CAS 是什么,为什么在这里被使用?
答案:CAS(compare-and-swap,比较并交换)是一条原子指令。它做三件事:
- 读出内存里的当前值。
- 和预期值比较。
- 相等就写入新值,不相等就什么都不做。
整个"比较 + 交换"不可分割,别的 goroutine 插不进来。Go 里对应 sync/atomic 的函数:
// 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,四步:
- 多核且有空闲 P(processor,GMP 调度里的逻辑处理器)时,自旋最多 4 次(active_spin = 4)。短临界区里自旋比挂起便宜。
- 自旋还抢不到,waiter 计数加一。
- 通过
sema把自己挂起,睡进等待队列(下一节讲 sema)。Linux 上底层基于 futex(内核的睡眠唤醒原语)。 - 被唤醒后重新竞争。抢输的等待者排回队首(源码注释: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 加入的非阻塞尝试加锁:
if mu.TryLock() { // 成功返回 true 并持有锁
defer mu.Unlock()
// ...
} else {
// 锁被占用,不等待,走这里
}语义:成功等价于 Lock;失败不建立任何同步关系(内存模型上,失败的 TryLock 可以当作什么都没发生)。同样的还有 RWMutex 的 TryRLock。
官方文档对 TryLock 的态度是泼冷水的:正确的用法很少见,用了往往是更深层问题的信号。典型反模式是"抢不到就重试"的循环——这是饥饿模式的温床。用它一般是"抢到就干,抢不到就放弃走另一条路"这种一次性决策,不要拿它当乐观锁。
什么是乐观锁?
疑问:什么是乐观锁?
答案分两派。悲观锁(mutex 就是):默认有人抢,先锁上再干活,干完释放,别人全程碰不了。乐观锁相反:默认没人抢,不加锁直接干活,提交那一刻验证"我读到的值还是不是现在的值",是就写入,不是就说明期间被别人改过,重试或放弃。
Go 里最常见的乐观锁是 sync/atomic 的 CAS(compare-and-swap,比较并交换)。以模拟扣款为例:
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 同时持有,写锁独占,读写互斥。
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 笔记 的内存模型一节,这里只讲 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):
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 返回后都可见——单例初始化的正确性靠的就是这条。
常见模式
保护共享数据,标准写法:
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 的写入:
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 管"把东西交给谁"。