Go 互斥锁
互斥锁让使用同一把锁的 goroutine 依次访问一段共享状态。Go 的 sync.Mutex 提供独占访问,sync.RWMutex 允许多个读者同时访问,但写者需要独占。
加锁是否正确,取决于锁保护了什么:一次余额更新、一个 map,还是几个字段之间的关系。锁的名字和调用次数都不能说明这个问题。解释并发代码时,要先找出需要共同成立的条件,再检查所有访问是否遵守同一套规则。本文示例和实现说明以 Go 1.22.12 为准。
1. 面试核心
先记住这几条,再读正文Mutex的零值可以直接使用。完整的读改写操作要放在同一个临界区内,读者和写者都要遵守同一套保护规则;分别给读和写加锁仍可能丢失更新。卡 · 无数据竞争不等于操作正确Mutex同时提供互斥和同步关系。同一把 Mutex 的解锁与后续成功的 Lock 返回建立同步关系,不能用不同锁或失败的尝试代替。Mutex和RWMutex首次使用后不能复制;包含它们的结构体也要避免值接收者、赋值或传参产生的复制。卡 · 为什么不能复制已使用的锁Mutex不可重入,也不绑定某个 goroutine。同一个 goroutine 再次加锁仍会等待;跨 goroutine 解锁虽被允许,仍要有明确的交接与释放责任。卡 · 不可重入与跨 goroutine 解锁RWMutex允许读者共享,写者与其他访问互斥。读锁下不能修改受保护状态,不能直接升级成写锁,也不应递归加读锁。 卡 · RWMutex 与读写访问卡 · 等待写者、递归读锁与升级- Go 1.22.12 的
Mutex结合快速获取、有限自旋和挂起等待。正常模式侧重吞吐,饥饿模式向等待者交接锁;实现阈值不是业务等待时限。卡 · Mutex 的等待与交接 TryLock成功才获得锁,失败不建立同步关系,也不允许读取受保护数据。Lock没有context参数,另起一个 goroutine 等锁也不会让锁等待自动取消。 卡 · 失败的尝试没有取得访问权卡 · Lock 不接受取消信号- 锁、channel 和任务计数分别表达不同约束。锁保护共享状态,channel 传递值或通知,WaitGroup 等待任务结束;选择依据是数据和执行关系,具体分工见 channel。
2. Mutex 与临界区
2.1 保护完整操作
临界区是需要作为一个整体访问共享状态的代码区域。mu.Lock() 成功返回后,当前执行路径获得这把锁;mu.Unlock() 释放它。其他使用同一把锁的路径要等到锁可用。
下面的 Counter 把 n 和保护它的 mu 放在一起,两个 goroutine 各加 1000 次:
package main
import (
"fmt"
"sync"
)
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Add() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}
func main() {
var c Counter
done := make(chan struct{}, 2)
for i := 0; i < 2; i++ {
go func() {
for j := 0; j < 1000; j++ {
c.Add()
}
done <- struct{}{}
}()
}
<-done
<-done
fmt.Println(c.Value())
}
输出 2000。c.n++ 包含读取、计算和写回,Add 保护的是这整个过程。Value 也使用同一把锁,因此它在与 Add 并发调用时仍遵守保护规则。done 只负责让主 goroutine 等到两项任务结束。
Mutex 的零值处于未锁定状态,不需要额外初始化。defer c.mu.Unlock() 在函数退出时释放锁,便于覆盖提前返回和正常的 panic 展开;它不会在循环每轮结束时执行。若循环每轮都要持锁处理,应把单轮操作放进单独函数,或在每轮明确释放。
2.2 无数据竞争不等于操作正确
分别给读取和写入加锁,只能防止它们各自与受保护访问并发,不能让两次调用自动组成一个整体。
假设两个 goroutine 都通过加锁读取取得 n = 0,再各自加锁写入 n = 1。所有访问都遵守了锁,仍然少了一次更新。正确做法是像 Add 一样,让读取、加一和写回处于同一个临界区。数据竞争与这种业务竞态需要分开检查。
锁也不会自动保护通过别名访问的数据。方法持锁返回内部 map 或切片后,如果调用方在锁外修改它,保护就被绕过了。可以返回独立副本、只暴露受保护的方法,或者明确转移所有权;具体规则取决于对象是否还被其他路径共享。
2.3 解锁与后续加锁的同步关系
锁还用于安排共享数据的可见性。按照 Go 内存模型,同一把 Mutex 上,第 n 次 Unlock 与之后第 m 次 Lock 的成功返回建立同步顺序,其中 n < m。前一个临界区中的写入,能够通过这条关系传递给后续临界区。
前提是访问确实使用同一把锁,而且没有其他路径在同步规则之外并发写入。变量旁边有一个 Mutex 字段,不会使编译器自动为这个变量加锁。基本同步目的见线程同步。
3. 复制、重入与释放责任
3.1 为什么不能复制已使用的锁
Mutex 与 RWMutex 首次使用后都不能复制。复制得到的是另一份锁状态,不是另一条指向原锁的引用;内部等待者也不会随结构体复制而迁移。
沿用前面的 Counter,下面的值接收者是错误设计:
func (c Counter) BadAdd() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
这个方法能够编译,但每次调用都会复制整个 Counter。对一个尚未使用的零值 Counter 调用 BadAdd,再读取原对象,得到的仍是 0:n 增加在副本上。go vet 会报告值接收者传递了锁。
如果结构体内保存的是 map、切片或指针,问题还会进一步变化:复制锁后,不同副本可能仍然指向同一份可变数据,于是各自加锁也无法互斥。共享引用的复制规则见Go 类型与值传递。
包含锁的可变对象通常通过指针传递,方法使用指针接收者。但仅改接收者还不够,赋值、值参数、接口装箱或容器中的值复制也可能复制整个对象。判断标准始终是:这次操作是否复制了已经使用的锁,是否让同一份数据改由不同锁保护。
3.2 不可重入与跨 goroutine 解锁
不可重入表示同一条执行路径再次申请已经持有的锁时,不会被当作特殊情况放行。Mutex 不记录 goroutine 身份;一个 goroutine 连续两次调用同一把锁的 Lock,第二次会等待锁释放。若解锁写在第二次加锁之后,这条路径就无法继续。
方法之间的调用也会产生同样的问题:Update 持锁调用 Value,而 Value 又申请同一把锁,就会自我等待。可以把需要持锁的内部操作与对外加锁入口分开,并明确内部函数的调用前提;不要让所有函数各自加锁后再任意嵌套调用。
Go 允许一个 goroutine 加锁,再由另一个 goroutine 解锁。这是 API 的行为,不代表可以随意转交:原路径必须停止受保护访问,接收方要按明确协议承担后续访问和释放责任。普通临界区仍优先在同一条执行路径上配对加锁和解锁。
对未锁定的 Mutex 调用 Unlock 属于运行时错误;Go 1.22.12 会终止程序,不能把它当成普通的、可由 recover 修复的业务异常。RWMutex 的读锁与写锁也必须分别用 RUnlock 和 Unlock 配对。
4. RWMutex 与读写访问
4.1 读者共享,写者独占
读写锁区分只读访问和可能修改共享状态的访问。RLock/RUnlock 取得和释放读锁,Lock/Unlock 取得和释放写锁。
| 已有访问 | 另一读者申请读锁 | 写者申请写锁 |
|---|---|---|
| 没有持锁者,也没有待处理写者 | 可以取得 | 可以竞争取得 |
| 存在读者 | 通常可共享;已有等待写者时,新读者也可能阻塞 | 等现有读者退出 |
| 存在写者 | 等写者释放 | 等写者释放 |
下面的 Store 把 map 留在内部,读取单个整数或复制一份快照:
package main
import (
"fmt"
"sync"
)
type Store struct {
mu sync.RWMutex
values map[string]int
}
func (s *Store) Set(key string, value int) {
s.mu.Lock()
defer s.mu.Unlock()
if s.values == nil {
s.values = make(map[string]int)
}
s.values[key] = value
}
func (s *Store) Get(key string) int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.values[key]
}
func (s *Store) Snapshot() map[string]int {
s.mu.RLock()
defer s.mu.RUnlock()
result := make(map[string]int, len(s.values))
for key, value := range s.values {
result[key] = value
}
return result
}
func main() {
var s Store
s.Set("count", 1)
snapshot := s.Snapshot()
snapshot["count"] = 9
fmt.Println(s.Get("count"), snapshot["count"])
}
输出 1 9。Snapshot 复制了 map 条目,调用方修改新 map 不会改动 s.values;当前值类型为 int,没有继续共享的可变对象。若值改为指针或切片,这种浅拷贝只复制条目,不能自动隔离条目指向的内容。
读锁只允许共享读取,不是“比较轻的写锁”。两个 goroutine 都持读锁时仍能同时执行,因此在读锁下执行计数增加、map 赋值或更新缓存,会破坏只读前提。
4.2 等待写者、递归读锁与升级
已有读者时,写者申请 Lock 会等待这些读者退出。为使写者能够取得锁,待处理的写锁还会阻止新的读锁直接进入。因此,不能依赖“读锁总能再次取得”。
递归读锁可能形成下面的等待关系:
- goroutine A 已经取得读锁。
- goroutine B 申请写锁,等待 A 释放。
- A 再次调用
RLock,这次被待处理写者阻塞。 - A 要等第二次
RLock返回才释放第一次读锁,B 又要等 A,双方无法继续。
同样,持读锁再调用 Lock 不是锁升级:写者也要等待当前这个读锁释放。持写锁再调用 RLock 也不能实现原子降级。
若确实需要从读取转为修改,应先释放读锁,再取得写锁,并重新检查条件。两次持锁之间状态可能变化,不能把之前读到的条件直接当作写入依据。
4.3 读多写少是否一定更快
RWMutex 的价值是让有意义的只读工作并行执行。读锁计数、缓存一致性、写者等待及临界区长度也有成本;读操作非常短时,额外管理成本可能抵消并行收益。
选择时同时看读写比例、并发数、持锁时间和延迟目标,再在实际工作负载下比较。不能仅凭“读操作占 99%”就断言读写锁一定优于 Mutex,也不能把单机基准结果外推到所有服务。
5. Mutex 的等待与交接
5.1 快速获取、自旋与挂起
Go 1.22.12 的 Mutex 用状态字段表示锁定、唤醒、饥饿和等待数量,并通过运行时信号量协调等待者。没有竞争时,快速路径尝试用原子比较并交换取得锁。
进入慢路径后,不是所有等待者都一直自旋,也不是每次竞争都立即让整个操作系统线程休眠。运行时允许时,goroutine 可以先有限自旋;仍未取得锁时登记等待并挂起,解锁再唤醒或交接。挂起的是等待的 goroutine,具体线程调度由运行时处理。
CAS 只能完成一次原子状态转换。等待队列、唤醒和状态切换仍是互斥锁实现的一部分,不能把 Mutex 简化成一个无界 CAS 循环。
5.2 正常模式与饥饿模式
正常模式允许被唤醒的等待者与新到达的 goroutine 竞争锁。新到达者已经在 CPU 上运行,可能先拿到锁;因此正常模式不是严格的先进先出交接。
饥饿模式把锁直接交给队首等待者,新到达者进入队尾等待,不再绕过等待者争抢。Go 1.22.12 的实现使用约 1ms 的等待阈值判断是否需要进入这种模式,并在最后一个等待者或短等待等条件下切回正常模式。
这个阈值是实现策略,不是“最多等待 1ms”的承诺。调度、临界区工作量和系统负载都会影响实际等待;也不能仅凭一段程序的获取顺序,证明某次运行进入了哪种内部模式。
6. TryLock 与等待取消
6.1 失败的尝试没有取得访问权
TryLock 尝试立即取得锁,返回是否成功。成功时与 Lock 一样需要配对 Unlock;失败时既没有锁,也没有建立解锁到加锁的同步关系。
package main
import (
"fmt"
"sync"
)
func main() {
var mu sync.Mutex
mu.Lock()
fmt.Println(mu.TryLock())
mu.Unlock()
acquired := mu.TryLock()
fmt.Println(acquired)
if acquired {
mu.Unlock()
}
}
输出 false 和 true。第一次尝试发生在锁被持有时,失败后不能读取受保护字段来“看看有没有更新”。第二次成功,才取得访问权。TryRLock 和 RWMutex.TryLock 也要按相应读写权限使用。
即使一次尝试失败,也不能据此推断稍后谁还持有锁或对象处于什么业务状态。反复调用 TryLock 忙等,会把原本的等待变成 CPU 消耗,还需要额外处理调度、退避与取消;应先判断为何不能使用普通加锁或更合适的任务协议。
6.2 Lock 不接受取消信号
Mutex.Lock 与 RWMutex.Lock 没有 context 或超时参数。等待调用不会因为请求的 context 已取消就自动返回。
一种有缺陷的做法是另起 goroutine 调用 Lock,外层用 select 等待超时。外层超时离开后,内部 goroutine 仍在等锁,甚至会在稍后取得锁;若无人继续执行对应的释放逻辑,任务和锁都会遗留。
需要可取消等待时,应先缩短持锁区间,避免在锁内等待网络或其他不受控工作,再设计有明确取消与结束确认的协调方式。channel 可以参与这种协议,但它也需要约定发送方、接收方和退出责任;具体见 select。不能仅把锁换成一个 channel 就默认问题已经解决。
7. 锁的范围与其他同步工具
7.1 锁与 channel 各自表达什么
channel 负责传值或通知,WaitGroup 等待工作结束;它们不会自动保护任务之间另外共享的状态。若这些状态仍由多个 goroutine 修改,就需要 Mutex 等同步规则;任务队列中的具体分工见 channel。
7.2 临界区长度与重新检查
临界区应覆盖完整不变量,同时避免不必要的慢操作。持锁调用外部服务,会让其他访问一起承担网络延迟;持锁调用未知回调,还可能再次进入同一对象,造成自我等待。
如果把慢操作移到锁外,就要重新分析状态是否可能变化。例如先读到“缓存中没有这个键”,解锁并加载,再加锁时可能已经有其他请求填入结果。应在再次持锁后重新检查条件,必要时结合版本或任务合并,不能直接把旧判断当作当前事实。
涉及多把锁时,需要一致的获取顺序或其他明确的避让协议。锁只能约束遵守协议的路径,无法替你证明整个系统没有循环等待;基本方法见死锁。
8 · 面试题卡片
本页题目与参考回答
8.1 分别给读取和写入加锁,为什么仍可能丢失更新?
考察重点:临界区、不变量、数据竞争与业务竞态。
相关内容:无数据竞争不等于操作正确。
参考回答
两次独立加锁没有把读改写变成一个整体。 两个 goroutine 都可能先读到同一个旧值,再依次写回各自算出的相同新值。
应在一个临界区内完成读取、计算和写回,并让所有访问遵守同一套保护规则。没有数据竞争只说明访问之间有必要的同步,不能保证业务操作的划分正确。
8.2 包含 Mutex 的结构体为什么不适合使用值接收者?
考察重点:结构体复制、锁身份与共享对象。
相关内容:为什么不能复制已使用的锁。
参考回答
值接收者会复制整个结构体,而 Mutex 首次使用后不能复制。 新副本不是原锁的别名,不能继续承担原锁的同步职责。
若数据也是普通值,方法可能只修改副本;若内部是 map、切片或指针,不同锁还可能保护同一份数据,造成并发访问错误。应使用指针接收者,并继续检查赋值和传参等其他复制位置;go vet 可以帮助发现部分问题。
8.3 Mutex 能否重入,能否由另一个 goroutine 解锁?
考察重点:不可重入、锁身份与释放责任。
相关内容:不可重入与跨 goroutine 解锁。
参考回答
Mutex 不可重入,但允许跨 goroutine 解锁。 它不按 goroutine 身份判断“自己已经持有”,再次 Lock 仍会等待。
跨 goroutine 释放必须有明确交接协议,原执行路径不能继续无约束访问。对未锁定的锁解锁属于运行时错误,不能依赖 recover 把它恢复成正常流程。
8.4 Mutex 与 RWMutex 有什么区别,怎样选择?
考察重点:独占与共享、读写比例、临界区长度和竞争代价。
相关内容:RWMutex 与读写访问。
参考回答
Mutex 的访问全部独占;RWMutex 允许多个读者共享,写者需要独占。 读锁下不能修改受保护状态,等待写者还会影响新读者的进入。
读写锁能否更快取决于只读工作是否有足够并行收益,以及锁管理和写等待的成本。根据并发数、临界区长度和读写比例实测,不只根据“读多写少”作判断。
8.5 持有读锁后再加读锁或写锁,有什么风险?
考察重点:等待写者、递归读锁、锁升级和条件变化。
相关内容:等待写者、递归读锁与升级。
参考回答
递归读锁可能被等待中的写者阻塞,持读锁再申请写锁也不能完成升级。 前者可能形成读者等写者、写者等原读锁的循环;后者让写者等待自己仍未释放的读锁。
需要转为修改时,先释放读锁,再取得写锁,并重新检查共享状态。释放到重新取得之间并不是原子过程。
8.6 Go Mutex 怎样处理竞争,正常模式与饥饿模式有什么作用?
考察重点:原子状态、自旋、挂起、唤醒与交接策略。
相关内容:Mutex 的等待与交接。
参考回答
Go 1.22.12 在无竞争时尝试快速原子获取,竞争时结合有限自旋与运行时等待。 正常模式允许被唤醒者与新到达者竞争,提高吞吐;饥饿模式向队首等待者交接,减少持续被插队的情况。
该版本使用约 1ms 的等待阈值参与模式切换,但这不是最长等待保证。状态字段、阈值和队列策略属于具体实现,不能代替 API 的互斥与同步契约。
8.7 TryLock 失败后能否读取受保护的数据?
考察重点:获取失败、访问权限与同步关系。
相关内容:失败的尝试没有取得访问权。
参考回答
不能仅因为尝试过加锁就读取。 TryLock 返回 false 时没有取得锁,也没有建立对应的同步关系;其他路径仍可能正在修改数据。
应放弃这次受保护访问或按既定协议等待。成功后才进入临界区,并配对释放;失败结果也不是可长期依赖的锁状态快照。
8.8 在 goroutine 中等待 Lock,外层超时返回,是否就取消了加锁?
考察重点:等待调用、取消通知与任务生命周期。
相关内容:Lock 不接受取消信号。
参考回答
没有,外层停止等待不会取消内部的 Lock。 内部 goroutine 可能继续等待,稍后获得锁;如果没有后续释放协议,就会遗留任务或占住锁。
先缩短临界区、移出不受控等待,再根据业务需要设计可取消的协调方式。退出时仍要确认派生任务结束及资源释放,不能只让调用方按时返回。
9. 参考资料
- Go 1.22.12:sync
- The Go Memory Model:Locks
- Go 1.22.12:Mutex 实现
- Go 1.22.12:RWMutex 实现
- Go 1.22.12:运行时信号量
- Go:Data Race Detector
来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明