并发编程(八):从 Mutex 到读写锁

沿用 counter 与 counter + ready 的例子,理解读写锁如何允许多个读者并发,以及 Java Lock API、Go sync.RWMutex 和 CPython 的对应情况。

中文发布于

目录


0. 回到 Mutex

还是用前面的 counter

由于它会被多个线程读写,因此我们用同一把 Mutex 保护 counter,这种方式当然没问题。

但是,如果 Thread A 和 Thread B 都要读取 counter,B仍然得等待A释放锁,

Thread A                         Thread B

lock
  │
  ▼
read counter
  │
  ▼
unlock ────────────────────────► lock
                                   │
                                   ▼
                               read counter
                                   │
                                   ▼
                                 unlock

读取不会修改 counter,所以只要没有写者,A 和 B 本来可以同时执行。读写锁正是放开了这一部分并发:读者使用读锁,可以同时进入;写者使用写锁,仍然需要独占。

Thread A                         Thread B

readLock                        readLock
    │                               │
    ▼                               ▼
read counter                    read counter
    │                               │
    ▼                               ▼
readUnlock                      readUnlock

因此,读写锁没有改变读写和写写之间的互斥,只是不再让两个读者互相等待:

两个线程的操作 Mutex 读写锁
读–读 不能并发 可以并发
读–写 不能并发 不能并发
写–写 不能并发 不能并发

1. 一把读写锁保证什么?

读写锁有两个入口:


1.1 Atomicity:写入不能与其他读写交错

如果线程 A 正在修改 counter,线程 B 无论想读还是想写,都需要等待 A 释放写锁:

Thread A                    Thread B

writeLock                       
    │                               │
    ▼                               │
counter++                           │
    │                               │
    ▼                               ▼
writeUnlock ───────────────────► readLock
                                    │
                                    ▼
                                read counter

因此,写锁保护的 counter++ 不会与其他读写交错。读锁只应该用于读取,不能在其中修改共享状态。

1.2 Visibility:读者需要看到前一个写者的更新

读写锁不只决定谁可以进入,还要让后续读者看到前一个写者的结果:

Thread A                    Thread B

writeLock
    │
    ▼
counter = 1
    │
    ▼
writeUnlock ─────  ───────► readLock
                                        │
                                        ▼
                                    read counter = 1

如果 B 在 A 释放写锁后获得读锁,B 就需要看到 A 在写锁内完成的 counter = 1

1.3 Ordering:写锁和读锁之间仍然有顺序边界

再放回 counter + ready 的例子:

Thread A                    Thread B

writeLock
    │
    ▼
counter = 1
    │
    ▼
ready = true
    │
    ▼
writeUnlock ─────  ───────► readLock
                                        │
                                        ▼
                                    read ready = true
                                        │
                                        ▼
                                    read counter = 1

这条顺序把 A 的两次写入、写锁的释放、B 获得读锁以及 B 的两次读取连在一起。因此,B 不能在读到 ready = true 时,仍然把 counter 读成 0


2. Java、Go 和 CPython 中的读写锁

下面不再重复前面的结论,而是看 Java 和 Go 的官方规则如何推出 Atomicity、Visibility 和 Ordering。

2.1 Java:ReentrantReadWriteLock

先看 Java ReadWriteLock 对 Atomicity 的规则:

“The write lock is exclusive.”

写锁被某个线程持有时,其他读者和写者都不能进入。没有写者时,多个线程可以同时持有读锁。

再看 Visibility 和 Ordering:

“A thread successfully acquiring the read lock will see all updates made upon previous release of the write lock.”

这条规则直接说明:A 释放写锁以后,后续获得读锁的 B 能看到 A 完成的更新。因此,它同时对应 Visibility 和 Ordering。

使用时,读取和写入必须分别获得对应的锁:

int counter = 0;

ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
Lock readLock = rwLock.readLock();
Lock writeLock = rwLock.writeLock();

int readCounter() {
    readLock.lock();
    try {
        return counter;
    } finally {
        readLock.unlock();
    }
}

void incrementCounter() {
    writeLock.lock();
    try {
        counter++;
    } finally {
        writeLock.unlock();
    }
}

// Thread A
int a = readCounter();

// Thread B
int b = readCounter();

Thread A 和 Thread B 都只获得读锁,所以两次 readCounter() 可以同时执行。incrementCounter() 获得写锁时,其他读者和写者都需要等待。

2.2 Go:sync.RWMutex

Go 的 sync.RWMutex 直接提供 RLock/RUnlockLock/Unlock。官方文档对 Atomicity 边界的定义是:

“The lock can be held by an arbitrary number of readers or a single writer.”

所以,多个 Reader 可以同时持有读锁,Writer 则必须独占这把锁。

再看 Visibility 和 Ordering 对应的原文:

“the n’th call to Unlock ‘synchronizes before’ that call to RLock”

这条规则对应前面的 counter 示例:A 在 Unlock 前完成写入,B 随后成功执行 RLock,因此 B 能看到 A 的写入。这解决了 Visibility,同时也用 Unlock → RLock 建立了 Ordering。官方文档还规定了 RUnlock → Lock 的 synchronized-before 关系,使后续写者也排在已有读者之后。

var counter int
var rw sync.RWMutex

func readCounter() int {
	rw.RLock()
	defer rw.RUnlock()
	return counter
}

func incrementCounter() {
	rw.Lock()
	defer rw.Unlock()
	counter++
}

// Goroutine A
a := readCounter()

// Goroutine B
b := readCounter()

Goroutine A 和 Goroutine B 可以同时读取 counter。只要有一个 Goroutine 获得写锁,其他读者和写者就需要等待。

2.3 CPython:标准库没有对应的 RWLock

Python 的 threading 标准库提供 LockRLock,但没有公开的 ReadWriteLockRWLock 类型。

这里的 RLock 是 Reentrant Lock,表示同一个线程可以重复获得同一把锁;它不是 Read Lock,也不会让多个读者同时进入。

因此,这里不为 CPython 强行补一个读写锁示例。如果项目确实需要读写锁,需要明确选择第三方实现或自行封装,不能把 RLock 当作替代品。

2.4 三种语言放在一起看

Java Go CPython
读写锁 API ReentrantReadWriteLock sync.RWMutex 标准库没有对应类型
读锁 readLock().lock() RLock() -
写锁 writeLock().lock() Lock() -
读–读并发 可以 可以 -
写入 独占 独占 -
规则来源 ReadWriteLock API 的内存同步规则 Go Memory Model 没有对应 API 规则

3. 什么时候使用读写锁?

读写锁适合下面的场景:

  • 读操作明显多于写操作;
  • 同一时间确实有多个线程竞争这份数据;
  • 读临界区足够长,并发读带来的收益能覆盖读写锁本身的开销。

它不一定比 Mutex 快。如果读操作很短、写入频繁,或者几乎没有竞争,直接使用 Mutex 通常更简单。是否需要读写锁,最后应该通过压测和 Profiling 判断。

还要注意锁的升级与降级:

  • Java ReentrantReadWriteLock 不支持从读锁升级为写锁,但支持从写锁降级为读锁;
  • Go sync.RWMutex 既不支持升级,也不支持降级。

不要在持有读锁时直接等待写锁,否则很容易把自己卡住。


4. 下一篇:AQS 与读写锁的实现

这一篇先说明读写锁保证什么,以及 Java 和 Go 分别提供了什么 API。它们的实现路径并不相同。

下一篇先从 Java ReentrantLock 开始建立 AQS:ReentrantLock 使用 AQS 的独占模式,ReentrantReadWriteLock 同时使用共享模式和独占模式。竞争失败后,AQS 负责把线程加入等待队列并进行阻塞和唤醒。

Go 没有 AQS。sync.RWMutex 直接组合原子计数、Mutex 和 Runtime Semaphore,记录读者、等待中的写者,并在发生竞争时挂起或唤醒 Goroutine。

最后再把两条路径向下连接到 CAS、原子加减、等待与唤醒,看看读写锁如何从语言 API 一直落到 Runtime 和 CPU。

讨论

使用 GitHub 账号参与讨论,评论会保存在 GitHub Issues 中。在 GitHub 查看