GO 1.26+ · 同步原语专题

Go Mutex 与 RWMutex:互斥、读写锁和饥饿模式

goroutine 是由 Go 运行时调度的轻量并发任务。Mutex(Mutual Exclusion,互斥锁)一次只允许一个 goroutine 进入临界区;RWMutex(Read-Write Mutex,读写锁)允许多个读者同时进入,但写者必须独占。临界区是必须整体保护的共享状态代码;饥饿模式则是当前运行时让等待过久者更直接接管锁的公平性策略。

适用:Go 1.26+重点:竞争 · 内存可见顺序 · 写者优先区分:公开保证与运行时策略
一、为什么需要锁:count++ 不是一个不可分割动作

两个 goroutine 同时执行 count++,底层概念上都要经历读取、加一、写回。若二者都读取旧值 0,再各自写回 1,执行了两次却只得到 1,这叫丢失更新。数据竞争是多个 goroutine 并发访问同一内存,至少一个写且缺少同步。

var mu sync.Mutex
var count int

func add() {
    mu.Lock()
    defer mu.Unlock()
    count++
}
锁保护的是不变量:不要只盯着某个变量。若余额和流水条数必须一起变化,二者应位于同一个明确的临界区。
二、Mutex 的语言使用边界:零值可用,但不能复制
规则原因
零值就是未加锁状态无需构造函数,可直接作为结构体字段
第一次使用后不能复制复制会把内部状态分叉成两把表面相似、实际独立的锁
解锁未锁定的 Mutex 是运行时错误Unlock 不是“确保已解锁”的幂等操作
锁不绑定 goroutine 身份可以一个 goroutine Lock、另一个 Unlock,但通常会让所有权难以推理
TryLock 只在少数算法有价值失败后通常仍需处理,循环尝试容易忙等
type Cache struct {
    mu   sync.Mutex
    data map[string]string
}

func (c *Cache) Put(k, v string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[k] = v
}
接收者必须谨慎:含锁结构体通常使用指针接收者。值接收者会复制整个结构体,也复制锁;可用 go vet 检查常见 copylocks 问题。
三、竞争时发生什么:正常竞争、唤醒与饥饿保护

快速路径会用原子操作尝试把未锁定状态改为已锁定。失败后进入慢路径,runtime 可能短暂自旋、排队和休眠。自旋表示暂不休眠、在处理器上短暂重试,适合锁很快释放的情况;等待太久时,当前实现会使用更公平的饥饿保护策略。

下面用 G0、G1、G2、G3 给四个 goroutine 编号;编号只用于沿图追踪,不是运行时提供的身份。G0 已持锁,G1、G2、G3 按顺序到达。

状态来历:G0 持有,G1、G2、G3 依次尝试并进入队列 G0 持有锁G1 请求G2 请求G3 请求 G1:快速路径失败 → 短暂自旋付出 CPU,仍未等到 G0 释放进入等待队列第 1 位并休眠 G2、G3:快速路径都失败本例不满足自旋条件,直接休眠依次成为队列第 2、3 位 G0 释放时:队首 G1 是否已等待过久?条件分支属于当前 runtime 的公平性策略 G0 Unlock 否:正常模式唤醒 G1 再竞争G2、G3 继续排队;一次唤醒有调度成本 是:更直接把所有权交给 G1减少后来者插队;G2、G3 仍按队列等待 每个请求者都有明确去向;具体状态位和阈值不是 Go API 的永久承诺。
最应记住:未竞争时走原子快速路径;竞争时才可能自旋、排队和唤醒,等待过久会偏向公平。常见误读是每次 Lock 都立刻陷入内核阻塞。
代价可见:锁持有很短时,自旋可避免休眠成本;竞争激烈或临界区过长时,自旋浪费 CPU、排队增加调度与尾延迟。优化应先缩短临界区和降低共享,而不是依赖内部状态位。
四、RWMutex:读者可以并行,但等待写者会挡住新读者

读锁 RLock 允许多个读者共享;写锁 Lock 等待现有读者退出后独占。为避免写者永远被连续新读者饿死,一旦写者正在等待,新来的读者会被阻塞。

状态来历:先有读者 → 写者等待 → 新读者也等待 → 写者独占 t0t1t2t3t4 R1持有读锁 R2同时持有读锁 W1等待已有 R1、R2写者独占 R3W1 已等待,所以新读者也等待读取 t2:最后一个旧读者释放后,W1 先进入t3:W1 释放后,R3 才能继续;防止写者被读流量淹没
最应记住:RWMutex 不是“读者永远优先”,等待中的写者会阻止新读者取得锁。常见误读是读多就一定用 RWMutex 更快。
禁止升级/降级:不能持有 RLock 再 Lock,也不能依赖先 Lock 后 RLock 完成原子降级。RWMutex 不可重入,递归取得同一把读锁也可能因等待写者而死锁。
五、锁不只管排队:还建立内存可见顺序

happens-before(先行发生)是内存模型里的顺序关系:若写入先行发生于读取,读取就必须观察到相应结果。对 Mutex,某次 Unlock 会先行发生于之后成功的 Lock;锁内写入因此能被后来持锁者看见。

// goroutine A
mu.Lock()
config.Ready = true
mu.Unlock()

// goroutine B
mu.Lock()
fmt.Println(config.Ready) // 同一把锁建立可见顺序
mu.Unlock()

这不是说 CPU 永远按源码顺序执行,而是同步原语给编译器和处理器建立了程序可依赖的观察保证。只“看起来不会同时执行”而没有同步,仍可能是数据竞争。

六、Mutex、RWMutex、atomic 和 Channel 怎么选

atomic 是标准库提供的原子操作:单次读取、写入或比较交换不会被其他 goroutine 看见中间状态,适合非常小的共享状态。

需求优先考虑原因
保护多个字段的不变量Mutex简单、临界区清楚
读临界区长、读并发高、写很少基准验证 RWMutex允许多个读者并行
单个计数或状态位atomic避免锁,但组合不变量困难
所有权转移、任务编排Channel表达数据和生命周期流动
复制后只读快照atomic.Pointer / atomic.Value读路径不修改共享对象
先测再换:RWMutex 有读者计数、写者协调和缓存一致性成本。临界区很短或写入不稀少时,普通 Mutex 可能更快、更容易推理。
七、最容易写错的六个边界
  1. 复制含锁结构体,或使用值接收者。
  2. 在持锁期间做网络、磁盘或可能长期阻塞的调用。
  3. 忘记异常路径 Unlock;获取成功后通常立即 defer。
  4. 调用函数内部再次取得同一把非重入锁。
  5. 返回受锁保护 map 的引用,调用方绕过锁修改。
  6. 读锁保护读操作,却在读路径懒加载写字段。
func (c *Cache) Snapshot() map[string]string {
    c.mu.RLock()
    defer c.mu.RUnlock()
    out := make(map[string]string, len(c.data))
    for k, v := range c.data { out[k] = v }
    return out // 返回隔离副本,不泄漏内部 map
}

测试时运行 go test -race ./...。Race Detector(数据竞争检测器)通过运行时插入检查逻辑,发现实际执行中缺少同步的冲突访问;它没报告不等于证明所有调度都安全,但报告的问题必须修。

八、面试话术与应用场景
90 秒回答:Mutex 用于保护共享状态的不变量,一次只允许一个 goroutine 进入临界区;零值可用,使用后不能复制。无竞争时当前 runtime 走原子快速路径,竞争时才可能自旋、排队和休眠,等待过久会采用更公平策略,但这些细节不是 API 永久保证。RWMutex 允许并发读、写者独占;一旦写者等待,新读者也会等待以防写者饥饿,且不能升级或降级。Unlock 与后续 Lock 还建立 happens-before,使写入对后来持锁者可见。选 RWMutex 前应以真实读写比例和临界区成本做基准。

应用场景

内存缓存、连接状态、限流器和指标聚合适合用锁保护进程内共享状态。若数据能通过消息传递明确转移所有权,或能构造不可变快照,可减少共享锁;不要用锁掩盖跨进程一致性问题。

资料依据