Go 源码剖析:sync.RWMutex 读写互斥锁原理

在并发场景中,很多业务具备读多写少的特征:大量协程并发读取共享资源,写操作相对稀少。 普通sync.Mutex是排他锁,不管读还是写,同一时间只允许一个协程持有锁,并发读场景性能很差。sync.RWMutex读写锁应运而生:读与读共享、读写互斥、写写互斥,大幅提升读密集场景并发能力。

本文基于 Go 标准库sync.RWMutex底层机制,拆解结构体、四大接口、锁竞争逻辑,同时解答经典问题:写锁如何避免饥饿。

一、RWMutex 基础特性

核心规则先行:

  1. ✅ 读锁不阻塞读锁:多个协程可以同时获取读锁并发读取
  2. ❌ 读锁阻塞写锁、写锁阻塞读锁
  3. ❌ 写锁阻塞写锁,同一时刻只能存在一个写协程

对外暴露 4 个核心 API:

  • RLock():获取读锁
  • RUnlock():释放读锁
  • Lock():获取写锁
  • Unlock():释放写锁

二、RWMutex 结构体核心字段

type RWMutex struct { w Mutex // 互斥锁,用于隔离多个写操作 writerSem uint32 // 写协程信号量,唤醒等待的写协程 readerSem uint32 // 读协程信号量,唤醒等待的读协程 readerCount int32 // 读计数器,标记当前读协程数量 readerWait int32 // 用于解决写锁饥饿问题 }

重点字段作用总览:

  1. w Mutex:底层互斥锁,保证同一时间只能有一个写协程
  2. readerCount:读者计数器,读写双方通信的标记
  3. readerWait:记录写锁到来之前,已经存在的读协程数量,防止写锁永久等待(写饥饿)

约定:

  • 无写操作:readerCount >= 0
  • 存在活跃写锁:readerCount < 0(写入时减去 1<<30)

三、四大接口底层执行流程

1. Lock () 获取写锁

  1. 先抢占内部互斥锁w,保证写写互斥,杜绝并发写;
  2. readerCount减去1 << 30,将计数器置为负数;

    这一步相当于打上标记:当前有写操作正在排队,后续新来的读锁全部阻塞;

  3. 将当前readerCount拷贝至readerWait,代表写锁到达前已存在的读协程数量
  4. 阻塞等待:直到所有正在执行的旧读协程全部释放锁,readerWait == 0,写协程正式执行业务。

关键点:写锁先标记、再等待。标记之后,新读请求直接阻塞,不会持续产生新读协程无休止抢占。

2. Unlock () 释放写锁

  1. 清除写标记:readerCount += 1 << 30
  2. 唤醒所有因为写锁阻塞的读协程;
  3. 释放内部互斥锁w,同时唤醒其他等待的写协程。

3. RLock () 获取读锁

  1. 原子操作readerCount++,增加读者计数;
  2. 判断readerCount是否为负数:
    • ≥0:无写操作,直接获取读锁成功;
    • <0:说明当前存在正在等待 / 执行的写协程,读协程阻塞等待信号量。

逻辑总结:只要写锁打上负数标记,新读请求全部排队。

4. RUnlock () 释放读锁

  1. 原子操作readerCount--,减少读者计数;
  2. 同时递减readerWait
  3. readerWait == 0:代表写锁到来之前所有旧读协程全部退出,唤醒等待中的写协程。

四、三大竞争关系原理拆解

4.1 写操作如何阻止其他写操作?

依靠结构体内置的Mutex w。 任何协程调用Lock()第一件事就是抢占该互斥锁。互斥锁天然排他,同一时间只会有一个协程拿到锁执行写逻辑,实现写写互斥。

4.2 写操作如何阻止读操作?

写锁执行时,执行readerCount -= 1 << 30,计数器变为负值。 后续所有协程调用RLock()时检测到readerCount < 0,判定存在写操作,主动阻塞。

注意:写锁标记生效之后新来的读协程会阻塞,但写锁到达前已经拿到读锁的协程可以继续执行,写锁需要等待它们全部释放。

4.3 读操作如何阻止写操作?

读协程执行RLock()会让readerCount ++。 写协程拿到内部互斥锁之后,需要等待readerWait归零,也就是等待所有已有读协程调用RUnlock()。只要还有活跃读协程,写协程持续阻塞。

五、核心难题:如何避免写锁饥饿?

什么是写饥饿?

如果没有readerWait机制:持续不断的读协程源源不断到来,readerCount永远大于 0,写协程会无限等待,永远无法获取锁。

readerWait 解决方案

  1. 写协程抢占内部互斥锁后,复制当前readerCountreaderWaitreaderWait=写锁到达这一刻,已经存在的读协程总数
  2. 只需要等待这一批 “旧读协程” 执行完毕;
  3. 每一次RUnlock()不仅递减readerCount,同时递减readerWait
  4. readerWait == 0,说明写锁到达前启动的读协程全部完成,立刻唤醒写协程。

这套机制的核心: 写锁到来之后新产生的读协程会被阻塞,不再新增任务,保证写协程一定能等到窗口期,从根源杜绝写饥饿。

六、使用注意事项(实践避坑)

  1. 锁配对RLock()必须搭配RUnlock()Lock()搭配Unlock(),禁止混用;
  2. 禁止递归加锁:同一个协程重复获取读锁 / 写锁会造成死锁;
  3. 适用场景:读多写少;读写频率接近时,RWMutex 额外的计数器、信号量开销可能高于普通 Mutex;
  4. 不要拷贝RWMutex:锁内部包含状态,拷贝会产生无效锁。

RWMutex设计十分巧妙: 依靠内置Mutex实现写写互斥;依靠readerCount作为标记协调读写冲突;依靠readerWait解决并发经典的写饥饿问题。 读懂计数器正负含义、return 前锁执行时序、信号量唤醒机制,就能彻底分清 Mutex 和 RWMutex 的选型场景。