第 2 章的spinlock把所有争抢者一视同仁:无论只是想读一眼、还是要动手改,都得排队独占。可现实里有一类极常见的场景——读多写少:一份数据被无数次读取,偶尔才更新一回。若读操作彼此之间本就无冲突,却仍被自旋锁串成单行道,多核的并行度就被白白浪费了。本章的主角qrwlock(queued read/write lock)正是为此而生:它允许多个读者同时进入临界区共享数据,只在写者出现时才要求独占。它和spinlock一样不睡眠、可用于中断上下文,区别只在于把“互斥”细化成了“读共享、写独占”。本章的主线,是看内核如何用一个 32 位原子字同时编码读者计数与写者状态,又如何用一个“等待标志”解决读写锁与生俱来的写者饥饿难题。
说明:以 x86-64 为主线,现代内核的
rwlock_t在架构层统一落到qrwlock。源码集中在kernel/locking/qrwlock.c与include/asm-generic/qrwlock.h、include/asm-generic/qrwlock_types.h。
3.1 rwlock 守护什么:读多写少的共享临界区
读写锁的语义可以用两条规则概括:
- 读者与读者相容。任意多个读者可以同时持锁进入临界区,因为纯读不改变数据,彼此之间没有冲突。
- 写者与所有人互斥。写者持锁时,既不允许别的写者进入,也不允许任何读者进入;反过来,只要还有读者在临界区,写者就必须等待。
这与第 2 章的spinlock形成鲜明对比:spinlock是“任何人进入都独占”,qrwlock则把这份独占只保留给写者,把读者之间的并行释放了出来。它适用的典型场景是那些读远多于写的共享结构——路由表、配置项、注册表这类“查得多、改得少”的数据。
需要强调的是,qrwlock依然是自旋锁家族的一员:争锁失败时不睡眠而是自旋,因此同样受第 2 章那两条硬约束的束缚——临界区必须短、持锁期间不能睡眠。它不是rwsem(第 6 章那把可睡眠的读写信号量),二者的分野和spinlock与mutex的分野如出一辙:一个自旋、一个睡眠。
3.2 从read_lock到queued_read_lock:架构层的映射
和spinlock一样,日常写的read_lock()/write_lock()也是逐层向下委托。在支持qrwlock的架构上,include/asm-generic/qrwlock.h末尾把架构接口直接映射到 queued 版本:
#definearch_read_lock(l)queued_read_lock(l)#definearch_write_lock(l)queued_write_lock(l)#definearch_read_trylock(l)queued_read_trylock(l)#definearch_write_trylock(l)queued_write_trylock(l)#definearch_read_unlock(l)queued_read_unlock(l)#definearch_write_unlock(l)queued_write_unlock(l)上层的read_lock同样会先preempt_disable、经 lockdep 埋点,再落到arch_read_lock——这套分层与第 2 章 2.2 节完全一致,不再赘述。本章只聚焦最底层的queued_read_lock/queued_write_lock如何实现“读共享、写独占”。
3.3 经典读写锁的病:写者饥饿
在看qrwlock的实现之前,先要理解它到底解决了什么问题。最朴素的读写锁实现里,读者只要看到“当前没有写者”就直接把读者计数加一进入临界区。这在读者稀疏时没问题,可一旦读者络绎不绝——前一个读者还没走、后一个读者又来了,读者计数永远不归零——那么等在一旁的写者就永远等不到那个“无人读”的瞬间,被活活饿死。这就是经典读写锁的通病:读者优先会导致写者饥饿。
qrwlock的核心设计目标,就是在保留“读者并行”的同时消除写者饥饿。它的办法是给写者一个抢占预约的能力:写者一旦开始等待,就立刻竖起一面旗,逼退所有还想走快路径的新读者,让在场的读者逐渐排空,从而保证写者能在有限时间内拿到锁。这面旗,就是下一节要讲的_QW_WAITING。
3.4 一个原子字装下读写状态:qrwlock的编码
qrwlock的全部状态压缩在一个 32 位原子字里。看它的类型定义(include/asm-generic/qrwlock_types.h):
typedefstructqrwlock{union{atomic_tcnts;struct{u8 wlocked;/* Locked for write? */u8 __lstate[3];};};arch_spinlock_twait_lock;}arch_rwlock_t;和第 2 章的qspinlock同一套路:一个union让同一份数据既能作为整体cnts做原子读-改-写,又能按字节单独访问其中的wlocked。这个 32 位的cnts被切成两段(include/asm-generic/qrwlock.h):
#define_QW_WAITING0x100/* A writer is waiting */#define_QW_LOCKED0x0ff/* A writer holds the lock */#define_QW_WMASK0x1ff/* Writer mask */#define_QR_SHIFT9/* Reader count shift */#define_QR_BIAS(1U<<_QR_SHIFT)把这几个常量翻译成位布局,就一目了然了:
| 位段 | 含义 | 取值 |
|---|---|---|
bit 0–7(低字节wlocked) | 写者持锁标志 | _QW_LOCKED=0x0ff |
| bit 8 | 写者等待标志 | _QW_WAITING=0x100 |
| bit 9–31 | 读者计数 | 每个读者贡献一个_QR_BIAS=0x200 |
其中_QW_WMASK(0x1ff)正好覆盖 bit 0–8,也就是“写者相关”的全部位——只要这九位里有任何一位非零,就代表有写者正持锁或正在等待。读者计数从 bit 9 起,每来一个读者就给cnts加一个_QR_BIAS,因此最多可容纳2 23 2^{23}223个并发读者。
这个编码的精妙之处,是把“写者独占的低字节”单列成了wlocked:写者解锁时无需对整个cnts做原子读-改-写,只要往这个字节写一个 0 就行——这一点在 3.8 节会看到。
3.5 快路径:读者加计数,写者抢低位
无争用时,读写两条路径都只需一次原子操作。
读者快路径(queued_read_lock):
staticinlinevoidqueued_read_lock(structqrwlock*lock){intcnts;cnts=atomic_add_return_acquire(_QR_BIAS,&lock->cnts);if(likely(!(cnts&_QW_WMASK)))return;/* The slowpath will decrement the reader count, if necessary. */queued_read_lock_slowpath(lock);}读者先无条件地给cnts加一个_QR_BIAS(读者计数加一),拿到加完后的新值。若cnts & _QW_WMASK为零——说明既没有写者持锁、也没有写者在等——读者就成功了,直接返回进入临界区。注意这里用的是atomic_add_return_acquire:acquire 语义(第 1 章 1.6 节)保证临界区里的读取不会被重排到加计数之前,读者看到的一定是最新数据。
只有当_QW_WMASK那九位里有非零位(有写者持锁或等待)时,读者才走进慢路径——并且它已经先把计数加上去了,慢路径会视情况把这一笔减回来。
写者快路径(queued_write_lock):
staticinlinevoidqueued_write_lock(structqrwlock*lock){intcnts=0;/* Optimize for the unfair lock case where the fair flag is 0. */if(likely(atomic_try_cmpxchg_acquire(&lock->cnts,&cnts,_QW_LOCKED)))return;queued_write_lock_slowpath(lock);}写者要的是独占,所以它用第 1 章 1.4 节的 CAS:期望cnts当前为 0(既无读者也无写者),若成立就一步把它换成_QW_LOCKED(低字节置为0xff)。这个 CAS 只有在锁完全空闲时才成功——只要有任何读者在场、或有别的写者,cnts就非零,CAS 失败,写者转入慢路径。
3.6 写者优先:_QW_WAITING如何逼退新读者
写者慢路径是理解qrwlock如何消除写者饥饿的关键(kernel/locking/qrwlock.c):
void__lockfuncqueued_write_lock_slowpath(structqrwlock*lock){intcnts;/* Put the writer into the wait queue */arch_spin_lock(&lock->wait_lock);/* Try to acquire the lock directly if no reader is present */if(!(cnts=atomic_read(&lock->cnts))&&atomic_try_cmpxchg_acquire(&lock->cnts,&cnts,_QW_LOCKED))gotounlock;/* Set the waiting flag to notify readers that a writer is pending */atomic_or(_QW_WAITING,&lock->cnts);/* When no more readers or writers, set the locked flag */do{cnts=atomic_cond_read_relaxed(&lock->cnts,VAL==_QW_WAITING);}while(!atomic_try_cmpxchg_acquire(&lock->cnts,&cnts,_QW_LOCKED));unlock:arch_spin_unlock(&lock->wait_lock);}它分三步,每一步都值得细看:
先抢
wait_lock。wait_lock是qrwlock内嵌的一把普通自旋锁(第 2 章那把公平的qspinlock),它的作用是把所有等待的写者串成 FIFO 队列——同一时刻只有一个写者能越过这道门去竞争真正的读写锁,从根上杜绝了写者之间的乱序与饥饿。竖旗
_QW_WAITING。若拿到wait_lock后锁并非完全空闲(还有读者在场),写者就执行atomic_or(_QW_WAITING, &lock->cnts),点亮 bit 8。这一位一旦亮起,就改变了新读者快路径的命运:回看 3.5 节,读者快路径判断的是cnts & _QW_WMASK,而_QW_WMASK恰好包含了_QW_WAITING这一位。于是从此刻起,所有新来的读者在快路径上都会看到_QW_WMASK非零,被迫转入慢路径去排队——新读者被这面旗挡在了门外。等读者排空,再夺锁。在场的老读者陆续
read_unlock退出,读者计数逐个递减。写者用atomic_cond_read_relaxed自旋等待,直到cnts恰好等于_QW_WAITING(读者计数归零、只剩自己竖的那面旗),再用 CAS 把它换成_QW_LOCKED,正式持锁。
这三步合起来,就是写者优先:写者一旦开始等待,就冻结了新读者的准入,保证在场读者有限时间内排空,写者不会被源源不断的新读者饿死。这正是对 3.3 节那个通病的解药。
一处对称的细节:
_QW_WAITING之所以选在 bit 8、而读者计数从 bit 9 起,就是为了让“只剩等待旗”这个状态恰好等于_QW_WAITING这个干净的数值,第 3 步的等待条件VAL == _QW_WAITING才能写得如此简洁。编码的位布局是为算法服务的。
把 3.4 至 3.6 合起来看,cnts这个 32 位字就在几个状态间迁移,写者优先的“排空”机制一图可见:
3.7 读者慢路径与中断特例
再看读者一侧的慢路径(kernel/locking/qrwlock.c):
void__lockfuncqueued_read_lock_slowpath(structqrwlock*lock){if(unlikely(in_interrupt())){atomic_cond_read_acquire(&lock->cnts,!(VAL&_QW_LOCKED));return;}atomic_sub(_QR_BIAS,&lock->cnts);arch_spin_lock(&lock->wait_lock);atomic_add(_QR_BIAS,&lock->cnts);atomic_cond_read_acquire(&lock->cnts,!(VAL&_QW_LOCKED));arch_spin_unlock(&lock->wait_lock);}它分两种情形:
普通上下文:读者先把 3.5 节快路径里预加的那笔
_QR_BIAS用atomic_sub减回去(此刻它还没资格算作在场读者),然后去抢wait_lock——排在写者队列的同一道门后面。轮到自己时再把计数加回来,用atomic_cond_read_acquire自旋等到_QW_LOCKED清零(写者放锁),即进入临界区并释放wait_lock。把读者也纳入wait_lock队列,是为了让读者和写者在这道门前公平排队,而不是读者一味插队。中断上下文的特例:
in_interrupt()为真时,读者跳过排队,只用atomic_cond_read_acquire自旋等到_QW_LOCKED清零就直接进入——注意它只看_QW_LOCKED,不管_QW_WAITING。这是一条刻意的例外:中断处理程序可能打断了一个正持读锁的进程上下文,若此时又强制中断里的读者去排在写者后面,而被打断的那个读者又要等中断返回才能read_unlock,就会形成死锁。所以只要写者还只是在等待(_QW_WAITING)而未真正持锁(_QW_LOCKED),中断里的读者就允许越过它立即读取——用一处受控的“读者插队”换取避免死锁。
对照 3.6 与 3.7:普通读者、所有写者都要过wait_lock这道公平门,唯独中断上下文的读者享有豁免——这是qrwlock在“写者优先”与“中断安全”之间的精心权衡。
这两条分岔画成流程图,中断读者为何能“插队”就一目了然:
3.8 解锁:读者减计数,写者写一个字节
解锁路径极其轻量(include/asm-generic/qrwlock.h):
staticinlinevoidqueued_read_unlock(structqrwlock*lock){(void)atomic_sub_return_release(_QR_BIAS,&lock->cnts);}staticinlinevoidqueued_write_unlock(structqrwlock*lock){smp_store_release(&lock->wlocked,0);}读者解锁:一次
atomic_sub_return_release,把读者计数减掉一个_QR_BIAS。release 语义(第 1 章 1.6 节)保证临界区里的读取都已完成,才让这次递减对他人可见。当最后一个读者退出、计数归零,等待的写者才会在 3.6 节第 3 步看到cnts == _QW_WAITING。写者解锁:这里正是 3.4 节那个编码红利的兑现——写者无需对整个
cnts做原子读-改-写,只用smp_store_release(&lock->wlocked, 0)往低字节写一个 0,就清掉了_QW_LOCKED。因为写者独占期间读者计数不可能变动(新读者早被_QW_WAITING挡住了),高位的读者计数与写者互不干扰,单字节写不会破坏它们。在强序的 x86 上,这条 release 写就是一条普通mov,不生成任何屏障指令——又一次印证第 1 章反复强调的“x86 解锁路径极廉价”。
3.9 误用与调试
qrwlock继承了spinlock的全部约束,再叠加读写锁特有的几个坑:
- 持读写锁期间睡眠或跑长临界区。和第 2 章一样,自旋期间 CPU 空转,临界区里不能调用任何可能睡眠的函数,也不能跑得太久。需要长临界区或阻塞,请改用
rwsem(第 6 章)。 - 读锁递归 + 写者等待导致的自死锁。同一 CPU 在持有读锁的情况下再次请求读锁,若此时恰有写者已竖起
_QW_WAITING,第二次读锁会被逼进慢路径去排在写者后面,而写者又在等这个读者退出——本 CPU 等自己,死锁。结论是:qrwlock的读锁不可递归。 - 企图把读锁升级为写锁。持有读锁再去
write_lock同一把锁,会等自己手里的读计数归零,必然死锁。读写锁不支持锁升级,需要写就一开始拿写锁。 - 中断上下文与进程上下文混用同一把锁却没关中断。虽然 3.7 节的中断特例避免了读者一侧的一类死锁,但写锁在中断里出现、而进程上下文持读锁未关中断,仍可能死锁。凡是会在中断里用到的读写锁,进程上下文侧要用
read_lock_irqsave/write_lock_irqsave。
调试手段与第 2 章一致:CONFIG_PROVE_LOCKING(lockdep)能识别读写锁的锁序颠倒、递归误用与中断上下文问题;CONFIG_DEBUG_SPINLOCK会检查未初始化使用、重复解锁等错误。
本章小结
qrwlock守护的是读多写少的短临界区:它在spinlock的“一律独占”之上,把独占权只留给写者,释放了读者之间的并行。它的全部状态压进一个 32 位原子字——低九位(_QW_WMASK)记写者持锁与等待,高位记读者计数;无争用时读者一次atomic_add加计数、写者一次 CAS 抢低位就能拿锁。它最关键的设计是用_QW_WAITING这面旗解决经典读写锁的写者饥饿:写者一旦开始等待就点亮这一位,逼退所有走快路径的新读者,让在场读者排空,从而写者优先、不被饿死;同时又用in_interrupt()的读者豁免避免了中断上下文的死锁。内嵌的wait_lock(一把qspinlock)把等待者串成 FIFO 队列,保证公平。回到第 1 章的公式“锁 = 原子操作 + 屏障 + 等待策略”:qrwlock的原子操作是atomic_add/CAS/atomic_or,屏障是 acquire/release,而它的等待策略最精巧之处,就在于用一位等待旗在读者并行与写者公平之间取得平衡。下一章我们转向osq_lock——一把 MCS 变体的乐观自旋队列,它是mutex与rwsem乐观自旋的公共底座。