ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

第 2 章 spinlock 与 qspinlock:从测试-设置到 MCS 队列

第 2 章 spinlock 与 qspinlock:从测试-设置到 MCS 队列 第 1 章把地基打好了原子操作保证一次RMW不可分割acquire/release 约束临界区不外泄。本章拆解第一把真正的锁——自旋锁。它是内核里最基础、也最常用的互斥原语拿不到锁的 CPU 不睡眠而是原地打转自旋反复重试直到锁被释放。正因为不睡眠它能用在中断处理、持有其它锁等无法调度的上下文里。但“原地打转”这件事在多核上并不简单——朴素的实现会让所有争抢者挤在同一条缓存行上互相拖累。本章的主线就是看内核如何从最朴素的test-and-set一路演进到qspinlock的 MCS 队列把“所有 CPU 争一条缓存行”降为“每个 CPU 自旋在自己的本地节点上”。说明以 x86-64 为主线它默认选用qspinlockticket_spinlock作为“更简单但仍有缓存争用”的对照版本一并剖析。2.1 spinlock 守护什么不睡眠的短临界区自旋锁的定位可以用一句话概括保护极短、且可能运行在不可睡眠上下文中的临界区。它与睡眠锁第 5、6 章的mutex/rwsem的根本分野在于争锁失败时的行为——睡眠锁让出 CPU 去调度别的任务自旋锁则把 CPU 钉在原地空转。这个选择带来两条硬约束临界区必须短。自旋期间 CPU 什么正事都不干纯粹烧时钟周期。临界区越长浪费的算力越多。因此自旋锁保护的应当是几十条指令级别的操作绝不能在里面等 I/O、等 DMA、或调用任何可能睡眠的函数。持锁期间不能睡眠。一旦持锁者被调度走而接管 CPU 的任务又来抢同一把锁就会永远自旋——持锁者没机会跑回来释放锁形成死锁。所以内核在加锁时会关闭抢占后面 2.2 会看到preempt_disable从机制上保证持锁者不被普通调度抢走。正因为不睡眠自旋锁是中断处理程序里唯一能用的互斥手段——中断上下文本就不允许调度。这也是它和睡眠锁最重要的场景区别。2.2 从spin_lock到arch_spin_lock一把锁的分层日常写的spin_lock()并不是底层实现它是一层层宏与内联函数向下委托的入口。以 SMP 内核为例调用链的核心在include/linux/spinlock_api_smp.hstaticinlinevoid__raw_spin_lock(raw_spinlock_t*lock){preempt_disable();spin_acquire(lock-dep_map,0,0,_RET_IP_);LOCK_CONTENDED(lock,do_raw_spin_trylock,do_raw_spin_lock);}这三行分别对应自旋锁的三件事preempt_disable()——关抢占。这正是 2.1 说的“持锁期间不被调度走”的落点。它保证当前 CPU 在持锁期间不会被普通任务抢占。spin_acquire(lock-dep_map, ...)——lockdep 登记。开了CONFIG_PROVE_LOCKING时把这次加锁记入死锁检测器否则是空操作。LOCK_CONTENDED(...)——真正去拿锁最终落到do_raw_spin_lock再到架构相关的arch_spin_lock。于是spinlock_t→raw_spinlock_t→arch_spinlock_t构成三层最上层是带 lockdep、可被PREEMPT_RT替换的spinlock_t中间raw_spinlock_t是不可被 RT 抢占化的“真自旋”最底层arch_spinlock_t才是架构提供的自旋实现。x86 的arch_spin_lock经arch/x86/include/asm/qspinlock.h映射到queued_spin_lock——也就是本章的主角qspinlock。把这层剥清楚很重要spin_lock的“互斥”来自最底层的arch_spin_lock而“不被调度走”来自preempt_disable。二者缺一不可。后面几节聚焦最底层的arch_spin_lock是怎么实现自旋互斥的。2.3 最朴素的实现test-and-set 与它的病理解qspinlock的最好方式是先看它要取代的东西差在哪。最朴素的自旋锁只有一个状态字节0 表示空闲1 表示占用。加锁就是不停地用第 1 章的 CAS 把 0 换成 1/* 概念示意非内核实际代码 */voidtas_lock(atomic_t*lock){while(atomic_cmpxchg_acquire(lock,0,1)!0)cpu_relax();/* 抢不到就空转重试 */}它能保证互斥但在多核上有两个致命缺陷缓存行颠簸cache-line bouncing。每个等待者都在对同一个lock反复执行带LOCK前缀的 CAS 写。第 1 章讲过LOCK前缀要独占缓存行——于是这一条缓存行在所有争抢 CPU 之间被反复抢来抢去每次 CAS 都触发一轮缓存一致性流量。争抢的 CPU 越多总线越拥堵吞吐不升反降。这正是第 1 章 1.8 节手写那把最小自旋锁时预告的问题。不公平。谁的 CAS 恰好赢下缓存行谁就拿锁与排队先后无关。极端情况下某个 CPU 可能长期抢不到产生饥饿。这两个缺陷——缓存争用与不公平——就是后续所有改进的靶子。2.4 ticket lock先把公平解决掉内核的通用简单实现是ticket_spinlockinclude/asm-generic/ticket_spinlock.h思路借鉴银行叫号状态字被切成两个 16 位字段高 16 位是“下一个发出的号”低 16 位是“当前叫到的号”。static__always_inlinevoidticket_spin_lock(arch_spinlock_t*lock){u32 valatomic_fetch_add(116,lock-val);/* 原子取号高 16 位 1 */u16 ticketval16;/* 我拿到的号 */if(ticket(u16)val)/* 号 当前叫号直接进 */return;atomic_cond_read_acquire(lock-val,ticket(u16)VAL);/* 否则等叫到我 */smp_mb();}static__always_inlinevoidticket_spin_unlock(arch_spinlock_t*lock){u16*ptr(u16*)lockIS_ENABLED(CONFIG_CPU_BIG_ENDIAN);u32 valatomic_read(lock-val);smp_store_release(ptr,(u16)val1);/* 叫下一个号 */}atomic_fetch_add(116, ...)用一次原子操作领到一个唯一递增的号谁先到谁号小。解锁只是把“当前叫号”加一等待者中号最小的那个自然被放行。这就得到了严格的FIFO 公平彻底解决了 2.3 的饥饿问题最多支持2 16 2^{16}216个 CPU。但 ticket lock 只治好了公平没治好缓存争用所有等待者仍然盯着同一个lock-val自旋atomic_cond_read_acquire就是在这个共享字上反复读。虽然自旋读比 CAS 写温和些多个读者可以共享缓存行的 Shared 态可一旦持锁者解锁写入新叫号这条缓存行就在所有等待 CPU 上失效、需要重新拉取——争抢者越多一次解锁引发的缓存失效风暴越大。要根治必须让每个等待者盯着各自不同的内存去自旋。这正是 MCS 队列与qspinlock登场的理由。2.5 qspinlock 的四字节状态编码qspinlock的全部状态压在一个 32 位原子字里。看它的类型定义include/asm-generic/qspinlock_types.htypedefstructqspinlock{union{atomic_tval;struct{u8 locked;/* 0- 7 位锁字节 */u8 pending;/* 8-15 位pending 位 */};struct{u16 locked_pending;/* locked pending 合起来 */u16 tail;/* 16-31 位队列尾 */};};}arch_spinlock_t;这个union是理解qspinlock的钥匙——同一个 32 位字既能作为整体val做原子 CAS又能按字节/半字单独访问其中一段。位域布局NR_CPUS 16K时为位段字段含义0–7locked锁是否被持有_Q_LOCKED_VAL 18–15pending有一个“候补者”在等_Q_PENDING_VAL 1816–17tail index队尾节点的上下文索引0–318–31tail cpu队尾节点所在 CPU 号1三个层级对应三种争用强度locked表示“有人持锁”pending表示“有且仅有一个候补者”tail则编码一条 MCS 等待队列的尾部。设计的精妙在于——争用不激烈时根本不碰队列第一个争抢者只需点亮pending位排一个“单人候补”完全不必付出构造 MCS 节点的代价。只有当候补位也被占、出现第二个及以上等待者时才真正拉起 MCS 队列。这是一条为“低争用是常态”优化的快慢分层设计。2.6 三级快速路径uncontended → pending → queueqspinlock的加锁入口极短include/asm-generic/qspinlock.hstatic__always_inlinevoidqueued_spin_lock(structqspinlock*lock){intval0;if(likely(atomic_try_cmpxchg_acquire(lock-val,val,_Q_LOCKED_VAL)))return;/* 无争用0 - locked拿锁走人 */queued_spin_lock_slowpath(lock,val);/* 有争用进慢路径 */}第一级无争用整个字是 0一次atomic_try_cmpxchg_acquire第 1 章 1.4 的 CAS 1.6 的 acquire把locked置 1 就完事。这是绝大多数加锁走的路径开销与朴素实现的一次 CAS 相同。注意这里acquire语义保证了临界区的访问不会被重排到加锁之前。CAS 失败才进queued_spin_lock_slowpath。慢路径用一个三元组(tail, pending, locked)描述状态机源码里的状态图非常传神uncontended (0,0,0) -:-- (0,0,1) ------------------------------:-- (*,*,0) pending : (0,1,1) -- (0,1,0) uncontended : (n,x,y) -- (n,0,0) queue : (*,x,y) -- (*,0,0) --- (*,0,1)第二级pending 候补如果此刻只是“有人持锁但还没有候补者”当前 CPU 就用queued_fetch_set_pending_acquire点亮pending位成为唯一候补然后在锁字上等持锁者释放/* slowpath 节选 */if(val~_Q_LOCKED_MASK)/* 已经有 pending 或 tail直接排队 */gotoqueue;valqueued_fetch_set_pending_acquire(lock);/* 0,0,* - 0,1,* 抢候补位 */if(unlikely(val~_Q_LOCKED_MASK)){/* 抢的瞬间被人插队 */if(!(val_Q_PENDING_MASK))clear_pending(lock);/* 撤销候补改去排队 */gotoqueue;}/* 候补成功等 locked 清零后接手 */if(val_Q_LOCKED_MASK)atomic_cond_read_acquire(lock-val,!(VAL_Q_LOCKED_MASK));clear_pending_set_locked(lock);/* 0,1,0 - 0,0,1清候补位、点亮锁位 */关键在于pending 这一级只需一个候补者在锁字上自旋还没有触及队列。这样“一个持锁者 一个候补者”这种轻度争用相当常见就被高效处理掉无需构造任何 MCS 节点。第三级MCS 队列只有当候补位也被占即已经有第二个等待者时才goto queue进入真正的排队。下一节详解。2.7 MCS 队列让每个等待者自旋在自己的节点上MCS 锁Mellor-Crummey Scott的核心思想只有一句每个等待者持有一个自己的节点只自旋在本节点的一个标志上前驱释放时仅写这一个节点的标志把它唤醒。节点结构极简include/asm-generic/mcs_spinlock.hstructmcs_spinlock{structmcs_spinlock*next;/* 指向队列里的后继 */intlocked;/* 1 表示轮到我了 */intcount;/* 嵌套计数见 qspinlock.c */};这些节点不在堆上分配而是每 CPU 静态预留kernel/locking/qspinlock.cstaticDEFINE_PER_CPU_ALIGNED(structqnode,qnodes[_Q_MAX_NODES]);每 CPU 恰好 4 个节点对应四种可能嵌套持自旋锁的上下文任务、软中断、硬中断、NMI。它们正好塞进一条 64 字节缓存行。这也解释了 2.5 里tail为何编码为“CPU 号 上下文索引”——凭这两者就能在全局唯一定位到某个节点。排队的核心逻辑queue:nodethis_cpu_ptr(qnodes[0].mcs);idxnode-count;/* 本上下文的嵌套层级 */tailencode_tail(smp_processor_id(),idx);/* 编码成 tail 字段 */...node-locked0;node-nextNULL;...oldxchg_tail(lock,tail);/* 原子地把自己设为新队尾取回旧队尾 */if(old_Q_TAIL_MASK){/* 队里已经有人 */prevdecode_tail(old,qnodes);WRITE_ONCE(prev-next,node);/* 把自己挂到前驱后面 */arch_mcs_spin_lock_contended(node-locked);/* 只自旋在自己的 node-locked */...}xchg_tail用一次原子交换把自己接到队尾是排队动作的原子核心。挂好之后等待者调用arch_mcs_spin_lock_contended——它就是在本地的node-locked上自旋kernel/locking/mcs_spinlock.h#definearch_mcs_spin_lock_contended(l)smp_cond_load_acquire(l,VAL)这一步是整章的胜负手每个等待者盯着各自节点里的locked自旋而不是共享的锁字。持锁者交棒时只写后继那一个节点其余等待者的缓存行纹丝不动——2.3、2.4 的缓存行颠簸被彻底消除。等待者规模从 O(N) CPU 争一条缓存行降为每次交棒只触碰一条缓存行。队头等待者的处理略有不同它不再自旋在 MCS 节点而是回到锁字上等locked与pending都清零然后接手kernel/locking/qspinlock.cvalatomic_cond_read_acquire(lock-val,!(VAL_Q_LOCKED_PENDING_MASK));locked:if((val_Q_TAIL_MASK)tail){/* 队里只有我 */if(atomic_try_cmpxchg_relaxed(lock-val,val,_Q_LOCKED_VAL))gotorelease;/* 清尾 拿锁一步到位 */}set_locked(lock);/* 否则只点亮锁位把队尾留给后人 */这样设计队头改回自旋在锁字是为了兼容原有 API解锁方无需知道 MCS 节点的存在只管把锁字的locked清零即可unlock路径因此保持简单。2.8 解锁与交棒一次 release 写有了前面的铺垫解锁反而是全章最简单的一步include/asm-generic/qspinlock.hstatic__always_inlinevoidqueued_spin_release(structqspinlock*lock){smp_store_release(lock-locked,0);/* 只把锁字节写 0release 语义 */}它就是第 1 章 1.6 节的smp_store_release一次带 release 语义的普通写把locked字节清零。release 保证临界区里的所有写在锁被放开前都已对他人可见。在强序的 x86 上这甚至不生成任何屏障指令是一条普通mov——第 1 章反复强调的“x86 上解锁路径极廉价”在这里得到印证。那队列里的后继怎么被唤醒交棒发生在慢路径的队头逻辑里而非解锁函数中。队头 CPU 拿到锁、准备进临界区前会把后继节点的locked置 1if(!next)nextsmp_cond_load_relaxed(node-next,(VAL));/* 等后继挂上来 */arch_mcs_spin_unlock_contended(next-locked);/* 唤醒后继 */其中arch_mcs_spin_unlock_contended定义为smp_store_release((l), 1)——又是一次 release 写把后继正自旋等待的node-locked置 1。后继的smp_cond_load_acquire立刻读到 1、结束自旋完成一次干净的交棒。整条队列就这样一个接一个地传递锁既公平FIFO又无缓存争用。2.9 误用与调试自旋锁最常见的坑几乎都源于违反 2.1 的两条约束在持锁期间睡眠。临界区内调用kmalloc(GFP_KERNEL)、mutex_lock、copy_from_user等可能睡眠的函数会触发scheduling while atomic或直接死锁。持自旋锁时只能用GFP_ATOMIC等不睡眠的接口。临界区过长。自旋锁保护的代码若跑得久其它 CPU 就长时间空转烧算力。临界区长、或可能阻塞时应改用mutex第 5 章。中断上下文与进程上下文抢同一把锁却没关中断。进程上下文持锁期间被中断打断中断处理程序又来抢同一把锁——本 CPU 自旋等一个自己持有的锁死锁。凡是会在中断里用到的自旋锁进程上下文侧必须用spin_lock_irqsave关本地中断。锁序不一致导致 AB-BA 死锁。两把锁 A、B一条路径按 A→B 加、另一条按 B→A 加两 CPU 各持一把等对方永久自旋。规则是全内核统一锁序。调试手段CONFIG_PROVE_LOCKINGlockdep能在运行期自动发现锁序颠倒、中断上下文误用、以及自旋锁里睡眠等问题——2.2 看到的spin_acquire正是它的埋点。CONFIG_DEBUG_SPINLOCK会检查重复解锁、未初始化就使用等错误。死锁检测的原理留到工具章展开。本章小结自旋锁守护的是极短、不可睡眠的临界区争锁失败不睡而自旋代价是持锁期间必须关抢占、临界区必须短。它的实现经历了三级演进——朴素test-and-set用一个 CAS 换互斥却带来缓存行颠簸与不公平ticket_spinlock用取号叫号解决了公平但所有等待者仍盯着同一个共享字自旋缓存争用未除qspinlock则用四字节状态编码把争用分成三级无争用一次 CAS 拿锁轻度争用点亮pending单人候补重度争用才拉起 MCS 队列让每个等待者自旋在各自的本地节点上从根上消除缓存行颠簸。解锁只是一次 release 写清零锁字交棒则由队头对后继节点做一次 release 写完成——公平与可扩展在此兼得。回到第 1 章的公式“锁 原子操作 屏障 等待策略”qspinlock的原子操作是 CAS 与xchg_tail屏障是 acquire/release而它最大的创新恰恰在于等待策略——MCS 队列。下一章我们看读多写少场景下的自旋锁变体qrwlock它如何在读者与写者之间做权衡、又如何避免写者饥饿。
返回列表