状态字与两条队列
主线位置:主线 1 换成读写锁之后的「锁这一侧」。
RawRwLock和RawMutex一样把等待队列交给停车场,自己只留一个字;不同的是它用两个 key 停车,并把「这个线程要什么锁」编码进ParkToken,好让解锁方用unpark_filter一次挑出该叫醒的人。
flowchart LR
subgraph word["state: AtomicUsize(64 位)"]
rd["位 4..63:读者数,ONE_READER = 0b10000"]
w["位 3:WRITER_BIT"]
up["位 2:UPGRADABLE_BIT"]
wp["位 1:WRITER_PARKED_BIT"]
p["位 0:PARKED_BIT"]
end
p -.-> q1["主队列 key = addr:读者、写者、可升级读者"]
wp -.-> q2["第二队列 key = addr + 1:已拿到 WRITER_BIT、等读者走光的那一个写者"]
文件头的设计说明
// This reader-writer lock implementation is based on Boost's upgrade_mutex:
// https://github.com/boostorg/thread/blob/fc08c1fe2840baeeee143440fba31ef9e9a813c8/include/boost/thread/v2/shared_mutex.hpp#L432
//
// This implementation uses 2 wait queues, one at key [addr] and one at key
// [addr + 1]. The primary queue is used for all new waiting threads, and the
// secondary queue is used by the thread which has acquired WRITER_BIT but is
// waiting for the remaining readers to exit the lock.
//
// This implementation is fair between readers and writers since it uses the
// order in which threads first started queuing to alternate between read phases
// and write phases. In particular is it not vulnerable to write starvation
// since readers will block if there is a pending writer.
三件事:
- 算法来自 Boost.Thread 的
upgrade_mutex(三种锁:共享、可升级、独占)。 - 两条队列。新来的等待者一律进主队列(key = 锁的地址);第二队列(key = 地址 + 1)只给一个线程用:已经抢到
WRITER_BIT、正在等剩下的读者退出的那个写者。RawRwLock是一个AtomicUsize,按usize对齐,addr + 1落在这个锁自己的字节里,不可能是另一把锁的地址,所以拿它当 key 不会和别人撞(停车场的 key 只是一个整数,不要求真能解引用)。 - 读写之间按入队顺序交替读阶段和写阶段,写者不会饿死,因为有写者在等时新读者也会阻塞。parking_lot
RwLock的文档把这叫「task-fair」(rwlock.rs:17-21),出处是 2016 年的提交79af298「Use a task-fair locking policy for RwLock」。具体怎么交替见 读、写与写者优先。
五个常量
// There is at least one thread in the main queue.
const PARKED_BIT: usize = 0b0001;
// There is a parked thread holding WRITER_BIT. WRITER_BIT must be set.
const WRITER_PARKED_BIT: usize = 0b0010;
// A reader is holding an upgradable lock. The reader count must be non-zero and
// WRITER_BIT must not be set.
const UPGRADABLE_BIT: usize = 0b0100;
// If the reader count is zero: a writer is currently holding an exclusive lock.
// Otherwise: a writer is waiting for the remaining readers to exit the lock.
const WRITER_BIT: usize = 0b1000;
// Mask of bits used to count readers.
const READERS_MASK: usize = !0b1111;
// Base unit for counting readers.
const ONE_READER: usize = 0b10000;
// Token indicating what type of lock a queued thread is trying to acquire
const TOKEN_SHARED: ParkToken = ParkToken(ONE_READER);
const TOKEN_EXCLUSIVE: ParkToken = ParkToken(WRITER_BIT);
const TOKEN_UPGRADABLE: ParkToken = ParkToken(ONE_READER | UPGRADABLE_BIT);
把常见的状态列出来(n 是读者数,可升级读者也算一个读者):
| 状态字 | 含义 |
|---|---|
0 | 空闲 |
n·ONE_READER | n 个读者,没人等 |
n·ONE_READER | UPGRADABLE_BIT | n 个读者,其中一个是可升级读者 |
WRITER_BIT | 一个写者持有 |
n·ONE_READER | WRITER_BIT | 一个写者已占住 WRITER_BIT,在等这 n 个读者退出;新读者进不来 |
上一行再 | WRITER_PARKED_BIT | 那个写者等不及,已在 addr + 1 上停车 |
任意 | PARKED_BIT | 主队列里有人 |
WRITER_BIT 的双重含义是这个设计的关键:读者数为 0 时它表示「写锁被持有」,不为 0 时表示「有一个写者已经排在最前面」。写锁因此分成两步拿:先抢 WRITER_BIT(此刻可能还有读者),再等读者数降到 0。这和 RawMutex 的单步加锁不同,后面两章都绕着这一点展开。
UPGRADABLE_BIT 与 WRITER_BIT 互斥,同一时刻至多一个可升级读者(可升级读、降级与 with_upgraded)。
ParkToken 就是增量
停车时每个线程带一个 ParkToken,三种 token 的值恰好是「这个线程拿到锁后,状态字要加多少」:读者加 ONE_READER,写者加 WRITER_BIT,可升级读者加 ONE_READER | UPGRADABLE_BIT。解锁方的 wake_parked_threads 遍历主队列时把每个要叫醒的 token 直接加到一个累加器上:
let filter = |ParkToken(token)| {
let s = new_state.get();
// If we are waking up a writer, don't wake anything else.
if s & WRITER_BIT != 0 {
return FilterOp::Stop;
}
// Otherwise wake *all* readers and one upgrader/writer.
if token & (UPGRADABLE_BIT | WRITER_BIT) != 0 && s & UPGRADABLE_BIT != 0 {
// Skip writers and upgradable readers if we already have
// a writer/upgradable reader.
FilterOp::Skip
} else {
new_state.set(s + token);
FilterOp::Unpark
}
};
累加的结果在公平交接时原样写回状态字:被叫醒的线程醒来就已经持有各自的锁,不用再抢。不公平时累加器只用来决定叫醒谁,状态字写成 0 或 PARKED_BIT,被叫醒的线程自己去抢。ParkToken 是 parking_lot_core 提供的一个 usize 新类型,停车场不解释它的值,只在 unpark_filter 里交给过滤函数(park 与四种 unpark)。Mutex 用的是另一个方向的令牌 UnparkToken,只用来传 TOKEN_HANDOFF。
读者计数溢出
读者数占 60 位(64 位平台)或 28 位(32 位平台)。加读者的地方都用 checked_add(ONE_READER):快路径溢出时返回 false 进慢路径(529-535 行),慢路径 .expect("RwLock reader count overflow") panic(555-560 行)。正常持有守卫的程序不可能有这么多读者,能触发的只有 mem::forget 读守卫的循环,32 位平台上约 2.7 亿次。停车场回调里不能 panic,所以 unlock_upgradable_slow 的回调在溢出时退回非公平路径(805-808 行注释「Panicking is not allowed in parking_lot callbacks」)。
hardware-lock-elision
elision.rs 119 行,只给 RwLock 的读锁用:
pub fn have_elision() -> bool {
cfg!(all(
feature = "hardware-lock-elision",
not(miri),
any(target_arch = "x86", target_arch = "x86_64"),
))
}
开了 feature 且在 x86 上时,读锁在状态字为 0 时用带 xacquire 前缀的 lock cmpxchg 加锁、解锁用带 xrelease 前缀的 lock xadd(Intel TSX 的 HLE 指令前缀,不支持的 CPU 上这两个前缀被忽略)。注释说只在锁「完全空闲」时用,因为冲突多时 elision 表现很差。本机是 aarch64,have_elision() 是编译期常量 false,相关分支全部被消掉,elision.rs 里非 x86 的实现是两个 unreachable!()。0.13 删除了这个 feature,见 去掉的与修正的。
小结
- 一个
usize:位 0PARKED_BIT(主队列有人)、位 1WRITER_PARKED_BIT(写者在第二队列)、位 2UPGRADABLE_BIT、位 3WRITER_BIT,位 4 起是读者数。 - 两条队列:主队列 key =
addr,所有新等待者;第二队列 key =addr + 1,只有「已占WRITER_BIT、等读者退出」的那个写者。 WRITER_BIT在有读者时表示「写者排在最前面」,新读者因此进不来;写锁分两步拿。- 三种
ParkToken的值就是入场后状态字的增量,wake_parked_threads把它们累加成新状态。 - 读者计数溢出会 panic;HLE 只在 x86 且开 feature 时生效,本机不生效,0.13 已删。