Golang读写锁底层实现揭秘
概述
在并发编程中,合理使用读写锁可以大幅度提升程序的性能,尤其是“读多写少”的场景。Go 标准库提供了 sync.RWMutex
,它允许多个读操作并发执行,但写操作会独占锁,阻止其他读写。要深入理解其如何在底层实现读写互斥,需要了解 RWMutex
的内部结构、状态变量、原子操作以及阻塞队列的配合。本文将带你从字段定义到方法逻辑、再到典型使用示例,配合ASCII 图解与详细说明,帮助你对 Golang 的读写锁实现有一个全方位的认识。
一、读写锁简介
sync.Mutex
:最基础的互斥锁,只允许一个 goroutine 在临界区执行。所有其他请求锁的 goroutine 都会被阻塞。sync.RWMutex
:分为读锁(RLock
/RUnlock
)和写锁(Lock
/Unlock
)。- 读锁(
RLock
):允许多个 goroutine 同时持有,只要没有任何持有写锁的 goroutine。 - 写锁(
Lock
):独占锁,所有持有读锁或写锁的 goroutine 必须先释放,再由写者获得。
- 读锁(
典型用法:
var rw sync.RWMutex
var data = make(map[string]int)
func read(key string) (int, bool) {
rw.RLock()
defer rw.RUnlock()
v, ok := data[key]
return v, ok
}
func write(key string, value int) {
rw.Lock()
defer rw.Unlock()
data[key] = value
}
在以上示例里,多个 read
可以并发执行,但 write
会阻塞所有当前的读者和写者,直到其完成并释放。
二、RWMutex
的内部结构
在 Go 的源码中(src/sync/rwmutex.go
),RWMutex
的定义(简化版)如下:
type RWMutex struct {
w Mutex // 用于用 write-lock 保护的内置互斥锁
writerCount int32 // 正在等待写锁或者持有写锁的写者数量
readerCount int32 // 当前持有读锁的读者数量
readerWait int32 // 已经判断为需要阻塞等待写者时,仍持有读锁的读者数量
}
w Mutex
:内部一个Mutex
,用于序列化写锁获取;写者要先拿到这个Mutex
,再等待读者释放后才能进入临界区。writerCount int32
:写者计数,既统计当前持有写锁的写者(理论上只能是 1),也统计正在等待写锁的写者数量。每当调用Lock()
时,就会atomic.AddInt32(&writerCount, 1)
。readerCount int32
:读者计数,记录当前已经成功获得读锁且未释放的读者数量。对每个调用RLock()
的 goroutine,会atomic.AddInt32(&readerCount, 1)
;RUnlock()
时会atomic.AddInt32(&readerCount, -1)
。readerWait int32
:读者等待计数,仅在有写者在等待或持有写锁时,额外跟踪那些本来应该释放读锁却暂时继续持有的读者数量,写者会等待读者全部释放后才开始。
下面给出这 4 个字段的视觉示意(ASCII):
┌────────────────────────────────────────────────────────────────┐
│ RWMutex │
│ ┌────────────────────────┬───────────────────────────────────┐ │
│ │ w Mutex │ Writer Count ( writerCount ) │ │
│ │ (内部互斥锁,用于序列化写者) │ int32 │ │
│ └────────────────────────┴───────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Reader Count ( readerCount ) int32 (当前持有读锁的读者) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Reader Wait ( readerWait ) int32 (在写者等待期间仍持有的读者数量) │ │
│ └─────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
三、RLock
/ RUnlock
的实现逻辑
下面详细剖析 RLock
和 RUnlock
两个方法的核心流程。
3.1 RLock
源码(简化版)
func (rw *RWMutex) RLock() {
// 1. 试图增加 readerCount
r := atomic.AddInt32(&rw.readerCount, 1)
// 2. 如果有写者正在等待或持有写锁,必须阻塞
if atomic.LoadInt32(&rw.writerCount) != 0 {
// 标记本来持有读锁的读者数量
atomic.AddInt32(&rw.readerWait, 1)
// 等待写者释放(写者释放时会通知所有阻塞的读者)
rw.w.Lock()
rw.w.Unlock()
atomic.AddInt32(&rw.readerWait, -1)
}
// 读锁成功获得
}
3.1.1 详细步骤与说明
增加
readerCount
r := atomic.AddInt32(&rw.readerCount, 1)
- 使用原子操作将
readerCount
加 1,表示这个 goroutine 已经“尝试”持有读锁。 - 返回值
r
记录当前增加后的读者数(可用于后续调试)。
- 使用原子操作将
检测写者存在情况
if atomic.LoadInt32(&rw.writerCount) != 0 { … }
- 只要
writerCount
不为 0,说明有写者正等待或持有写锁,则此时需要将本 goroutine 挂入一个等待队列,不可立即返回持有读锁。 在检测到
writerCount>0
的情况下,会执行下面几个原子和阻塞操作:atomic.AddInt32(&rw.readerWait, 1)
:将readerWait
(那些在写者等待期间仍持有锁的读者数量)加 1。- 阻塞等待:通过
rw.w.Lock()
阻塞,直到写者最终调用rw.w.Unlock()
并唤醒此处阻塞。 atomic.AddInt32(&rw.readerWait, -1)
:解阻塞后,表示当前读者不再持锁,减少readerWait
。
- 只要
返回
- 如果
writerCount==0
(目前没有写者),直接获得读锁,无需阻塞。
- 如果
注意:把readerCount
提前加 1,是为了确保“正在读取”的状态在写者判断时被看到,从而写者会等待读者全部退出(包括本次增加的读者)。如果不先加readerCount
,就会引起竞态:写者误判“无读者”,直接拿到写锁,导致读者跑到写锁内部,破坏互斥。
3.2 RUnlock
源码(简化版)
func (rw *RWMutex) RUnlock() {
// 1. 减少 readerCount
newReaderCount := atomic.AddInt32(&rw.readerCount, -1)
if newReaderCount < 0 {
panic("RUnlock of unlocked RWMutex")
}
// 2. 如果减少后上一步写者正在等待,并且已没有持有读锁的读者了,则通知写者
if atomic.LoadInt32(&rw.writerCount) != 0 && atomic.LoadInt32(&rw.readerCount) == atomic.LoadInt32(&rw.readerWait) {
// 唤醒所有正通过 rw.w.Lock() 阻塞的读者,此处用 Broadcast 语义
rw.w.Unlock()
}
}
3.2.1 详细步骤与说明
减少
readerCount
newReaderCount := atomic.AddInt32(&rw.readerCount, -1)
- 表示本 goroutine 放弃读锁,将
readerCount
减 1。 - 如果结果为负,说明调用了过多的
RUnlock()
,会抛出 panic。
- 表示本 goroutine 放弃读锁,将
判断写者等待并通知
if atomic.LoadInt32(&rw.writerCount) != 0 && atomic.LoadInt32(&rw.readerCount) == atomic.LoadInt32(&rw.readerWait) { rw.w.Unlock() }
只有在以下两个条件同时满足时,才“通知等待的写者”:
writerCount != 0
:表示至少有一个写者正在等待(或持有锁)。readerCount == readerWait
:- 此时
readerWait
表示那些在写者等待阶段被“挤”出来、但仍标记“持有读锁”的读者数量。 readerCount
表示当前真正持有读锁的读者总数。- 当它们相等时,意味着所有“逃逸”到
readerWait
的读者实际上已经释放,此刻写者可以安全获得写锁。
- 此时
- 一旦条件满足,就执行
rw.w.Unlock()
,相当于唤醒一个或多个在RLock
中因写者等待而阻塞的读者或写者。通常rw.w
上有一个等待读锁/写锁的队列,Unlock()
会唤醒队列中的所有阻塞方。
注意:在RLock
阻塞时,是通过rw.w.Lock()
将自己放到w
(内部的Mutex
)的等待队列;对应的RUnlock
只需要调用rw.w.Unlock()
,就会同时唤醒所有在w
上阻塞的 goroutine(读者或写者),再由它们自行检查能否完成拿锁。
四、Lock
/ Unlock
的实现逻辑
接下来,剖析写锁(Lock
/Unlock
)的内部流程。
4.1 Lock
源码(简化版)
func (rw *RWMutex) Lock() {
// 1. 标记一个写者开始等待
atomic.AddInt32(&rw.writerCount, 1)
// 2. 获取内部互斥锁,序列化所有写者
rw.w.Lock()
// 3. 等待读者全部释放
if atomic.AddInt32(&rw.readerCount, -atomic.LoadInt32(&rw.readerCount)) != 0 {
// 有读者在持锁,放弃 临时计数,写者挂起
rw.w.Unlock() // 释放一次,以便其他读者/写者判断
// 再次尝试:循环等待,直到所有读者都释放
for {
if atomic.LoadInt32(&rw.readerCount) == 0 {
break
}
// 阻塞等待,仍通过 rw.w.Lock() 或其他机制
runtime.Gosched() // 或者再锁再解锁以等待
}
}
// 此时写者拥有 w 锁,且 readerCount 已为 0,可以安全执行写入
}
(注:上面代码是极度简化的伪代码,用于演示思路,实际源代码更复杂)
4.1.1 详细步骤与说明
增加写者计数
atomic.AddInt32(&rw.writerCount, 1)
- 表明当前有一个写者开始等待或正持有写锁。这个增量保证后续读者在检查
writerCount
时会被发现。
- 表明当前有一个写者开始等待或正持有写锁。这个增量保证后续读者在检查
获取内部互斥锁
w
rw.w.Lock()
- 因为
w
是一个普通的Mutex
,所有调用Lock()
的写者会被序列化(即排队)。只有第一个写者获取到w
后才能进入下一步。其他写者在这一步就会被阻塞。
- 因为
等待读者释放
- 一旦持有了
w
,写者必须确保当前没有任何读者持锁,才能进入写临界区。否则会阻塞。 - 上述简化实现中,先将
readerCount
减到 0,如果结果非零,就表示仍有读者未释放;此时需要让当前写者“放弃”单次锁(rw.w.Unlock()
),去等所有读者都释放后再重试。 实际 Go 源码中,并不会像上面那样循环手动减
readerCount
,而是通过判断与readerWait
的关系来准确阻塞:- 真实流程:写者直接
rw.w.Lock()
,独占内部 Mutex。 - 然后检查
readerCount > 0
时,会调用系统调用阻塞,让出 P,等待最后一个读者在RUnlock
中检测到“写者在等待且所有读者已退出”时,执行rw.w.Unlock()
,唤醒写者。
- 真实流程:写者直接
- 简而言之:写者只要持有内部
w
且readerCount > 0
,就会被阻塞在w.Lock()
阶段;直到读者全部释放并在RUnlock
中调用w.Unlock()
才会继续。
- 一旦持有了
执行写操作
当写者走到这里时,意味着:
- 它已经获得内部互斥锁
w
; - 此刻没有任何读者持有读锁(
readerCount == 0
)。
- 它已经获得内部互斥锁
- 可以安全地进入临界区执行写操作(对共享资源写入)。
4.2 Unlock
源码(简化版)
func (rw *RWMutex) Unlock() {
// 1. 减少写者计数
atomic.AddInt32(&rw.writerCount, -1)
// 2. 释放内部互斥锁
rw.w.Unlock()
}
4.2.1 详细步骤与说明
减少写者计数
atomic.AddInt32(&rw.writerCount, -1)
- 表示当前写者即将离开临界区,不再等待或持有写锁;允许新读者/写者进入。
释放
w
rw.w.Unlock()
- 内部互斥锁
w
解锁时,会唤醒所有在该互斥锁上阻塞的 goroutine(可能是等待写锁的写者,也可能是某些在RLock
中因写者等待而阻塞的读者)。 - 这些被唤醒的读者会重新检查
writerCount
,如果此时写者已经全部离开(writerCount == 0
),它们就会正常返回并持有读锁。 - 同样,等待写锁的写者会竞争
w
,第一个成功的写者会进而检测读者情况,以此类推。
- 内部互斥锁
注意:因为Unlock()
的rw.w.Unlock()
会唤醒同在w
上等待的所有 goroutine,所以写者Unlock()
后,所有阻塞在RLock()
的读者会被一次性唤醒。它们都要先做一个判断,看当前是否还存在等待的写者;如果writerCount
恰好此时为 0,就直接获得读锁;如果有新写者已经开始等待,则被唤醒的读者又会在RLock()
中再次阻塞。这样保证写者优先与公平性。
五、读写锁的状态转换图解(ASCII)
下面用 ASCII 图示简化展示多个读者和写者竞争锁时,RWMutex
中 readerCount
、readerWait
、writerCount
的状态演变。假设有 2 个读者(R1, R2)和 1 个写者(W1)按时间顺序发起操作。
初始状态(所有计数为 0):
readerCount = 0, readerWait = 0, writerCount = 0
Step 1: R1.RLock()
-------------------
readerCount += 1 → 1
writerCount == 0 → 无写者等待
R1 获得读锁
状态: readerCount=1, readerWait=0, writerCount=0
Step 2: R2.RLock()
-------------------
readerCount += 1 → 2
writerCount == 0 → 无写者等待
R2 获得读锁
状态: readerCount=2, readerWait=0, writerCount=0
Step 3: W1.Lock()
-------------------
writerCount += 1 → 1
尝试 rw.w.Lock() → 成功(因为当前只有读者,没有写者,但写者直接拿到内部互斥锁)
检测 readerCount > 0 → true (readerCount=2),写者必须阻塞
W1 阻塞在 rw.w.Lock() 阶段
状态(暂): readerCount=2, readerWait=0, writerCount=1
Step 4: R1.RUnlock()
----------------------
readerCount -= 1 → 1
writerCount != 0(写者在等待) && readerCount == readerWait (1 == 0)? → false
→ 写者尚需等待读者
状态: readerCount=1, readerWait=0, writerCount=1
Step 5: R2.RUnlock()
----------------------
readerCount -= 1 → 0
writerCount != 0 && readerCount == readerWait (0 == 0)? → true
→ 写者被唤醒:执行 rw.w.Unlock()
此时写者 W1 从 阻塞中醒来,再次尝试 rw.w.Lock():
- 因为之前 `rw.w.Unlock()`,写者会进入临界区
- 再检测 readerCount(此时 = 0) → 写者可安全写入
状态: readerCount=0, readerWait=0, writerCount=1 (W1 持有内部 Mutex)
Step 6: W1.Unlock()
----------------------
writerCount -= 1 → 0
rw.w.Unlock() → 唤醒所有阻塞在 rw.w 上的 goroutine(如果有新的 R 或新的写者)
状态: readerCount=0, readerWait=0, writerCount=0
此时若有其他 R 或 W 正在等待,都可按顺序竞争锁
上面简化演示了“一轮”读写者竞争的典型流程。可以看到:
- 读者在调用
RLock()
时,会先递增readerCount
,只要writerCount == 0
,就可直接返回;否则会增加readerWait
并阻塞在rw.w.Lock()
。 - 写者在调用
Lock()
时,先递增writerCount
并同样尝试锁住内部w
。如果有任意读者持有锁(readerCount > 0
),就会被阻塞。只有当最后一个读者在RUnlock()
中发现写者在等待且自己释放时,才会调用rw.w.Unlock()
唤醒写者。 - 写者持锁后,
readerCount
必为 0,表示没有任何读者持锁,可安全执行写操作。完成后,通过writerCount--
并rw.w.Unlock()
,唤醒其他等待者。
六、示例:使用读写锁保护共享资源
下面通过一个具体示例来演示 RWMutex
在并发场景下的典型用法与性能优势。
package main
import (
"fmt"
"sync"
"time"
)
type SafeMap struct {
mu sync.RWMutex
m map[string]int
}
func NewSafeMap() *SafeMap {
return &SafeMap{
m: make(map[string]int),
}
}
// 读操作:使用 RLock/RUnlock 并发读
func (s *SafeMap) Get(key string) (int, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
v, ok := s.m[key]
return v, ok
}
// 写操作:使用 Lock/Unlock 独占写
func (s *SafeMap) Set(key string, value int) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[key] = value
}
func main() {
sm := NewSafeMap()
var wg sync.WaitGroup
// 写者:每秒写一次
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 5; i++ {
sm.Set("count", i)
fmt.Println("写入 count =", i)
time.Sleep(200 * time.Millisecond)
}
}()
// 多个读者:每 100ms 读一次
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 10; j++ {
v, _ := sm.Get("count")
fmt.Printf("读者 %d 读到 count = %d\n", id, v)
time.Sleep(100 * time.Millisecond)
}
}(i + 1)
}
wg.Wait()
fmt.Println("All done.")
}
运行示例输出(可能类似):
读者 1 读到 count = 0
读者 2 读到 count = 0
读者 3 读到 count = 0
写入 count = 0
读者 1 读到 count = 0
读者 2 读到 count = 0
写入 count = 1
读者 3 读到 count = 1
...
All done.
- 并发读者:在写者未写入之前,所有读者可以同时 获取
RLock()
,并行读取count
。 - 写者:调用
Lock()
,此时会等待读者释放(最多 3 个读者),在读者离开后独占写入; - 读者阻塞:当写者持有写锁时,后续的
RLock()
会阻塞在内部的rw.w.Lock()
,直到写者释放。
从高并发表现来看,如果读操作远多于写操作,就能显著提高并发吞吐:多个读者同时执行不会互相排斥,只有在写者到来时才会短暂停顿。
七、深入探讨:写者优先与公平性
在 Go 的 RWMutex
实现中,存在写者优先的倾向,这样能避免写者饥饿(Writer Starvation)。简单总结:
- 当有写者在等待时,后续的读者会在
RLock
中检测到writerCount > 0
,从而阻塞到rw.w.Lock()
。这时,即使readerCount
降到 0,写者也能立刻获得写锁。 - 写者获得写锁后,会一直占用,直到释放;读者要等到写者释放并将
writerCount--
后,才会重新唤醒。 - 这保证了写者不会被源源不断的读者抢占而长期等待。但也意味着:一旦有写者等待,后续读者就不会再进入,这对读取频繁、写入偶尔发生的场景最为合适;如果写入非常频繁,则会导致大量读者阻塞,浪费性能。
可以通过一个 ASCII 图示来对比“有写者 vs 无写者”时读者的行为:
(1) 无写者在等待时的并发读:
Time →
┌─────────────────────────────────────────────┐
│ R1.RLock() R2.RLock() R3.RLock() │
│ R1.Read() R2.Read() R3.Read() │
│ … all run concurrently … │
└─────────────────────────────────────────────┘
(2) 写者到来后,阻塞后续的读者:
Time →
┌───────────────────────────────────────────────────────────────────┐
│ R1.RLock() R2.RLock() R3.RLock() │
│ R1.Read() R2.Read() R3.Read() │
│ W1.Lock() (开始等待,writerCount=1) │
│ ┌───────────────┐ │
│ │ R4.RLock() │ → 发现 writerCount>0,阻塞到 rw.w.Lock() │
│ └───────────────┘ │
│ ┌───────────────┐ │
│ │ R5.RLock() │ → 同样发现 writerCount>0,阻塞 │
│ └───────────────┘ │
│ R1.RUnlock() R2.RUnlock() R3.RUnlock() │
│ → 最后一个读者 R3.RUnlock() 时,rCount=0, │
│ 满足 (writerCount>0 && rCount == readerWait), │
│ 调用 rw.w.Unlock() 唤醒 W1 │
│ W1 获得写锁 → R4、R5 继续阻塞 │
└───────────────────────────────────────────────────────────────────┘
- 写者优先:只要有写者等待,后续读者都必须先阻塞,不能再星星点点地进入临界区。这样可避免写者可能被“无限”延迟。
- 公平性:写者与读者各自排队,当写者获锁后,只有写者释放(并将
writerCount--
)后,新的读者才能进入;若多个写者排队,则它们按顺序依次获取。
八、小结与学习指南
- 核心字段:
RWMutex
中的w Mutex
(内部互斥锁)、writerCount
、readerCount
、readerWait
共同协作,实现了读写互斥与写者优先的策略。 RLock
/RUnlock
:RLock()
先原子递增readerCount
,若发现writerCount>0
,就会挂起在rw.w.Lock()
,并递增readerWait
;RUnlock()
原子递减readerCount
,若此时writerCount>0
且readerCount==readerWait
,说明最后一个读者离开,调用rw.w.Unlock()
,唤醒写者。
Lock
/Unlock
:Lock()
原子递增writerCount
并获取内部w
,在readerCount>0
时挂起,直到被最后一个读者唤醒;Unlock()
原子递减writerCount
,并调用rw.w.Unlock()
唤醒所有等待的读者/写者。
- 写者优先:在有写者等待时,后续读者会被阻塞,以保证写者不会被华丽的读者“插队”而长时间饥饿。
- 使用场景:当读操作远多于写操作时,
RWMutex
可以大幅提升并发性能;如果写操作频繁,则应慎重,因为频繁阻塞读者会带来额外开销。
通过本文中的代码示例、ASCII 图解与详细步骤说明,希望你能从底层实现层面彻底掌握 Golang sync.RWMutex
的工作原理。在设计并发程序时,依据实际读写比例选择合适的锁策略,既保证线程安全,又能发挥并发性能。
评论已关闭