Go 源码剖析:sync.RWMutex 读写互斥锁原理
在并发场景中,很多业务具备读多写少的特征:大量协程并发读取共享资源,写操作相对稀少。 普通sync.Mutex是排他锁,不管读还是写,同一时间只允许一个协程持有锁,并发读场景性能很差。sync.RWMutex读写锁应运而生:读与读共享、读写互斥、写写互斥,大幅提升读密集场景并发能力。
本文基于 Go 标准库sync.RWMutex底层机制,拆解结构体、四大接口、锁竞争逻辑,同时解答经典问题:写锁如何避免饥饿。
一、RWMutex 基础特性
核心规则先行:
- ✅ 读锁不阻塞读锁:多个协程可以同时获取读锁并发读取
- ❌ 读锁阻塞写锁、写锁阻塞读锁
- ❌ 写锁阻塞写锁,同一时刻只能存在一个写协程
对外暴露 4 个核心 API:
RLock():获取读锁RUnlock():释放读锁Lock():获取写锁Unlock():释放写锁
二、RWMutex 结构体核心字段
type RWMutex struct { w Mutex // 互斥锁,用于隔离多个写操作 writerSem uint32 // 写协程信号量,唤醒等待的写协程 readerSem uint32 // 读协程信号量,唤醒等待的读协程 readerCount int32 // 读计数器,标记当前读协程数量 readerWait int32 // 用于解决写锁饥饿问题 }重点字段作用总览:
w Mutex:底层互斥锁,保证同一时间只能有一个写协程readerCount:读者计数器,读写双方通信的标记readerWait:记录写锁到来之前,已经存在的读协程数量,防止写锁永久等待(写饥饿)
约定:
- 无写操作:
readerCount >= 0- 存在活跃写锁:
readerCount < 0(写入时减去 1<<30)
三、四大接口底层执行流程
1. Lock () 获取写锁
- 先抢占内部互斥锁
w,保证写写互斥,杜绝并发写; - 给
readerCount减去1 << 30,将计数器置为负数;这一步相当于打上标记:当前有写操作正在排队,后续新来的读锁全部阻塞;
- 将当前
readerCount拷贝至readerWait,代表写锁到达前已存在的读协程数量; - 阻塞等待:直到所有正在执行的旧读协程全部释放锁,
readerWait == 0,写协程正式执行业务。
关键点:写锁先标记、再等待。标记之后,新读请求直接阻塞,不会持续产生新读协程无休止抢占。
2. Unlock () 释放写锁
- 清除写标记:
readerCount += 1 << 30; - 唤醒所有因为写锁阻塞的读协程;
- 释放内部互斥锁
w,同时唤醒其他等待的写协程。
3. RLock () 获取读锁
- 原子操作
readerCount++,增加读者计数; - 判断
readerCount是否为负数:- ≥0:无写操作,直接获取读锁成功;
- <0:说明当前存在正在等待 / 执行的写协程,读协程阻塞等待信号量。
逻辑总结:只要写锁打上负数标记,新读请求全部排队。
4. RUnlock () 释放读锁
- 原子操作
readerCount--,减少读者计数; - 同时递减
readerWait; - 当
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 解决方案
- 写协程抢占内部互斥锁后,复制当前
readerCount到readerWait;readerWait=写锁到达这一刻,已经存在的读协程总数 - 只需要等待这一批 “旧读协程” 执行完毕;
- 每一次
RUnlock()不仅递减readerCount,同时递减readerWait; - 当
readerWait == 0,说明写锁到达前启动的读协程全部完成,立刻唤醒写协程。
这套机制的核心: 写锁到来之后新产生的读协程会被阻塞,不再新增任务,保证写协程一定能等到窗口期,从根源杜绝写饥饿。
六、使用注意事项(实践避坑)
- 锁配对:
RLock()必须搭配RUnlock(),Lock()搭配Unlock(),禁止混用; - 禁止递归加锁:同一个协程重复获取读锁 / 写锁会造成死锁;
- 适用场景:读多写少;读写频率接近时,RWMutex 额外的计数器、信号量开销可能高于普通 Mutex;
- 不要拷贝
RWMutex:锁内部包含状态,拷贝会产生无效锁。
RWMutex设计十分巧妙: 依靠内置Mutex实现写写互斥;依靠readerCount作为标记协调读写冲突;依靠readerWait解决并发经典的写饥饿问题。 读懂计数器正负含义、return 前锁执行时序、信号量唤醒机制,就能彻底分清 Mutex 和 RWMutex 的选型场景。