第九关回答内核里最实际的问题:“互斥”到底有几种形态。答案是三种:忙等的(spinlock,拿不到就原地打转)、睡眠的(mutex,拿不到就让出 CPU),以及第三种“作弊”的——RCU,读端根本不拿锁。前两种的区别不在性能,而在上下文:持有自旋锁时你不能被调度走,拿互斥锁时你可以。
这一关的三个机制支点:qspinlock 的 32 位字(把 MCS 排队锁塞进 4 个字节)、mutex 的乐观自旋 + MCS 等待队列(先赌持有者马上释放,赌输了再睡)、RCU 的宽限期(读端零同步开销,写者等所有 CPU 过一次静默期)。M01 的 task_struct 在这里露面——mutex 的 owner 字段存的就是它;M03 的 slab 藏在 qnode、mutex_waiter 背后;M07 会给 mutex 的睡眠算一笔上下文切换的账。
现代内核的自旋锁是 qspinlock(kernel/locking/qspinlock.c),注释开宗明义:“This queued spinlock implementation is based on the MCS lock”——经典的 MCS 排队锁。但传统 MCS 需要 8 字节 tail 指针 + 每个节点 8 字节 next,而 spinlock_t 的 ABI 是 4 字节。做法是把 {tail, next->locked} 压缩进一个 u32(include/asm-generic/qspinlock_types.h):
locked 字节(真正的锁位,只用 1 位,扩成一字节是为了原子字节写的性能)pending 字节(第二个竞争者的“我先占个位”)加锁分三档。无竞争 (0,0,0):一次原子操作置 locked,即 (0,0,1),这就是 fast path。第二个竞争者到来:先置 pending 位,然后自旋在锁字节上等持有者释放——(0,1,1) → (0,1,0) → (0,0,1)。只有第三个及以后的竞争者才进 MCS 队列:从 per-CPU 的 qnodes 数组取一个节点(4 个节点对应 task/softirq/hardirq/nmi 四层嵌套上下文,注释写得明明白白),xchg_tail 把自己挂到队尾,前驱的 next 指向自己,然后自旋在自己节点的 locked 上。
这是整个设计的精髓:等待者从不触碰锁的缓存行。传统 test-and-set 自旋锁里,N 个 CPU 自旋在同一个字上,每次释放都是一次缓存行广播风暴;排队之后,每个等待者只读自己的 per-CPU 节点,锁的缓存行只在“交接”那一刻传递一次。解锁时:如果队首是唯一的排队者,用 cmpxchg 把 tail 清零、锁位清零;否则只置锁位、唤醒 next 节点。
mutex_lock()(kernel/locking/mutex.c)的第一行就是全部哲学:
void __sched mutex_lock(struct mutex *lock)
{
might_sleep();
if (!__mutex_trylock_fast(lock))
__mutex_lock_slowpath(lock);
}
__mutex_trylock_fast 是一次 atomic_long_try_cmpxchg_acquire(&lock->owner, 0, current)——把 owner 从 0 换成自己。owner 字段里存的是 task_struct 指针 | 标志位(M01 的老熟人):MUTEX_FLAG_WAITERS(有人在等)、MUTEX_FLAG_HANDOFF / MUTEX_FLAG_PICKUP(直接交接)。
慢路径的第一步不是睡眠,而是乐观自旋(mutex_spin_on_owner)。注释写了 rationale:“if the lock owner is running, it is likely to release the lock soon”——如果持有者正在另一个 CPU 上跑,它大概率马上释放,自旋几十条指令远比两次上下文切换便宜。但有两个熔断条件(mutex_can_spin_on_owner):持有者不在 CPU 上(被抢占了,自旋纯属烧电,用 owner_on_cpu / vcpu_is_preempted 检测),或者自己需要被调度(need_resched)。自旋者之间用 MCS 排队(注释原话:“The mutex spinners are queued up using MCS lock”),经 osq(optimistic spin queue)实现,保证只有一个自旋者在和持有者竞速。
自旋也抢不到,才真正睡眠:__mutex_add_waiter 把自己挂到等待链表(队首指针 first_waiter),置 WAITERS 位,schedule()。解锁时唤醒队首;还有一招更狠的 __mutex_handoff:解锁者直接把锁交接给指定的等待者(置 PICKUP),跳过竞速,防饥饿。两条铁律写在 mutex_lock 的文档注释里:might_sleep()——中断上下文不许拿 mutex;递归加锁不允许。
新手常问“哪个更快”,这是问错了问题。选型只看两条:
spinlock 忙等——如果持有者被调度走,等待者在空转烧 CPU,所以自旋锁临界区里禁止调度、禁止睡眠,临界区必须短。中断处理函数里只能用自旋锁(不能睡)。mutex 睡眠——持有者被调度走天经地义,临界区可长,但拿锁路径上有 might_sleep(),中断上下文碰不得。一句话:临界区短 + 不能睡 → spinlock;临界区长 + 能睡 → mutex。内核里大量的“先自旋后睡眠”混合策略(mutex 的乐观自旋就是),本质是在赌“持有者是不是马上就完事”。
RCU(Read-Copy Update)的读者长这样(include/linux/rcupdate.h:1435):
static __always_inline void rcu_read_lock(void)
{
__rcu_read_lock();
__acquire_shared(RCU);
rcu_lock_acquire(&rcu_lock_map);
}
非抢占内核下 __rcu_read_lock() 就是 preempt_disable();可抢占内核下是 current->rcu_read_lock_nesting++。注意共同点:没有原子操作,没有共享缓存行写入——计数器是 per-task 的。读端临界区的全部成本,就是禁止抢占期间“不被换出去”。这就是 deck 里“读者零开销”的底气:对比自旋锁/互斥锁每次加锁的原子 cmpxchg,RCU 读端的扩展性是另一个量级。
作弊的代价全在写者。写者不原地改,而是复制一份、改副本、一次指针发布切换新版本。旧版本不能立即释放——可能还有读者正读着它。两种等法(include/linux/rcupdate.h:62 起):call_rcu(head, func) 把释放回调排队,宽限期结束后异步执行,写者不等;synchronize_rcu()(定义在 kernel/rcu/tree.c:4879)同步等一个宽限期(grace period)。
宽限期的定义是 RCU 全机制最漂亮的一句:所有 CPU 都经过一次静默期(quiescent state)。静默期指“不可能再持有旧版本引用的状态”:上下文切换、进入用户态、CPU idle 都是(kernel/rcu/tree.c 里 rcu_softirq_qs、“scheduler is a quiescent state”的注释即是此意)。一旦每个 CPU 都静默过一次,旧版本上就不可能还有读者——释放安全。这就是“写者等一个静默期”的全部含义。
所以 RCU 是代价转移:把“每次读的同步成本”搬到“每次写的等待成本”+“旧版本内存翻倍”。读多写少(路由表、dentry 缓存的读路径)是稳赚;写频繁时宽限期追不上更新速度,旧版本堆积成山——那就别用 RCU。
qspinlock.c 里藏着一段注释,把锁和内存屏障的关系说透了:pending 等待循环“must be a load-acquire such that we match the store-release that clears the locked bit and create lock sequentiality”——加锁是 acquire,解锁是 release,两者配对才构成“锁的顺序性”。源码里 smp_cond_load_acquire、smp_wmb、smp_rmb 嵌在加锁/解锁路径各处:锁从来不只是“互斥”,它是“互斥 + 内存屏障”的打包。屏障的完整语义(acquire/release 到底保证什么、编译器和 CPU 各重排什么)是 M18 的主场,这里只记结论:能正确用锁,就已经用对了 90% 的屏障场景。
内核有个运行时锁正确性检查器 lockdep,原理简单粗暴:给每把锁注册一个 lock_class(mutex_init → lockdep_init_map_wait,mutex.c:268),运行时记录“谁在拿着 A 的时候拿了 B”,一旦加锁顺序成环,立刻 splat 报错——死锁不必真的死一次,画出环就够了。RCU 也有自己的 lockdep map(rcu_lock_map,RCU_LOCKDEP_WARN):没进 rcu_read_lock() 就解引用 __rcu 指针,调试内核会当场抓现行。代价是运行时开销,所以只在 CONFIG_DEBUG_LOCK_ALLOC / CONFIG_PROVE_RCU 的调试内核里全开。
为什么 qspinlock 要搞 MCS 排队,而不是简单的 test-and-set?
因为缓存行风暴。test-and-set 下 N 个 CPU 自旋在同一个锁字上,每次持有者释放都是一次写广播,所有等待者的缓存行同时失效、同时重读——CPU 越多越慢。排队之后,等待者自旋在自己的 per-CPU 节点上,锁的缓存行只在“持有者→队首”交接时传递一次。从 O(N) 的广播退化成 O(1) 的点对点传递,顺带还拿到了 FIFO 公平性。代价是 4 字节里塞下 tail+pending+locked 的编码杂技——为了兼容 spinlock_t 的旧 ABI,值得。
为什么 mutex 睡眠前要先乐观自旋,而不是直接睡?
因为睡眠贵:一次 schedule() 进、一次唤醒出,是两次上下文切换(M07 会算这笔账,微秒级)。而“持有者正在别的 CPU 上跑、几十纳秒后释放”是高频场景——自旋几十条指令赌赢,省掉两次切换;赌输(持有者被抢占/临界区长)才付睡眠的钱。mutex_can_spin_on_owner 的 owner_on_cpu 检查就是止损线:持有者都不在 CPU 上了,自旋每一圈都是纯浪费。
为什么 RCU 不给读者加个轻量锁(比如 per-CPU 计数),而要写者等宽限期?
因为读端的任何共享状态都是扩展瓶颈。per-CPU 计数看似无竞争,但写者要“等所有读者退完”就得扫所有 CPU 的计数器——CPU 越多,写的代价越大,读多写少的场景直接反杀。RCU 的宽限期把“等读者”翻译成“等静默期”:写者不跟踪任何读者,只等一个全局可观测的事件(所有 CPU 调度过一次)。协调成本从 O(读者数) 降到 O(1) 个等待,代价是写延迟和旧版本内存——读多写少时,这买卖稳赚。
mutex 的 owner 字段存的就是 task_struct 指针(| flags)——“谁拿着锁”在内核里就是“哪个 task”。M01 的 D 状态从锁视角看更清楚:D 状态进程卡在临界区里,如果临界区拿的是自旋锁,调度和睡眠都是非法的——这就是 D 状态“连 SIGKILL 都杀不掉”的锁解释。qnode(per-CPU 排队节点)、mutex_waiter、rcu_head 都是高频小对象,活在 slab 里——锁的扩展性一半在算法,一半在“节点分配不成为新的瓶颈”。mutex 睡眠 = 两次上下文切换,M07 会量化这笔开销——到时候回看 9.2 的乐观自旋,就知道它在省什么钱。io_uring 的提交完成队列、ext4 的 journal,都是“高并发 + 正确性”的锁试验场,本关的选型框架到时候直接套用。出题原则:10 题中记忆性题目不超过 3 道,其余为应用/推理/权衡类——场景预测、设计 trade-off 比较、从代码路径找问题、解释“为什么这样设计而不是那样”。难度递进,最后两道需要想一会儿。
三个字段:bit 0–7 locked 字节(真正的锁位,只用 1 位,扩成一字节是为了原子字节写性能)、bit 8–15 pending 字节(第二个竞争者的占位)、bit 16–31 tail(其中 16–17 是 per-CPU 节点索引,18–31 是 CPU 号 +1)。出处 include/asm-generic/qspinlock_types.h 的位域注释。记住这个布局,后面所有状态变迁 (tail, pending, locked) 都是在这 32 位里跳。
省掉了 MCS 队列的搭建成本:取 per-CPU 节点、xchg_tail 发布、prev->next 链接、前驱唤醒——一整套流程。两竞争者的场景极高频(锁刚被拿走、马上回来),pending 让第二个竞争者走轻量路径:(0,1,1) 自旋等 locked 清零 → (0,1,0) → (0,0,1) 直接拿锁。只有第三个及以后的竞争者才证明“这是真排队”,值得付 MCS 的搭建费。pending 是“大概率只有两个人抢”这个现实的编码。
owner = task 指针 | 标志位(指针低几位本来就对齐为 0,拿来塞 flag)。标志有 WAITERS(等待链表非空)、HANDOFF / PICKUP(直接交接)。__mutex_handoff:解锁者不把锁释放回“无主”再让大家竞速,而是直接把 owner 写成指定的等待者(置 PICKUP)——跳过竞速窗口,保证队首一定能拿到,防饥饿。代价是交接期间锁看起来“一直有人拿着”,少了一次释放-获取的屏障配对由 handoff 路径自己保证。
mutex_lock() 不行:第一行就是 might_sleep(),慢路径会 schedule()——中断上下文不许调度,睡了就崩。spin_lock() 可以,但通常要用 spin_lock_irqsave():自旋锁忙等不调度,符合中断上下文;但必须关本地中断,否则同 CPU 的中断处理函数再来拿同一把锁就自死锁了。判据永远是那句:“这条路径上能不能睡?”
熔断条件(mutex_can_spin_on_owner):(1) 持有者不在 CPU 上(owner_on_cpu() 为假,含被抢占和 vCPU 被宿主机抢占);(2) 自己需要被调度(need_resched())。持有者被抢占时继续自旋 = 纯烧 CPU 等一个不知道什么时候回来的 task——持有者都没在跑,释放遥遥无期,每一圈自旋都是浪费,还可能饿死真正能推进的人。这就是“乐观”的边界:只赌“在跑的人马上完事”,不赌“被抢占的人”。
等待者们各自自旋在自己的 per-CPU qnode->locked 上,不碰锁字。只有队首在等锁字节(pending 路径)或等前驱唤醒。对比 test-and-set:200 个 CPU 自旋在同一个锁字上,持有者每次释放都是一次写广播——200 个缓存行同时失效、同时重读,流量 O(N),核越多越慢。qspinlock 把等待变成了点对点交接:锁的缓存行只在“持有者→队首”那一刻传递一次,其余时间每个 CPU 只读自己节点的缓存行(O(1) 流量)。这就是 9.1 “可扩展性”的量化含义。
执行的是 preempt_disable()(+ lockdep 的 map acquire,生产内核无影响)——本质是 per-task 抢占计数器 +1。说“零开销”指三零:零原子操作、零共享缓存行写入、零内存屏障(对比 spinlock 的 cmpxchg)。它“做”了唯一一件事:保证自己不被抢占/调度走,从而不可能进入静默期——写者等的宽限期因此能正确覆盖自己。不是什么都没做,是把同步成本从“共享状态”搬到了“per-task 状态”。
不能。发布瞬间,可能有读者正走在旧版本上(读端不拿锁,写者看不见他们)。必须等一个宽限期:所有 CPU 都经过一次静默期(上下文切换 / 进入用户态 / idle)。可观测的定义:宽限期结束 ⇔ 每个 CPU 都发生过至少一次“不可能再持有旧引用”的事件。等法二选一:synchronize_rcu() 同步等(写者阻塞);call_rcu(head, func) 把 kfree 逻辑包成回调排队,宽限期后异步执行,写者不等。提前释放 = 读者读到释放后的内存 = use-after-free。
会崩:宽限期追不上更新速度。每次写都要复制整个结构 + 等一个 GP,1:1 的读写比下写者永远在等 GP、旧版本排队释放,内存翻倍再翻倍,更新延迟被 GP(毫秒级)钉死。RCU 的代价转移到了 (1) 写延迟(等宽限期,synchronize_rcu 是毫秒级等待 vs 读端的纳秒级)和 (2) 内存(新旧版本并存期翻倍)。判据:读的频率 × 读的同步成本节省 > 写的频率 × (GP 等待 + 复制成本) 时才用 RCU。写多读少请回退到读写锁/自旋锁。
选 atomic_t + spin_lock_irqsave()(或纯 atomic_inc)。死因逐个:mutex——中断上下文 might_sleep(),直接出局;RCU——计数器是原地递增,没有“旧版本”可言,RCU 的复制-发布模式不适用(且读端要的是精确值不是快照);裸 spin_lock()——同 CPU 的中断处理函数可能在持锁期间打断进程上下文并再次加锁 → 自死锁,必须 irqsave 关本地中断。纯 atomic_inc() 其实就够了(单字递增不需要临界区),spinlock 版本是“还要顺手保护旁边的数据”时的形态。本题的考点:中断上下文先杀 mutex,单计数器场景原子操作比任何锁都便宜。
queued_spin_lock_slowpath,MCS 排队实现本体(已核实存在,652 行)__mutex_trylock_fast / __mutex_lock_slowpath,乐观自旋(已核实存在,1664 行)synchronize_rcu(约 4879 行处),宽限期检测(已核实存在,7075 行)rcu_read_lock / call_rcu 接口与注释(已核实存在,2029 行)qspinlock 的 32 位字布局(NR_CPUS < 16K 时,include/asm-generic/qspinlock_types.h,已核实):
bit: 31 18 17 16 15 8 7 0
+----------------+----- +-------------+-----------+
| tail cpu (+1) | idx | pending | locked |
+----------------+------+-------------+-----------+
14 bits 2 bits 8 bits 8 bits
状态机:(0,0,0) --trylock--> (0,0,1) 拿锁
(0,0,1) --第2人--> (0,1,1) pending,自旋锁字节
(0,1,1) --释放--> (0,1,0) --> (0,0,1) 队首拿锁
第3人起:xchg_tail 进 MCS 队列,自旋在自己的 per-CPU node 上
mutex 加锁路径(kernel/locking/mutex.c,已核实):
mutex_lock()
├─ fast: cmpxchg(owner: 0 → current) 成功 → 返回
└─ slow: __mutex_lock_slowpath()
├─ 乐观自旋 mutex_spin_on_owner()
│ 熔断:!owner_on_cpu(owner) 或 need_resched() → 停
│ 抢到 → 返回(自旋者经 MCS/osq 排队,只一个竞速)
└─ 睡眠:__mutex_add_waiter 挂 first_waiter 链表
置 MUTEX_FLAG_WAITERS → schedule()
解锁:唤醒队首;或 __mutex_handoff 直接交接 (PICKUP)
RCU 宽限期(kernel/rcu/tree.c + include/linux/rcupdate.h,已核实):
写者:复制 → 改副本 → 一次指针发布(新版本上线)
├─ call_rcu(head, func):回调排队,写者不等
└─ synchronize_rcu():同步等一个 grace period
grace period 结束 ⟺ 所有 CPU 都经过一次静默期
CPU0: 上下文切换 ✓ CPU1: 进入用户态 ✓ CPU2: idle ✓
读端 rcu_read_lock():preempt_disable()(非PREEMPT_RCU)
→ 零原子操作、零共享缓存行写入 —— “不被调度走”即“不静默”
在页面里每题写下你的答案(关键词即可),卡住的题点一下“卡住了”标记,
点“生成作答汇总”后复制、贴到聊天里——对错由我来判定,我逐题批改并据此
调整下一关的深浅;准备好了说“下一关”。
点下面按钮生成汇总文本,复制后贴到「内核二十关」侧聊里,对错由我判定。