Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

3.11 并发与同步

本章回答

内核里到底有几种「同时」?为什么 `spin_lock` 之后不能睡眠,而 `mutex_lock` 可以?`smp_mb()` 与 `READ_ONCE()` 各防什么?RCU 的「宽限期」在等谁,为什么读侧完全不用加锁?`lockdep` 凭什么能在死锁发生前就报警?优先级反转是什么,为什么实时系统必须处理它?

读完你会

列出内核并发的五个来源与对应的保护手段;按「能否睡眠、谁和谁竞争」选对锁;写出带 `READ_ONCE`/`smp_store_release` 的无锁快路径;用 RCU 保护一个链表并解释 `synchronize_rcu()` 等的是什么;读懂 `lockdep` 报告、`hung task` 与 `rcu stall`;用 `perf lock contention` 找锁热点。

前面十章里,「锁」这个词出现了几十次:zone->lock、mmap_lock、rcu_read_lock、node->list_lock。这一章把它讲透。

内核并发比用户态难,因为「同时」有五个来源,而且它们可以叠加:

  1. 多 CPU:真并行,两个核同时执行内核代码;
  2. 中断:打断当前代码,在同一个 CPU 上「插队」;
  3. 软中断/tasklet:在中断返回路径上执行,仍可能抢占进程上下文;
  4. 抢占:CONFIG_PREEMPT 下,内核代码随时可能被换出;
  5. 内核线程:workqueue、kswapd 等在各 CPU 上并发跑。

一个 spin_lock 只挡住「其他 CPU 上的 spin_lock」,挡不住本地中断(除非用 spin_lock_irqsave);一个 mutex 只挡住「会睡眠的路径」,挡不住中断上下文(中断里根本不能拿 mutex)。选错锁的后果不是「慢一点」,而是死锁、数据损坏、随机 panic。

本章的主线是按场景选工具:先讲并发来源与竞争的本质,再讲原子操作与屏障(最底层),然后自旋锁、睡眠锁、RCU(三种典型场景),最后是调试。奔跑吧第 4 章是主要母本,OSTEP 第 28–32 章给理论对照。

3.11.1 竞争从哪里来:一个计数器引发的血案

先看一个「看起来没问题」的代码:

/* 错误示范:无保护的引用计数 */
static int refcnt;

static void get_obj(void)
{
	refcnt++;          /* 编译成 load; add; store 三条指令 */
}

static void put_obj(void)
{
	if (--refcnt == 0)
		free_obj();
}

refcnt++ 不是原子操作。两个 CPU 同时执行时可能交错:

CPU0: load  refcnt (=1)          CPU1: load  refcnt (=1)
CPU0: add   1        (=2)        CPU1: add   1        (=2)
CPU0: store refcnt = 2           CPU1: store refcnt = 2
结果:两次 get,计数只加了 1 → 对象被提前释放 → use-after-free

这类错误在内核里叫 race condition,它的特点是「平时不出现,压力大时随机崩」。修复有三种层次:

手段适用代价
原子操作(atomic_t/refcount_t)单个计数、标志位一条带 lock 前缀的指令
自旋锁(spinlock_t)极短的临界区,不能睡眠忙等,关抢占
睡眠锁(mutex/semaphore/rwsem)临界区长、可能睡眠上下文切换
RCU读多写少,读侧不能阻塞写侧延迟释放,内存开销
per-CPU每 CPU 独立计数读总和要遍历

选型的第一个问题是:临界区里能不能睡眠?能,就用睡眠锁;不能(中断上下文、持 spinlock 时),只能用自旋锁或原子操作。

定义 3.11.1 · 临界区与原子上下文

临界区是「访问共享数据、必须互斥执行」的代码段。原子上下文是「不能睡眠」的执行环境,包括:硬中断处理、软中断/tasklet、持有 spinlock_t/rcu_read_lock()(非抢占 RCU 下)、preempt_disable() 区段。判断方法:in_interrupt()、in_atomic()、preempt_count();调试配置下 might_sleep() 会在原子上下文里睡眠时打印警告。

3.11.2 原子操作与内存屏障:CPU 的重排序

原子操作

atomic_t 是一组「读-改-写」原子接口,底层是带 lock 前缀的指令(x86)或 ldxr/stxr 循环(ARM64):

atomic_t counter = ATOMIC_INIT(0);

atomic_inc(&counter);              /* counter++,原子 */
int v = atomic_read(&counter);     /* 普通读,不加锁 */
atomic_set(&counter, 10);
int old = atomic_xchg(&counter, 20);
int prev = atomic_cmpxchg(&counter, 20, 30);  /* 相等则换,返回旧值 */

refcount_t 是「带溢出保护的引用计数」,2017 年之后内核强制用它替代 atomic_t 做引用计数:

refcount_t refs = REFCOUNT_INIT(1);
refcount_inc(&refs);               /* 从 0 增加会 WARN 并拒绝 */
if (refcount_dec_and_test(&refs))
	free_obj();                /* 归零返回 true */

它比 atomic_t 多做了两件事:溢出时饱和(不会回绕到 0)、从 0 增加时报警(说明有 use-after-free)。代价是多几条指令,值得。

内存屏障

原子性解决「不丢更新」,但解决不了「顺序」。CPU 和编译器都会重排内存访问:

/* 生产者 */
data = 42;
ready = 1;          /* 消费者看到 ready==1 时,data 一定是 42 吗? */

/* 消费者 */
while (!ready)
	;
print(data);        /* 可能是 0! */

在 x86 的 TSO 模型下,store-store 不会重排,所以这段在 x86 上「通常」正确;在 ARM64 的弱内存模型下,ready = 1 可能先于 data = 42 可见,消费者读到 0。编译器也可能把 data 的写挪到 ready 之后(它不知道别的 CPU 在看)。

正确的写法:

/* 生产者 */
WRITE_ONCE(data, 42);
smp_store_release(&ready, 1);      /* 保证之前的写先可见 */

/* 消费者 */
while (!smp_load_acquire(&ready))
	;
print(READ_ONCE(data));            /* 一定看到 42 */

READ_ONCE/WRITE_ONCE 只阻止编译器优化(把访问合并、删除、重排),不产生硬件屏障;smp_load_acquire/smp_store_release 同时提供编译器与硬件语义(x86 上 release 就是普通 store,acquire 就是普通 load,因为 TSO 已经保证;ARM64 上是 ldar/stlr)。

屏障家族:

接口作用典型用途
barrier()仅编译器屏障防止编译器跨过它重排
mb() / rmb() / wmb()全/读/写硬件屏障与设备或异架构交互
smp_mb() / smp_rmb() / smp_wmb()SMP 下的硬件屏障(UP 下退化为编译器屏障)核间同步
smp_load_acquire() / smp_store_release()获取/释放语义无锁发布指针、标志位
dma_wmb() / dma_rmb()DMA 一致性屏障驱动与设备共享描述符
io_stm()/io_ldm()I/O 顺序MMIO 寄存器序列
内存屏障的五个误解
  1. 「volatile 能当屏障」:volatile 只保证「每次都从内存读/写」,不保证顺序,也不保证原子性。内核几乎不用 volatile 做同步(寄存器定义除外)。
  2. 「WRITE_ONCE 有顺序保证」:它只防编译器,不防 CPU 重排。要顺序得配 smp_store_release 或 smp_mb()。
  3. 「原子操作会顺带保证周围内存的顺序」:默认不会(relaxed)。atomic_inc() 前后的普通读写可以重排;需要顺序用 atomic_inc_return() + 屏障,或 smp_mb__after_atomic()。
  4. 「x86 上不需要屏障」:x86 的 TSO 确实强,但 store-load 仍可重排(StoreLoad),且编译器重排与架构无关。写可移植代码要按最弱模型思考。
  5. 「屏障越多越安全」:多余的屏障会显著降低性能(尤其是 mfence)。屏障要配对:发布侧一个 release,消费侧一个 acquire,不要到处撒。

3.11.3 自旋锁:短临界区的忙等

spinlock_t 是「拿不到就原地转」的锁,临界区必须极短,且绝对不能睡眠。

/* include/linux/spinlock.h 的典型用法 */
spinlock_t lock;

spin_lock(&lock);
/* 临界区:几条指令,不调用可能睡眠的函数 */
spin_unlock(&lock);

/* 如果临界区会被中断处理程序访问,必须关本地中断 */
unsigned long flags;
spin_lock_irqsave(&lock, flags);
...
spin_unlock_irqrestore(&lock, flags);

/* 如果只和软中断竞争 */
spin_lock_bh(&lock);
...
spin_unlock_bh(&lock);

三种变体的区别:

接口关抢占关本地中断关软中断用在
spin_lock是否否只和进程上下文竞争
spin_lock_bh是否是和 softirq/tasklet 竞争
spin_lock_irqsave是是(保存原状态)是和硬中断竞争
spin_lock_irq是是(不保存)是确定中断是开的

选错的经典后果是死锁:进程上下文拿了 spin_lock,本地中断来了,中断处理程序也去拿同一把锁——同一个 CPU 上自旋,永远拿不到。spin_lock_irqsave 就是为这个场景准备的。

6.x 的 spinlock_t 底层是 qspinlock(队列自旋锁,4.2+):每个等待者在 per-CPU 的 MCS 队列节点上自旋,而不是所有人抢同一个 cache line。这解决了「锁释放时所有 CPU 同时抢总线」的惊群问题。CONFIG_QUEUED_SPINLOCKS 默认开。

spin_lock 内部会 preempt_disable(),所以临界区里不能调用可能睡眠的函数(might_sleep() 会报警)。等待期间 CPU 空转(cpu_relax() 里是 pause 指令),所以临界区超过几微秒就该考虑换锁。

定义 3.11.2 · 读写锁与顺序锁
  • rwlock_t:多读单写。读侧并发,写侧独占。缺点是不公平(读者源源不断时写者饿死),且读侧也要关抢占。新代码读多写少优先用 RCU 或 seqlock。
  • seqlock_t:写侧加锁并递增序号,读侧不加锁,读完检查序号是否变化(变化或为奇数就重试)。适合「读多、写少、读侧不能阻塞、数据可以重读」的场景(如 jiffies、时间戳)。
  • seqcount_t:seqlock 的「序号」部分,由调用者自己提供写侧互斥(如 seqcount_mutex_t)。

一个用 seqlock 的读侧:

/* 读侧:无锁,检查序号重试 */
u64 read_counter(struct my_dev *dev)
{
	unsigned int seq;
	u64 val;

	do {
		seq = read_seqbegin(&dev->seq);
		val = dev->counter;
	} while (read_seqretry(&dev->seq, seq));
	return val;
}

/* 写侧:加锁、更新、递增序号 */
void update_counter(struct my_dev *dev, u64 v)
{
	write_seqlock(&dev->seq);
	dev->counter = v;
	write_sequnlock(&dev->seq);
}

read_seqbegin() 里含 smp_rmb(),read_seqretry() 里含 smp_mb();写侧在修改前后各递增一次序号(变成奇数→偶数),读侧看到奇数或变化就重试。它的前提是「读侧可以重复读」——如果读操作有副作用,不能用。

3.11.4 睡眠锁:mutex、semaphore、completion

临界区长、或者需要睡眠(分配内存、等 I/O、拷贝用户数据)时,用睡眠锁。它们在内核里由 struct mutex、struct semaphore、struct completion 实现,共同点是拿不到就睡眠,因此只能在进程上下文使用。

接口语义可递归优先级继承典型用途
mutex二值互斥否否大多数「保护一段数据」的场景
ww_mutexwound/wait 互斥否否多个资源需要固定顺序加锁(GPU)
rt_mutex带优先级继承的互斥否是实时任务共享资源
semaphore计数信号量—否生产者-消费者、限流
rwsem读写信号量—是(写者)读多写少且可能睡眠(mmap_lock)
completion一次性/计数完成通知——等初始化完成、等 I/O 结束

mutex 的几个硬规则:

  • 不能递归:同一个任务重复 mutex_lock() 会死锁(CONFIG_DEBUG_MUTEXES 会检测)。
  • 不能用于中断上下文:mutex_lock() 可能睡眠。
  • 持有期间不能 kfree() 掉锁本身,也不能让锁所在的内存失效。
  • mutex_unlock() 必须由持有者调用(内核会检查 owner)。

mutex 在 6.x 有一个优化:CONFIG_MUTEX_SPIN_ON_OWNER 允许在「owner 正在别的 CPU 上跑」时先自旋一会儿,避免立即睡眠的上下文切换开销。这是「乐观自旋」:如果 owner 很快释放,自旋比睡眠快;如果 owner 被抢占,自旋会浪费 CPU,所以有阈值。

completion 是最轻的「等一个事件」:

struct completion done;
init_completion(&done);

/* 等待方 */
wait_for_completion(&done);                 /* 睡眠直到 complete */
wait_for_completion_timeout(&done, HZ);     /* 带超时,返回剩余 jiffies */

/* 通知方(可以在中断上下文) */
complete(&done);                            /* 唤醒一个等待者 */
complete_all(&done);                        /* 唤醒所有,且之后 wait 立即返回 */

complete() 可以在中断上下文调用(它只用 spinlock),这是它比 mutex 更适合「中断通知进程」的原因。

睡眠锁的五个坑
  1. 在 spin_lock 里 mutex_lock:直接触发 BUG: sleeping function called from invalid context,或死锁。
  2. mutex_lock 后 goto 忘了 unlock:内核没有 RAII,每个错误分支都要解锁。用 cleanup.h 的 guard(mutex)(&m)(6.5+)可以自动解锁。
  3. down() 与 down_interruptible() 混用:前者不可被信号打断,后者返回 -EINTR。混用会让信号语义混乱。
  4. complete() 早于 wait_for_completion():completion 有计数,早到的 complete() 会被记住,后续 wait 立即返回——这不是 bug,是设计。但 complete_all() 之后计数是 UINT_MAX/2,不能重用。
  5. rwsem 读锁里再拿写锁:同一任务会死锁(读锁未释放,写锁等所有读者退出)。递归读锁在 rwsem 上也不保证成功。

3.11.5 RCU:读侧零开销的同步

RCU(Read-Copy-Update)解决的是「读多写极少」的场景:链表、路由表、配置、struct file 的 f_op。它的核心思想是写侧复制、延迟释放:

  1. 写侧不修改正在被读的数据,而是复制一份、改副本、把指针原子地换过去;
  2. 旧数据不能立刻释放——可能还有读者在用;
  3. 等所有「替换前就开始读」的读者都结束后,再释放旧数据。这段等待叫宽限期(grace period)。
/* 读侧:不需要锁,只需要 RCU 读锁(在 PREEMPT_RCU 下只是标记) */
rcu_read_lock();
p = rcu_dereference(global_ptr);
if (p)
	use(p);                 /* 不能睡眠,不能交出 p 的所有权 */
rcu_read_unlock();

/* 写侧:分配新对象、改副本、发布、等待、释放 */
new = kmalloc(...);
*new = *old;
new->field = value;
rcu_assign_pointer(global_ptr, new);   /* 发布:release 语义 */
synchronize_rcu();                      /* 等所有旧读者退出 */
kfree(old);
时间 ─────────────────────────────────────────────────────────►
       │ 写侧:rcu_assign_pointer(p, new)
       │           │
       │           ├── 宽限期开始(所有 CPU 记录当前状态)
       │           │
  读者 A ─────────┼──── 读 old ────┐
       │           │                │ 读锁释放 → quiescent state
  读者 B ─────────┼──────── 读 old ────────┐
       │           │                        │
  读者 C ─────────────────┼── 读 new(看到新指针)
       │           │                        │
       │           ▼                        ▼
       │      synchronize_rcu() 返回(所有旧读者都退出)
       │           │
       │           └── kfree(old) 安全

读图:宽限期不是「固定时长」,而是「等所有在替换前进入读侧的 CPU 都经过一次静止状态(quiescent state)」。静止状态包括:上下文切换、返回用户态、进入 idle、显式 rcu_quiescent_state()。所以一个「一直占着 CPU 不切换、也不进内核」的读者会拖住宽限期——这是 rcu stall 的常见原因。

RCU 的 API 分两类:

场景读侧写侧
指针发布rcu_dereference()rcu_assign_pointer()
链表遍历list_for_each_entry_rcu()list_add_rcu()/list_del_rcu()
同步等待—synchronize_rcu()(阻塞)
异步释放—call_rcu(&head, func)
简单释放—kfree_rcu(ptr, rhf)
等待所有回调—rcu_barrier()

rcu_read_lock() 在 6.8 的 CONFIG_PREEMPT_RCU 下不关抢占,只是给当前任务加一个「在读侧」的标记;读侧可以被抢占,甚至可以在读侧睡眠吗?不行——被抢占可以,主动睡眠(schedule() 以外的阻塞)会让宽限期无法推进,且 rcu_read_unlock() 可能在不同 CPU 上执行。需要睡眠的读侧用 SRCU(sleepable RCU)。

RCU 的变体:

变体特点用途
RCU(preemptible)读侧可抢占通用
RCU-bh读侧关下半部网络收包(已合并进 RCU)
RCU-sched读侧关抢占调度器、早期启动
SRCU读侧可睡眠需要睡眠的读侧
Tasks RCU跟踪任务CONFIG_TASKS_RCU

写侧要小心「发布顺序」:rcu_assign_pointer() 之前对 new 的所有初始化必须完成,否则读者可能看到半成品。rcu_assign_pointer() 内含 release 屏障,rcu_dereference() 内含 acquire 依赖屏障,两者配对。

RCU 的调优与观测

/sys/kernel/debug/rcu/ 下有每个 flavor 的状态;rcupdate.rcu_cpu_stall_timeout(默认 21 秒)控制 stall 报警阈值;rcu_nocbs=2,3 把回调处理卸载到专门的 rcuoc/N 线程(实时场景常用);rcu_expedited 让 synchronize_rcu() 走 IPI 快速路径(代价是干扰其他 CPU)。rcu_barrier() 用于模块卸载前等待所有回调完成。

3.11.6 per-CPU 与无锁编程

如果数据本来就是「每个 CPU 一份」,根本不需要锁:

/* 定义 per-CPU 变量 */
static DEFINE_PER_CPU(u64, packet_count);

/* 本 CPU 递增:单条指令,无锁 */
this_cpu_inc(packet_count);

/* 读本 CPU 的值:需要关抢占,防止迁移到别的 CPU */
u64 n = this_cpu_read(packet_count);

/* 读所有 CPU 的总和 */
u64 total = 0;
int cpu;
for_each_possible_cpu(cpu)
	total += per_cpu(packet_count, cpu);

this_cpu_* 系列在 x86 上编译成「带 %gs 段前缀的指令」,单条指令完成,比「关抢占 + 取指针 + 操作 + 开抢占」快得多。代价是「读总和」要遍历所有 CPU,且结果不是某一时刻的快照(各 CPU 并发更新)。

get_cpu_var()/put_cpu_var() 是「关抢占 + 取引用」的组合;6.x 推荐用 local_lock_t(5.16+)表达「per-CPU + 需要保护」的场景:

static DEFINE_PER_CPU(struct stats, stats);
static DEFINE_PER_CPU(local_lock_t, stats_lock);

local_lock(&stats_lock);
this_cpu_inc(stats.packets);
local_unlock(&stats_lock);

local_lock 在非 RT 内核上退化为「关抢占」,在 PREEMPT_RT 上变成真正的 per-CPU 睡眠锁——同一份代码在两种内核上都正确,这是它被推荐的原因。

无锁编程的其他工具:

工具用途
cmpxchg 循环无锁更新(CAS)
llist无锁单链表(llist_add/llist_del_all)
kfifo单生产者/单消费者无锁队列
static_key/jump_label运行时打补丁的开关(tracepoint 用)
refcount_t带溢出保护的引用计数

无锁不等于「没有同步」:它把锁换成了「原子操作 + 内存序 + 重试」,正确性更难保证。内核的原则是「除非有明确收益(热路径、中断上下文),否则用锁」。

3.11.7 死锁、优先级反转与锁调试

四种典型死锁

类型场景预防
ABBA任务 1 拿 A 等 B,任务 2 拿 B 等 A全局锁序;ww_mutex
自死锁同一任务重复拿同一把非递归锁不在持锁时重入;mutex 调试检测
中断反转进程持锁,中断处理程序拿同一把锁spin_lock_irqsave
优先级反转低优先级持锁,高优先级等待,中优先级抢占低优先级优先级继承(rt_mutex)

优先级反转是实时系统的经典问题:低优先级任务 L 持有锁,高优先级任务 H 等锁;此时中优先级任务 M 就绪,抢占了 L,导致 H 被 M 间接阻塞。解法是优先级继承:H 等锁时,L 临时提升到 H 的优先级,尽快执行完释放锁。rt_mutex 实现这套机制,PREEMPT_RT 把所有 spinlock 换成 rt_mutex。

lockdep:在死锁发生前报警

lockdep(CONFIG_PROVE_LOCKING)不需要真的死锁就能发现「可能死锁」:它记录每把锁的获取顺序,构建一张「锁类依赖图」,一旦发现环就报警。

# 打开 lockdep 后,内核会打印类似报告:
======================================================
WARNING: possible circular locking dependency detected
------------------------------------------------------
kworker/0:1/123 is trying to acquire lock:
ffff888012345678 (&b->lock){+.+.}-{3:3}, at: func_b+0x1a/0x40

but task is already holding lock:
ffff888087654321 (&a->lock){+.+.}-{3:3}, at: func_a+0x2b/0x50

other info that might help us debug this:
 Possible unsafe locking scenario:
       CPU0                    CPU1
       ----                    ----
  lock(&a->lock);
                               lock(&b->lock);
                               lock(&a->lock);
  lock(&b->lock);

读 lockdep 报告的三步:① 看「谁在等谁」(holding / trying to acquire);② 看 Possible unsafe locking scenario 的交叉顺序;③ 找代码里对应的加锁点,统一锁序或改用 ww_mutex。

配套的调试设施:

设施配置抓什么
lockdepCONFIG_PROVE_LOCKING锁序环、睡眠上下文
DEBUG_ATOMIC_SLEEPCONFIG_DEBUG_ATOMIC_SLEEP原子上下文睡眠
DEBUG_MUTEXESCONFIG_DEBUG_MUTEXES递归锁、非持有者解锁
DEBUG_SPINLOCKCONFIG_DEBUG_SPINLOCK未初始化锁、重复解锁
hung taskCONFIG_DETECT_HUNG_TASK120 秒不调度的 D 状态任务
rcu stall默认开启宽限期超时
sysrq-w—打印所有阻塞任务的内核栈

hung task 报告长这样:

INFO: task java:1234 blocked for more than 120 seconds.
      Not tainted 6.8.0 #1
"echo 0 > /proc/sys/kernel/hung_task_timeout_secs" disables this message.
task:java            state:D stack:0     pid:1234  ppid:1      flags:0x00004002
Call Trace:
 __schedule+0x2c3/0x8a0
 schedule+0x5e/0xe0
 io_schedule+0x46/0x70
 ...

state:D(不可中断睡眠)+ 栈顶是 io_schedule,说明卡在 I/O;栈顶是 mutex_lock,说明等锁。sysrq-w 能一次性打印所有 D 状态任务,是「机器卡住但还能响应 sysrq」时的第一手材料。

锁问题的排查顺序
  1. 先看是不是死锁:sysrq-w(或 echo w > /proc/sysrq-trigger)打印阻塞任务栈;有环就是死锁。
  2. 看 lockdep 有没有报:dmesg | grep -A30 'possible circular';开了 lockdep 的话,死锁前就有报告。
  3. 看是不是活锁/饥饿:perf lock contention -a sleep 5 找争用最重的锁与调用栈。
  4. 看是不是优先级反转:实时任务延迟高但 CPU 不满,查 rt_mutex 的 PI 链(/proc/PID/sched 的 prio 变化)。
  5. 看是不是 RCU stall:dmesg | grep 'rcu.*stall',找长时间不调度或关抢占的代码。
与 OSTEP、CSAPP 的对照

OSTEP 第 31 章用信号量解生产者/消费者、读者—写者、哲学家就餐;第 32 章把并发缺陷分成原子性违反、顺序违反与死锁,并给出死锁四条件(互斥、持有并等待、非抢占、循环等待)。CSAPP 第 12 章用进度图(progress graph)把两条指令流的交错画成二维区域,死锁表现为两条轨迹都停在禁用区边缘、无法继续推进。内核里的对应物:semaphore 对应计数信号量,mutex 对应二值信号量,lockdep 的环检测对应“循环等待”,ww_mutex 的 wound/wait 对应“破坏持有并等待”。把这张图与 3.11.12 的 PI 时间线一起看,比背 API 更能解释“为什么需要这些机制”。

mm 的锁矩阵:一把锁保护什么

锁保护对象持有者能否睡眠
mmap_lock(读写)VMA 树、页表结构缺页、mmap/munmap可以(写侧)
pte_lock(spinlock)单个 PTE缺页、mprotect不能
zone->lock伙伴系统 free_area页分配/释放不能
PG_locked(page flag)单个 page 的 I/O回写、锁页可以等
slab_lock/kmem_cacheslab freelist分配/释放对象不能(部分)
anon_vma->lockanon_vma 链RMAP 遍历不能

顺序规则:先 mmap_lock 再 pte_lock,先 PG_locked 再 zone->lock;反向持锁是 lockdep 报告的典型 ABBA。mmap_lock 本身仍是 rwsem;6.4 引入的 per-VMA lock 是缺页路径的补充,读缺页可以只锁 VMA,但不是替换 mmap_lock。

3.11.8 锁的结构体:字段、缓存行与所有权

3.11.3–3.11.5 讲了“用哪种锁”,这一节讲“锁里面是什么”。字段布局决定了三件事:锁占几个字节、争用时谁在哪个 cache line 上自旋、调试信息从哪里来。

spinlock:从 ticket 到 qspinlock

/* include/linux/spinlock_types.h —— 6.8 节选 */
typedef struct spinlock {
	union {
		struct raw_spinlock rlock;
#ifdef CONFIG_DEBUG_LOCK_ALLOC
		struct {
			u8 __padding[LOCK_PADSIZE];
			struct lockdep_map dep_map;
		};
#endif
	};
} spinlock_t;

typedef struct raw_spinlock {
	arch_spinlock_t raw_lock;
#ifdef CONFIG_DEBUG_SPINLOCK
	unsigned int magic, owner_cpu;
	void *owner;
#endif
} raw_spinlock_t;

x86 的 arch_spinlock_t 是 4 字节的 qspinlock:

/* arch/x86/include/asm/qspinlock_types.h —— 6.8 */
typedef struct qspinlock {
	union {
		atomic_t val;
		struct {
			u8 locked;
			u8 pending;
		};
		struct {
			u16 locked_pending;
			u16 tail;
		};
	};
} arch_spinlock_t;
字段/位含义谁写
locked(bit 0)锁是否被持有持锁者 release 时清
pending(bit 1)有人排队但还没拿到第一个自旋者置位
tail(bit 16–31)MCS 队列尾(CPU 号 + 1)入队者用 xchg_tail() 更新
val整个 32 位原子值atomic_cmpxchg 一次改

qspinlock 的关键是 tail:每个等待者在自己的 per-CPU MCS 节点上自旋,而不是所有人抢同一个 cache line。锁释放时只需把 locked 清掉并唤醒队头,避免“惊群”式总线风暴。4.2 之前是 ticket spinlock,所有人在同一个 next/owner 上自旋;核数一多,释放锁的那次写会让所有等待者的 cache line 同时失效。

CONFIG_DEBUG_SPINLOCK 会在锁后面追加 magic/owner_cpu/owner,用来抓“未初始化”“重复解锁”“非持有者解锁”。这些字段让 spinlock_t 从 4 字节膨胀到几十字节,生产内核通常关闭。

mutex:owner + 等待队列 + 乐观自旋

/* include/linux/mutex.h —— 6.8 节选 */
struct mutex {
	atomic_long_t		owner;		/* 低 3 位是 flags,高位是 task 指针 */
	raw_spinlock_t		wait_lock;
#ifdef CONFIG_MUTEX_SPIN_ON_OWNER
	struct optimistic_spin_queue osq;	/* 乐观自旋队列 */
#endif
	struct list_head	wait_list;
#ifdef CONFIG_DEBUG_MUTEXES
	void			*magic;
#endif
};
字段含义边界
owner持有者 task + flags(MUTEX_FLAG_WAITERS/HANDOFF/PICKUP)0 表示无人持有;低 3 位不是地址的一部分
wait_lock保护 wait_list 与 owner 状态转换极短临界区,不睡眠
osq乐观自旋队列(CONFIG_MUTEX_SPIN_ON_OWNER)owner 在跑才自旋;被抢占就睡
wait_listmutex_waiter 链表FIFO,但 HANDOFF 会指定下一个持有者

owner 的低位编码是 mutex 实现里最绕的部分:MUTEX_FLAG_WAITERS 表示有人在等,MUTEX_FLAG_HANDOFF 表示解锁时直接把锁交给队头,MUTEX_FLAG_PICKUP 表示队头已接手。mutex_unlock() 看到 WAITERS 会走慢路径,唤醒队头并可能设置 HANDOFF。读懂这三个 flag,才能解释“为什么 mutex 的解锁有时比加锁还慢”。

rwsem、semaphore、completion

结构体关键字段语义
rw_semaphorecount(读者计数 + 写者位)、owner、osq、wait_list多读单写;写者优先,避免读者饿死写者
semaphorelock、count、wait_list计数信号量;count 可为任意非负数
completiondone、wait(swait_queue_head)一次性/计数完成通知;done 是累计完成数
seqlock_tseqcount(序号)+ spinlock_t lock写侧互斥 + 序号;读侧无锁重试
seqcount_tsequence(奇数表示写进行中)只提供序号,写侧互斥由调用者负责
callback_head/rcu_headnext、funcRCU 回调;rcu_head 是 callback_head 的别名

rw_semaphore 的 count 不是简单的“读者数”:最高位表示写者持有/等待,低位是读者计数。down_read() 在写者等待时会排队(RWSEM_WAITING_BIAS),这是“写者优先”的实现方式。读多写少且写者可能饿死的场景,应该用 RCU 或 seqlock,而不是 rwsem。

锁与 cache line

一个 spinlock_t 只有 4 字节,但它所在的 cache line 可能还放着别的热数据。两个 CPU 争同一把锁时,cache line 在两者之间来回弹(cache line bouncing),每次弹的成本是几十到几百纳秒。两个工程手段:

  1. __cacheline_aligned:把锁单独放到一个 cache line,避免与数据互相失效。
  2. per-CPU 分片:每个 CPU 一把锁 + 一份数据,读总和时再合并(3.11.6 的 per-CPU 思路)。

pahole -C mutex vmlinux 能看到锁的实际大小与对齐;perf c2c 能抓到 cache line 争用(需要硬件支持)。

定义 3.11.3 · 锁的“所有权”

内核锁的所有权不是语言层面的概念,而是记录在锁结构里的状态:spinlock_t 用 locked 位表示“有人持有”,不记录是谁;mutex 用 owner 记录 task 指针;rt_mutex 用 owner + pi_waiters 记录优先级继承链。所有权信息越完整,能做的检查越多(递归检测、非持有者解锁检测、PI),代价是更多字段与更慢的路径。

锁结构体的四个坑
  1. “spinlock_t 就是一个 int”:开了 DEBUG_SPINLOCK/LOCKDEP 后它可能几十字节;驱动里用 sizeof(spinlock_t) 做 ABI 假设会错。
  2. “mutex.owner 直接就是 task 指针”:低 3 位是 flags,必须用 mutex_owner() 掩码后再解引用。
  3. “rw_semaphore.count 是读者数”:它编码了写者位与等待偏置,直接读会得到负数或巨大值。
  4. “rcu_head 是一个独立结构”:6.x 里它是 callback_head 的宏别名,kfree_rcu(ptr, rhf) 依赖字段名 rhf 在结构体里的偏移。

3.11.9 原子操作与内存序:从 API 到 litmus test

3.11.2 给了 READ_ONCE/smp_store_release 的最小用法。这一节把原子 API 补齐,并用两个 litmus test 说明“为什么 x86 上能跑、ARM64 上会挂”。

原子 API 的分层

层接口语义典型用途
读/写READ_ONCE/WRITE_ONCE单次访问,防编译器合并/重排标志位、无锁读
基础原子atomic_read/set/inc/dec/add/sub读-改-写原子,默认 relaxed计数器
返回旧值atomic_fetch_add/sub/xchg/cmpxchg返回操作前的值引用计数、CAS 循环
条件原子atomic_add_unless、atomic_try_cmpxchg条件成立才改无锁链表、状态机
带序原子atomic_set_release、atomic_read_acquire、smp_mb__after_atomic显式内存序发布/订阅
引用计数refcount_*溢出饱和 + 0→1 报警对象生命周期
64 位atomic64_*、atomic_long_*架构相关(32 位机可能用锁)统计、指针打包

atomic_t 的实现是 typedef struct { int counter; } atomic_t;,所有操作通过 arch_atomic_* 落到 lock 前缀指令(x86)或 ldxr/stxr 循环(ARM64)。atomic_read() 是普通读,不带屏障;需要 acquire 语义时用 atomic_read_acquire() 或读后加 smp_mb()。

refcount 的饱和语义

refcount_t refs = REFCOUNT_INIT(1);

refcount_inc(&refs);            /* 1 -> 2 */
if (refcount_dec_and_test(&refs))
	free_obj();             /* 2 -> 1 -> 0,返回 true */
if (!refcount_inc_not_zero(&refs))
	/* 已经是 0:对象正在销毁,不能再拿引用 */

refcount_t 比 atomic_t 多做的保护:

场景atomic_trefcount_t
从 0 增加变成 1,use-after-free 被掩盖WARN,拒绝增加(保持 0)
溢出回绕到负数饱和到 REFCOUNT_SATURATED,永久报警
减到 0 再减下溢成负数WARN,拒绝
并发 dec_and_test需要额外屏障内置 release/acquire

代价是每条操作多几条指令与分支。引用计数一律用 refcount_t,不要用 atomic_t——这是 2017 年之后内核的硬规则。

CAS 循环的写法

无锁更新用 cmpxchg 循环,模板是“读—算—试”:

/* 把 obj->state 从 OLD 改成 NEW,失败则重读重试 */
static bool try_transition(struct obj *o, int old, int new)
{
	int cur = atomic_read(&o->state);

	do {
		if (cur != old)
			return false;
	} while (!atomic_try_cmpxchg(&o->state, &cur, new));
	return true;
}

atomic_try_cmpxchg() 与 atomic_cmpxchg() 的区别:前者在失败时把 cur 更新为当前值,循环里不用再读一次;后者只返回旧值。x86 上两者都编译成 lock cmpxchg,差别在循环体的指令数。

CAS 循环的三个陷阱:

  1. ABA:值从 A 变 B 又变回 A,CAS 会成功,但中间状态已经丢了。解法是加版本号/标记位(3.11.11)。
  2. 活锁:高争用时所有线程都在重试,CPU 空转。解法是退避(cpu_relax()、指数退避)或换锁。
  3. 内存序:cmpxchg 默认是 full barrier;atomic_try_cmpxchg_acquire/release 可以降序,但要与另一侧配对。

两个 litmus test

消息传递(store buffer):

/* CPU0 */                    /* CPU1 */
data = 42;                    while (!flag)
smp_store_release(&flag, 1);      ;
                              r = smp_load_acquire(&data);
架构不加屏障的结果原因
x86(TSO)通常正确store-store 不重排,store-load 才重排
ARM64(弱序)r 可能读到 0store 可以乱序到达缓存一致性点

IRIW(独立读独立写):

/* CPU0: */ x = 1; smp_wmb(); 
/* CPU1: */ y = 1; smp_wmb();
/* CPU2: */ r1 = y; smp_rmb(); r2 = x;
/* CPU3: */ r3 = x; smp_rmb(); r4 = y;

在弱内存模型下,CPU2 可能看到 y=1, x=0,CPU3 同时看到 x=1, y=0——两个读者对写顺序的判断相反。x86 的 TSO 不允许这种结果;ARM64 需要 smp_mb() 而不是 smp_rmb()/smp_wmb() 才能排除。

屏障矩阵

接口阻止的重排x86 实现ARM64 实现
barrier()编译器空空
smp_rmb()LoadLoad空(TSO 保证)dmb ishld
smp_wmb()StoreStore空(TSO 保证)dmb ishst
smp_mb()全部四种mfence 或 lock adddmb ish
smp_load_acquire()后续访问不能上移普通 loadldar
smp_store_release()之前访问不能下移普通 storestlr
dma_wmb()/dma_rmb()设备描述符顺序空/mfencedmb 变体
io_stm()/io_ldm()MMIO 顺序sfence/lfencedmb

“x86 不需要屏障”是半真半假:TSO 确实保证 LoadLoad、LoadStore、StoreStore,但 StoreLoad 仍可重排。所以“写标志 → 读数据”的 Dekker 型代码在 x86 上也需要 mfence 或 lock 前缀。

KCSAN 与 data_race

数据竞争不一定会崩,可能只是读到撕裂值。CONFIG_KCSAN 用编译器插桩检测“无标记的并发访问”,报告里会指出两个访问点。对于“故意不加锁、但能接受撕裂”的统计字段,用 data_race() 标注,告诉 KCSAN 这是有意为之:

/* 统计字段:允许撕裂,只要最终一致 */
data_race(stat->packets++);

data_race() 不是屏障,也不保证原子性;它只是让工具闭嘴。真正的共享状态仍要用 READ_ONCE/WRITE_ONCE 或锁。

原子与屏障的五个坑
  1. “atomic_inc() 自带 full barrier”:默认是 relaxed(除少数架构);需要顺序时用 atomic_inc_return() + smp_mb__after_atomic() 或 atomic_fetch_add_acquire()。
  2. “READ_ONCE 能防止 CPU 重排”:它只防编译器;CPU 侧的顺序要 smp_load_acquire() 或 smp_mb()。
  3. “volatile 可以替代 READ_ONCE”:volatile 不保证原子性,也不保证与其他访问的顺序;内核只在 MMIO 寄存器等场景用它。
  4. “refcount_dec_and_test() 返回 true 就可以立刻 free”:还要保证没有新读者能拿到引用(通常配合 refcount_inc_not_zero() 或 RCU)。
  5. “屏障越多越安全”:mfence 在热路径上可能占 20–30 个周期;屏障要配对,不要撒胡椒面。

3.11.10 RCU 的宽限期:等谁、怎么等、等不到怎么办

3.11.5 讲了 RCU 的“复制—替换—延迟释放”。这一节回答三个更底层的问题:宽限期(grace period, GP)由谁推进?call_rcu() 的回调在哪里执行?宽限期卡住时内核怎么报警?

Tree RCU 的三层结构

rcu_state(全局)
├── rcu_gp_kthread      推进 GP 的内核线程
├── rcu_gp_seq          GP 序号(每完成一次 +4)
├── node[]              rcu_node 树(按 CPU 数分层)
│    └── rcu_node       每个节点负责一组 CPU 的 QS 汇总
└── rcu_data(per-CPU)
     ├── cblist          回调链表(分段:NORMAL/WAIT/DONE)
     ├── gp_seq          本 CPU 看到的 GP 序号
     └── qs_pending      是否还未报告静止状态
结构作用关键字段
rcu_state全局 GP 状态机gp_seq、gp_kthread、n_online_cpus、expedited
rcu_node树节点,汇总子节点 QSgp_seq、qsmask、expmask、lock
rcu_data每 CPU 回调与 QS 状态cblist、gp_seq、qs_pending、cpu
rcu_segcblist回调分段链表head、tails[]、len

GP 的推进不是“等固定时间”,而是自底向上收集静止状态(quiescent state, QS):每个 CPU 报告一次 QS,rcu_node 汇总,根节点收齐后 rcu_gp_kthread 推进序号。

什么算静止状态

QS触发点说明
上下文切换__schedule() 里 rcu_note_context_switch()最常见;任务切换意味着旧读侧不可能还在跑
返回用户态rcu_user_enter()用户态不会持有 rcu_read_lock()
进入 idlercu_idle_enter()idle 循环里没有读侧
显式报告rcu_qs() / rcu_all_qs()长循环里主动报告
软中断结束rcu_softirq_qs()软中断上下文退出

关键:QS 不是“读锁释放”。rcu_read_unlock() 只是减少嵌套计数;真正让 GP 前进的是“这个 CPU 经过了一个不可能持有读锁的状态”。所以一个在用户态死循环、从不进内核的进程,不会阻止 GP;一个在内核里关抢占死循环的 CPU,会。

call_rcu 的回调路径

/* kernel/rcu/tree.c: call_rcu() —— 6.8 节选,已简化 */
void call_rcu(struct rcu_head *head, rcu_callback_t func)
{
	unsigned long flags;
	struct rcu_data *rdp;

	head->func = func;
	head->next = NULL;
	local_irq_save(flags);
	rdp = this_cpu_ptr(&rcu_data);
	rcu_segcblist_enqueue(&rdp->cblist, head);   /* 入本 CPU 回调链表 */
	if (!rcu_segcblist_is_offloaded(&rdp->cblist))
		invoke_rcu_core();                   /* 唤醒 GP 线程/软中断 */
	local_irq_restore(flags);
}

回调执行:

rcu_core() / rcu_do_batch()
 ├─ 取 rdp->cblist 里已经过 GP 的回调
 ├─ 逐个调用 head->func(head)
 └─ 超过预算(blimit)就放回,等下一轮

call_rcu() 可以在原子上下文调用(它只关本地中断、入链表),这是它比 synchronize_rcu() 更适合中断/软中断的原因。synchronize_rcu() 会睡眠等 GP 完成,只能在进程上下文用。

宽限期的四个变体

变体触发方式代价场景
普通 GPsynchronize_rcu() / call_rcu()等待若干毫秒通用
Expedited GPsynchronize_rcu_expedited()向所有 CPU 发 IPI,干扰大模块卸载、紧急回收
无回调 GPrcu_barrier()等所有已排队回调执行完模块卸载前
SRCUsynchronize_srcu()读侧可睡眠,GP 更慢需要睡眠的读侧

rcu_barrier() 不是“再等一个 GP”,而是“等所有 CPU 的回调链表清空”。模块卸载时如果只调 synchronize_rcu(),仍可能有 call_rcu() 的回调在排队并引用模块代码;必须 rcu_barrier()。

回调卸载(NOCB)

默认回调在软中断/rcuc/N 线程里执行。实时场景希望把回调从隔离核上挪走:

rcu_nocbs=2,3        # 2/3 号 CPU 的回调交给 rcuo/N 线程
rcu_nocb_poll        # rcuo 线程轮询,而不是等 IPI

对应的内核线程:rcuop/N(offload 回调)、rcuog/N(GP)、rcuoc/N(callback)。它们默认绑定到 housekeeping CPU,隔离核上不再跑 RCU 回调。

stall:宽限期为什么卡住

rcu_cpu_stall_timeout 默认 21 秒。超过后打印:

INFO: rcu_preempt detected stalls on CPUs/tasks:
	Tasks blocked on level-0 rcu_node (CPUs 0-7): P1234
	(detected by 0, t=21002 jiffies, g=12345, q=678)

常见原因:

现象原因排查
某 CPU qs_pending=1 很久该 CPU 关中断/关抢占死循环sysrq-l 看所有 CPU 栈
某任务 rcu_read_lock() 不释放读侧睡眠/死循环栈里找 rcu_read_lock
rcu_preempt 线程不跑实时任务占满 CPUps -eLo pid,cls,rtprio,comm
GP 序号不动rcu_gp_kthread 被饿死perf sched 看 rcu_gp

rcu stall 不是“RCU 坏了”,而是“有 CPU/任务没有经过静止状态”。先找那个 CPU,而不是调 stall_timeout。

RCU 的五个坑
  1. “rcu_read_lock() 会关抢占”:CONFIG_PREEMPT_RCU 下不关,只加计数;读侧可以被抢占,但不能主动睡眠。
  2. “读侧可以睡眠”:普通 RCU 不行;需要睡眠用 SRCU,且 srcu_read_lock() 返回的索引要传给 srcu_read_unlock()。
  3. “synchronize_rcu() 返回就没人用旧对象了”:它保证旧读者退出,但新读者可能已经拿到新指针;旧对象的释放仍需保证“没有新引用”。
  4. “call_rcu() 的回调一定在同一个 CPU 执行”:可能被 NOCB 线程或迁移执行;回调里不能用 this_cpu 假设。
  5. “rcu_barrier() 可以替代 synchronize_rcu()”:前者等回调执行完,后者等读者退出;顺序通常是先 synchronize_rcu() 再 rcu_barrier()。

3.11.11 无锁队列与内存回收:ABA、hazard pointer 与 kfifo

“无锁”不是“没有同步”,而是把锁换成了“原子操作 + 内存序 + 重试 + 回收协议”。这一节讲四种常见结构,以及它们各自的安全边界。

四种无锁结构

结构生产者/消费者核心操作安全性依赖
kfifo单生产者/单消费者(SPSC)读写索引各自原子推进索引不共享写;内存序配对
llist多生产者/单消费者cmpxchg 头插节点不释放,或消费者负责回收
Treiber 栈多生产者/多消费者cmpxchg 换头ABA 防护 + 安全回收
MS 队列多生产者/多消费者cmpxchg + xchgABA + 回收;实现复杂

kfifo 是内核里最常用的无锁队列:生产者只写 in,消费者只写 out,两者用 smp_wmb()/smp_rmb() 配对,避免索引先于数据可见。它不支持多生产者——多生产者要先加锁或用 kfifo 的 record 接口。

llist(lock-less list)只保证“头插”的原子性:llist_add() 用 cmpxchg 把新节点接到头部,llist_del_all() 一次性摘下整条链。它适合“生产者只增、消费者批量取”的场景(如 task_work、irq_work),不适合任意删除。

ABA:CAS 循环的经典漏洞

Treiber 栈的 pop:

old = head;                 // 读到 A
next = old->next;           // 读到 B
CAS(&head, old, next);      // 期望 A,写入 B

如果在这三步之间,另一个线程把 A、B 都弹出,又把 A 压回,head 又等于 A,CAS 会成功——但 B 已经不在栈里(甚至已被释放)。结果 head 指向一个游离节点,栈结构损坏。这就是 ABA。

三种防护:

方案做法代价
标记指针(tagged pointer)指针低位/高位存版本号,每次修改 +1需要对齐位或 128 位 CAS
hazard pointer读者先登记“我在用这个节点”,回收者检查每读一次要写共享数组
RCU / epoch延迟到所有读者退出再回收需要宽限期,内存占用高

内核里 kfifo 不需要 ABA 防护(索引只增不减),llist 靠“不删除”回避,Treiber 栈在用户态库(如 liburcu)里才常见。写内核代码时,优先用 kfifo/llist/RCU,而不是自己实现一个带回收的无锁栈。

回收协议对比

协议读者开销回收延迟适用
引用计数每次 inc/dec立即(归零)对象生命周期清晰
RCUrcu_read_lock() 几乎为零一个 GP读多写少、读者不能阻塞
SRCU读侧可睡眠更长需要睡眠的读侧
hazard pointer每次读登记/撤销等到没有 hazard用户态无锁结构
epoch每线程进入 epoch等到所有线程过 epoch批量回收

3.11.12 优先级反转与优先级继承:rt_mutex 的链式调整

优先级反转不是“低优先级任务变慢”,而是“高优先级任务被中优先级任务间接阻塞”。这一节走一遍 PI 链的调整过程,并说明为什么只有 rt_mutex 能解决它。

反转的时间线

时刻    L(低优先级,持锁)   M(中优先级)        H(高优先级)
t0      持锁运行
t1                           就绪,抢占 L
t2                                              就绪,等锁 → 阻塞
t3                           继续运行(H 被 M 间接阻塞)
t4                           让出
t5      L 继续,释放锁
t6                                              H 拿到锁

H 的等待时间 = L 剩余临界区 + M 的运行时间。M 与这把锁无关,却决定了 H 的延迟——这就是“反转”。

PI 的调整过程

优先级继承(priority inheritance, PI)的规则:当 H 等 L 持有的锁时,L 临时提升到 H 的优先级,直到释放锁。

rt_mutex_adjust_prio_chain(task, ...)
 ├─ 1. 找到 task 正在等的 rt_mutex(task->pi_blocked_on)
 ├─ 2. 找到该锁的 owner
 ├─ 3. 若 owner 的优先级低于 waiter,提升 owner
 │      (rt_mutex_setprio(),参数是 owner 与 waiter 任务)
 ├─ 4. owner 自己也可能在等另一把锁 → 沿链继续
 └─ 5. 链上出现环 → 死锁,打印/返回 -EDEADLK

相关结构:

/* kernel/locking/rtmutex.c —— 6.8 节选 */
struct rt_mutex_base {
	raw_spinlock_t		wait_lock;
	struct rb_root_cached	waiters;	/* 按优先级排序 */
	struct task_struct	*owner;
};

/* include/linux/sched.h —— task_struct 里的 PI 字段 */
struct rb_root_cached	pi_waiters;	/* 等我持有的锁的任务 */
struct task_struct	*pi_top_task;	/* 链上最高优先级的等待者 */
struct rt_mutex_waiter	*pi_blocked_on;	/* 我正在等的锁 */

pi_waiters 是“谁在等我”,pi_blocked_on 是“我在等谁”。两者构成一张有向图,rt_mutex_adjust_prio_chain() 沿着 blocked_on → owner 走,边走边提升优先级。链的长度通常很短(1–3 层),但每层都要拿 wait_lock,所以 PI 不是免费的。

为什么 mutex 不能 PI

普通 mutex 的 owner 只记录“谁持有”,没有 pi_waiters 树,也没有“等锁任务按优先级排序”的等待队列。它无法在 H 阻塞时反向找到 L 并提升 L。CONFIG_DEBUG_MUTEXES 能检测“递归/非持有者解锁”,但不做 PI。

rt_mutex 的等待队列是按优先级排序的红黑树(waiters),不是 FIFO 链表。这保证高优先级等待者先被唤醒,也保证 PI 链调整时能快速找到“最高优先级的等待者”。

ww_mutex:多资源死锁的 wound/wait

两个任务以不同顺序拿 A、B 会 ABBA 死锁。ww_mutex(wound/wait)的做法:

  1. 每个事务有一个 ww_acquire_ctx,记录已获得的锁与顺序;
  2. 拿锁时若发现“自己比持有者更老/更重要”,可以“wound”(迫使持有者回滚);
  3. 否则等待,并在等待时释放已持有的锁,避免死锁。
struct ww_acquire_ctx ctx;
ww_acquire_init(&ctx, &reservation_ww_class);
ww_mutex_lock(&res->lock, &ctx);
...
ww_mutex_unlock(&res->lock);
ww_acquire_fini(&ctx);

GPU 驱动(drm_gem)大量使用 ww_mutex:一个 ioctl 可能要锁多个 buffer,顺序由用户态决定,必须靠 wound/wait 打破环。

PREEMPT_RT 的锁语义

锁非 RTPREEMPT_RT
spinlock_t关抢占、忙等变成 rt_mutex,可睡眠、支持 PI
raw_spinlock_t关抢占、忙等仍是忙等(用于真正不能睡眠的路径)
mutex可睡眠、无 PI可睡眠、支持 PI
local_lock_t关抢占per-CPU 睡眠锁

所以 RT 上“spin_lock 里不能睡眠”不再是绝对规则,但“raw_spin_lock 里不能睡眠”仍是。写可移植代码时,问自己:这段代码在 RT 上会被调度吗?如果会,临界区必须短且可睡眠安全。

PI 的四个坑
  1. “PI 能消除阻塞”:它只缩短阻塞(让 L 尽快跑完),不能消除临界区本身的长度。
  2. “普通 mutex 也支持 PI”:不支持;PTHREAD_PRIO_INHERIT 在 glibc 里映射到 FUTEX_LOCK_PI,内核侧由 rt_mutex 实现。
  3. “PI 链越长越好”:链越长,调整开销越大,且更容易出现“链式阻塞”;设计上要避免多层锁嵌套。
  4. “PI 能解决死锁”:不能。它只处理优先级反转;ABBA 死锁要靠锁序或 ww_mutex。

3.11.13 lockdep 深入:锁类、子类与报告字段

3.11.7 给了 lockdep 报告的读法。这一节讲它凭什么“提前”发现死锁,以及为什么有时会误报。

锁类与子类

lockdep 不跟踪“这一把锁”,而是跟踪锁类(lock class):同一代码位置、同一类型的锁视为一类。这样它不需要枚举所有实例,就能建立“类之间的获取顺序图”。

问题:同一段代码可能以不同嵌套深度拿同一类锁(如递归遍历树)。这时要告诉 lockdep “这是不同子类”:

/* 同一把锁在不同嵌套层,用子类区分 */
mutex_lock_nested(&obj->lock, SINGLE_DEPTH_NESTING);
...
mutex_unlock(&obj->lock);

/* 或者给不同实例分配不同锁类 */
static struct lock_class_key a_key, b_key;
lockdep_set_class(&a->lock, &a_key);
lockdep_set_class(&b->lock, &b_key);
接口作用场景
lockdep_set_class(lock, key)给锁实例指定类同一结构体的不同用途
lockdep_set_subclass(lock, subclass)在类内再分层递归/嵌套
mutex_lock_nested(lock, subclass)加锁时指定子类遍历树、父子节点
spin_lock_nested(lock, subclass)同上自旋锁版本
lockdep_assert_held(lock)断言“当前持有”函数入口检查
lockdep_assert_irqs_disabled()断言中断关闭原子上下文

依赖图与环检测

lockdep 在每次 lock_acquire() 时记录“当前持有的锁 → 新锁”的有向边,在 lock_release() 时更新。它维护一张全局图,插入边时做环检测:

持锁栈: A → B → C
新请求: C → A
检查: A → B → C → A 形成环 → 报警

报警不需要真的发生死锁,只要“存在一条可能形成环的路径”。这就是它“提前”的原因,也是它误报的来源:两个实际上不会同时发生的上下文,可能在图上连成环。

报告里的关键字段

======================================================
WARNING: possible circular locking dependency detected
------------------------------------------------------
kworker/0:1/123 is trying to acquire lock:
ffff888012345678 (&b->lock){+.+.}-{3:3}, at: func_b+0x1a/0x40

but task is already holding lock:
ffff888087654321 (&a->lock){+.+.}-{3:3}, at: func_a+0x2b/0x50

Chain exists of:
  &c->lock --> &a->lock --> &b->lock

 Possible unsafe locking scenario:
       CPU0                    CPU1
       ----                    ----
  lock(&a->lock);
                               lock(&b->lock);
                               lock(&a->lock);
  lock(&b->lock);
字段含义
{+.+.}锁状态位:+ 表示曾在此上下文获取,. 表示未获取;四位分别是 hardirq/softirq/softirq-read? 具体含义见 Documentation/locking/lockdep-design.rst
{3:3}锁的 read/write 状态与子类
Chain exists of已存在的依赖链,解释了为什么“看似无关”的锁会成环
Possible unsafe locking scenario两个 CPU 的交错序列,帮助构造复现
*** DEADLOCK ***环已确认,严重级别更高

{+.+.} 是 lockdep 的 usage bits(该锁在 hardirq/softirq 上下文的读/写使用标记),{3:3} 是 read/write 状态与子类。四位的精确顺序与含义以 Documentation/locking/lockdep-design.rst 为准;读报告时真正重要的是后面的 Chain exists of 与交叉加锁序列,不要凭记忆解读 usage bits。

统计与调试接口

接口内容
/proc/lockdep所有锁类及其依赖
/proc/lockdep_statsdebug_locks、chain_lookup_hits、cyclic_chains、hardirqs_off 等
/proc/lockdep_chains依赖链快照
dmesg运行时报告
sysrq-d打印所有持有的锁

/proc/lockdep_stats 的 debug_locks 为 0 表示 lockdep 已经关闭(通常在首次报警后自动关闭,避免刷屏)。debug_locks 没有受支持的运行时重置接口,通常只能重启内核;生产内核一般只在调试内核上开 lockdep。

误报与抑制

场景现象处理
同一类锁的不同实例报告“自己等自己”lockdep_set_class() 区分实例
递归嵌套报告“重复获取”mutex_lock_nested() 指定子类
中断与进程的“不可能同时”路径图上成环但实际不会确认后用 lockdep_off() 局部关闭(谨慎)
读锁与写锁的类混用读侧被当成写侧用 rwsem_acquire_read() 等注解
初始化顺序启动时误报lockdep_free_key_range() / 延迟初始化

lockdep_off()/lockdep_on() 是全局开关,只能用于“确认无环但工具无法表达”的极少数场景;用它掩盖真实问题,下一个接手的人会付出更大代价。

lockdep 的四个坑
  1. “lockdep 报警就是死锁”:它报的是“可能存在环”,实际是否触发取决于时序;但每一条都值得解释清楚。
  2. “关掉 lockdep 就能提升性能”:它会增加锁路径开销(哈希、图查询),生产内核通常本来就没开;为性能关掉它,通常不是瓶颈所在。
  3. “lockdep_assert_held() 是运行时检查”:开了 lockdep 才有,没开是空宏;不能用它做业务校验。
  4. “同一把锁只有一个类”:默认按“代码位置 + 锁对象”生成类,动态分配的锁可能共用类;需要 lockdep_set_class() 才能区分。

3.11.14 实验:亲手制造 ABA,量一量锁争用

前两节讲了 ABA 与 PI 的原理,这一节给两个用户态就能跑的实验。它们不依赖内核模块,在 macOS/Linux 上都能编译运行;输出里的数值会变,趋势与结论不变。

实验一:让无标记 CAS“成功”地破坏栈

这个程序先用单线程重放一次 ABA 交错:t1 读到栈顶 A 与 A->next=B 后暂停;t2 把 A、B 都弹出,再把 A 压回;t1 的 CAS 以为“栈顶还是 A”,于是把栈顶改成 B——而 B 已经不在栈里。然后对比带 2 位 tag 的版本:同样的交错,tag 从 0 变 3,CAS 失败。

/* aba_stack.c — Treiber 栈的 ABA:无标记 CAS 会“成功地把头指到已摘除节点”
 * 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -pthread -o aba_stack aba_stack.c
 * 运行: ./aba_stack
 */
#define _GNU_SOURCE
#include <pthread.h>
#include <sched.h>
#include <stdatomic.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>

struct node {
	int value;
	struct node *next;
};

#define TAG_BITS 2
#define TAG_MASK ((uintptr_t)((1u << TAG_BITS) - 1))

static _Atomic(uintptr_t) head;
static _Atomic long pushed, popped;
static _Atomic int ready;

static struct node *untag(uintptr_t v)
{
	return (struct node *)(v & ~TAG_MASK);
}

static uintptr_t tag_inc(uintptr_t v)
{
	return (v & TAG_MASK) + 1;
}

/* 带标记的 push:每次修改都让 tag 递增,ABA 时旧 tag 对不上 */
static void push(struct node *n)
{
	uintptr_t old = atomic_load_explicit(&head, memory_order_relaxed);
	uintptr_t neu;

	do {
		n->next = untag(old);
		neu = (uintptr_t)n | (tag_inc(old) & TAG_MASK);
	} while (!atomic_compare_exchange_weak_explicit(
		&head, &old, neu, memory_order_release, memory_order_relaxed));
	atomic_fetch_add_explicit(&pushed, 1, memory_order_relaxed);
}

static struct node *pop(void)
{
	uintptr_t old = atomic_load_explicit(&head, memory_order_acquire);
	uintptr_t neu;

	do {
		struct node *n = untag(old);

		if (!n)
			return NULL;
		neu = (uintptr_t)n->next | (tag_inc(old) & TAG_MASK);
	} while (!atomic_compare_exchange_weak_explicit(
		&head, &old, neu, memory_order_acquire, memory_order_relaxed));
	atomic_fetch_add_explicit(&popped, 1, memory_order_relaxed);
	return untag(old);
}

/* 单线程重放 ABA 的交错:t1 读头后暂停,t2 做三次操作让头“看起来没变” */
static void demo_aba(void)
{
	struct node a = {.value = 1, .next = NULL};
	struct node b = {.value = 2, .next = NULL};
	struct node *uhead = &a;          /* 无标记版本 */
	uintptr_t snapshot;

	/* --- 无标记:指针相等就认为“没人动过” --- */
	b.next = NULL;
	a.next = &b;
	{
		struct node *t1_next = uhead->next;   /* t1 记下 a->next = b */
		struct node *p;

		p = uhead; uhead = p->next;           /* t2: pop a */
		p = uhead; uhead = p->next;           /* t2: pop b */
		a.next = uhead; uhead = &a;           /* t2: push a */
		(void)t1_next;

		/* t1 的 CAS:期望 &a,写入 &b */
		if (uhead == &a) {
			uhead = &b;
			printf("untagged: CAS succeeded -> head=b (b already popped!)\n");
		} else {
			printf("untagged: CAS failed\n");
		}
	}

	/* --- 带标记:同样的交错,tag 让 CAS 失败 --- */
	atomic_store(&head, (uintptr_t)&a);
	a.next = &b;
	snapshot = atomic_load(&head);
	(void)pop();                            /* t2: pop a */
	(void)pop();                            /* t2: pop b */
	push(&a);                               /* t2: push a,头指针又等于 &a */

	uintptr_t expected = snapshot;
	bool ok = atomic_compare_exchange_strong(&head, &expected, (uintptr_t)&b);
	printf("tagged:   CAS %s (tag %lu -> %lu)\n",
	       ok ? "succeeded" : "failed",
	       (unsigned long)(snapshot & TAG_MASK),
	       (unsigned long)(atomic_load(&head) & TAG_MASK));
}

static void *worker(void *arg)
{
	long id = (long)arg;
	struct node *nodes = calloc(10000, sizeof *nodes);
	int i;

	if (!nodes)
		return NULL;
	for (i = 0; i < 10000; i++) {
		nodes[i].value = (int)(id * 10000 + i);
		push(&nodes[i]);
	}
	/* 等所有线程推完再开始弹,避免“栈空时 pop 到 NULL” */
	atomic_fetch_add(&ready, 1);
	while (atomic_load(&ready) < 4)
		sched_yield();

	for (i = 0; i < 10000; i++)
		(void)pop();
	/* 不 free:其他线程可能仍持有这些节点的 next 指针;
	 * 真实实现要用 hazard pointer / RCU / epoch 回收。 */
	return NULL;
}

int main(void)
{
	pthread_t th[4];
	long i;

	demo_aba();

	atomic_store(&head, 0);
	atomic_store(&pushed, 0);
	atomic_store(&popped, 0);
	for (i = 0; i < 4; i++)
		if (pthread_create(&th[i], NULL, worker, (void *)i) != 0) {
			perror("pthread_create");
			return 1;
		}
	for (i = 0; i < 4; i++)
		pthread_join(th[i], NULL);

	printf("stress: pushed=%ld popped=%ld head=%p (expect NULL)\n",
	       atomic_load(&pushed), atomic_load(&popped), (void *)untag(atomic_load(&head)));
	return 0;
}

编译与运行:

cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -pthread -o aba_stack aba_stack.c
./aba_stack

在 macOS/arm64(Apple clang)上的实际输出:

untagged: CAS succeeded -> head=b (b already popped!)
tagged:   CAS failed (tag 0 -> 3)
stress: pushed=40000 popped=40000 head=0x0 (expect NULL)

三行分别说明:

  1. 无标记版本:CAS 成功,但 head=b 指向一个已经弹出的节点——这就是 ABA 的破坏力。
  2. 带标记版本:tag 不匹配,CAS 失败,重试后读到正确的栈顶。
  3. 并发压力测试:4 线程各 push/pop 10000 次,最终 head=NULL、push/pop 计数相等。

代码里的 TAG_BITS=2 只是演示;2 位 tag 每 4 次修改就回绕,真实实现要用 16 位以上,或 128 位 CAS(x86 cmpxchg16b、ARM64 casp)。tag 的本质是“让 ‘A 变回 A’ 这件事在 CAS 看来不再相同”。

注意压力测试没有 free 节点:一个线程 pop 到别的线程的节点后,原线程可能仍持有它的 next 指针;立刻 free 会让另一个线程读到已释放内存。真实的无锁栈必须配 hazard pointer、epoch 或 RCU 回收——这正是“无锁不等于无管理”的地方。

实验二:spinlock 与 mutex 的吞吐对比

同一个临界区(counter++),分别用 atomic_flag 自旋锁与 pthread_mutex,4 个线程各做 20 万次,比较吞吐。

/* lock_bench.c — 同一个临界区,spinlock 与 mutex 的吞吐对比
 * 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -pthread -o lock_bench lock_bench.c
 * 运行: ./lock_bench spin 4 200000
 *       ./lock_bench mutex 4 200000
 */
#define _GNU_SOURCE
#include <pthread.h>
#include <sched.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

static atomic_flag slock = ATOMIC_FLAG_INIT;
static pthread_mutex_t mlock = PTHREAD_MUTEX_INITIALIZER;
static long counter;
static long iters;
static int use_mutex;

static void spin_lock(void)
{
	while (atomic_flag_test_and_set_explicit(&slock, memory_order_acquire))
		; /* 真实内核用 cpu_relax();用户态可用 sched_yield 降低争用 */
}

static void spin_unlock(void)
{
	atomic_flag_clear_explicit(&slock, memory_order_release);
}

static void *worker(void *arg)
{
	(void)arg;
	for (long i = 0; i < iters; i++) {
		if (use_mutex) {
			pthread_mutex_lock(&mlock);
			counter++;
			pthread_mutex_unlock(&mlock);
		} else {
			spin_lock();
			counter++;
			spin_unlock();
		}
	}
	return NULL;
}

static double now_sec(void)
{
	struct timespec ts;

	clock_gettime(CLOCK_MONOTONIC, &ts);
	return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
}

int main(int argc, char *argv[])
{
	int nthreads = argc > 2 ? atoi(argv[2]) : 4;
	pthread_t *th;
	double t0, t1;
	long total;

	if (argc > 1 && strcmp(argv[1], "mutex") == 0)
		use_mutex = 1;
	else if (argc > 1 && strcmp(argv[1], "spin") == 0)
		use_mutex = 0;
	else {
		fprintf(stderr, "usage: %s spin|mutex [threads] [iters]\n", argv[0]);
		return 2;
	}
	iters = argc > 3 ? atol(argv[3]) : 200000;
	th = calloc((size_t)nthreads, sizeof *th);
	if (!th) {
		perror("calloc");
		return 1;
	}

	t0 = now_sec();
	for (int i = 0; i < nthreads; i++)
		if (pthread_create(&th[i], NULL, worker, NULL) != 0) {
			perror("pthread_create");
			return 1;
		}
	for (int i = 0; i < nthreads; i++)
		pthread_join(th[i], NULL);
	t1 = now_sec();

	total = counter;
	printf("%s threads=%d total=%ld sec=%.3f Mops/s=%.2f\n",
	       use_mutex ? "mutex" : "spin", nthreads, total, t1 - t0,
	       (double)total / (t1 - t0) / 1e6);
	free(th);
	return 0;
}

编译与运行:

cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -pthread -o lock_bench lock_bench.c
./lock_bench spin 4 200000
./lock_bench mutex 4 200000
./lock_bench spin 1 200000
./lock_bench mutex 1 200000

macOS/arm64 上的实际输出(Apple M 系列,4 线程):

spin threads=4 total=800000 sec=0.114 Mops/s=7.01
mutex threads=4 total=800000 sec=0.017 Mops/s=46.73
spin threads=1 total=200000 sec=0.001 Mops/s=160.00
mutex threads=1 total=200000 sec=0.002 Mops/s=121.58

读法:

  • 无争用:spinlock 160 Mops/s,mutex 122 Mops/s——自旋省掉了系统调用与睡眠,快约 30%。
  • 4 线程争用:spinlock 掉到 7 Mops/s,mutex 反而 47 Mops/s——自旋锁的 cache line 在 4 个核之间来回弹,每次失败重试都在加剧争用;mutex 让拿不到锁的线程睡眠,把 CPU 让给持锁者。
  • 这就是内核里“临界区极短才用 spinlock”的量化依据:临界区一长、争用一高,自旋的浪费会超过睡眠的开销。

用户态的 atomic_flag 没有 cpu_relax(),也没有排队机制;内核的 qspinlock 用 MCS 队列把等待者分散到各自的 cache line,正是为了解决这里看到的“所有人抢同一个 cache line”。

观测:perf lock 与 bpftrace

用户态实验能说明趋势,定位线上锁热点要靠 perf lock:

# 全系统采集 5 秒,按争用时间排序
sudo perf lock contention -a --sleep 5

# 只看某个进程
sudo perf lock contention -p $PID --sleep 5

输出字段:

 contended   total wait     max wait     avg wait         type   caller
      1234      12.34 s      120.00 ms     10.00 ms     spinlock   func+0x1a
       567       1.23 s       50.00 ms      2.17 ms        mutex   other+0x2b

contended 是争用次数,total wait 是累计等待,max wait 是单次最长;caller 是加锁点。先看 max wait 找毛刺,再看 total wait 找吞吐瓶颈。

没有 perf lock 的内核(< 5.16)可以用 bpftrace 数争用事件:

sudo bpftrace -e '
tracepoint:lock:contention_begin
{
	@[ksym(args->lock_addr)] = count();
}
interval:s:5 { exit(); }'

args->lock_addr 是锁的地址,ksym() 转成符号;如果显示 0xffff...,说明该锁是动态分配的,需要结合调用栈(kstack)定位。

观测:lockdep 报告与统计

# 有没有环
sudo dmesg | grep -A30 "possible circular"

# lockdep 自身的统计
cat /proc/lockdep_stats | head -20

# 当前持有的锁(sysrq-d)
echo d | sudo tee /proc/sysrq-trigger

/proc/lockdep_stats 里先看 debug_locks 是否为 1;为 0 说明 lockdep 已停止报告。cyclic_chains 大于 0 表示检测到过环,chain_lookup_hits/misses 反映依赖图的规模与命中率。

例 3.11.2 · 一次“自旋锁把 P99 拉高”的复盘

某服务 8 核,P99 从 5 ms 涨到 40 ms,CPU 利用率只有 40%。perf lock contention -a --sleep 5 显示一把统计锁 total wait 排第一,caller 在 update_stats();perf c2c 显示该锁所在 cache line 的 HITM 次数很高。

根因:统计更新在热路径上,8 个线程抢同一把 spinlock,临界区里还有一次 memcpy。修法:改成 per-CPU 计数 + 定期汇总(3.11.6),锁争用消失,P99 回到 6 ms。这个例子说明:spinlock 的敌人不是“锁本身”,是“临界区长度 × 争用频率”。

3.11.15 陷阱清单:并发与同步篇

#现象原因判断/修复
1BUG: sleeping function called from invalid context在 spinlock/中断/关抢占区段睡眠用 might_sleep() 定位;把分配/拷贝移到锁外
2同一任务重复 mutex_lock() 卡死mutex 不可递归改用 mutex_lock_nested() 仅用于跨层;或重构锁序
3进程持锁后本地中断死锁spin_lock 没关本地中断,中断处理程序拿同一把锁与硬中断共享用 spin_lock_irqsave()
4seqlock 读侧一直重试写侧太频繁,读者永远赶不上改 RCU 或双缓冲;读侧加退避
5RCU 读侧睡眠后 GP 卡住普通 RCU 读侧不能阻塞改 SRCU;或把睡眠操作移出读侧
6call_rcu() 回调里睡眠回调在软中断/rcuoc 线程上下文回调只做释放/唤醒,耗时工作丢 workqueue
7kfree_rcu(ptr, rhf) 编译失败/崩溃rhf 字段不在结构体里,或偏移不对确认 struct rcu_head rhf; 是成员,不是指针
8refcount_inc() 后对象已释放从 0 增加,说明引用已失效用 refcount_inc_not_zero();修生命周期
9无锁栈偶发丢节点ABAtagged pointer / hazard pointer / RCU
10lockdep 报“自己等自己”同一类锁的不同实例lockdep_set_class() 区分实例
11PI 不生效用的是普通 mutex,不是 rt_mutex用户态用 PTHREAD_PRIO_INHERIT;内核用 rt_mutex
12PREEMPT_RT 上 spinlock 里睡眠没报错RT 把 spinlock_t 换成可睡眠 rtmutex需要真原子用 raw_spinlock_t
13perf lock 没输出内核 < 5.16,或 perf_event_paranoid 限制用 bpftrace lock:contention_begin;调 perf_event_paranoid
14用 volatile 做同步,偶发丢更新它不保证原子性/顺序READ_ONCE/WRITE_ONCE + 屏障,或直接加锁
15加了 smp_mb() 性能掉一半全屏障在热路径上代价高用 acquire/release 配对;只在必要时全屏障

第 12 条需要展开:PREEMPT_RT 合并进主线是 6.12,6.8 上要打 RT 补丁。RT 的 spinlock_t 是 rt_mutex,spin_lock() 可能睡眠;但 raw_spinlock_t 仍是“关抢占、忙等”。所以“spinlock 里不能睡眠”这句话在 RT 上要改成“raw_spinlock 里不能睡眠”。

第 15 条是性能问题:smp_mb() 在 x86 上是 mfence(或 lock 前缀),在 ARM64 上是 dmb ish,都会阻止乱序执行。能用一个 release + 一个 acquire 解决的,不要用两个全屏障。

3.11.16 实验:seqlock、RCU 与锁争用

第一个模块用 seqlock 保护一个 64 位计数器,读侧无锁重试:

// seqlock_demo.c — seqlock 读写(Linux 6.8)
#include <linux/init.h>
#include <linux/module.h>
#include <linux/seqlock.h>
#include <linux/delay.h>

struct demo_data {
	seqlock_t seq;
	u64 counter;
	u64 timestamp;
};

static struct demo_data demo;

static int __init seqlock_demo_init(void)
{
	u64 v;

	seqlock_init(&demo.seq);
	for (v = 1; v <= 5; v++) {
		write_seqlock(&demo.seq);
		demo.counter = v * 1000;
		demo.timestamp = ktime_get_ns();
		write_sequnlock(&demo.seq);
		udelay(10);
	}

	/* 读侧:无锁,序号变化就重试 */
	{
		unsigned int seq;
		u64 c, t;
		do {
			seq = read_seqbegin(&demo.seq);
			c = demo.counter;
			t = demo.timestamp;
		} while (read_seqretry(&demo.seq, seq));
		pr_info("seqlock_demo: counter=%llu ts=%llu\n",
			(unsigned long long)c, (unsigned long long)t);
	}
	return 0;
}

static void __exit seqlock_demo_exit(void) { }

module_init(seqlock_demo_init);
module_exit(seqlock_demo_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("seqlock reader/writer demo");

第二个模块用 RCU 保护一个链表,写侧「复制-替换-等待-释放」:

// rcu_demo.c — RCU 发布与回收(Linux 6.8)
#include <linux/init.h>
#include <linux/module.h>
#include <linux/rcupdate.h>
#include <linux/slab.h>

struct item {
	int value;
	struct rcu_head rh;
};

static struct item __rcu *global_item;

static int __init rcu_demo_init(void)
{
	struct item *old, *new;

	/* 初始发布 */
	new = kmalloc(sizeof(*new), GFP_KERNEL);
	if (!new)
		return -ENOMEM;
	new->value = 1;
	rcu_assign_pointer(global_item, new);

	/* 读侧 */
	rcu_read_lock();
	old = rcu_dereference(global_item);
	pr_info("rcu_demo: read value=%d\n", old->value);
	rcu_read_unlock();

	/* 写侧:换一个新对象,旧对象延迟释放 */
	new = kmalloc(sizeof(*new), GFP_KERNEL);
	if (!new)
		return -ENOMEM;
	new->value = 2;
	old = rcu_dereference_protected(global_item, 1);
	rcu_assign_pointer(global_item, new);
	synchronize_rcu();             /* 等所有旧读者退出 */
	kfree(old);
	pr_info("rcu_demo: published value=2\n");
	return 0;
}

static void __exit rcu_demo_exit(void)
{
	struct item *p = rcu_dereference_protected(global_item, 1);

	RCU_INIT_POINTER(global_item, NULL);
	synchronize_rcu();
	kfree(p);
}

两个模块都在 QEMU 里加载,dmesg 看输出。注意 RCU 模块的写侧假设「只有一个写者」(用 rcu_dereference_protected(..., 1) 断言),多写者需要额外的互斥锁——RCU 只保证「读者看到一致的对象」,不保证「多个写者不互相覆盖」。

锁争用观测用 perf lock(5.16+):

# 采 5 秒,按争用时间排序
perf lock contention -a --sleep 5 -b
# 只看某个进程
perf lock contention -p $PID -t 5
# 看某把锁的调用栈
perf lock contention -a -L &mmap_lock --sleep 3

bpftrace 也能数(需要 lock:contention_begin tracepoint):

bpftrace -e 'tracepoint:lock:contention_begin { @[ksym(args->lock_addr)] = count(); } interval:s:5 { exit(); }'
例 3.11.1 · 一次 `mmap_lock` 争用导致的延迟毛刺

某 Java 服务 P99 延迟周期性飙到 200 ms,CPU 与 GC 都正常。perf lock contention -a --sleep 5 显示 &mm->mmap_lock 的争用时间排第一,调用栈集中在 do_user_addr_fault 与 mmap_region。

根因:一个日志库每次写都 mmap/munmap 一小块内存,与业务线程的缺页处理抢 mmap_lock 写锁。缺页本应只拿读锁,但 mmap 是写锁,二者互斥。

修法:日志库改用固定缓冲 + pwrite,不再频繁 mmap;或把日志写到独立进程,隔离地址空间。这个例子说明:锁争用不一定是「锁太大」,可能是「拿锁的频率太高」。

3.11.17 本章出口检查

检查项达标线没达标去哪
列出并发五来源多核/中断/软中断/抢占/内核线程3.11.1
说清 READ_ONCE 与 smp_mb 的区别编译器 vs 硬件3.11.2
选对 spinlock 变体按「和谁竞争」选 irqsave/bh3.11.3
说清 seqlock 的读侧重试序号奇偶与变化3.11.3
区分 mutex/semaphore/completion语义与上下文限制3.11.4
解释 RCU 宽限期等所有旧读者经过静止状态3.11.5、3.11.10
读懂 lockdep 报告找出交叉加锁顺序3.11.7、3.11.13
说出 spinlock/mutex/rwsem 的关键字段locked/tail、owner、count3.11.8
写出 CAS 循环并防 ABAtag/版本号 + 安全回收3.11.9、3.11.11
解释 PI 链的调整过程pi_waiters → pi_blocked_on → owner3.11.12
用 perf lock/bpftrace 找锁热点读 total wait/max wait/caller3.11.14、3.11.16
即时自测 · 3.11

Q1 一个自旋锁保护的临界区里调用了 kmalloc(GFP_KERNEL),会发生什么?如果必须分配,应该怎么做?

Q2 smp_store_release(&flag, 1) 与 WRITE_ONCE(flag, 1); smp_mb(); 有什么区别?哪个更轻?

Q3 读者用 rcu_read_lock() 读到一个对象,读完释放读锁;写者此时调用 synchronize_rcu(),为什么能保证读者不会用到已释放的对象?

Q4 mutex 与 semaphore 都能睡眠,什么时候必须用后者?

Q5 lockdep 报告「possible circular locking dependency」,但系统从没死锁过。这是误报吗?该怎么处理?

Q6 synchronize_rcu() 与 rcu_barrier() 都在等,它们分别等什么?模块卸载时应该先调哪个?

Q7 一个 Treiber 栈用 2 位 tag 防 ABA,压力测试仍然偶发丢节点。可能的原因是什么?

答案

A1 GFP_KERNEL 可能睡眠(直接回收、等 I/O),而 spin_lock 持有期间禁止睡眠,会触发 BUG: sleeping function called from invalid context,或者死锁(调度器在持锁状态下切走)。正确做法:用 GFP_ATOMIC 做最小分配,或把分配移到锁外(先分配、再拿锁、失败回滚),或改用 mutex。

A2 语义上等价(release 保证之前的写先可见),但 smp_store_release 在 x86 上就是一条普通 mov(TSO 已保证 store-store 顺序),而 smp_mb() 是 mfence(或 lock 前缀指令),重得多。能配对用 acquire/release 就不要用全屏障。

A3 synchronize_rcu() 会等到「在它开始之前进入读侧的所有 CPU 都经过一次静止状态」。静止状态意味着那些读侧临界区已经结束(上下文切换、返回用户态、idle 等),所以旧读者不可能还在用对象;而新读者看到的是新指针,不会拿到旧对象。这就是「延迟释放」的正确性来源。

A4 需要计数而不是互斥时:允许多个持有者同时进入(如限制并发数为 N 的限流、生产者-消费者缓冲区计数)。mutex 只有 0/1 两个状态。另外 semaphore 可用于「中断里 up()、进程里 down()」的跨上下文通知,mutex 不行(中断不能 unlock 一个自己没持有的 mutex)。

A5 不是误报,是「潜在死锁」:lockdep 发现的是锁序图里的环,实际是否触发取决于时序。要处理:① 统一加锁顺序(按地址/ID 排序);② 用 ww_mutex 的 wound/wait 机制;③ 缩小临界区,避免嵌套;④ 用 lockdep_set_class() 给不同实例分配合适的锁类(如果确实不会互相等待)。不能简单地把 lockdep 关掉。

A6 synchronize_rcu() 等的是读侧临界区:所有在它开始前进入读侧的 CPU 都经过一次静止状态。rcu_barrier() 等的是回调:所有已经 call_rcu() 排队的回调都执行完。模块卸载的顺序是先把指针摘掉、synchronize_rcu() 等旧读者退出,再 rcu_barrier() 等回调跑完,最后才能释放模块代码/数据。

A7 2 位 tag 只有 4 个值,高争用下很快回绕:读侧保存的 (ptr|tag) 可能在 CAS 前被 4 次修改“抵消”,又变回同一个值。要增大 tag 位宽(用指针高位或 128 位 CAS),或改用 hazard pointer/RCU 回收;tag 只能降低 ABA 概率,不能证明不存在。

母本对应

母本章节本书落点程度
奔跑吧 Linux 内核第 4 章 4.1 原子操作与内存屏障3.11.2、3.11.9完整(原子接口、屏障、TSO/弱序)
奔跑吧 Linux 内核第 4 章 4.2 spinlock3.11.3、3.11.8完整(qspinlock 字段、变体)
奔跑吧 Linux 内核第 4 章 4.3 信号量3.11.4、3.11.8完整(结构体与语义)
奔跑吧 Linux 内核第 4 章 4.4 Mutex 互斥体3.11.4、3.11.8、3.11.12完整(MCS、乐观自旋、PI)
奔跑吧 Linux 内核第 4 章 4.5 读写锁3.11.3、3.11.8完整(rwsem/seqlock 字段)
奔跑吧 Linux 内核第 4 章 4.6 RCU3.11.5、3.11.10完整(宽限期、Tree RCU、回调、NOCB)
奔跑吧 Linux 内核第 4 章 4.7 内存管理中的锁3.11.7、3.11.7.1、3.11.13完整(mm 锁矩阵、mmap_lock、lockdep)
OSTEP第 28–32 章 锁、条件变量、信号量、并发问题3.11.1、3.11.4、3.11.7、3.11.12对照(理论模型、生产者消费者、死锁)
CSAPP第 12 章 并发编程3.11.1、3.11.4、3.11.14背景(线程、信号量、并行)

下一站:3.12 VFS 与文件系统 把同步工具用到具体的子系统:路径解析怎么在 RCU 下走 dcache、inode 的锁怎么分工、ext4 的日志如何保证崩溃一致性。