Linux内核分析之内核同步-01
16.1 自旋锁 —— spinlock_t 与 ticket lock
自旋锁(spinlock)是内核中最基础的锁原语:当锁不可用时,获取者不让出 CPU,而是在原地"自旋"反复检测锁状态,直到持锁者释放。这种策略在临界区极短的场景下远优于睡眠——省去了两次上下文切换的代价,但代价是持锁期间必须禁止抢占,且等待者白白消耗 CPU。Linux 7.0.10 中,自旋锁的实现呈现"四层结构":spin_lock() API 层之下是 x86_64 的排队自旋锁(qspinlock),而经典的票号锁(ticket lock)则保留在 include/asm-generic/ticket_spinlock.h 中作为其他架构的备选实现。本节将结合 Linux 7.0.10 内核源码,逐行分析自旋锁的分层设计、ticket lock 与 qspinlock 两种算法,以及快慢路径的完整流转。
16.1.1 自旋锁的使用规则与 preempt_count
什么时候用自旋锁
自旋锁适用的判据只有一条:临界区足够短,且不能睡眠。典型场景是保护一段纯内存操作的数据结构(链表摘除、计数器更新、状态翻转)。如果临界区内可能睡眠(分配内存、拷贝用户空间数据、获取互斥锁),自旋锁会造成两个问题:一是其他 CPU 的自旋者被无限拉长等待时间;二是本 CPU 在持有自旋锁时禁止了抢占,睡眠会破坏调度器的公平性假设,might_sleep() 检查会直接报错。
持有自旋锁期间内核给出的硬约束,全部通过 preempt_count 计数器实现。include/linux/preempt.h 中定义了这个 32 位字的位域划分:
// include/linux/preempt.h:33-53
* PREEMPT_MASK: 0x000000ff
* SOFTIRQ_MASK: 0x0000ff00
* HARDIRQ_MASK: 0x000f0000
* NMI_MASK: 0x00f00000
* PREEMPT_NEED_RESCHED: 0x80000000
*/
#define PREEMPT_BITS 8
#define SOFTIRQ_BITS 8
#define HARDIRQ_BITS 4
#define NMI_BITS 4
#define PREEMPT_SHIFT 0
#define SOFTIRQ_SHIFT (PREEMPT_SHIFT + PREEMPT_BITS)
#define HARDIRQ_SHIFT (SOFTIRQ_SHIFT + SOFTIRQ_BITS)
#define NMI_SHIFT (HARDIRQ_SHIFT + HARDIRQ_BITS)
preempt_count 从低位到高位分为四段:抢占计数(8 位)、软中断禁用计数(8 位)、硬中断计数(4 位)、NMI 计数(4 位),最高位是 PREEMPT_NEED_RESCHED 标志(由架构相关代码管理,见 13.4 节)。spin_lock() 的第一步 preempt_disable() 就是把 PREEMPT 段加 1:
// include/linux/preempt.h:211-233
#define preempt_disable() \
do { \
preempt_count_inc(); \
barrier(); \
} while (0)
...
#define preempt_enable() \
do { \
barrier(); \
if (unlikely(preempt_count_dec_and_test())) \
__preempt_schedule(); \
} while (0)
preempt_enable() 的嵌套语义(line 228-232):只有当整个计数器减到 0 时才调用 __preempt_schedule() 触发抢占。这正是"嵌套关抢占"的基础——schedule_preempt_disabled()(互斥锁慢路径所用)和软中断退出时的抢占检查都依赖这一语义。barrier() 是编译器屏障(15.2 节),保证计数器操作不会被编译器重排到临界区之外。
自旋锁的变体家族
| API | 关中断 | 禁软中断 | 适用场景 |
|---|---|---|---|
spin_lock() |
否 | 否 | 持锁者与所有竞争者都在进程上下文,且不需要与中断处理共享数据 |
spin_lock_bh() |
否 | 是 | 与软中断(如 tasklet)共享数据,进程上下文一侧 |
spin_lock_irq() |
是 | 否 | 与硬中断共享数据,且确定退出时中断总是开的 |
spin_lock_irqsave() |
是(保存标志) | 否 | 与硬中断共享数据,不确定进入时中断状态(最通用、最安全) |
选择的原则是代价最小够用:spin_lock() 最便宜(只关抢占),spin_lock_irqsave() 最贵(多一次标志读写加关中断指令)。反过来说,如果持锁路径可能被中断打断、而中断处理程序又会获取同一把锁,就必须用 irq 变体,否则会死锁——中断在本 CPU 上抢不到自己持有的锁,而持锁者又无法运行到释放点。
16.1.2 spinlock_t 的四层结构
类型定义:raw_spinlock_t 与 spinlock_t
Linux 7.0.10 把锁类型定义拆分到独立的头文件中。最内层是 raw_spinlock_t:
// include/linux/spinlock_types_raw.h:14-24
context_lock_struct(raw_spinlock) {
arch_spinlock_t raw_lock;
#ifdef CONFIG_DEBUG_SPINLOCK
unsigned int magic, owner_cpu;
void *owner;
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
};
typedef struct raw_spinlock raw_spinlock_t;
核心字段只有一个:arch_spinlock_t raw_lock,即架构相关的底层锁字。x86_64 上它是 struct qspinlock(16.1.4 节)。CONFIG_DEBUG_SPINLOCK 下附加的 magic/owner_cpu/owner 用于调试时识别锁的持有者;CONFIG_DEBUG_LOCK_ALLOC 下的 dep_map 是 lockdep 的登记入口(16.4 节)。
context_lock_struct(name) 是本树新引入的容器定义宏(include/linux/compiler-context-analysis.h:98-117):当 clang 开启线程安全上下文分析时,它展开为带锁属性注解的结构体声明,供编译器做静态锁检查;普通编译下退化为普通的 struct name {...}。本树的各类锁容器(raw_spinlock/spinlock/mutex/seqlock)统一通过它定义,配套的锁 API 上大量出现 __no_context_analysis 注解,把运行时检查交给编译器前端。这是 Linux 内核正在推进的"编译器辅助锁分析"方向,阅读源码时遇到这两个标记知道其作用即可。
外层 spinlock_t 是驱动和子系统实际使用的类型:
// include/linux/spinlock_types.h:16-30
/* Non PREEMPT_RT kernels map spinlock to raw_spinlock */
context_lock_struct(spinlock) {
union {
struct raw_spinlock rlock;
#ifdef CONFIG_DEBUG_LOCK_ALLOC
# define LOCK_PADSIZE (offsetof(struct raw_spinlock, dep_map))
struct {
u8 __padding[LOCK_PADSIZE];
struct lockdep_map dep_map;
};
#endif
};
};
typedef struct spinlock spinlock_t;
union 的巧妙之处(line 18-28):spinlock_t 与 raw_spinlock_t 二进制布局完全一致——启用 lockdep 时,dep_map 通过 __padding 精确对齐到 raw_spinlock.dep_map 的偏移处。这样 spin_lock() 只需取 &lock->rlock 就能直接转发,两种类型之间零转换开销。
在 CONFIG_PREEMPT_RT 实时内核下,这个映射被整体替换:spinlock_t 变成 struct rt_mutex_base lock(spinlock_types.h:51-56),自旋锁变为可睡眠的 rt_mutex,只有 raw_spinlock_t 仍然是真正的自旋锁。这与 13.1 节所述的 RT 口径一致:实时内核中"普通的自旋锁被转换为基于 rt_mutex 的可睡眠锁"。
四层调用链
从 spin_lock() 到底层锁字,本树展开为清晰的四层:
spin_lock(&lock) // include/linux/spinlock.h:339
| 取 &lock->rlock,纯转发
v
raw_spin_lock(&lock->rlock) // include/linux/spinlock.h:218
| debug/lockdep 构建下包装 _raw_spin_lock()
v
_raw_spin_lock() // kernel/locking/spinlock.c:152
| __lockfunc 导出符号,SMP 实现入口
v
__raw_spin_lock(lock) // include/linux/spinlock_api_smp.h:154
preempt_disable()
spin_acquire(&lock->dep_map, ...) // lockdep 登记
LOCK_CONTENDED(lock, trylock, lock) // 真正的锁操作
内层实现:
// include/linux/spinlock_api_smp.h:154-171
static inline void __raw_spin_lock(raw_spinlock_t *lock)
__acquires(lock) __no_context_analysis
{
preempt_disable();
spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);
LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);
}
static inline void __raw_spin_unlock(raw_spinlock_t *lock)
__releases(lock)
{
spin_release(&lock->dep_map, _RET_IP_);
do_raw_spin_unlock(lock);
preempt_enable();
}
操作顺序是"先关抢占,再拿锁"(line 155-158),解锁时对称地"先放锁,再开抢占"。这个顺序不能颠倒:如果先拿锁再关抢占,可能在两步之间被抢占到其他 CPU,锁的持有者就"消失"在别的运行队列上了。spin_acquire()/spin_release() 是 lockdep 的登记点(无 lockdep 构建下为空操作)。
LOCK_CONTENDED 宏(include/linux/lockdep.h:441-448)是 lockstat 的包装:开启 CONFIG_LOCK_STAT 时先 trylock 统计"无争用命中率",失败再进慢路径并统计等待时间;普通构建下退化为直接调用 do_raw_spin_lock(lock),即最终落到 arch_spin_lock()——x86_64 上就是 queued_spin_lock()(16.1.5 节)。
可抢占内核下的自旋循环
在 CONFIG_PREEMPT 内核中,_raw_spin_lock() 用 BUILD_LOCK_OPS 宏生成一个"自旋中保持可抢占"的循环:
// kernel/locking/spinlock.c:67-78
#define BUILD_LOCK_OPS(op, locktype) \
static void __lockfunc __raw_##op##_lock(locktype##_t *lock) \
{ \
for (;;) { \
preempt_disable(); \
if (likely(do_raw_##op##_trylock(lock))) \
break; \
preempt_enable(); \
\
arch_##op##_relax(&lock->raw_lock); \
} \
} \
trylock-repanic 循环(line 69-77):每次先 preempt_disable() 后尝试 trylock;失败立即 preempt_enable() 交还调度权,让本 CPU 上的高优先级任务得以运行,然后再进入下一轮。这样"等待者"本身不会被饿死调度。BUILD_LOCK_OPS(spin, raw_spinlock) 在 spinlock.c:126 实例化,生成的 _raw_spin_lock() 等 noinline 函数放在 .spinlock.text 段(spinlock.h:84 的 __lockfunc 标记),使 spin_lock() 这个 __always_inline 薄封装不必把慢路径代码内联到每个调用点。
16.1.3 ticket lock —— 票号算法
在排队自旋锁(qspinlock)出现之前,x86 内核长期使用票号锁。Linux 7.0.10 中它退居 include/asm-generic/ticket_spinlock.h,供嵌入式等架构选用(本树 x86 不使用,arch/x86/Kconfig:140 已硬性 select ARCH_USE_QUEUED_SPINLOCKS),但其算法仍是理解所有公平自旋锁的起点。
取号与叫号
票号锁把 32 位锁字分成两个 16 位半字:低 16 位是服务号(head,当前正在被服务的票),高 16 位是取号机(tail,下一个发出的票):
// include/asm-generic/ticket_spinlock.h:33-51
static __always_inline void ticket_spin_lock(arch_spinlock_t *lock)
{
u32 val = atomic_fetch_add(1<<16, &lock->val);
u16 ticket = val >> 16;
if (ticket == (u16)val)
return;
/*
* atomic_cond_read_acquire() is RCpc, but rather than defining a
* custom cond_read_rcsc() here we just emit a full fence. ...
*/
atomic_cond_read_acquire(&lock->val, ticket == (u16)VAL);
smp_mb();
}
取号(line 35-36):atomic_fetch_add(1<<16, &lock->val) 一次原子操作完成"读旧值 + tail 加 1",旧值的高半字就是本任务领到的票号 ticket。
快速无争用路径(line 37-38):如果领到的票恰好等于当前服务号(ticket == (u16)val,即锁无人持有),直接返回,整个加锁就是一条 lock xadd 指令。
等待(line 43-45):否则进入 atomic_cond_read_acquire() 自旋,条件是"服务号推进到自己的票号"。这个原语在 x86 上展开为带 pause 指令的自旋读循环(减少流水线冲刷与总线流量),最终读到匹配值时附带 acquire 语义;随后的 smp_mb() 补足注释中所说的全序要求。
trylock 与 unlock 的对应实现(ticket_spinlock.h:53-69):
// include/asm-generic/ticket_spinlock.h:53-69
static __always_inline bool ticket_spin_trylock(arch_spinlock_t *lock)
{
u32 old = atomic_read(&lock->val);
if (old >> 16)
return false;
return atomic_try_cmpxchg(&lock->val, &old, old + (1 << 16));
}
static __always_inline void ticket_spin_unlock(arch_spinlock_t *lock)
{
/*
* unlock() needs release semantics:
*/
u16 val = atomic_add_return_release(1, &lock->val) >> 16;
...
}
trylock 的判据是 old >> 16 == 0——tail 为 0 表示"没有任何人排队",此时一次 cmpxchg 把 tail 加 1 就等于领到了正在被服务的票。unlock 则是服务号 head 加 1(release 语义),等价于柜台"叫下一个号"。
公平性与代价
票号锁的核心价值是严格 FIFO 公平:票号顺序即获取顺序,任何等待者最多等 (N-1) 张票,不存在"刚来的抢走等了很久的"这一不公平现象(x86 早期无队列的 xchg 自旋锁就有此问题)。
代价同样明显:
- 所有等待者自旋同一个缓存行。
lock->val所在的缓存行被 unlock 时的写操作打回失效,所有 CPU 的自旋者同时发起缓存行争用(cache line ping-pong),CPU 数越多风暴越烈。 - 自旋无差别耗电。等待者无法区分"即将轮到我"和"前面还有几十个",只能一律忙等。
- 唤醒风暴。unlock 瞬间 N 个自旋者同时看到服务号变化、同时重试缓存行访问。
qspinlock 的设计目标正是消除这三点:用 per-CPU 的 MCS 队列节点把"自旋在全局锁字上"改为"自旋在自己的缓存行上",并用 pending 位在锁字里"原地消化"第二个竞争者。
16.1.4 qspinlock 的 32 位编码
锁字布局
x86_64 的 arch_spinlock_t 是一个 32 位字(include/asm-generic/qspinlock_types.h:14-44):
// include/asm-generic/qspinlock_types.h:14-44
typedef struct qspinlock {
union {
atomic_t val;
/*
* By using the whole 2nd least significant byte for the
* pending bit, we can allow better optimization of the lock
* acquisition for the pending bit holder.
*/
#ifdef __LITTLE_ENDIAN
struct {
u8 locked;
u8 pending;
};
struct {
u16 locked_pending;
u16 tail;
};
#else
...
#endif
};
} arch_spinlock_t;
位域划分由 qspinlock_types.h:67-93 的宏推导:
// include/asm-generic/qspinlock_types.h:67-93
#define _Q_SET_MASK(type) (((1U << _Q_ ## type ## _BITS) - 1)\
<< _Q_ ## type ## _OFFSET)
#define _Q_LOCKED_OFFSET 0
#define _Q_LOCKED_BITS 8
#define _Q_PENDING_OFFSET (_Q_LOCKED_OFFSET + _Q_LOCKED_BITS)
#if CONFIG_NR_CPUS < (1U << 14)
#define _Q_PENDING_BITS 8
#else
#define _Q_PENDING_BITS 1
#endif
#define _Q_TAIL_IDX_OFFSET (_Q_PENDING_OFFSET + _Q_PENDING_BITS)
#define _Q_TAIL_IDX_BITS 2
#define _Q_TAIL_CPU_OFFSET (_Q_TAIL_IDX_OFFSET + _Q_TAIL_IDX_BITS)
#define _Q_TAIL_CPU_BITS (32 - _Q_TAIL_CPU_OFFSET)
#define _Q_TAIL_OFFSET _Q_TAIL_IDX_OFFSET
#define _Q_LOCKED_VAL (1U << _Q_LOCKED_OFFSET)
#define _Q_PENDING_VAL (1U << _Q_PENDING_OFFSET)
| 字段 | 位范围 | 含义 |
|---|---|---|
locked(bit 0-7) |
_Q_LOCKED_VAL = 1 |
锁被持有(0=空闲,非 0=持有) |
pending(bit 8) |
_Q_PENDING_VAL = 0x100 |
已有一个"等待中的第二竞争者"在锁字上自旋 |
tail idx(2 位) |
bit 16-17 | 队列节点在该 CPU 的嵌套层级编号 |
tail cpu+1(14 位) |
bit 18-31 | 队尾节点所在的 CPU 编号加 1 |
三个编码细节值得注意:
pending独占整个第二字节(本树CONFIG_NR_CPUS < 16384时 8 位),注释说明这是为了让 pending 持有者的优化不受 locked 字节读写的影响(line 18-21)。- CPU 编号统一 +1 编码:
tail = 0才能表示"队列为空",这与 OSQ 的encode_cpu()(16.2.4 节)同一个套路。 - 若系统支持 16384 个以上 CPU,pending 缩为 1 位给 tail 腾位;slowpath 入口处的
BUILD_BUG_ON(CONFIG_NR_CPUS >= (1U << _Q_TAIL_CPU_BITS))(qspinlock.c:136)在编译期把关 tail 字段的容量。
locked_pending 与 tail 两个 16 位视图(line 26-28)服务于"一次性读写低半字/高半字"的优化路径。
16.1.5 queued_spin_lock —— 快路径与三态状态机
快路径:一次 cmpxchg
// include/asm-generic/qspinlock.h:107-129
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;
queued_spin_lock_slowpath(lock, val);
}
...
static __always_inline void queued_spin_unlock(struct qspinlock *lock)
{
/*
* unlock() needs release semantics:
*/
smp_store_release(&lock->locked, 0);
}
无争用加锁只是一条 lock cmpxchg(line 108-112):把锁字从 0 换成 _Q_LOCKED_VAL,成功即获得锁,acquire 语义保证临界区访问不会被重排到加锁之前。失败时 cmpxchg 把锁字当前值留在 val 里传给慢路径——慢路径不需要再读一次锁字,这个细节避免了重复的缓存行访问。
解锁同样是单条指令级操作(line 119-127):smp_store_release() 在 x86 上退化为对 locked 字节的普通 store(x86 的 store 自带 release 语义),但不解锁 MCS 队列——队首唤醒由拿锁者传递(16.1.8 节),这是 qspinlock 与 ticket lock 的本质区别之一。
三态状态机
kernel/locking/qspinlock.c:116-129 的注释给出了完整状态机,(*,*,*) 记为 (pending, tail, locked):
* fast : slow : unlock
* : :
* uncontended (0,0,0) -:--> (0,0,1) ------------------------------:--> (*,*,0)
* : | ^--------.------. / :
* pending : (0,1,1) +--> (0,1,0) \ | :
* uncontended : (n,x,y) +--> (n,0,0) --' | :
* queue : | ^--' | :
* contended : (*,x,y) +--> (*,0,0) ---> (*,0,1) -' :
* queue : ^--' :
三态设计的本质是让锁按竞争烈度逐级"升温":
- uncontended:锁字在
(0,0,0)与(0,0,1)之间翻转,纯 cmpxchg 路径。 - pending:第二个竞争者把锁字推到
(0,1,x),只允许一个任务在锁字旁"原地自旋",其余不再涌入。 - queue:第三个及以后的竞争者进入 per-CPU 的 MCS 队列
(n,x,y),各自自旋在自己的缓存行上。
16.1.6 slowpath 阶段一:pending 位
慢路径入口 queued_spin_lock_slowpath()(qspinlock.c:130 起)的第一段处理 pending 位:
// kernel/locking/qspinlock.c:156-206
/*
* If we observe any contention; queue.
*/
if (val & ~_Q_LOCKED_MASK)
goto queue;
/*
* trylock || pending
*
* 0,0,* -> 0,1,* -> 0,0,1 pending, trylock
*/
val = queued_fetch_set_pending_acquire(lock);
if (unlikely(val & ~_Q_LOCKED_MASK)) {
/* Undo PENDING if we set it. */
if (!(val & _Q_PENDING_MASK))
clear_pending(lock);
goto queue;
}
if (val & _Q_LOCKED_MASK)
smp_cond_load_acquire(&lock->locked, !VAL);
/*
* take ownership and clear the pending bit.
*
* 0,1,0 -> 0,0,1
*/
clear_pending_set_locked(lock);
lockevent_inc(lock_pending);
return;
逐段解读:
- line 158-160:进入慢路径时观察到的锁字若已含 pending 或 tail 位(
val & ~_Q_LOCKED_MASK),说明竞争已经很热,直接跳过 pending 阶段去排队,避免在锁字上堆积。 - line 165-166:
queued_fetch_set_pending_acquire()尝试原子地"置 pending 位并读回旧值"——x86 上这是一条btsl(arch/x86/include/asm/qspinlock.h:24-31)。谁成功置上 pending,谁就是唯一的"锁字旁等待者"。 - line 168-175:如果置位后发现有别人也在排队(旧值含 pending/tail),说明自己抢 pending 失败,回滚(
clear_pending())并转入队列。 - line 177-178:pending 持有者只需等
locked字节清零。smp_cond_load_acquire()在 x86 上展开为带pause的自适应自旋读,acquire 语义直接接住锁的所有权。 - line 183-184:
clear_pending_set_locked()一次写完成"清 pending + 置 locked"((0,1,0) → (0,0,1)),状态机中标注的这条转换路径就是本阶段。
bounded 自旋:若锁持有者迟迟不放,x86 定义 _Q_PENDING_LOOPS (1 << 9)(arch/x86/include/asm/qspinlock.h:14),pending 等待者自旋 512 次后放弃,转入队列,避免 pending 位成为新的风暴点。
16.1.7 slowpath 阶段二:MCS 队列入队
per-CPU 节点数组
队列节点来自 per-CPU 数组(qspinlock.c:80):
static DEFINE_PER_CPU_ALIGNED(struct qnode, qnodes[_Q_MAX_NODES]);
_Q_MAX_NODES = 4(kernel/locking/qspinlock.h:16-28),四个槽位对应四种嵌套上下文:task、softirq、hardirq、NMI。同一 CPU 在不同嵌套层级中同时争用同一把锁时,各用各的节点,互不覆盖。若 NMI 中再发生第五层嵌套(实际不可能),代码退化为"不排队、直接自旋"(qspinlock.c:230-235)——正确性由 NMI 的极短临界区保证。
tail 编码与链入
// kernel/locking/qspinlock.h:52-69
static inline __pure u32 encode_tail(int cpu, int idx)
{
u32 tail;
tail = (cpu + 1) << _Q_TAIL_CPU_OFFSET;
tail |= idx << _Q_TAIL_IDX_OFFSET; /* assume < 4 */
return tail;
}
...
static inline struct qnode *decode_tail(u32 tail, struct qnode *qnodes)
{
int cpu = ((tail >> _Q_TAIL_CPU_OFFSET) - 1) % nr_cpu_ids;
int idx = (tail & _Q_TAIL_MASK) >> _Q_TAIL_IDX_OFFSET;
return per_cpu_ptr(&qnodes[idx], cpu);
}
tail 字段只存 (cpu+1, idx) 这一对编号——锁字里不存指针,32 位锁字的容量瓶颈就此消除。入队主体在 qspinlock.c:212-291:
// kernel/locking/qspinlock.c:212-291
queue:
lockevent_inc(lock_slowpath);
pv_queue:
node = this_cpu_ptr(&qnodes[0].mcs);
idx = node->count++;
tail = encode_tail(smp_processor_id(), idx);
...
old = xchg_tail(lock, tail);
next = NULL;
/*
* if there was a previous node; link it and wait until reaching the
* head of the waitqueue.
*/
if (old & _Q_TAIL_MASK) {
prev = decode_tail(old, qnodes);
/* Link @node into the waitqueue. */
WRITE_ONCE(prev->next, node);
pv_wait_node(node, prev);
arch_mcs_spin_lock_contended(&node->locked);
关键操作序列:
- xchg_tail(line 227 附近):原子地把锁字的 tail 字段换成自己的编码,拿回旧 tail。这条
xchg是整个排队机制的"登记动作",之后锁字上不再有本任务的事。 - 链入队列(line 288):旧 tail 非空说明有前驱,
WRITE_ONCE(prev->next, node)把自己挂到前驱的 next 指针上。 - 自旋在自己的节点上(line 291):
arch_mcs_spin_lock_contended(&node->locked)等待自己节点的locked标志——前驱解锁时会写这个字段。等待发生在本 CPU 私有的缓存行上,与前驱的解锁写、后继的读各不相扰,cache line ping-pong 问题就此消除。
16.1.8 slowpath 阶段三:队首拿锁与锁传递
排队者升到队首后,进入拿锁循环(qspinlock.c:325-379):
// kernel/locking/qspinlock.c:325-379
/*
* We're pending, we're at the head of the waitqueue, wait for the
* owner to release the lock.
*/
...
for (;;) {
if (atomic_cond_read_acquire(&lock->val,
!(VAL & _Q_LOCKED_PENDING_MASK)))
break;
}
/* take ownership and clear the tail */
set_locked(lock);
...
if (next) {
arch_mcs_spin_unlock_contended(&next->locked);
...
}
队首的双重等待(line 328 附近):队首节点可以安全地盯回全局锁字——因为自己是唯一有资格拿锁的等待者,atomic_cond_read_acquire() 等条件 !(VAL & _Q_LOCKED_PENDING_MASK),即 locked 与 pending 都清零。读到后 set_locked()(qspinlock.h:79-99,直接置 _Q_LOCKED_VAL)接管锁。
锁传递(line 368-372):持锁者用完锁,queued_spin_unlock() 清掉 locked 字节;队首拿锁者不读全局锁字等待,而是由前驱显式把后继节点的 locked 字段置 1——arch_mcs_spin_unlock_contended(&next->locked)(line 370)。这样"下一个是谁"的信息始终走 MCS 链传播,全局锁字上永远只有至多一个竞争读,风暴被彻底隔离。随后释放节点计数(line 379,node->count--),本次加锁完成。
16.1.9 pvqspinlock —— 半虚拟化扩展
在 KVM 虚拟机里,自旋者可能在宿主机上被调度走(vCPU 抢占),此时"自旋"纯属烧别人的 CPU。CONFIG_PARAVIRT_SPINLOCKS(arch/x86/Kconfig:827-836,默认关闭)启用 pvqspinlock:内核在启动时通过 PV OP 探测 hypervisor 是否支持,把慢路径替换为 __pv_queued_spin_lock_slowpath()。
实现技巧在 qspinlock.c:386-410:整个文件被二次 #include 自身(定义 _GEN_PV_LOCK_SLOWPATH 后再包含一遍),由预处理生成一份带 PV 钩子的慢路径副本;kernel/locking/qspinlock_paravirt.h 提供 pv 侧逻辑,用 _Q_SLOW_VAL = 3<<0(qspinlock_paravirt.h:24)标记"锁字已被 PV 哈希表登记",空闲 vCPU 上的自旋者通过 pv_wait() 交给 hypervisor 睡眠,由持锁者的 pv_kick() 唤醒。虚拟化场景分析(第 49-51 章)会再次遇到这套机制。
16.1.10 使用实例与总结
驱动中保护通知链注册的典型 irqsave 用法:
// drivers/char/random.c:156-168
int __cold execute_with_initialized_rng(struct notifier_block *nb)
{
unsigned long flags;
int ret = 0;
spin_lock_irqsave(&random_ready_notifier.lock, flags);
if (crng_ready())
nb->notifier_call(nb, 0, NULL);
else
ret = raw_notifier_chain_register((struct raw_notifier_head *)&random_ready_notifier.head, nb);
spin_unlock_irqrestore(&random_ready_notifier.lock, flags);
return ret;
}
临界区只有"读一个标志或挂一个链表节点"两条纯内存操作,用自旋锁最合适;因为回调可能在任意上下文触发,故用 irqsave 变体(14.3 节的 siglock 规则与此同理)。
本章要点回顾:
| 层 | 内容 | 位置 |
|---|---|---|
| API 层 | spin_lock() = preempt_disable + 底层锁 |
include/linux/spinlock.h:339 |
| 生成层 | BUILD_LOCK_OPS 可抢占自旋循环 | kernel/locking/spinlock.c:67 |
| 算法层 | ticket lock(FIFO 票号,其他架构可选) | include/asm-generic/ticket_spinlock.h:33 |
| 算法层 | qspinlock(x86 默认,三态升温) | kernel/locking/qspinlock.c:130 |
qspinlock 的三态设计——"一个 cmpxchg、一个 pending 位、一条 MCS 队列"——体现了内核锁实现的典型演进路径:先让最常见情况接近免费,再让次常见情况可忍受,最后才付出队列的完整代价。读写场景下的同类演进见 16.3 节的 qrwlock。
16.2 互斥锁 —— mutex_t 与乐观等待
互斥锁(mutex)是内核为"可能睡眠的临界区"提供的标准互斥原语:拿不到锁时,任务不空转烧 CPU,而是挂到等待队列上睡眠,让出处理器给更有价值的工作。它的名字常被写作 mutex_t,但内核源码中的实际类型是 struct mutex(本节沿 SUMMARY 目录沿用 "mutex_t" 作为主题名,正文以源码为准)。互斥锁的设计精髓在"乐观等待":锁被短暂持有时,先赌一把在原地自旋几个周期,赌赢就省下两次上下文切换;赌输了再排队睡眠,而且排队时还要经过 OSQ 队列排队自旋这一级缓冲。本节将结合 Linux 7.0.10 内核源码,逐行分析 struct mutex 的编码设计、加锁五级阶梯(快路径 → trylock → 乐观自旋 → 睡眠等待 → handoff),以及解锁路径的唤醒逻辑。
16.2.1 struct mutex 的数据结构
类型定义
// include/linux/mutex_types.h:41-54
context_lock_struct(mutex) {
atomic_long_t owner;
raw_spinlock_t wait_lock;
#ifdef CONFIG_MUTEX_SPIN_ON_OWNER
struct optimistic_spin_queue osq; /* Spinner MCS lock */
#endif
struct list_head wait_list;
#ifdef CONFIG_DEBUG_MUTEXES
void *magic;
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
};
注意一个常见误解:本树的 struct mutex 没有 count 字段。老教材描述的 "count 0↔1、负数表示有等待者" 的三态计数器,在本树中已完全编码进 owner 一个字。
四个核心字段:
owner(atomic_long_t):整个锁的状态机。值为 0 表示未锁;否则高位的任务指针记录持锁者,低 3 位是状态标志。wait_lock(raw_spinlock_t):保护wait_list与 owner 变更的内部自旋锁。它必须是raw_spinlock_t——在 PREEMPT_RT 内核中普通自旋锁会变睡眠锁,而锁内部的簿记锁绝不能睡眠。osq(optimistic_spin_queue):乐观自旋者之间的 MCS 排队锁(16.2.4 节),仅在 SMP 且开启CONFIG_MUTEX_SPIN_ON_OWNER(kernel/Kconfig.locks:227-229,默认 y)时存在。wait_list:struct mutex_waiter双向链表,睡眠等待者按 FIFO 排队。
owner 低 3 位标志
// kernel/locking/mutex.h:23-36
* @owner: contains: 'struct task_struct *' to the current lock owner,
* NULL means not owned. ...
* Bit0 indicates a non-empty waiter list; unlock must issue a wakeup.
* Bit1 indicates unlock needs to hand the lock to the top-waiter
* Bit2 indicates handoff has been done and we're waiting for pickup.
*/
#define MUTEX_FLAG_WAITERS 0x01
#define MUTEX_FLAG_HANDOFF 0x02
#define MUTEX_FLAG_PICKUP 0x04
#define MUTEX_FLAGS 0x07
| 标志 | 值 | 语义 |
|---|---|---|
MUTEX_FLAG_WAITERS |
0x01 | 等待队列非空;解锁者必须做唤醒 |
MUTEX_FLAG_HANDOFF |
0x02 | 解锁时必须把锁直接移交给队首等待者(防抢锁饿死) |
MUTEX_FLAG_PICKUP |
0x04 | 移交已完成,持锁资格待队首"拾取" |
任务指针与标志共存的编码依赖任务指针的天然对齐——task_struct 按 L1_CACHE_BYTES 对齐(见 8.1 节),低 3 位永远为 0,可安全挪作标志位。__mutex_owner()/__owner_task()(kernel/locking/mutex.h:38-48)负责在指针与标志间拆分。
等待者结构
// kernel/locking/mutex.h:14-21
struct mutex_waiter {
struct list_head list;
struct task_struct *task;
struct ww_acquire_ctx *ww_ctx;
#ifdef CONFIG_DEBUG_MUTEXES
void *magic;
#endif
};
mutex_waiter 挂在睡眠任务的内核栈上(__mutex_lock_common() 中的栈变量 struct mutex_waiter waiter),而非堆分配——等待者数量天然等于睡眠任务数,无需预分配。这要求互斥锁的睡眠等待绝不能跨任务栈生命周期,也解释了为什么 signal_pending 一类条件退出必须在同一栈帧内完成清理。
16.2.2 mutex_lock() 五级阶梯总览
从最快到最慢,mutex_lock() 的获取路径是五级阶梯:
mutex_lock()
|
|-- [1] __mutex_trylock_fast() owner: 0 -> current (无锁 cmpxchg)
| 失败
|-- [2] __mutex_trylock() 含 WAITERS/HANDOFF 标志语义的 trylock
| 失败
|-- [3] mutex_optimistic_spin() OSQ 排队 + 盯 owner 自旋 (不睡眠)
| 失败
|-- [4] wait_list 排队 + schedule_preempt_disabled() 睡眠 (FIFO)
| 被唤醒
|-- [5] __mutex_trylock_or_handoff() HANDOFF 语义拾取
v
获得锁
这个阶梯的思想与 qspinlock 三态(16.1.5 节)一脉相承:竞争越轻的路径越便宜,代价高昂的睡眠永远是最后选项。
第 1 级:无争用快路径
// kernel/locking/mutex.c:152-170
static __always_inline bool __mutex_trylock_fast(struct mutex *lock)
{
unsigned long curr = (unsigned long)current;
unsigned long zero = 0UL;
MUTEX_WARN_ON(lock->magic != lock);
if (atomic_long_try_cmpxchg_acquire(&lock->owner, &zero, curr))
return true;
return false;
}
无争用加锁 = 一次 cmpxchg:把 owner 从 0 换成自己的任务指针。acquire 语义确保临界区访问不越过加锁点。入口函数(mutex.c:285-291)只是快路径失败后转入慢路径:
// kernel/locking/mutex.c:285-291
void __sched mutex_lock(struct mutex *lock)
{
might_sleep();
if (!__mutex_trylock_fast(lock))
__mutex_lock_slowpath(lock);
}
might_sleep() 是一个运行期契约检查:在原子上下文(preempt_count 非 0,15.1 节)调用 mutex_lock 会在此处报 BUG——互斥锁绝不能在中断、软中断或持自旋锁时使用。解锁的对称快路径 __mutex_unlock_fast()(mutex.c:165-170)是 release 语义的 cmpxchg:owner 从 current 换回 0。
一个本树特有的事实:mutex_lock() 整个函数被 #ifndef CONFIG_DEBUG_LOCK_ALLOC 包裹(mutex.c:282-293),深度调试构建下直接进入 nested 慢路径入口 __mutex_lock(),让 lockdep 完整接管参数检查——快路径只在正常构建下生效。
16.2.3 __mutex_lock_common() —— 慢路径主体
慢路径入口 __mutex_lock_common()(mutex.c:577-770)的流程骨架:
// kernel/locking/mutex.c:613-692
preempt_disable();
mutex_acquire_nest(&lock->dep_map, subclass, 0, nest_lock, ip);
trace_contention_begin(lock, LCB_F_MUTEX | LCB_F_SPIN);
if (__mutex_trylock(lock) ||
mutex_optimistic_spin(lock, ww_ctx, NULL)) {
/* got the lock, yay! */
lock_acquired(&lock->dep_map, ip);
...
preempt_enable();
return 0;
}
raw_spin_lock_irqsave(&lock->wait_lock, flags);
/*
* After waiting to acquire the wait_lock, try again.
*/
if (__mutex_trylock(lock)) {
...
goto skip_wait;
}
...
if (!use_ww_ctx) {
/* add waiting tasks to the end of the waitqueue (FIFO): */
__mutex_add_waiter(lock, &waiter, &lock->wait_list);
}
...
set_current_state(state);
for (;;) {
bool first;
...
if (__mutex_trylock(lock))
goto acquired;
...
raw_spin_unlock_irqrestore_wake(&lock->wait_lock, flags, &wake_q);
schedule_preempt_disabled();
慢路径的五步:
- 第 2 级 trylock(line 617):
__mutex_trylock()与快路径不同,它理解 owner 标志语义(16.2.5 节),能处理"有等待者但锁刚好空出"等状态。 - 第 3 级乐观自旋(line 617):
mutex_optimistic_spin()自旋期间不睡眠,成功则直接返回(此时 preempt 已关)。 - 锁 wait_lock 后第三次 trylock(line 627-632):进入队列前的最后一次尝试——
wait_lock的获取本身有时间窗口,期间锁可能已被释放。 - FIFO 入队(line 642-645):
__mutex_add_waiter()把栈上的 waiter 挂到wait_list尾部,并把 owner 置上MUTEX_FLAG_WAITERS。 - 睡眠循环(line 653-692):
set_current_state(state)设睡眠状态(互斥锁用TASK_UNINTERRUPTIBLE),释放wait_lock,schedule_preempt_disabled()交出 CPU。被唤醒后回到循环顶部再 trylock——醒来不等于拿到锁,这与 8.2 节 "唤醒与获得锁是两回事" 的状态机规则完全一致。
trylock-repanic 语义(line 660-664):每次被唤醒都重新持 wait_lock、重试 trylock,防止虚假唤醒和竞争窗口破坏一致性。
第 2 级 trylock 的原子语义
// kernel/locking/mutex.c:84-134
static inline bool __mutex_trylock_common(struct mutex *lock, bool handoff)
{
unsigned long curr, owner = atomic_long_read(&lock->owner);
for (;;) {
unsigned long flags;
/* We own the lock already? */
if (__owner_task(owner) == current) {
/*
* o -> 0: ... trylock, but we already own the lock
*/
MUTEX_WARN_ON(!owner & MUTEX_FLAG_WAITERS);
return false;
}
flags = owner & MUTEX_FLAGS;
if (flags & MUTEX_FLAG_PICKUP) {
if (!handoff)
return false;
flags &= ~MUTEX_FLAG_PICKUP;
} else if (flags & MUTEX_FLAG_HANDOFF) {
if (!handoff)
return false;
}
curr = (unsigned long)current | flags;
if (atomic_long_try_cmpxchg_acquire(&lock->owner,
&owner, curr)) {
...
return true;
}
}
}
普通 trylock(handoff=false)只接受"无标志的空闲锁"(owner==0);带 PICKUP/HANDOFF 标志的锁只能由指定路径接收。cmpxchg 循环处理标志在竞争窗口内的变化——每次失败后用最新的 owner 值重算。
handoff 拾取(16.2.6 节):睡眠循环中队首等待者用 __mutex_trylock_or_handoff(lock, first)(mutex.c:708)接收移交的锁,此时 cmpxchg 保留 HANDOFF→清除 PICKUP 的转换路径。
16.2.4 第 3 级:乐观自旋与 OSQ
mutex_optimistic_spin()
// kernel/locking/mutex.c:444-481
static __always_inline bool
mutex_optimistic_spin(struct mutex *lock, struct ww_acquire_ctx *ww_ctx,
struct mutex_waiter *waiter)
{
if (!waiter) {
/*
* The purpose of the mutex_can_spin_on_owner() function is
* to eliminate the overhead of osq_lock() and osq_unlock()
* in case spinning isn't possible. ...
*/
if (!mutex_can_spin_on_owner(lock))
goto fail;
/*
* In order to avoid a stampede of mutex spinners trying to
* acquire the mutex all at once, the spinners need to take a
* MCS (queued) lock first before spinning on the owner field.
*/
if (!osq_lock(&lock->osq))
goto fail;
}
for (;;) {
struct task_struct *owner;
/* Try to acquire the mutex... */
owner = __mutex_trylock_or_owner(lock);
if (!owner)
break;
if (!mutex_spin_on_owner(lock, owner, ww_ctx, waiter))
goto fail_unlock;
...
流程:
- 预检(line 450):
mutex_can_spin_on_owner()先看持锁者是否正在某个 CPU 上运行(owner_on_cpu()),若持锁者已睡眠,自旋毫无意义;同时若本任务need_resched()也放弃(mutex.c:403-404)。 - OSQ 排队自旋(line 457-459):注释点明动机——防止"自旋者踩踏"(stampede)。所有想乐观自旋的任务先在
lock->osq这把 MCS 锁上排队,同一时刻只有一个自旋者盯着 owner 字段。这把全局自旋风暴重新隔离到 per-CPU 节点上,与 qspinlock 的 MCS 队列(16.1.7 节)同一设计哲学。 - 自旋主循环(line 464-472):
__mutex_trylock_or_owner()一次原子操作返回两种结果——拿到锁返回 NULL(break),拿不到返回当前 owner;非 NULL 则检查mutex_spin_on_owner()决定继续与否。
放弃自旋的条件
// kernel/locking/mutex.c:355-421
static bool mutex_spin_on_owner(struct mutex *lock,
struct task_struct *owner,
struct ww_acquire_ctx *ww_ctx,
struct mutex_waiter *waiter)
{
bool ret = true;
...
lockdep_assert_preemption_disabled();
for (;;) {
int prev = READ_ONCE(waiter->ww_ctx ? waiter->ww_ctx->deadlock : 0);
...
/*
* If the owner changed, we must not spin.
*/
if (!owner_on_cpu(owner)) {
ret = false;
break;
}
/*
* Provide a ST_SPIN_ON_OWNER spark for debugging.
*/
if (need_resched()) {
...
ret = false;
break;
}
...
cpu_relax();
}
...
}
两条退出线(mutex.c:377-380):
- 持锁者睡眠:
owner_on_cpu()为假——owner 不在任何 CPU 上运行,说明临界区比"短"长得多,继续自旋纯属浪费。 - 本任务需要调度:
need_resched()——自旋者不是最高优先级了,必须退出让路。
退出后若 need_resched() 为真,mutex_optimistic_spin() 走 schedule_preempt_disabled() 再返回(mutex.c:508-515),保证让出 CPU 而非带着 pending 的调度请求继续运行。OSQ 排队自旋的价值在于自旋者的退出自动把机会让给队里下一位,全队共享一个"是否值得自旋"的判断。
OSQ:乐观自旋队列的实现
OSQ 本体只是一个 32 位 tail 字段(include/linux/osq_lock.h:10-16):
// kernel/locking/osq_lock.c:15-42
struct optimistic_spin_node {
struct optimistic_spin_node *next, *prev;
int locked; /* 1 if lock acquired */
int cpu; /* encoded CPU # + 1 value */
};
static DEFINE_PER_CPU_SHARED_ALIGNED(struct optimistic_spin_node, osq_node);
/*
* We use the value 0 to represent "no CPU", thus the encoded value
* will be the CPU number incremented by 1.
*/
static inline int encode_cpu(int cpu_nr)
{
return cpu_nr + 1;
}
节点同样是 per-CPU 单实例(line 21 的 DEFINE_PER_CPU_SHARED_ALIGNED)。其安全性依赖文件头注释(osq_lock.c:6-13)声明的两条前提:OSQ 只用于睡眠锁的乐观自旋(不会在中断上下文被调用),且自旋期间抢占已关——否则同一 CPU 嵌套两层自旋会覆盖同一个节点。
// kernel/locking/osq_lock.c:93-148
bool osq_lock(struct optimistic_spin_queue *lock)
{
struct optimistic_spin_node *node = this_cpu_ptr(&osq_node);
struct optimistic_spin_node *prev, *next;
int curr = encode_cpu(smp_processor_id());
int old;
node->locked = 0;
node->next = NULL;
node->cpu = curr;
/*
* We need both ACQUIRE (pairs with corresponding RELEASE in
* unlock() uncontended, or fastpath) and RELEASE (to publish
* the node fields we just initialised) semantics when updating
* the lock tail.
*/
old = atomic_xchg(&lock->tail, curr);
if (old == OSQ_UNLOCKED_VAL)
return true;
prev = decode_cpu(old);
node->prev = prev;
...
smp_wmb();
WRITE_ONCE(prev->next, node);
...
/*
* Wait to acquire the lock or cancellation. Note that need_resched()
* will come with an IPI, which will wake smp_cond_load_relaxed() ...
*/
if (smp_cond_load_relaxed(&node->locked, VAL || need_resched() ||
vcpu_is_preempted(node_cpu(node->prev))))
return true;
快路径(line 107-109):atomic_xchg 把 tail 换成自己的 CPU 编码;旧值为 OSQ_UNLOCKED_VAL = 0(osq_lock.h:18)即无人排队,直接获得自旋资格。
排队(line 111-124):smp_wmb() 保证节点字段初始化先于指针发布(release 语义),随后 prev->next = node 链入队列——与 qspinlock 的 WRITE_ONCE(prev->next, node)(16.1.7 节)完全同构。
带逃生口的等待(line 138-147):smp_cond_load_relaxed 的条件里内建了两个退出项——need_resched()(调度需求经 IPI 到达,能中断自旋读)和 vcpu_is_preempted()(虚拟机里前驱 vCPU 可能被抢占,等待无意义)。需要退出时进入"取消排队"流程:先 cmpxchg 摘除自己(osq_lock.c:159-183 稳定 prev),再 osq_wait_next() 稳定 next(:192),最后重连 prev->next 与 next->prev(:204-205)——双向链表的无锁删除三步曲。
解锁路径 osq_unlock()(osq_lock.c:210-234)三分支:无竞争 cmpxchg 归零;node->next 已就位则直接 WRITE_ONCE(next->locked, 1) 踢醒下一位;否则 osq_wait_next() 兜底等待后继稳定后传递。
16.2.5 第 4 级:睡眠等待与第 5 级 handoff
为什么需要 handoff
睡眠等待有一个固有的不公平:锁释放瞬间,新来的乐观自旋者(还在 CPU 上跑着)几乎总能抢过刚被唤醒、还在穿内核的睡眠者。极端情况下等待队列头部会一直被"起跑线更靠前的新人"截胡——队首等待者被唤醒后再次 trylock 失败、再次睡眠,形成抢锁饿死(lock stealing starvation)。
MUTEX_FLAG_HANDOFF 就是解药。解锁者发现队首等待者已被唤醒过一次仍没拿到锁(或直接由 __mutex_unlock_slowpath() 决定),就给 owner 置上 HANDOFF 标志:这把锁已经有主了,别人休想偷。
移交与拾取
移交动作在 __mutex_handoff()(mutex.c:235-253):
// kernel/locking/mutex.c:235-253
static void __mutex_handoff(struct mutex *lock,
struct task_struct *task)
{
unsigned long owner = atomic_long_read(&lock->owner);
for (;;) {
unsigned long old, new;
MUTEX_WARN_ON(__owner_task(owner) != current);
MUTEX_WARN_ON(!(owner & MUTEX_FLAG_WAITERS));
old = owner;
new = (unsigned long)task;
if (task) {
new |= MUTEX_FLAG_PICKUP;
new &= ~MUTEX_FLAG_HANDOFF;
}
...
if (atomic_long_try_cmpxchg(&lock->owner, &old, new))
break;
...
}
}
owner 的指针部分直接换成队首任务,标志位做 HANDOFF → PICKUP 的转换——锁的所有权在 cmpxchg 中显式过户。队首任务醒来后 __mutex_trylock_common(handoff=true) 沿 PICKUP 路径拾取(16.2.3 节)。整个链路中,乐观自旋者看到带 HANDOFF/PICKUP 标志的 owner 一律放弃偷锁(__mutex_trylock_or_owner() 对标志位敏感)。
waiter 醒来后的二次乐观自旋:__mutex_lock_common() 中队首等待者被唤醒时把栈上 waiter 传入 mutex_optimistic_spin(lock, ww_ctx, &waiter)(mutex.c:711-723)——此时不再 OSQ 排队(if (!waiter) 分支),因为队首身份本身就是"自旋资格"。这是手握 HANDOFF 的等待者被进一步插队时的最后防线。
睡眠状态的唤醒路径
mutex_unlock() 的慢路径负责唤醒(mutex.c:931-982):
// kernel/locking/mutex.c:940-981
/*
* Release the lock before (potentially) taking the spinlock such that
* other contenders can get on with things ASAP.
*
* Except when HANDOFF, in that case we must not clear the owner field,
* but instead set it to the top waiter.
*/
owner = atomic_long_read(&lock->owner);
for (;;) {
MUTEX_WARN_ON(__owner_task(owner) != current);
if (owner & MUTEX_FLAG_HANDOFF)
break;
if (atomic_long_try_cmpxchg_release(&lock->owner, &owner, __owner_flags(owner))) {
if (owner & MUTEX_FLAG_WAITERS)
break;
return;
}
}
raw_spin_lock_irqsave(&lock->wait_lock, flags);
debug_mutex_unlock(lock);
if (!list_empty(&lock->wait_list)) {
/* get the first entry from the wait-list: */
struct mutex_waiter *waiter =
list_first_entry(&lock->wait_list,
struct mutex_waiter, list);
...
wake_q_add(&wake_q, next);
}
解锁慢路径的三个关键点:
- 先放锁再拿 wait_lock(line 940-953 注释):释放锁字(owner 换回 0,保留标志)发生在获取
wait_lock之前,让等锁者尽快前进。唯一例外是 HANDOFF——此时 owner 不能清零,要等__mutex_handoff()过户。 - release cmpxchg(line 950):
atomic_long_try_cmpxchg_release()保证临界区写先于锁释放可见(15.2 节的 RELEASE 语义)。 - wake_q 批量唤醒(line 963-979):从
wait_list取队首 waiter,wake_q_add()挂入 wake_q(一种延迟唤醒列表,避免持自旋锁期间直接try_to_wake_up()的开销,见 13.2 节调度器侧的 wake_q 处理),在释放wait_lock后统一唤醒。
mutex_unlock() 本体(mutex.c:546-554)只是 fastpath 失败后转入此慢路径。
16.2.6 mutex 与 spinlock 的选择、使用实例与总结
选择判据
| 维度 | spinlock | mutex |
|---|---|---|
| 等待时行为 | 自旋(烧 CPU) | 睡眠(让出 CPU) |
| 临界区限制 | 不能睡眠、不能长 | 可以睡眠、可以长 |
| 可用上下文 | 进程/软中断/硬中断 | 仅进程上下文 |
| 无争用开销 | preempt_disable + cmpxchg | might_sleep + cmpxchg |
| 公平性 | qspinlock FIFO(排队后) | FIFO + HANDOFF 防饿死 |
| 典型场景 | 中断共享数据、极短临界区 | 子系统全局状态、可睡眠路径 |
两条经验法则:临界区内会睡眠 → mutex(spinlock 下 might_sleep() 直接报错);中断上下文会获取 → spinlock(mutex 在原子上下文是致命错误)。两者之间的灰色地带(临界区短但进程上下文、竞争不激烈)通常选 mutex——睡眠的代价被乐观自旋消化掉大半。
mm 子系统的 mutex 实例
mm/ksm.c 用一把全局 mutex 串行化 KSM 的启停控制:
// mm/ksm.c:3513-3546
static ssize_t run_store(struct kobject *kobj, struct kobj_attribute *attr,
const char *buf, size_t count)
{
unsigned int flags;
int err;
err = kstrtouint(buf, 10, &flags);
if (err)
return -EINVAL;
...
mutex_lock(&ksm_thread_mutex);
wait_while_offlining();
if (ksm_run != flags) {
ksm_run = flags;
if (flags & KSM_RUN_UNMERGE) {
set_current_oom_origin();
err = unmerge_and_remove_all_rmap_items();
clear_current_oom_origin();
if (err) {
ksm_run = KSM_RUN_STOP;
count = err;
}
}
}
mutex_unlock(&ksm_thread_mutex);
ksm_thread_mutex(mm/ksm.c:487 的 DEFINE_MUTEX)保护的临界区内调用 unmerge_and_remove_all_rmap_items()——内部大量内存分配与页面扫描,必须可睡眠。这正是"用 mutex 不用 spinlock"的教科书场景。
要点总结:
- 状态机浓缩在
atomic_long_t owner一个字:指针 + WAITERS/HANDOFF/PICKUP 三标志,本树无独立 count 字段。 - 五级阶梯:cmpxchg 快路径 → 标志感知 trylock → OSQ 排队乐观自旋 → FIFO 睡眠 → HANDOFF 防饿死移交。
- 乐观自旋的两条纪律——持锁者在跑才值得等、
need_resched()立刻让路——把"自旋"限制在真正划算的窗口内。 - handoff 机制用"所有权显式过户"化解睡眠锁固有的抢锁不公平。
互斥锁的等待队列、睡眠唤醒与调度器深度联动,其 schedule_preempt_disabled() 之后的细节(如何进入 __schedule()、如何被 ttwu 唤醒)在 13.2 节已有完整分析。
16.3 读写锁与顺序锁
读多写少是内核数据结构的普遍形态:路由表、挂载表、配额统计……绝大多数访问是查询,修改只占零头。为这类负载准备两套原语——读写锁允许多个读者并行、写者独占;顺序锁则更进一步,让读者完全无锁,靠"序号对不上就重试"实现乐观读取。Linux 7.0.10 中 x86_64 采用排队读写锁(qrwlock),其设计与 qspinlock(16.1 节)一脉相承;顺序锁家族则演进出了绑定关联锁的类型变体(seqcount_spinlock_t 等)与双副本乒乓的 latch 模式。本节将结合 Linux 7.0.10 内核源码,逐行分析 qrwlock 的计数编码与公平性设计、seqcount/seqlock 的奇偶序号协议,以及它们在时间子系统中的真实应用。
16.3.1 读写锁的问题域
读写锁的语义契约:
- 读-读共享:任意多个读者可同时持锁;
- 读-写互斥、写-写互斥:写者独占。
设计读写锁要回答两个尖锐问题:
- 读者会饿死写者吗? 若读者源源不断,计数永远非零,写者无限等待。
- 写者会饿死读者吗? 若写者一到就封锁新读者,而已有写者在长临界区内,读者也无限等待。
Linux 的 qrwlock 给出的答案是写者优先排队、读者不饿死:写者到达即立起"我在等"的标志,新读者看到标志就不再进入,已进入的读者排空后写者拿锁;写者退出后读者恢复进入。下面看实现。
16.3.2 qrwlock 的计数编码
锁字布局
// include/asm-generic/qrwlock_types.h:13-32
struct qrwlock {
atomic_t cnts;
u8 wlocked;
arch_spinlock_t wait_lock;
};
cnts 是 32 位计数核心,位域由 qrwlock.h:27-31 定义:
// include/asm-generic/qrwlock.h:27-31
#define _QW_WAITING 0x100 /* A writer is waiting */
#define _QW_LOCKED 0x0ff /* A writer holds the lock */
#define _QW_WMASK 0x1ff /* Writer mask */
#define _QR_SHIFT 9 /* Reader count shift */
#define _QR_BIAS (1U << _QR_SHIFT)
| 字段 | 位范围 | 含义 |
|---|---|---|
_QW_LOCKED(bit 0-7) |
0x0ff | 写者持锁标志(写者拿锁时写入整个低字节) |
_QW_WAITING(bit 8) |
0x100 | 有写者在排队——读者的"止步线" |
| 读者计数(bit 9-31) | _QR_BIAS = 1<<9 |
每个读者加一次 _QR_BIAS |
wlocked 字段是 cnts 低字节的别名视图(union,qrwlock_types.h:14-25),供解锁时的字节写入优化。wait_lock 是一把内嵌的 qspinlock,专管写者之间的排队——rwlock 是用 spinlock 搭出来的。
x86_64 的启用链:arch/x86/Kconfig:139 select ARCH_USE_QUEUED_RWLOCKS → kernel/Kconfig.locks:252-254 把 CONFIG_QUEUED_RWLOCKS 默认置 y(SMP 且非 PREEMPT_RT)。arch/x86/include/asm/qrwlock.h:5-6 只是 include asm-generic 的实现,无架构特化代码。
调用链
读写锁 API 的分层与自旋锁完全平行(16.1.2 节):read_lock()(include/linux/rwlock.h:31-45)→ _raw_read_lock()(include/linux/rwlock_api_smp.h:159-241,preempt_disable + LOCK_CONTENDED)→ do_raw_read_lock()(rwlock.h:31-45)→ arch_read_lock(),最后经 include/asm-generic/qrwlock.h:139-145 的宏重映射落到 queued_read_lock()。写侧对称。API 层的 __raw_read_lock() 示例:
// include/linux/rwlock_api_smp.h:225-239
static inline void __raw_write_lock(rwlock_t *lock)
__acquires(lock) __no_context_analysis
{
preempt_disable();
rwlock_acquire(&lock->dep_map, 0, 0, _RET_IP_);
LOCK_CONTENDED(lock, do_raw_write_trylock, do_raw_write_lock);
}
与自旋锁完全一致:先 preempt_disable() 再拿锁,LOCK_CONTENDED 里是真正的锁操作。
16.3.3 读者路径:一次原子加计数
// include/asm-generic/qrwlock.h:78-92
static inline void queued_read_lock(struct qrwlock *lock)
{
int cnts;
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);
}
快路径是一条 lock add(line 80-84):atomic_add_return_acquire(_QR_BIAS) 原子地把读者计数加 1 并拿回新值。检查新增值的高位写者域:_QW_WMASK(0x1ff)覆盖 _QW_LOCKED 和 _QW_WAITING——只要没有任何写者持锁或排队,读者立即放行。注意语义细节:用 _QR_BIAS 加进高位读者域,绝不会碰低 9 位的写者域,读写两个域在同一个字里互不侵蚀。
写者出现后(_QW_WAITING 置位),新读者加完计数发现自己撞线,转入慢路径(慢路径会把 _QR_BIAS 退掉再排队,见 16.3.6 节)。解锁对称(qrwlock.h:108-114):atomic_sub_return_release(_QR_BIAS),release 语义保证读者临界区的读操作先于计数递减可见。
16.3.4 写者路径:快路径与排队
快路径
// include/asm-generic/qrwlock.h:94-102
static inline void queued_write_lock(struct qrwlock *lock)
{
int cnts = 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);
}
写锁快路径要求整个 cnts 恰好为 0:无读者(高 23 位全零)、无排队写者(bit 8 = 0)、无持锁写者(低 8 位 = 0),一次 cmpxchg 把低字节写成 _QW_LOCKED 即独占。注释里的 "unfair lock case" 指的是:即使 _QW_WAITING 已置位,只要此刻计数清零,仍可能被恰好路过的写者用这条路径抢走——公平性由慢路径兜底而非绝对保证。
慢路径:立 WAITING、等排空
// kernel/locking/qrwlock.c:66-91
void __lockfunc queued_write_lock_slowpath(struct qrwlock *lock)
{
int cnts;
trace_contention_begin(lock, LCB_F_SPIN | LCB_F_WRITE);
/* 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))
goto unlock;
/* 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);
trace_contention_end(lock, 0);
}
四步流程:
- 排队(line 73-74):先抢内嵌的
wait_lock——多个写者在此串行化,同一时刻只有一个写者操作 cnts 的写者域。这把锁是 qspinlock,享受 16.1 节的全部分层优化。 - 直接尝试(line 77-79):排队成功后先试一次"计数全零 → 直接拿锁",覆盖"读者刚好排空"的运气场景。
- 立起止步线(line 82-83):
atomic_or(_QW_WAITING)——从此刻起新读者必然撞上_QW_WMASK而进入慢路径,读者洪峰被截断。 - 等排空再拿锁(line 86-89):
atomic_cond_read_relaxed()自旋等计数退化为纯_QW_WAITING(现有读者全部退出),随后 cmpxchg 把_QW_WAITING原地升级为_QW_LOCKED。循环+cmpxchg 的组合处理竞争窗口——条件满足到拿锁之间计数可能再变。
这就是"写者优先排队、读者不饿死写者"的实现:写者只需等"已在场"的读者退场,不必等无穷无尽的新读者;而读者虽被拦在线外,却只在写者实际持锁期间(通常极短)等待,写者释放后(低 9 位清零)读者立即恢复进入——两边都不会被饿死。
读侧慢路径(qrwlock.c:21-59)做了对称的礼貌处理:非中断上下文的读者先回退 _QR_BIAS 再抢 wait_lock 排队,保证同一时刻只有一个"排队读者"在等写者退出,其余读者在 wait_lock 上自旋(而非全都压在 cnts 缓存行上);中断上下文的读者则被允许越过"仅在等待(_QW_WAITING 置位但未持锁)"的写者直接进——中断处理必须尽快完成,不能因一个还没拿到锁的写者而卡死,这也规避了"写者持 wait_lock 时中断读者抢 wait_lock"的自死锁。
解锁
写解锁(qrwlock.h:113-123)是 smp_store_release(&lock->wlocked, 0)——利用 union 别名直接清低字节,一条 release store 完事,被拦的读者与新写者随即竞争。读解锁如 16.3.3 节所述是 release 语义的计数递减。
16.3.5 seqcount —— 读者无锁的序号协议
动机与奇偶约定
读写锁仍要求读者执行"原子加计数 + 检查",并且写者要付出拦截全部读者的代价。seqcount 把代价推到极限不对称:读者零原子操作、零缓存行写,只在最后核对一次序号;写者独占式地翻动序号。类型定义:
// include/linux/seqlock_types.h:33-38
typedef struct seqcount {
unsigned sequence;
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
} seqcount_t;
整个原语只有 unsigned sequence 一个字。写者协议是奇偶约定:序号为偶数表示"无写进行中",奇数表示"写进行中"。写者进入写段时 ++(变奇),退出时再 ++(变偶):
// include/linux/seqlock.h:421-455
#define raw_write_seqcount_begin(s) \
do { \
if (seqprop_preemptible(s)) \
preempt_disable(); \
\
do_raw_write_seqcount_begin(seqprop_ptr(s)); \
} while (0)
static inline void do_raw_write_seqcount_begin(seqcount_t *s)
{
kcsan_nestable_atomic_begin();
s->sequence++;
smp_wmb();
}
...
static inline void do_raw_write_seqcount_end(seqcount_t *s)
{
smp_wmb();
s->sequence++;
kcsan_nestable_atomic_end();
}
两处 smp_wmb() 的位置就是协议的内存序核心(line 436-439 与 448-451):
- 进入写段:
sequence++(变奇)之后写屏障——保证序号变奇这一事件先于任何数据修改对其他 CPU 可见。读者若看到奇数序号,它随后读到的必是"写到一半"的数据,重试即可。 - 退出写段:
smp_wmb()之后sequence++(变偶)——保证全部数据修改先于序号翻回偶数可见。读者看到"偶数且序号未变"就意味着读到的数据是完整一致的快照。
这两道屏障与 15.2 节的 RELEASE/ACQUIRE 配对分析同理:序号是这个协议里唯一的"发布点"。
seqprop_preemptible()(seqlock.h:442-448 的宏分支)决定写段是否自动 preempt_disable()——裸 seqcount_t 可抢占,绑定锁的变体(16.3.7 节)按锁属性决定。
读者:读-验证-重试
// include/linux/seqlock.h:272-320, 386-413
#define __read_seqcount_begin(s) \
({ \
unsigned __seq; \
\
while (unlikely((__seq = seqprop_sequence(s)) & 1)) \
cpu_relax(); \
\
kcsan_atomic_next(KCSAN_SEQLOCK_REGION_MAX); \
__seq; \
})
...
#define raw_read_seqcount(s) \
({ \
unsigned __seq = seqprop_sequence(s); \
\
kcsan_atomic_next(KCSAN_SEQLOCK_REGION_MAX); \
__seq; \
})
...
static inline int do___read_seqcount_retry(const seqcount_t *s, unsigned start)
{
kcsan_atomic_next(0);
return unlikely(READ_ONCE(s->sequence) != start);
}
...
static inline int do_read_seqcount_retry(const seqcount_t *s, unsigned start)
{
smp_rmb();
return do___read_seqcount_retry(s, start);
}
标准读循环三步:
seq = read_seqcount_begin(&s); // 等到偶数序号并记下
...读共享数据... // 纯读,无任何原子操作
if (read_seqcount_retry(&s, seq)) // 序号变了 → 有写穿插,重来
goto 重试;
do_read_seqcount_retry() 的 smp_rmb()(line 395-399):确保读者对数据的读取完成后再核对序号——没有这道屏障,弱序架构上数据读取可能被重排到序号读取之后,出现"序号没变但数据是新旧混合"的撕裂。x86 上它是空操作(强序),但抽象层必须写。__read_seqcount_begin() 的自旋(line 275-280)只在起点撞上奇数序号时发生——等写者完成;raw_seqcount_begin()(seqlock.h:362-369)提供了更激进的变体:奇数序号不等待,直接 (seq & ~1) 闯入读,把代价留给 retry 判定(读到的必是写了一半的数据,但 retry 必然失败,浪费的只是这一次读)。
seqlock_t:seqcount + spinlock 的组合
裸 seqcount_t 的写者必须自己保证写段互斥。seqlock_t 把互斥锁直接打包进来:
// include/linux/seqlock_types.h:84-92
context_lock_struct(seqlock) {
/*
* Make sure that readers don't starve writers on PREEMPT_RT: use
* seqcount_spinlock_t instead of seqcount_t. Check __SEQ_LOCK().
*/
seqcount_spinlock_t seqcount;
spinlock_t lock;
};
typedef struct seqlock seqlock_t;
// include/linux/seqlock.h:877-896
static inline void write_seqlock(seqlock_t *sl)
__acquires(sl) __no_context_analysis
{
spin_lock(&sl->lock);
do_write_seqcount_begin(&sl->seqcount.seqcount);
}
static inline void write_sequnlock(seqlock_t *sl)
__releases(sl) __no_context_analysis
{
do_write_seqcount_end(&sl->seqcount.seqcount);
spin_unlock(&sl->lock);
}
写者 = spin_lock + 翻号,读者完全无锁(read_seqbegin()/read_seqretry() 在 seqlock.h:835-856,只是带注解的 seqcount 接口)。9.3 节遇到过的 mount_lock(seqlock_t,保护挂载点引用计数与序号读取)就是此类型的实际用户。变体 write_seqlock_bh()/write_seqlock_irq()/write_seqlock_irqsave()(seqlock.h:905-985)对应自旋锁的 bh/irq 家族;另有排他读接口 read_seqlock_excl*(:1011-1130)与"先乐观后退让"的 read_seqbegin_or_lock()/need_seqretry()/done_seqretry()(:1146-1186)——后者用于遍历很长的结构时先乐观试一遍、失败率太高就升级为持锁读。
16.3.6 绑定关联锁的 seqcount 变体
裸 seqcount_t 对写者零保护——忘记加锁的写者只会造成静默数据竞争。本树把"写者串行化锁"做成 seqcount 类型的一部分:
// include/linux/seqlock.h:227-231
SEQCOUNT_LOCKNAME(raw_spinlock, raw_spinlock_t, false, raw_spin)
SEQCOUNT_LOCKNAME(spinlock, spinlock_t, __SEQ_RT, spin)
SEQCOUNT_LOCKNAME(rwlock, rwlock_t, __SEQ_RT, read)
SEQCOUNT_LOCKNAME(mutex, struct mutex, true, mutex)
生成 seqcount_raw_spinlock_t、seqcount_spinlock_t、seqcount_rwlock_t、seqcount_mutex_t 四个类型(定义于 seqlock_types.h:62-72),内嵌对应的锁指针。SEQCOUNT_LOCKNAME() 的第三个参数(preemptible 列)决定写段是否自动关抢占:seqcount_mutex_t 可抢占(mutex 自己会调度),spinlock 变体在 PREEMPT_RT 下由 __SEQ_RT 标记特殊处理。
双收益:其一,write_seqcount_begin() 自动取关联锁,写者想漏都漏不掉;其二,lockdep 能验证写侧临界区确实被正确加锁——seqprop_assert_lock_held() 检查把锁序错误提前到运行时。初始化(seqlock.h:131-134)形如 seqcount_spinlock_init(s, name, lock),静态版 SEQCNT_RAW_SPINLOCK_ZERO() 等(:244-248)。类型分发用 _Generic(__seqprop,seqlock.h:250-264)——同一套 read_seqcount_begin()/write_seqcount_begin() 接口对四种类型透明。
内核的倾向很明确:新代码应使用绑定锁的变体而非裸 seqcount_t,14.2 节信号子系统的 seqlock_t stats_lock(signal.h:160)与时间子系统的用法均属此类。
16.3.7 seqcount_latch —— 双副本乒乓
序号协议有一个死角:读者在写段的中间到达怎么办?标准 seqlock 的答案是读者重试,但若读者处在 NMI 这类不可阻塞、不可重试失败太多次的上下文(如 perf 采样读时钟),重试的代价无法接受。latch(门闩)方案引入两份数据副本:
// include/linux/seqlock.h:606-612
typedef struct seqcount_latch {
seqcount_t seqcount;
} seqcount_latch_t;
写者每写一轮就翻动一次序号(write_seqcount_latch(),seqlock.h:694-700),序号的奇偶恰好充当副本选择器:偶数读副本 0,奇数读副本 1。写者按"翻号 → 写另一份 → 再翻号 → 写回第一份"的节奏更新,任何时刻读者按当前奇偶读对应副本,读到的都是某一份完整数据——读者永远不需要重试。写段本身完全可抢占,甚至可以被 NMI 中的读者随时打断(seqlock.h:600-609 注释),代价是数据要存两份、写入两遍。典型用户是时间子系统的 clocksource 切换(tk_core 旁的 latch 副本),与 19.5 节 TLB、20.4 节 fixmap 中的时间读取路径呼应。
16.3.8 时间子系统的真实用例
get_jiffies_64() 的序号读循环
32 位系统上 64 位 jiffies_64 无法单条指令读取,序号协议派上用场:
// kernel/time/jiffies.c:43-59
__cacheline_aligned_in_smp DEFINE_RAW_SPINLOCK(jiffies_lock);
__cacheline_aligned_in_smp seqcount_raw_spinlock_t jiffies_seq =
SEQCNT_RAW_SPINLOCK_ZERO(jiffies_seq, &jiffies_lock);
...
u64 get_jiffies_64(void)
{
unsigned int seq;
u64 ret;
do {
seq = read_seqcount_begin(&jiffies_seq);
ret = jiffies_64;
} while (read_seqcount_retry(&jiffies_seq, seq));
return ret;
}
jiffies_seq 是 seqcount_raw_spinlock_t(16.3.6 节的绑定锁变体),写侧在 tick 处理中持 jiffies_lock 并 write_seqcount_begin。这个 do-while 循环是 seqcount 读侧的标准形态,get_jiffies_64() 在系统负载下偶发的重试完全被锁变量免去——读者之间零争用。
ktime_get() —— 高精度时间的序号读
// kernel/time/timekeeping.c:814-832
static __always_inline ktime_t __ktime_get_fast_ns(...)
...
ktime_t ktime_get(void)
{
struct timekeeper *tk = &tk_core.timekeeper;
unsigned int seq;
ktime_t base;
u64 nsecs;
do {
seq = read_seqcount_begin(&tk_core.seq);
base = tk->tkr_mono.base;
nsecs = timekeeping_get_ns(&tk->tkr_mono);
} while (read_seqcount_retry(&tk_core.seq, seq));
...
}
ktime_get() 是内核调用最频繁的时钟接口之一(打印时间戳、定时器、网络协议栈都要读),这正是它必须无锁的原因:如果每个读者都要抢一把自旋锁,时钟读取本身会成为系统的串行点。写侧——update_wall_time() 更新 timekeeper——才持锁并翻动 tk_core.seq。
三种原语的适用对照
| 原语 | 读者开销 | 写者开销 | 适用场景 |
|---|---|---|---|
| rwlock(qrwlock) | 原子加计数 + 检查 | 拦截全部读者 | 读临界区较长、读者不需重试的确定性 |
| seqcount | 零原子,纯读 + 末尾核对 | 翻号两次 + 写屏障 | 读多写极少、读侧可容忍重试、数据小(重读便宜) |
| seqcount_latch | 零原子、零重试 | 双副本写两遍 | NMI 等不可重试读者的读 |
选择 seqcount 的隐性条件:重试意味着整个读循环重来,因此被保护的数据必须小到"重读不心疼"。大结构遍历用 read_seqbegin_or_lock() 退让模式或干脆回到 rwlock。
要点总结:
- qrwlock 用一个 32 位字同时编码读者计数(bit 9+)与写者域(低 9 位),写者慢路径以
_QW_WAITING为读者止步线,实现写者优先排队且双方不饿死。 - seqcount 把读者开销压到零原子操作,代价是写者翻号与读者可能重试;两道
smp_wmb()锚定序号与数据的可见顺序。 - 绑定锁变体(
seqcount_spinlock_t等)让 lockdep 接管写侧检查,是新代码的推荐形态;latch 双副本则服务于 NMI 读者。 - 时间子系统(
jiffies_seq、tk_core.seq)是这套原语最密集的用户,"高频读 + 偶发写"正是它们的诞生理由。
16.4 死锁检测与 lockdep
死锁是并发编程中最阴险的缺陷:四条发生条件(互斥、持有并等待、不可剥夺、循环等待)在代码里往往相隔数千行、跨越数十条路径,code review 很难发现,而触发时系统通常已经挂死。lockdep(lock dependency validator,锁依赖验证器)的思路是釜底抽薪:在运行时把每一次锁获取记录成依赖图的边,一旦图上出现环、或同一把锁在中断使能与使用上下文上不一致,立刻报告完整证据链——死锁还没发生就被定罪。它的代价(慢一个数量级、内存开销大)注定只用于调试内核,但几乎所有内核死锁类 bug 修复背后都有它。本节将结合 Linux 7.0.10 内核源码,逐行分析 lockdep 的核心数据结构、依赖边与环检测、中断上下文一致性检查,以及它在锁实现各层(16.1-16.3 节)中的接入点。
16.4.1 开启方式与总体架构
Kconfig 开关
lockdep 由一组嵌套的配置项控制(lib/Kconfig.debug):
| 配置项 | 位置 | 作用 |
|---|---|---|
LOCK_DEBUGGING_SUPPORT |
Kconfig.debug:1444-1447 | lockdep 基础框架的使能前提 |
PROVE_LOCKING |
Kconfig.debug:1449-1472 | "证明锁正确性"总开关,select LOCKDEP、DEBUG_SPINLOCK、DEBUG_LOCK_ALLOC、TRACE_IRQFLAGS 等 |
DEBUG_LOCK_ALLOC |
Kconfig.debug:1576-1590 | 锁类被释放/重初始化时的检查与 dep_map 接入 |
LOCKDEP |
Kconfig.debug:1591-1596 | 依赖跟踪引擎本体 |
DEBUG_LOCKDEP |
Kconfig.debug:1641-1648 | 引擎自身的数据结构自检 |
PROVE_LOCKING 是日常用语"开 lockdep"的实际所指。除锁依赖外它还连带开启中断使能轨迹跟踪(TRACE_IRQFLAGS),这是 16.4.4 节中断一致性检查的基础。
四个核心数据结构
lockdep 的世界由四个静态大表构成(kernel/locking/lockdep.c:207-225, 3326-3328, 413):
lock_classes[MAX_LOCKDEP_KEYS] 锁类数组 (lockdep.c:208)
list_entries[] 依赖边数组 (lockdep.c:210)
lock_chains[] 锁链缓存 (lockdep.c:3326)
chain_hlocks[] 锁链的索引存储 (lockdep.c:3328)
容量由编译期参数决定(include/linux/lockdep_types.h:202-204、kernel/locking/lockdep_internals.h:99-125、lib/Kconfig.debug:1601-1639):
MAX_LOCKDEP_KEYS_BITS = 13→ 最多 8192 个锁类;MAX_LOCKDEP_ENTRIES = 1 << CONFIG_LOCKDEP_BITS(默认 2^15 = 32768 条依赖边);MAX_LOCKDEP_CHAINS = 1 << CONFIG_LOCKDEP_CHAINS_BITS(默认 2^16 = 65536 条锁链)。
运行中超限会报 BUG: MAX_LOCKDEP_KEYS too low!(register_lock_class 内,lockdep.c:1331 附近),提示增大对应配置并重编。理解这四个表的关系是理解 lockdep 的一切的前提。
16.4.2 锁类与锁的实例
lock_class_key:静态实例的身份
lockdep 区分锁类(lock class)与锁实例(lock instance)。同一个数组的一万个元素是同一把"逻辑锁"(同一个锁类)的不同实例——对依赖分析而言它们行为一致,只需记录一次。锁类由编译期静态对象的地址标识:
// include/linux/lockdep_types.h:70-83
struct lockdep_subclass_key {
char __one_byte;
} __attribute__ ((packed));
struct lock_class_key {
struct lockdep_subclass_key subkeys[MAX_LOCKDEP_SUBCLASSES];
};
每把锁声明处都隐含一个 static struct lock_class_key __key(由 DEFINE_SPINLOCK/DEFINE_MUTEX 等宏在展开时生成,取 &__key 的地址作身份)。运行时通过哈希表 classhash_table(lockdep.c:413)把 key 地址映射到锁类。
// include/linux/lockdep_types.h:98-147
struct lock_class {
struct hlist_node hash_entry;
struct list_head lock_entry;
struct lockdep_subclass_key *key;
unsigned int subclass;
unsigned int dep_gen_id;
/*
* IRQ/softirq usage tracking bits:
*/
unsigned long usage_mask;
struct stack_trace usage_traces[XXX_LOCK_USAGE_STATES];
...
/*
* These fields represent a directed graph of lock dependencies,
* round-robin-dispatched...
*/
struct list_head locks_after, locks_before;
...
const char *name;
int name_version;
...
};
两个关键字段:
usage_mask:这把锁在各类上下文中的使用历史位图(16.4.4 节);locks_after/locks_before:依赖图的邻接表——"本锁 → 之后获取的锁"与反方向的边列表。
lockdep_map 是锁实例侧的登记结构(lockdep_types.h:186-204),嵌在每个锁容器里(16.1.2 节见过的 dep_map 字段):key 指向锁类身份,class_cache[2] 缓存最近解析出的 lock_class 指针避免反复查哈希,name 供报告打印。
register_lock_class:锁类注册
// kernel/locking/lockdep.c:1285-1394
static struct lock_class *
register_lock_class(struct lockdep_map *lock, unsigned int subclass, int force)
{
struct lock_class *class = NULL;
struct hlist_head *hash_head;
struct lockdep_subclass_key *key;
...
key = lock->key->subkeys + subclass;
hash_head = classhash_table + hash_pointer(key);
...
/*
* We have to do the hash-walk again, to avoid taking
* a lock-class whose key is identical to this one...
*/
hlist_for_each_entry_rcu(class, hash_head, hash_entry) {
if (class->key == key) {
...
goto out_unlock_set;
}
}
...
/*
* Allocate a new key from the static array...
*/
if (nr_lock_classes >= MAX_LOCKDEP_KEYS) {
...
dump_lock_classes();
...
pr_err("BUG: MAX_LOCKDEP_KEYS too low!\n");
...
return NULL;
}
...
/*
* We haven't seen this lock before, so we need to
* allocate a new class...
*/
class = lock_classes + nr_lock_classes++;
...
class->name = lock->name;
class->key = key;
class->subclass = subclass;
...
hlist_add_head_rcu(&class->hash_entry, hash_head);
...
首次获取一把锁时:key 哈希查找未命中 → 从 lock_classes[] 顺序分配一个槽位 → 填 name/key/subclass → 头插进哈希桶 → 写回 lock->class_cache[0]。这个惰性注册意味着 lockdep 的依赖图随内核运行逐步生长——没走过的代码路径不产生锁类,没发生过的锁序不产生依赖边,所以"跑了三天没报"只是"三天内走过的路径都干净"。
16.4.3 依赖图与死锁检测
held_lock:任务的持锁栈
每个任务在 task_struct 里有一个持有锁的数组(curr->held_locks,容量 MAX_LOCK_DEPTH = 48,include/linux/sched.h:1270):
// include/linux/lockdep_types.h:206-257
struct held_lock {
u64 prev_chain_key;
unsigned long acquire_ip;
struct lockdep_map *instance;
struct lockdep_map *nest_lock;
#ifdef CONFIG_LOCK_STAT
u64 waittime_stamp;
u64 holdtime_stamp;
#endif
unsigned int class_idx:MAX_LOCKDEP_KEYS_BITS;
...
unsigned int read:2; /* 0: exclusive, 1: read, 2: recursive read */
unsigned int check:1;
unsigned int hardirqs_off:1;
unsigned int softirqs_off:1;
unsigned int references:12;
unsigned int pin_count;
};
lock_acquire()/lock_release()(lockdep.c:5825-5894,各锁 API 经 spin_acquire/LOCK_CONTENDED 调到这里)本质上就是在维护这个数组:获取时压栈一条 held_lock,释放时弹出并在 check_prevs_add() 中与栈上更早的锁建边。
主流程:__lock_acquire()
lock_acquire() 关中断、递增 lockdep_recursion(防止 lockdep 自身用的锁被反复跟踪)后进入 __lock_acquire()(lockdep.c:5077 起),按序执行:
- 解析锁类:
register_lock_class()(16.4.2 节)查/建锁类,填hlock->class_idx; - check_wait_context:等待类型一致性(wait_type_outer/inner,本树 lockdep_map 新增字段的用途);
- mark_usage:按当前上下文更新锁类 usage 位(16.4.4 节);
- 计算 chain key:把"当前持锁栈 + 新锁"的类序号哈希成一个 64 位键,查
lock_chains[]缓存——命中则本条锁序历史上已验证过,直接返回,这是 lockdep 的主要性能优化; - check_deadlock:同锁重入检查;
- check_prevs_add:为栈上每对相邻锁建依赖边并做环检测。
check_deadlock:同锁重入
// kernel/locking/lockdep.c:3056-3097
static int
check_deadlock(struct task_struct *curr, struct held_lock *next)
{
...
for (i = 0; i < curr->lockdep_depth; i++) {
prev = curr->held_locks + i;
if (prev->instance == next->nest_lock)
nest = prev;
if (hlock_class(prev) != hlock_class(next))
continue;
/*
* Allow read-after-read recursion of the same
* lock class (i.e. read_lock(lock)+read_lock(lock)):
*/
if ((next->read == 2) && prev->read)
continue;
...
if (nest)
return 2;
print_deadlock_bug(curr, prev, next);
return 0;
}
return 1;
}
规则:同任务重复获取同一锁类的非读锁 → 直接报 DEADLOCK(print_deadlock_bug 打印两处获取点的栈);读写锁的读-读重入放行(next->read == 2 && prev->read);nest_lock(mutex_lock_nested 等的嵌套声明)命中时返回 2 表示合法递归。SINGLE_DEPTH_NESTING(10.4 节 Cow 相关分析中出现的同类机制)就是绕过此检查的显式嵌套声明。
check_prev_add:建边与 BFS 环检测
// kernel/locking/lockdep.c:3155-3170
/*
* Prove that the new <prev> -> <next> dependency would not
* create a circular dependency in the graph. (We do this by
* a breadth-first search into the graph starting at <next>,
* and check whether we can reach <prev>.)
*
* The search is limited by the size of the circular queue (i.e.,
* MAX_CIRCULAR_QUEUE_SIZE) which keeps track of a breadth of nodes
* in the graph whose neighbours are to be checked.
*/
ret = check_noncircular(next, prev, trace);
if (unlikely(bfs_error(ret) || ret == BFS_RMATCH))
return 0;
if (!check_irq_usage(curr, prev, next))
return 0;
环检测的问法值得细品:新边是 prev → next(先拿 prev 再拿 next)。要判断它是否成环,lockdep 从 next 出发做 BFS,看能否反向走到 prev——若能,则存在 next →…→ prev → next 的循环等待,即 A-B 死锁在未来某次调度中必然可复现。报错时打印的正是 BFS 找到的完整环上每条边的两端获取栈——这就是 lockdep 报告里 "Possible unsafe locking scenario" 部分的来源。每条新边还会经 check_irq_usage()(lockdep.c:2780)做中断维度的一致性证明(16.4.4 节),通过后 add_lock_to_list() 把边挂进 locks_after/locks_before 双向邻接表(lockdep.c:3235 起,冗余边剔除在 :3219-3223)。
为什么"没死锁"不等于"安全":依赖图记录的是所有可能同时发生的锁序组合。CPU 0 上 A→B、CPU 1 上 B→A 只需各发生一次(哪怕相隔几小时、从未并发),图上就已有环——lockdep 不等真实死锁,它证明的是未来必然可能。
16.4.4 中断上下文一致性:usage 位
usage 位图
死锁的另一大来源是"在持锁时开了中断,而中断处理要拿同一把锁"。lockdep 用 usage 位跟踪每把锁类在哪里被使用、在哪里被开启中断:
// kernel/locking/lockdep_internals.h:13-24
enum lock_usage_bit {
#define LOCKDEP_STATE(__STATE) \
LOCK_USED_IN_##__STATE, \
LOCK_USED_IN_##__STATE##_READ, \
LOCK_ENABLED_##__STATE, \
LOCK_ENABLED_##__STATE##_READ,
#include "lockdep_states.h"
#undef LOCKDEP_STATE
LOCK_USED,
LOCK_USED_READ,
LOCK_USAGE_STATES,
};
lockdep_states.h:7-8 目前只有 HARDIRQ 与 SOFTIRQ 两个状态,展开成 2 状态 × 4 类 = 8 个位,加 LOCK_USED/LOCK_USED_READ 共 10 位。四类语义:USED_IN_*(该锁曾在此上下文中被持有)、ENABLED_*(持有该锁期间此上下文被开启过)、_READ 变体(读方式持有)。
mark_usage:采集上下文
// kernel/locking/lockdep.c:4617-4645
static int
mark_usage(struct task_struct *curr, struct held_lock *hlock, int check)
{
if (!check)
goto lock_used;
/*
* If non-trylock use in a hardirq or softirq context, then
* mark the lock as used in these contexts:
*/
if (!hlock->trylock) {
if (hlock->read) {
if (lockdep_hardirq_context())
if (!mark_lock(curr, hlock,
LOCK_USED_IN_HARDIRQ_READ))
return 0;
...
} else {
if (lockdep_hardirq_context())
if (!mark_lock(curr, hlock, LOCK_USED_IN_HARDIRQ))
return 0;
...
__lock_acquire() 在每次获取锁时调用它:当前在硬中断里 → 置 LOCK_USED_IN_HARDIRQ(读为 _READ 变体);同时若该锁是在中断未关闭时获取的(hlock->hardirqs_off == 0),置 LOCK_ENABLED_HARDIRQ——即"持这把锁期间,硬中断可能闯进来"。mark_lock()(lockdep.c:4712-4774)置位后对 new_bit < LOCK_USED 的情况调用 mark_lock_irq()(:4256)→ valid_state()(:4051):比对锁类的 usage 位组合,发现"曾 ENABLED_HARDIRQ 又 USED_IN_HARDIRQ"这类矛盾即报 inconsistent lock usage(BUG: inconsistent lock state),报告同样附带两条路径的完整栈。
一个直观例子:驱动在 spin_lock(&lock)(未关中断)保护的临界区里调用了会开中断的函数,而中断处理程序又 spin_lock_irqsave(&lock)——第一次组合使 lock 记下 ENABLED_HARDIRQ,中断到达时又记下 USED_IN_HARDIRQ,lockdep 当场报错,而真实死锁可能一周才发生一次。
16.4.5 lockdep 在锁实现中的接入点与运行视图
接入点回顾
前三节的锁实现里,lockdep 的钩子无处不在,串起来看:
| 接入点 | 所在实现 | 作用 |
|---|---|---|
dep_map 字段 |
spinlock_types_raw.h:20-22、mutex_types.h:51-53、seqcount | 每锁实例的登记入口 |
preempt_disable() + spin_acquire() |
spinlock_api_smp.h:154-160 | 持锁栈压栈(16.1.2 节) |
LOCK_CONTENDED |
lockdep.h:441-471 | trylock 统计 + 等待登记 |
mutex_acquire_nest() |
mutex.c:614 | mutex 慢路径登记(支持 nest_lock 声明) |
rwlock_acquire() |
rwlock_api_smp.h:227-231 | 读写锁登记(read 标志区分) |
seqprop_assert_lock_held() |
seqlock.h:158-188 | 校验 seqcount 写侧持锁(16.3.6 节) |
RCU_LOCKDEP_WARN() |
rcupdate.h 多处 | RCU 读侧上下文校验(17.1 节) |
任务的退场清理
第 14.1 节分析 do_exit() 时出现过 lockdep_free_task()——任务退出时释放其 held_locks 相关状态。配套地,lockdep_init()(3.1 节 start_kernel 中调用)在启动时初始化哈希表与静态数组,locking_selftest()(kernel/locking/ 下 selftest 用例)用预设的锁场景自检引擎本身。用户态视图由 kernel/locking/lockdep_proc.c 提供:/proc/lockdep(锁类列表,lockdep_proc.c:120)、/proc/lockdep_chains(:185)、/proc/lockdep_stats(:231,四张表的占用率)——调试时先看 stats 判断是否撞容量上限。
实践要点
- 每个
DEFINE_*一把逻辑锁:同名锁若被kfree后重建(如 per-file 锁),需lockdep_register_key()/lockdep_unregister_key()换身份,否则锁类串味。 - subclass 表达锁序:
lock_acquire_nested(lock, subclass)显式声明同类锁的层次(如 inode 锁 i_mutex 系列的 I_MUTEX_PARENT/I_MUTEX_CHILD),lockdep 按子类分别跟踪,允许"父→子"禁止"子→父"。 - 误报处理:lockdep 报告也可能源于设计上就安全的锁序(如双向迁移场景),此时用
lockdep_set_subclass或lock_acquire_nest标注豁免,而不是关掉检查。 - 成本:PROVE_LOCKING 内核慢一个数量级、内存涨数 MB、
held_locks使 task_struct 变大——只用于 debug 构建、CI 压测与 syzkaller 这类 fuzzing 基建。
要点总结:
- lockdep 把死锁检测从"事后救火"变成"事前证明":运行时收集锁序依赖边,BFS 判环,中断使能/使用做交叉验证。
- 四张静态表(锁类/依赖边/锁链/链索引)各有编译期上限,超限即 BUG——lockdep 的图是有界的。
- 每条新依赖边触发反向 BFS + 中断一致性检查,靠 chain key 缓存避免重复验证。
- 接入点遍布 16.1-16.3 节所有锁容器(dep_map + 各 acquire 钩子),与 PREEMPT_RT、seqcount 关联锁、RCU 校验共同构成内核的运行时并发正确性体系。
至此第 16 章四种锁原语的实现与防护网都已展开。锁解决的是"互斥",下一章的 RCU 将展示另一极:读侧连原子操作都不要的同步。