第 1 章把地基打好了:原子操作保证一次RMW不可分割,acquire/release 约束临界区不外泄。本章拆解第一把真正的锁------自旋锁。它是内核里最基础、也最常用的互斥原语:拿不到锁的 CPU 不睡眠,而是原地打转(自旋)反复重试,直到锁被释放。正因为不睡眠,它能用在中断处理、持有其它锁等无法调度的上下文里。但"原地打转"这件事在多核上并不简单------朴素的实现会让所有争抢者挤在同一条缓存行上互相拖累。本章的主线,就是看内核如何从最朴素的 test-and-set 一路演进到 qspinlock 的 MCS 队列,把"所有 CPU 争一条缓存行"降为"每个 CPU 自旋在自己的本地节点上"。
说明:以 x86-64 为主线,它默认选用
qspinlock;ticket_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.h:
c
static inline void __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:
c
/* 概念示意,非内核实际代码 */
void tas_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_spinlock(include/asm-generic/ticket_spinlock.h),思路借鉴银行叫号:状态字被切成两个 16 位字段,高 16 位是"下一个发出的号",低 16 位是"当前叫到的号"。
c
static __always_inline void ticket_spin_lock(arch_spinlock_t *lock)
{
u32 val = atomic_fetch_add(1<<16, &lock->val); /* 原子取号:高 16 位 +1 */
u16 ticket = val >> 16; /* 我拿到的号 */
if (ticket == (u16)val) /* 号 == 当前叫号,直接进 */
return;
atomic_cond_read_acquire(&lock->val, ticket == (u16)VAL); /* 否则等叫到我 */
smp_mb();
}
static __always_inline void ticket_spin_unlock(arch_spinlock_t *lock)
{
u16 *ptr = (u16 *)lock + IS_ENABLED(CONFIG_CPU_BIG_ENDIAN);
u32 val = atomic_read(&lock->val);
smp_store_release(ptr, (u16)val + 1); /* 叫下一个号 */
}
atomic_fetch_add(1<<16, ...) 用一次原子操作领到一个唯一递增的号,谁先到谁号小。解锁只是把"当前叫号"加一,等待者中号最小的那个自然被放行。这就得到了严格的 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.h):
c
typedef struct qspinlock {
union {
atomic_t val;
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--7 | locked |
锁是否被持有(_Q_LOCKED_VAL = 1) |
| 8--15 | pending |
有一个"候补者"在等(_Q_PENDING_VAL = 1<<8) |
| 16--17 | tail index | 队尾节点的上下文索引(0--3) |
| 18--31 | tail cpu | 队尾节点所在 CPU 号(+1) |
三个层级对应三种争用强度:locked 表示"有人持锁",pending 表示"有且仅有一个候补者",tail 则编码一条 MCS 等待队列的尾部。设计的精妙在于------争用不激烈时根本不碰队列 :第一个争抢者只需点亮 pending 位排一个"单人候补",完全不必付出构造 MCS 节点的代价。只有当候补位也被占、出现第二个及以上等待者时,才真正拉起 MCS 队列。这是一条为"低争用是常态"优化的快慢分层设计。
2.6 三级快速路径:uncontended → pending → queue
qspinlock 的加锁入口极短(include/asm-generic/qspinlock.h):
c
static __always_inline void queued_spin_lock(struct qspinlock *lock)
{
int val = 0;
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 位,成为唯一候补,然后在锁字上等持锁者释放:
c
/* slowpath 节选 */
if (val & ~_Q_LOCKED_MASK) /* 已经有 pending 或 tail:直接排队 */
goto queue;
val = queued_fetch_set_pending_acquire(lock); /* 0,0,* -> 0,1,* 抢候补位 */
if (unlikely(val & ~_Q_LOCKED_MASK)) { /* 抢的瞬间被人插队 */
if (!(val & _Q_PENDING_MASK))
clear_pending(lock); /* 撤销候补,改去排队 */
goto queue;
}
/* 候补成功,等 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.h):
c
struct mcs_spinlock {
struct mcs_spinlock *next; /* 指向队列里的后继 */
int locked; /* 1 表示轮到我了 */
int count; /* 嵌套计数,见 qspinlock.c */
};
这些节点不在堆上分配,而是每 CPU 静态预留(kernel/locking/qspinlock.c):
c
static DEFINE_PER_CPU_ALIGNED(struct qnode, qnodes[_Q_MAX_NODES]);
每 CPU 恰好 4 个节点,对应四种可能嵌套持自旋锁的上下文:任务、软中断、硬中断、NMI 。它们正好塞进一条 64 字节缓存行。这也解释了 2.5 里 tail 为何编码为"CPU 号 + 上下文索引"------凭这两者就能在全局唯一定位到某个节点。
排队的核心逻辑:
c
queue:
node = this_cpu_ptr(&qnodes[0].mcs);
idx = node->count++; /* 本上下文的嵌套层级 */
tail = encode_tail(smp_processor_id(), idx); /* 编码成 tail 字段 */
...
node->locked = 0;
node->next = NULL;
...
old = xchg_tail(lock, tail); /* 原子地把自己设为新队尾,取回旧队尾 */
if (old & _Q_TAIL_MASK) { /* 队里已经有人 */
prev = decode_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):
c
#define arch_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.c):
c
val = atomic_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))
goto release; /* 清尾 + 拿锁,一步到位 */
}
set_locked(lock); /* 否则只点亮锁位,把队尾留给后人 */
这样设计(队头改回自旋在锁字)是为了兼容原有 API:解锁方无需知道 MCS 节点的存在,只管把锁字的 locked 清零即可,unlock 路径因此保持简单。
2.8 解锁与交棒:一次 release 写
有了前面的铺垫,解锁反而是全章最简单的一步(include/asm-generic/qspinlock.h):
c
static __always_inline void queued_spin_release(struct qspinlock *lock)
{
smp_store_release(&lock->locked, 0); /* 只把锁字节写 0,release 语义 */
}
它就是第 1 章 1.6 节的 smp_store_release:一次带 release 语义的普通写,把 locked 字节清零。release 保证临界区里的所有写在锁被放开前都已对他人可见。在强序的 x86 上,这甚至不生成任何屏障指令,是一条普通 mov------第 1 章反复强调的"x86 上解锁路径极廉价",在这里得到印证。
那队列里的后继怎么被唤醒?交棒发生在慢路径的队头逻辑里 ,而非解锁函数中。队头 CPU 拿到锁、准备进临界区前,会把后继节点的 locked 置 1:
c
if (!next)
next = smp_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_LOCKING(lockdep)能在运行期自动发现锁序颠倒、中断上下文误用、以及自旋锁里睡眠等问题------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,它如何在读者与写者之间做权衡、又如何避免写者饥饿。