并发编程(八):读写锁——从语言规则到 CPU

目录


0. 这一篇继续回答什么?

上一篇最后回到 Mutex:它能保护一整段临界区,但无论读还是写,同一时刻都只允许一个执行单元进入。

如果两个线程都只读取共享状态,它们其实没有必要互相等待:

Thread A                         Thread B

read counter                    read counter

读写锁就是在 Mutex 的基础上进一步区分 Reader 和 Writer:

Read  + Read   → 可以并发
Read  + Write  → 互斥
Write + Write  → 互斥

这一篇继续沿用前面的两个例子:

  • counter++:看 Write Lock 如何保证 Atomicity;
  • counter + ready:看 Write Lock → Read Lock 如何建立 Visibility 和 Ordering。

同样先从语言规则解释保证是什么,再继续往下看 JDK、HotSpot 和 CPU 如何实现。


1. Java ReadWriteLock 在语言层保证什么?

1.1 Atomicity

Java ReadWriteLock 对 Reader / Writer 的边界定义得很直接:

“The read lock may be held simultaneously by multiple reader threads, so long as there are no writers.”

同时:

“The write lock is exclusive.”

因此,读取可以并发,但写入仍然需要独占。

还是用 counter++:

ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
Lock writeLock = rw.writeLock();

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

两个线程同时更新时:

初始:counter = 0

Thread A                         Thread B

writeLock.lock()
                                 writeLock.lock()
                                 等待
read counter -> 0
counter + 1 -> 1
write counter -> 1
writeLock.unlock()
                                 writeLock.lock() 返回
                                 read counter -> 1
                                 counter + 1 -> 2
                                 write counter -> 2
                                 writeLock.unlock()

最终:

counter = 2

这里和 Mutex 一样,Atomicity 来自 Write Lock 把冲突的临界区串行化,不是 counter++ 本身变成了原子指令。

Read Lock 只应该保护读取。多个 Reader 可以同时进入,因此不能靠 Read Lock 保护共享写入。

1.2 Visibility 和 Ordering

ReadWriteLock 还规定了 Write Lock 与关联 Read Lock 之间的内存同步语义:

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

还是套回同一个 counter / ready:

int counter = 0;
boolean ready = false;

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

// Thread A
writeLock.lock();
try {
    counter = 1;
    ready = true;
} finally {
    writeLock.unlock();
}

// Thread B
readLock.lock();
try {
    if (ready) {
        System.out.println(counter);
    }
} finally {
    readLock.unlock();
}

如果 B 在 A 释放 Write Lock 之后成功获得 Read Lock:

Thread A                              Thread B

counter = 1
    │
    │ program order
    ▼
ready = true
    │
    ▼
writeLock.unlock()
    │
    │ memory synchronization
    ▼
                                    readLock.lock() returns
                                        │
                                        │ program order
                                        ▼
                                    read ready == true
                                    read counter

因此:

write counter
    ↓ happens-before
write ready
    ↓
writeLock.unlock
    ↓ memory synchronization
readLock.lock returns
    ↓ happens-before
read counter

所以当 B 已经获得后续 Read Lock 并读到 ready == true 时,前面的 counter = 1 对 B 可见,不能出现:

ready == true
counter == 0

这里同时得到两个保证:

  • Visibility:Writer 在释放 Write Lock 之前完成的写入,对后续获得 Read Lock 的 Reader 可见;
  • Ordering:Writer 的写入、Write Lock 的释放、Read Lock 的获取和 Reader 的读取形成 happens-before 顺序。

2. JDK 如何实现 ReentrantReadWriteLock?

2.1 分层

先看实现分层:

Java Source Code
实例:readLock.lock() / writeLock.lock()
作用:声明读临界区与写临界区
        │
        ▼
JVM Bytecode
实例:invokeinterface Lock.lock / Lock.unlock
作用:调用 ReadLock / WriteLock API
        │
        ▼
Runtime / Language Implementation
实例:ReentrantReadWriteLock.Sync / AbstractQueuedLongSynchronizer
     + Unsafe CAS / LockSupport
作用:实现 Reader / Writer 状态、竞争、排队与唤醒
        │
        ▼
OS Thread
实例:Runnable / Parked Java Thread
作用:竞争失败时进入阻塞,收到 unpark 后重新参与竞争
        │
        ▼
x86-64 Hardware
实例:Atomic Instruction / Cache Coherence / Memory Ordering
作用:提供 Atomicity、Visibility 与 Ordering 的硬件基础

2.2 一次 Write Lock → Read Lock 的完整实现路径

继续使用 counter / ready,先由 Thread A 写入,再由 Thread B 读取:

Writer 获取并释放 Write Lock 后,Reader 如何获取 Read Lock,以及竞争失败时 Java Thread 如何进入 Park / Wakeup 路径。

上面的时序图只保留跨层主流程。Runtime 内部的 Reader / Writer 竞争路径可以进一步展开为:

Read Lock 和 Write Lock 分别进入 Shared / Exclusive 模式;冲突时进入 AQLS 同步队列并 Park,释放后再 Wakeup 重试。

这里有两个核心状态:

high 32 bits
  → shared count
  → Reader 数量

low 32 bits
  → exclusive count
  → Writer 重入次数

当前 JDK 25 的 ReentrantReadWriteLock.Sync 继承 AbstractQueuedLongSynchronizer。Reader 走 Shared 模式,Writer 走 Exclusive 模式;两者最终都竞争同一个 64-bit state。

相关实现可以查看 OpenJDK 25 的 ReentrantReadWriteLock.java 和 AbstractQueuedLongSynchronizer.java。

2.3 Runtime / Language Implementation 层的三种保证

2.3.1 Atomicity

Atomicity 对应流程图里的状态竞争:

Writer
  → CAS exclusive count
  → 成功后独占写临界区

Reader
  → CAS shared count
  → 多个 Reader 可以分别成功

Writer 获取锁时,如果已经存在 Reader,或者 Write Lock 被其他线程持有,获取失败。

如果当前线程本来就持有 Write Lock,则属于可重入获取,直接增加 exclusive count。

Reader 获取锁时,如果其他线程持有 Write Lock,获取失败;否则通过 CAS 增加 shared count。

因此 Runtime 真正维护的是:

Read + Read
  → 允许多个 Shared acquire

Read + Write
  → 冲突

Write + Write
  → 冲突

2.3.2 Visibility 和 Ordering

AbstractQueuedLongSynchronizer 的 state 是 volatile long:

private volatile long state;

它的基础操作具有:

getState()
  → volatile read

setState()
  → volatile write

compareAndSetState()
  → volatile read + volatile write

Write Lock 释放时更新 state;后续 Reader 获取锁时读取并 CAS 同一个 state。

所以:

Writer writes
    ↓
release Write Lock
    ↓
volatile / CAS state synchronization
    ↓
acquire Read Lock
    ↓
Reader reads

这就是语言层 Visibility / Ordering 在 Runtime / Language Implementation 层的落点。

2.4 Hardware 层的三种保证

继续向下,仍然回到三类硬件能力:

Atomicity
  → Atomic Instruction
  → x86-64:LOCKed CAS 等原子 RMW

Visibility
  → Cache Coherence
  → 让其他 CPU Core 不再继续使用已经失效的旧 Cache Line

Ordering
  → x86-64 Memory Ordering + LOCKed RMW / 必要的顺序约束
  → 保证锁状态变化前后的内存操作顺序

compareAndSetState() 最终通过 Unsafe.compareAndSetLong 落到目标架构的原子 CAS。

读写锁没有新的硬件原语。它是在 Atomic、线程等待 / 唤醒之上,编码出“多个 Reader 或一个 Writer”这套规则。


3. Go Runtime 如何实现 sync.RWMutex?

Go 标准库直接提供 sync.RWMutex。

它和 Java ReadWriteLock 具有相同的核心 Reader / Writer 互斥模型:多个 Reader 可以并发,一个 Writer 必须独占;完整 API 语义并不完全相同。

两者最重要的差异是:

Java ReentrantReadWriteLock Go sync.RWMutex
Read Lock 重入 支持 不支持递归 RLock
Write Lock 重入 支持 不支持
Write → Read 降级 支持 不支持
Read → Write 升级 不支持 不支持
Fairness 可选 fair / nonfair 不提供 fair 模式

Go Memory Model 还明确规定:

“For any call to RLock, there exists an n such that the n'th call to Unlock ‘synchronizes before’ that call to RLock.”

3.1 分层

Go Source Code
实例:rw.RLock() / rw.Lock()
作用:声明读临界区与写临界区
        │
        ▼
Go Implementation
实例:sync.RWMutex / sync.Mutex / sync/atomic / Runtime Semaphore
作用:实现 Reader / Writer 状态、竞争、等待与唤醒
        │
        ▼
Goroutine / Scheduler
实例:Runnable / Parked Goroutine
作用:竞争失败时 Park Goroutine,Wakeup 后重新调度
        │
        ▼
x86-64 Hardware
实例:Atomic Instruction / Cache Coherence / Memory Ordering
作用:提供 Atomicity、Visibility 与 Ordering 的硬件基础

Go 这里和 Java 的关键区别是:runtime_SemacquireRWMutex* Park 的是 Goroutine,不是直接阻塞一个固定的 OS Thread。

3.2 一次 Write Lock → Read Lock 的完整实现路径

继续使用 counter / ready:

var counter int
var ready bool
var rw sync.RWMutex

先由 Goroutine A 写入,再由 Goroutine B 读取:

Writer 获取并释放 Write Lock 后,Reader 如何获取 Read Lock,以及冲突时 Goroutine 如何 Park / Wakeup。

上面的时序图只保留跨层主流程。Go Implementation 内部的 Reader / Writer 竞争路径可以进一步展开为:

Reader 通过 readerCount 进入;Writer 先与其他 Writer 互斥,再宣布 Writer pending,并等待已有 Reader 离开。

核心状态是:

w
  → Writer 之间互斥

readerCount
  → >= 0:当前 Reader 数量
  → < 0:已经有 Writer pending

readerWait
  → Writer 还需要等待多少 Reader 离开

readerSem / writerSem
  → Reader / Writer 的等待与唤醒

相关实现可以查看 Go 源码的 sync/rwmutex.go。

3.3 Runtime / Language Implementation 层的三种保证

3.3.1 Atomicity

Atomicity 对应上面流程图里的两个核心状态操作。

Reader 进入时:

readerCount.Add(1)

这个更新必须是 Atomic。多个 Reader 可以分别成功增加计数。

Writer 进入时,先通过内部 w Mutex 保证 Writer 之间互斥,再执行:

readerCount.Add(-rwmutexMaxReaders)

把状态切到 “Writer pending”。

因此:

Read + Read
  → 允许多个 Reader

Read + Write
  → Writer pending 后,新 Reader 被阻塞

Write + Write
  → 先由 w Mutex 串行化

这里不是用一个统一的 state 编码所有状态,而是组合 Mutex + Atomic Counter + Semaphore 实现同一套 Reader / Writer 规则。

3.3.2 Visibility 和 Ordering

Go 对 RWMutex 的同步关系由 API 直接定义,而不是从 readerCount 的 Atomic 操作单独推导。

官方规则包括:

Unlock
  → synchronized-before
  → 后续对应的 RLock

RUnlock
  → synchronized-before
  → 后续对应的 Lock

还是套回 counter / ready:

Goroutine A                           Goroutine B

counter = 1
ready = true
    │
    ▼
Unlock() ── synchronized-before ──► RLock() returns
                                        │
                                        ▼
                                    read ready == true
                                    read counter == 1

因此,当 B 通过这次同步获得 Read Lock 后,A 在 Write Lock 内完成的写入对 B 可见,并保持正确的顺序。

实现上,Mutex、Runtime Semaphore 和 Atomic 状态共同完成这条同步路径;不能把 readerCount.Add 本身理解成 Reader 之间的同步关系。

3.4 Hardware 层的三种保证

继续向下,最终仍然是前面几篇的三类硬件能力:

Atomicity
  → Atomic Instruction
  → x86-64:LOCK XADDL / LOCK CMPXCHGL 等原子 RMW

Visibility
  → Cache Coherence
  → 让其他 CPU Core 不再继续使用已经失效的旧 Cache Line

Ordering
  → x86-64 Memory Ordering + LOCKed RMW
  → 提供同步路径需要的顺序约束

readerCount.Add / readerWait.Add 最终会落到 x86-64 的原子加法路径;内部 w Mutex 的状态竞争则继续复用前面 Mutex 已经展开过的 CAS / Atomic 路径。

所以 Go RWMutex 也没有新的硬件原语。它只是用 Atomic、Mutex 和 Runtime Semaphore 组合出 Reader / Writer 规则。


4. CPython:为什么标准库没有 Read-Write Lock?

Python 不是做不了 Read-Write Lock。事实上,CPython 很早就有过把 RWLock 加进 threading 的提案:Issue 8800。

这个提案最终没有进入标准库。从 Issue 8800 的长期讨论来看,至少有两个明显阻力。

1. 传统 CPython 下,GIL 让 Read-Write Lock 的收益有限

当时 CPython 开发者直接指出:

“since the GIL serializes everything anyway, this isn't likely to benefit many situations”

核心问题很简单:

Read-Write Lock 的主要收益
  → 多个 Reader 真正并行

传统 CPython
  → GIL 让 Python Code 同一时刻主要只有一个 Thread 执行

所以即使允许多个线程同时获得 Read Lock,它们通常也不能并行执行 Python Code。

这不表示 RWLock 完全没价值。I/O、释放 GIL 的 C Extension 等场景仍然可能受益。但对传统 CPython 的通用线程代码来说,它的收益没有 Java / Go 那么直接。

Free-threaded CPython 改变了这一点:没有 GIL 时,多个 Python Thread 可以真正并行执行,Reader 并发的价值明显提高。

所以第一点的前提正在变化。但即使 RWLock 的收益变得更明确,标准库仍然要面对第二个问题:API 和调度语义没有形成统一意见。

2. Issue 8800 对 API 和调度 Policy 没有形成统一意见

讨论里主要有两类争议。

第一类是 API 形态:

一个统一的 RWLock
还是
两个关联的 Shared / Exclusive Lock

例如,有开发者认为应该返回两个独立的 Shared / Exclusive Lock:

“having two different lock primitives ... would be much more flexible, pythonic”

也有开发者更倾向一个统一的 RWLock。

第二类是 调度 Policy:

Reader Priority / Writer Priority?
是否 Fair / FIFO?
Writer 等待时,新 Reader 能不能继续进入?
Read Lock 能不能升级成 Write Lock?
Write Lock 能不能降级成 Read Lock?

这些选择都会直接影响 RWLock 的行为和 API 语义,而 Issue 8800 没有形成一个统一方案。

所以问题不是实现不了,而是 标准库该固定哪一套 RWLock API 和 Policy,没有统一意见。一旦进入标准库,这些语义就需要长期保持兼容。

截至现在,threading 标准库仍然只提供 Lock、RLock、Condition、Semaphore、Event 等基础同步对象,没有加入 Read-Write Lock。

如果应用确实需要 RWLock,通常有两条路:

使用第三方 / 自定义 RWLock
        │
        └── 直接获得 Read Lock / Write Lock 语义

使用 threading.Lock + Condition
        │
        └── 自己定义 Reader / Writer 计数与优先级 Policy

早期的 Issue 8800 提案本身就是使用 Condition 和 Lock 组合实现 RWLock。

所以 Python 缺少的是 标准库统一定义的 RWLock API 与 Policy,不是缺少实现这种同步语义的能力。


5. 两种实现的共同套路

看完 Java 和 Go,再把具体实现拿掉,只看 ReadWriteLock 最终需要解决什么问题。

5.1 谁能进入?——原子维护 Reader / Writer 状态

Mutex 只需要回答“锁住了没有”。

ReadWriteLock 需要同时维护:

Reader Count
+
Writer State

Java:

64-bit state
  → shared count
  → exclusive count

Go:

readerCount
+
Writer Mutex
+
readerWait

编码方式不同,但进入条件相同:

Reader
  → 没有冲突的 Writer

Writer
  → 没有其他 Writer
  → 没有 Reader

这些状态变化都必须依靠 Atomic 操作完成,否则多个执行单元可能同时看到旧状态并错误进入。

5.2 冲突的人怎么办?——Park / Wakeup

状态检查失败以后,等待者不能无限空转。

两种实现最终都进入:

检查 Reader / Writer 状态
        │
        ├── 可以进入
        │      └── 更新状态后进入
        │
        └── 冲突
               │
               ▼
            排队 / 等待
               │
               ▼
             Park
               │
               ▼
        相关持有者释放
               │
               ▼
            Wakeup
               │
               └── Retry

具体等待对象不同:

Java
  → AQLS Sync Queue
  → LockSupport.park()
  → OS Thread Parked

Go
  → Runtime Semaphore
  → Park Goroutine
  → Scheduler 运行其他 Goroutine

所以共同套路是 Conflict → Park → Wakeup → Retry,只是 Java Park 的是 Thread,Go Park 的是 Goroutine。

5.3 后来的 Reader 为什么能看到前一个 Writer 的数据?

最后仍然是同步边界:

Writer writes
    │
    ▼
Write Unlock [Release]
    │
    ▼
Read Lock [Acquire]
    │
    ▼
Reader reads

Java 通过 AQLS 的 volatile / CAS 状态更新落实这条边界。

Go 由 RWMutex 的 synchronized-before 规则定义语义,再由内部 Mutex、Runtime Semaphore 和 Atomic 路径落实。

因此两种实现最终都必须保证:

Writer 释放之前的写入,排在后续 Reader 获取锁之后的读取之前。


6. ReadWriteLock 和 Mutex 的关系

两者都保护临界区,区别在于是否区分 Reader 和 Writer:

Mutex ReadWriteLock
核心问题 一段临界区互斥 区分读临界区与写临界区
Atomicity 整段临界区互斥 冲突操作互斥;多个 Reader 可并发
Visibility 是 是
Ordering 是 是
读–读 互斥 可以并发
读–写 互斥 互斥
写–写 互斥 互斥
典型场景 通用共享状态保护 读多写少,且读临界区存在真实竞争

可以把关系简化成:

Mutex
  → 所有人都走一个 Exclusive 入口

ReadWriteLock
  → Reader 走 Shared 入口
  → Writer 走 Exclusive 入口
  → 只放开 Read + Read

ReadWriteLock 不是比 Mutex “更强”的锁。

它只是把 Mutex 的独占边界进一步细分,让互不冲突的 Reader 可以并发。


7. 下一篇:AQS / AQLS——Java 锁的统一同步框架

这一篇已经多次碰到:

ReentrantReadWriteLock
        ↓
Sync
        ↓
AbstractQueuedLongSynchronizer

它不是某一把锁自己的实现细节,而是 Java 并发锁体系里更底层的同步框架。

下一篇继续下钻 AQS 家族:

AQS / AQLS
  → state
  → Exclusive / Shared
  → CLH-style Queue
  → acquire / release
  → park / unpark
  → Condition Queue

其中 JDK 25 的 ReentrantReadWriteLock 实际使用的是 AbstractQueuedLongSynchronizer,它和 AbstractQueuedSynchronizer 的核心模型相同,只是同步状态使用 long。

Go 没有一个与 AQS 对称的统一框架。

Go 更倾向于让不同同步原语直接组合:

Atomic State
+
Mutex
+
Runtime Semaphore
+
Goroutine Scheduler

例如 sync.RWMutex 就直接组合 w、readerCount、readerWait、readerSem 和 writerSem,而不是统一继承某个 AQS 式 Synchronizer。

所以 AQS / AQLS 是 Java 这条实现链里非常重要的一层,也是下一篇单独展开的对象。


本文首发于 ThinkerQAQ 的个人博客,由作者本人同步发布。原文可能持续修订,最新版本请以个人博客为准。

posted @ 2026-10-07 19:15  ThinkerQAQ  阅读(34)  评论(0)    收藏  举报