云计算百科
云计算领域专业知识百科平台

庖丁解 vmCore · 第七篇 · 结喉之处:锁与死锁的解剖

庖丁解 vmCore 十篇 · 第七篇 · 结喉之处:锁与死锁的解剖

庖丁曰:技经肯綮之未尝,而况大軱乎。
锁者,肯綮也;死锁者,肯綮相缠而刀不得入也。
vmcore 者,缠结既成之定影——依乎天理,批大郤,导大窾。


目录

  • 一、从 call trace 到锁:本篇的位置
    • 1.1 第六篇留下的那个偏移
    • 1.2 什么样的现场需要解剖锁
    • 1.3 死锁与 panic:入场动作的差异
    • 1.4 承上:三轴原则在锁分析中的落点
  • 二、锁分析的底层逻辑:为什么可调试性天差地别
    • 2.1 从等待图倒推:分析到底需要什么
    • 2.2 为什么按"能否睡眠"分类
    • 2.3 两条路线的分野
    • 2.4 锁类型速查表
  • 三、路线 A:读结构定持有者
    • 3.1 owner 编码:一套规则,多锁通用
    • 3.2 等待链遍历:通用手法
    • 3.3 rw_semaphore 的三处特殊性
    • 3.4 路线 A 何时会失灵
  • 四、路线 B:自旋锁的交叉推断
    • 4.1 val 能回答什么,不能回答什么
    • 4.2 路线判定:看这把锁的结构里有没有持有者字段
    • 4.3 MCS 队列:它记录的不是 task,因此对等待图没有贡献
    • 4.4 交叉推断四步法
    • 4.5 架构差异如何改变"能拿到什么现场"
  • 五、死锁类型图谱
  • 六、等待链重建:完整方法论
  • 七、案例一:mutex ABBA 死锁(路线 A 全流程)
  • 八、案例二:rwsem 死锁(与案例一的操作差异)
  • 九、案例三:回到 jbd2 现场(真实案例,路线 B 与失效边界)
  • 十、lockdep 告警的事后解读
  • 十一、D 状态海的辨别:并非全是死锁
    • 11.1 核心动作:找到终端节点
    • 11.2 单点阻塞:直接型与经锁型
    • 11.3 RCU Stall:机制不同的一类
    • 11.4 seqlock 读者空转:不表现为 D 状态
    • 11.5 辨别流程
  • 十二、小结

在这里插入图片描述


一、从 call trace 到锁:本篇的位置

1.1 第六篇留下的那个偏移

第六篇的结尾停在一个悬而未决的地方。那份 4.19 ARM64 生产现场里,两颗 CPU 上的 jbd2 内核线程同时 soft lockup,且都卡在同一个函数的同一个偏移:

el1_irq+0xb8/0x140 <- 中断边界
ext4_process_freed_data+0x258/0x530 <- 被中断时正在执行
ext4_journal_commit_callback+0x50/0x120
jbd2_journal_commit_transaction+0x164c/0x1a60
kjournald2+0xd0/0x2b8

第六篇当时给出的判断是:两块独立磁盘的日志线程卡在同一处,用巧合解释不合理,一个值得优先验证的假设是自旋锁争抢——但同时也强调了,这只是假设,热点循环、下游阻塞同样能造成同一现象,必须反汇编确认 +0x258 处究竟是什么,假设才成立。

本篇第九章会把这个验证补上。在此之前,需要先建立读锁结构的方法。

1.2 什么样的现场需要解剖锁

第五篇的转向信号是"call trace 走到尽头,答案在内存里";第六篇是"一条 call trace 不够,需要横向看所有进程"。本篇的转向信号更具体:横向看完进程之后,发现多个进程的阻塞点收敛到同步原语上。

现场特征说明本篇对应章节
foreach UN bt 中多个进程停在 __mutex_lock / rwsem_down_* 睡眠锁竞争,持有者可直接读出 三、七、八
backtrace 停在 queued_spin_lock_slowpath 自旋锁竞争,无持有者记录 四、九
soft lockup / hard lockup,卡点落在持锁区间 CPU 被自旋等待占住 四、九
dmesg 有 possible circular locking dependency lockdep 已经把环算出来了
大量 D 状态但追不出环 可能根本不是死锁 十一

与前两篇的分工: 第五篇回答"这个值为什么是错的",第六篇回答"这件事跟谁有关",本篇回答**“他们卡在谁手里”**。

1.3 死锁与 panic:入场动作的差异

panic 型 vmcore 与死锁型 vmcore 的调查方向是相反的。

panic 有明确的崩溃点,第一动作是 bt——找到那条出事的调用链,顺着它往回追。死锁没有崩溃点,系统是在"还能运行但要害被卡住"的状态下被强行取的现场,第一动作是 ps——先看有哪些任务不动了,再从这批静止的任务里找关系。

panic vmcore -> one crash site, follow the trace backwards
entry command: bt

deadlock vmcore -> no crash site, many stalled tasks
entry command: ps (then ps -m for ordering)

触发死锁场景 vmcore 的常见途径有三条,各自的现场特征不同:

触发途径内核参数现场特征
hung_task 超时 panic kernel.hung_task_panic=1 睡眠锁死锁,D 状态任务群
watchdog 检测 CPU 卡死 softlockup_panic / hardlockup_panic 自旋锁死锁,CPU 被占住
SysRq-c 手动触发 kernel.sysrq 运维介入,现场取决于触发时机

注意 hung_task 的参数名。 触发 panic 的是 kernel.hung_task_panic,而 kernel.hung_task_timeout_secs(默认 120)只控制何时打印告警。二者常被混称,实测以 sysctl -a | grep hung_task 为准。

识别死锁场景的三个信号:

信号一:dmesg 的 hung_task 打印。

INFO: task worker-thread:4821 blocked for more than 120 seconds.
Call Trace:
__schedule+0x2b9/0x860
__mutex_lock.isra.0+0x209/0x540
volume_lock_acquire+0x38/0x70 [demo_drv]

信号二:dmesg 的 watchdog 告警。

watchdog: BUG: soft lockup – CPU#2 stuck for 22s! [kworker/2:1:4822]
watchdog: BUG: hard LOCKUP on cpu 2

信号三:crash 中的 D 状态任务群。

crash> ps | grep " UN "
4821 4815 0 ffff888207a3c000 UN 0.0 20480 1024 worker-thread
4822 4815 1 ffff888207b48000 UN 0.0 20480 1024 worker-thread
4823 4815 2 ffff888207c5a000 UN 0.0 20480 1024 worker-thread

同名进程扎堆 D 状态是一个强信号——它意味着这些任务走的是同一段代码路径,很可能卡在同一个资源上。配合等待时长:

crash> ps -m | grep " UN "
[0 00:05:47.882] 4821 … UN worker-thread
[0 00:05:45.201] 4822 … UN worker-thread
[0 00:05:41.094] 4823 … UN worker-thread

按第六篇 3.3 节,ps -m 给出的是 last-run 时间差,对睡眠态近似反映连续未运行时长。此处近六分钟未被调度,远超正常 I/O 等待量级,值得深查——但这是排序线索而非判据,真正的确认要靠等待链追踪。

上述三个 PID 及后文出现的 demo_drv 相关符号,均为第七章教学案例的预演,非真实生产数据,详见该章开头的说明。

1.4 承上:三轴原则在锁分析中的落点

锁的实现是三轴依赖的重灾区。 同一段分析方法,换个架构、换个内核版本、换个编译配置,读到的结构可能完全不同:

本篇知识点受哪一轴约束具体分界
spinlock 是 qspinlock 还是 ticket lock 版本 + 配置 CONFIG_QUEUED_SPINLOCKS;x86_64 自 4.2、ARM64 自 4.19 起默认
spinlock 是否记录 owner 配置 CONFIG_DEBUG_SPINLOCK 开启时才有 owner / owner_cpu
rwsem 的 count 位域布局 版本 5.3 前后经历重写,位定义完全不同
mutex 是否有 osq 乐观自旋 配置 CONFIG_MUTEX_SPIN_ON_OWNER
mutex 是否有 magic 校验字段 配置 CONFIG_DEBUG_MUTEXES
ARM64 能否触发 hard lockup 架构 + 配置 + 固件 需 CONFIG_ARM64_PSEUDO_NMI 且 GICv3 固件支持
lockdep 依赖图是否可用 配置 CONFIG_PROVE_LOCKING

因此本篇给出的所有位域、偏移、常量,都应视为某组三轴取值下的实例。动手前先确认:

crash> sys # 架构 + 内核版本
crash> struct mutex # 有无 osq / magic 字段
crash> struct qspinlock # 该内核的实际结构定义
crash> struct rw_semaphore # count 与 owner 的实际布局

struct <type> 不带地址即可打印该内核的结构定义与各字段偏移——这是本篇最重要的一条实测习惯。


二、锁分析的底层逻辑:为什么可调试性天差地别

在讲任何一种锁的字段编码之前,先要回答两个问题:我们到底在找什么?为什么有些锁好查,有些锁难查? 想清楚这两点,后面所有操作都是自然推论,不需要死记。

2.1 从等待图倒推:分析到底需要什么

死锁分析的本质,是在静态快照中重建一张有向图并检测其中的环。这张图只有两类边:

WAIT edge: TASK —- waits for —-> LOCK
source: the task's backtrace
cost: cheap, always available

HOLD edge: LOCK —- held by —–> TASK
source: the lock structure itself
cost: depends entirely on the lock type

下文统一称二者为等待边与持有边。

等待边永远拿得到。 任何阻塞的任务,bt 都会显示它停在哪个加锁函数上,往上一帧就是持锁点所在的调用者。这一步与锁的类型无关。

持有边是全部困难的所在。 一把锁被谁持有,这个信息不会写在等待者的栈上,只可能记录在锁自己的结构里。若锁不记录持有者,这条边就断了——图连不起来,环也就无从找起。

在往下推之前,先认识两个字段。 它们是后文反复出现的主角,各自一句话就能说清:

owner — found in sleepable locks (mutex / rw_semaphore / rt_mutex)
type: atomic_long_t
value: the holder's task_struct pointer
(low bits borrowed for flags; mask them off)
in one sentence:
it tells you directly WHO holds this lock

val — found in spinning locks (spinlock_t / rwlock_t)
type: atomic_t, only 32 bits, the entire lock state
compressed into one word
layout: locked / pending / tail sub-fields
in one sentence:
it tells you the lock IS taken and how many are
queued — but never WHO took it

两个字段的差别,恰好对应上面两类边的差别:owner 直接给出持有边;val 只能证明这条边存在,说不出它指向谁。

由此得出本篇的第一个核心结论:

owner 字段之所以重要,不是因为它是一个字段,而是因为它是持有边的唯一直接来源。

同理,自旋锁的 val 字段之所以要逐位拆解,是因为它是那些没有 owner 的锁上,唯一能挤出信息的地方——它至少能告诉你持有边是否存在(锁是否被持有),哪怕说不出"边指向谁"。

2.2 为什么按"能否睡眠"分类

锁可以按很多维度分类:持有时长、读写语义、公平性、是否可重入。本篇选择"能否睡眠"这个维度,不是因为它最能描述锁的功能,而是因为它直接决定 vmcore 里能读到什么。

推导链条如下:

CAN SLEEP
-> the waiter gives up the CPU and stops running entirely
-> it can no longer poll the lock by itself
-> SOMEBODY ELSE must wake it up on release
-> the releaser must know WHOM to wake
-> the lock must therefore record every waiter's task_struct
-> implemented as an intrusive linked list: wait_list
=> a complete, traversable roster in the vmcore

-> hold time may be long; recording the owner has real value
(deadlock detection; optimistic spinning needs to know
whether the owner is still running)
=> owner field exists, HOLD edge READABLE

CANNOT SLEEP
-> the waiter does NOT yield the CPU; it observes the release itself
(x86_64: PAUSE busy-wait; ARM64: WFE low-power wait, woken by SEV)
-> it notices the release on its own; nobody needs to wake it
-> no roster of waiters is required
-> (an MCS queue does exist, but it holds per-CPU nodes for
cache-line locality — NOT task_struct pointers, see 4.2)
=> no waiter roster to read

-> hold time is designed to be extremely short
-> recording an owner costs a store on every acquire/release
for negligible benefit => omitted in production builds
=> no owner field, HOLD edge must be INFERRED

推导的关键环节是"睡着的人无法自己醒来"。 睡眠锁的等待者交出了执行权,不可能再去查看锁的状态,只能由释放方唤醒;而唤醒必须知道找谁,于是锁不得不记下每一个等待者——wait_list 就是这份名单。自旋锁的等待者不交出执行权,能够自行观察到锁被释放,因此不需要有人来叫,也就不需要这份名单。具体的等待方式随架构而异:x86_64 使用 PAUSE 忙等,持续占用 CPU;ARM64 使用 WFE 进入低功耗等待,由释放方的 SEV 唤醒(见 4.5 节)。两者的共同点在于唤醒不依赖锁结构记录等待者身份——WFE 由硬件事件唤醒,释放方无需知道具体在等谁。

注意一处容易混淆的地方:自旋锁并非没有队列。 qspinlock 有 MCS 队列,但它的存在目的完全不同——不是为了唤醒,而是让每个 CPU 自旋在各自的缓存行上以避免颠簸。因此它记录的是 per-CPU 的 mcs_spinlock 节点,不是 task_struct 指针,对等待图没有贡献(详见 4.3 节)。

睡眠锁的 wait_list自旋锁的 MCS 队列
存在目的 释放时需要知道唤醒谁 让各 CPU 自旋在不同缓存行
记录内容 等待者的 task_struct per-CPU 的 mcs_spinlock 节点
对等待图的贡献 直接给出"谁在等" 只能得到队尾在哪颗 CPU
可遍历性 标准链表,list 一条命令 需解析 per-CPU 基址,成本高回报低

换言之,"能否睡眠"这个分类标准是从可调试性倒推出来的。 它之所以有效,是因为睡眠能力决定了锁的实现必须提供什么设施,而这些设施恰好就是 vmcore 分析所依赖的。

理解这条推导,比记住"哪些锁能睡"更有用——遇到本篇没覆盖的锁类型(如各种驱动自定义的同步原语),同样可以用它来判断该往哪个方向查。

2.3 两条路线的分野

由 2.1 与 2.2 得到全篇的骨架:

is the HOLD edge directly readable?
|
+——————-+——————-+
| YES | NO
v v
PATH A: read the structure PATH B: infer from context
—————————– —————————–
mutex / rw_semaphore / rt_mutex spinlock_t / rwlock_t / raw_*
(unless CONFIG_DEBUG_SPINLOCK)

owner & ~0x7 -> task_struct* val tells you "held", not "by whom"
wait_list -> traversable MCS queue, not a plain list
holder inferred from per-CPU bt

cost: minutes cost: hours, and may fail entirely

这两条路线对应本篇的第三、四章。 后面所有的字段编码、命令序列,都归属于其中一条。案例章节(七、八、九)则分别走通这两条路。

一个务实的建议:遇到现场先判断走哪条路线,再决定投入多少时间。 路线 A 通常十几分钟能出结论;路线 B 可能耗掉半天,且第九章会展示一种连推断都无法进行的情形。

2.4 锁类型速查表

把所有常见锁的关键信息集中在一处,后续章节不再逐一展开:

锁类型路径持有者从哪读等待链关键字段编码
mutex A owner & ~0x7 mutex_waiter 链表 bit0 WAITERS、bit1 HANDOFF、bit2 PICKUP
rw_semaphore A 写者同上;读者不可得 rwsem_waiter 链表(含 type) count bit0 写锁、bit1 有等待者、>>8 读者数
rt_mutex A owner 字段 plist 或红黑树(随版本,实测为准) 支持优先级继承
semaphore A wait_list 链表 count 为可用配额
spinlock_t / raw_spinlock_t B 默认无;DEBUG 配置下有 owner MCS,不便遍历 val:locked[7:0]、pending[15:8]、idx[17:16]、tail_cpu[31:18]
rwlock_t B 默认无 MCS,不便遍历 cnts:wlocked 为 byte 0,读者数 >>8
seqlock_t 写侧看内部 spinlock 不参与等待链 seqcount 奇数=写者活跃
RCU 无(非锁) stall 另有判别方式,见第十一章

表中两行需要特别说明:

seqlock_t 不参与等待链。 它的读者是重试而非阻塞,永远不会出现在 D 状态任务里;写侧的互斥依赖内部的 spinlock,若写者卡住,按路线 B 处理即可。因此本篇不为它单设章节。它唯一值得警惕的是一种误判模式(读者空转导致 CPU 高但无进程阻塞),归入第十一章。

semaphore 有等待链但无 owner。 这是个特例:能睡眠,所以有 wait_list 可遍历;但它是计数信号量,语义上允许多个持有者,因此不记录单一 owner。分析时只能拿到"谁在等",拿不到"谁持有"。

rt_mutex 的等待队列结构随内核版本变化。 早期实现为 plist_head(优先级排序链表),较新版本改为 rb_root_cached(红黑树)。遍历前须用 struct rt_mutex 确认该内核的实际类型——两种结构的遍历命令不同,套错会直接失败。


三、路线 A:读结构定持有者

睡眠锁的分析之所以简单,是因为第二类边直接写在结构里。本章讲清一套通用手法,各锁的差异只在少数几处。

3.1 owner 编码:一套规则,多锁通用

mutex 与 rw_semaphore 的 owner 字段采用低位复用标志、高位存指针的同类思路:类型都是 atomic_long_t。但二者的标志位数量与语义并不相同——mutex 用低 3 位,rwsem 用低 2 位且含义完全不同。共通的只是提取持有者的动作,掩码 ~0x7 对两者都适用(对 rwsem 多清了一位未使用的 bit2,不影响结果)。

63 3 2 1 0
+——————————————+—+—+—+
| task_struct pointer [63:3] | . | . | . |
+——————————————+—+—+—+
flag bits

低 3 位能被复用,是因为 task_struct 由 slab 分配器分配,对齐粒度至少 8 字节(实际通常是 L1_CACHE_BYTES,典型 64 字节),指针低 3 位天然为零。

注意与内核栈区分:THREAD_SIZE(通常 16KB)是内核栈的大小,与 task_struct 的对齐粒度无关。二者自 4.9(x86_64)/ 4.10(ARM64)起已分离,见第六篇 4.4 节。

通用提取式:

holder = owner.counter & ~0x7UL

holder == 0 -> lock is free
holder != 0 -> holder is a task_struct pointer

各锁的标志位含义不同,但提取持有者的动作完全一样。

mutex 的三个标志位:

位宏名置位时机
bit 0 MUTEX_FLAG_WAITERS wait_list 非空
bit 1 MUTEX_FLAG_HANDOFF 持有者正向第一等待者移交
bit 2 MUTEX_FLAG_PICKUP 移交目标已被唤醒,可来接锁

MUTEX_FLAGS 掩码为 0x07,自 4.10 mutex 重写以来稳定。仍建议 p MUTEX_FLAGS 或查目标内核 include/linux/mutex.h 确认。

crash 操作:

crash> p/x ((struct mutex *)0xffffffffc0789040)>owner.counter
$1 = 0xffff888207b48001

拆解:holder = 0xffff888207b48001 & ~0x7 = 0xffff888207b48000,bit0 = 1 表示有等待者。

crash> task 0xffff888207b48000
PID: 4822
COMM: worker-thread

counter 显示为负数是正常现象——atomic_long_t 是有符号类型,内核地址的最高位为 1,直接打印会呈现为大负数。用 p/x 以十六进制读取即可。

3.2 等待链遍历:通用手法

睡眠锁的等待者都以链表形式挂在锁的 wait_list 上。等待者描述符的第一个成员都是 list_head,因此 wait_list.next 的值同时也是第一个等待者描述符的地址。

struct mutex_waiter {
struct list_head list; /* offset 0 */
struct task_struct *task;
struct ww_acquire_ctx *ww_ctx;
};

"偏移 0"不是跨版本保证,用 struct mutex_waiter -o 确认一次成本极低。

通用遍历命令:

crash> list <waiter_type>.list -s <waiter_type>.task <wait_list.next>

实例:

crash> list mutex_waiter.list -s mutex_waiter.task 0xffff888207b48e20
ffff888207b48e20
task = 0xffff888207a3c000
ffff888207c5adc0
task = 0xffff888207c5a000

list 的参数形式随 crash 版本有差异,用前 help list 确认。

一个有用的交叉验证手段。 mutex_waiter 在当前实现中不是从 slab 分配的,它是 __mutex_lock() 的局部变量,存活在等待进程自己的内核栈帧上:

static int __mutex_lock(struct mutex *lock, ...)
{
struct mutex_waiter waiter; /* on the caller's kernel stack */
list_add_tail(&waiter.list, &lock->wait_list);
for (;;) {
if (mutex_try_to_acquire(lock))
break;
schedule();
}
list_del(&waiter.list);
}

因此 wait_list 中每个节点的地址,应落在某个等待进程的内核栈范围内。上例中 0xffff888207b48e20 与 PID 4821 的栈地址吻合,说明链表完好;对不上则说明链表可能已被破坏。

这属于当前实现下的可用手段,而非内核的固有设计。ww_mutex 等变体的等待路径不完全相同,未来版本也可能调整。

3.3 rw_semaphore 的三处特殊性

rw_semaphore 的分析流程与 mutex 基本一致,只有三处需要额外注意。

特殊性一:count 字段承载了状态与读者计数。

63 8 7 … 3 2 1 0
+———————————+————-+—+—+—+
| reader count [63:8] | reserved | H | W | L |
+———————————+————-+—+—+—+
RWSEM_FLAG_HANDOFF <—–+ | |
RWSEM_FLAG_WAITERS <———+ |
RWSEM_WRITER_LOCKED <————-+

count 值含义
0x0 完全空闲
0x1 写者持有,无等待者
0x3 写者持有,有等待者
0x100 一个读者持有
0x302 三个读者,且有等待者(通常是等待的写者)

这是本篇版本轴最陡的一处。 rw_semaphore 在 5.3 前后经历重写:早期实现的 count 用"负数表示写锁"的偏移编码(RWSEM_ACTIVE_WRITE_BIAS 一组常量),与上述位标志编码完全不同。分析 5.3 之前的内核(含 4.19 LTS)时上表不适用,须查该版本的 rwsem.h。

特殊性二:读者持有时,owner 不含具体进程。

写者持有时 owner 的读法与 mutex 完全相同;但读者持有时,该字段的行为在不同内核版本间并不一致——某些版本设为固定标记(如 RWSEM_READER_OWNED),某些保留最后一个读者的 task_struct 指针,某些则置为 NULL。

无论哪种形式,都不能用它追溯读锁的持有者:固定标记不含进程信息;即使保留了某个读者的指针,也无法证明该任务当前仍持有读锁,更无法给出完整的读者集合(读锁可被多个任务同时持有)。

因此"谁持有读锁"在 vmcore 中无法直接读出——持有边在这里又断了一次,唯一可靠的方法是遍历 backtrace,找出停在读临界区内的任务。这是路线 A 中唯一会退化的场景。

特殊性三:等待者队列中读者与写者混排,须靠 type 区分。

struct rwsem_waiter {
struct list_head list;
struct task_struct *task;
enum rwsem_waiter_type type; /* WAITING_FOR_WRITE / _FOR_READ */
unsigned long timeout;
bool handoff_set;
};

crash> list rwsem_waiter.list -s rwsem_waiter.task,rwsem_waiter.type \\
((struct rw_semaphore *)0xffffffffc0801200)>wait_list.next
task = 0xffff888209201000
type = 1 ← RWSEM_WAITING_FOR_WRITE

mutex 的 wait_list 中所有等待者性质相同,因而无需类型标记;rw_semaphore 的队列里读者与写者共存,二者对应完全不同的问题类型,故需 type 字段加以区分:

type 取值等待性质对应的问题类型
RWSEM_WAITING_FOR_WRITE 等待写锁 与另一写者互等,经典 ABBA
RWSEM_WAITING_FOR_READ 等待读锁 写者优先策略导致读者排队(见第五章)

若省略该字段,rw_semaphore 队列中的这两类等待者在输出中完全相同——均表现为"某任务阻塞在该 rw_semaphore 上",但二者的成因与修复方向截然不同。

type 的枚举值以目标内核的 enum rwsem_waiter_type 为准,不同版本次序可能不同。

3.4 路线 A 何时会失灵

即使是睡眠锁,也有读不出持有者的情况:

1. rwsem held by READERS -> owner has no task info (see 3.3)
2. semaphore -> no owner field by design
3. lock structure corrupted -> owner points to garbage; validate with
`task <addr>` before trusting it
4. wait_list corrupted -> `list` traversal loops or aborts;
cross-check with waiter stack addresses (3.2)

第三、四条尤其要留意:读出来的值必须验证。owner 解出的地址若不是有效的 task_struct,task <addr> 会报错——这时候不是"没找到持有者",而是"锁结构本身已被破坏",问题性质从死锁转为内存损坏,应转回第五篇的方法。


四、路线 B:自旋锁的交叉推断

4.1 val 能回答什么,不能回答什么

自旋锁默认不记录持有者,全部信息压缩在一个 32 位的 val 里。理解它的信息边界,比背下位域更重要:

val CAN tell you:
– is the lock held right now (locked byte != 0)
– is anyone spinning in fast path (pending byte != 0)
– is there an MCS queue, and on which CPU is its tail
(tail field)

val CANNOT tell you:
– WHO holds it <- this is HOLD edge, and it is missing
– who is in the queue (except the tail CPU)
– how long it has been held

这就是路线 B 全部困难的根源。 val 只能确认"边存在",说不出"边指向谁",因此持有者必须从别处推断。

qspinlock 的真实位域布局:

/* include/asm-generic/qspinlock_types.h */
typedef struct qspinlock {
union {
atomic_t val;
struct {
u8 locked; /* byte 0 */
u8 pending; /* byte 1 */
};
struct {
u16 locked_pending; /* bytes 0-1 */
u16 tail; /* bytes 2-3 */
};
};
} arch_spinlock_t;

locked 与 pending 各占一整个字节,这是理解位域的关键:

31 18 17 16 15 8 7 0
+——————+——+—————+—————+
| tail_cpu | idx | pending | locked |
| [31:18] |[17:16]| [15:8] | [7:0] |
+——————+——+—————+—————+

字段位范围OFFSET 宏语义
locked [7:0] _Q_LOCKED_OFFSET = 0 非零表示锁被持有
pending [15:8] _Q_PENDING_OFFSET = 8 快速路径上的第二个竞争者
tail_idx [17:16] _Q_TAIL_IDX_OFFSET = 16 上下文:0=task 1=softirq 2=hardirq 3=NMI
tail_cpu [31:18] _Q_TAIL_CPU_OFFSET = 18 队尾 CPU 编号 +1(0 表示队列空)

pending 宽度是配置相关的。 上表适用于 NR_CPUS < 16K 的常规配置(_Q_PENDING_BITS = 8);超大规模配置下 pending 压缩为 1 位,各字段偏移随之改变。用 p _Q_TAIL_CPU_OFFSET 实测,或直接看该内核的 qspinlock_types.h。

实用读法:不必手工移位,crash 直接给字节视图。

crash> struct qspinlock ffffffffc0909040
struct qspinlock {
{
val = { counter = 262145 },
{ locked = 1, pending = 0 },
{ locked_pending = 1, tail = 4 }
}
}

locked = 1 → 锁被持有;tail = 4,4 >> 2 = 1 即 tail_cpu = 1,对应 CPU 0 在排队(编码是 CPU 号 +1)。

需要手工换算时:

tail_cpu_field = (val >> 18) -> CPU number + 1 (0 means empty)
tail_idx = (val >> 16) & 0x3 -> 0=task 1=softirq 2=hardirq 3=NMI
pending = (val >> 8) & 0xFF
locked = val & 0xFF

常见取值速查(均可用上式复核):

val十进制lockedpendingtail_idxtail_cpu 字段状态
0x00000000 0 0 0 0 空闲
0x00000001 1 1 0 0 已持有,无竞争者,队列空
0x00000101 257 1 1 0 已持有,一个 CPU 在快速路径自旋
0x00040001 262145 1 0 0(task) 1 → CPU 0 已持有,队尾在 CPU 0,task 上下文
0x00080001 524289 1 0 0(task) 2 → CPU 1 已持有,队尾在 CPU 1,task 上下文
0x00050001 327681 1 0 1(softirq) 1 → CPU 0 已持有,队尾在 CPU 0,softirq 上下文

以 0x00050001 为例复核:val >> 18 = 1,tail_cpu 字段为 1,对应实际 CPU 0(该字段编码为 CPU 编号加一);(val >> 16) & 0x3 = 1,tail_idx 为 1,对应 softirq 上下文。tail_cpu 字段为 0 时表示队列为空,此时不存在队尾,tail_idx 亦无意义——表中前三行的该列标为 — 即因如此。

qrwlock 同理。 rwlock_t 在启用 CONFIG_QUEUED_RWLOCKS 时使用 qrwlock 实现,同属路线 B,同样无 owner:

cnts layout:
byte 0 = wlocked 0x00 free / 0x01 _QW_WAITING / 0xFF _QW_LOCKED
bits [31:8] = reader count

reader_count = cnts >> 8
when wlocked == 0xFF (write-locked), this is always 0
otherwise it reflects the actual reader count

wlocked == 0x01(_QW_WAITING)是最有诊断价值的中间态:写者已在排队,正等现存读者退出。此时读者计数仍然有效,且正是写者要等的对象。若该状态长期不变,说明存在"写者被读者流饿死"的现象。

4.2 路线判定:看这把锁的结构里有没有持有者字段

路线 A 与路线 B 的分野不取决于锁的名字,而取决于它的结构里是否记录了持有者。判定方式对任何锁都一样——用锁的实际类型名查看结构定义:

crash> struct <lock_type>

输出中出现 owner、owner_cpu 或语义等价的字段,即走路线 A(3.1 节的提取式通用);没有,则走路线 B。

锁类型默认结构判定
mutex / rw_semaphore / rt_mutex 恒有 owner 路线 A
raw_spinlock / spinlock_t 无;CONFIG_DEBUG_SPINLOCK 下有 owner、owner_cpu 视配置
qrwlock / rwlock_t 路线 B
驱动自定义原语 未知,须实测 视结构

关于最后一行。 该判定同样适用于驱动自定义的同步原语,但不能按字段名匹配——判据是结构中是否存在能定位到持有者的成员。符合该条件的字段有多种形式:类型为 struct task_struct * 的指针、记录 PID 的整型字段、记录持有者 CPU 编号的字段等;其命名可能是 owner、holder、locking_task 或其他任意标识符。判定时应审查字段的类型与语义,而非名称。

判定为路线 B 时,转入 4.4 节的推断流程。

4.3 MCS 队列:它记录的不是 task,因此对等待图没有贡献

自旋锁的等待者组织成 MCS 队列。这个队列在技术上可以遍历,但它记录的是 per-CPU 的节点而非 task_struct,因此无法产出等待图所需的任何一类边——这是本节的核心结论,机制细节服务于理解这一点。

struct mcs_spinlock {
struct mcs_spinlock *next; /* 队列中的下一个等待者 */
int locked; /* 0=仍在等待,1=可以尝试获锁 */
int count; /* 嵌套层数 */
};

节点的存放位置:每个 CPU 持有一个含 4 个元素的 per-CPU 数组,四个元素分别对应 task / softirq / hardirq / NMI 四层嵌套上下文。

val 的 tail 字段用两个子字段共同定位队尾节点:

tail_cpu [31:18] -> which CPU (encoded as CPU number + 1)
tail_idx [17:16] -> which of that CPU's 4 array slots (context level)

每个竞争者把自己追加到队尾,并在前驱节点的 locked 字段上自旋。各 CPU 因此自旋在不同的缓存行上,这是 MCS 避免缓存行颠簸的核心机制——也解释了为什么这个队列的设计目标是缓存局部性,而非记录等待者身份。

由此,遍历 MCS 队列对分析的实际价值:

想知道的信息MCS 队列能否提供
谁持有这把锁(持有边) 否——队列只含等待者,持有者从不入队
哪些任务在等(等待边) 否——节点里没有 task_struct 指针
队尾在哪颗 CPU 能,且 tail 字段直接给出,无需遍历

三行结论一致:该给的信息 tail 已经给了,该要的信息队列里没有。 因此实战中不解析 MCS 链,持有者身份走 4.4 节的推断。

4.4 交叉推断四步法

本节的前提是已按 4.2 节的路线判定确认该锁无持有者字段。四个步骤对任何无 owner 的锁都成立,其中前两步需要读锁的具体字段,各锁的读法见步骤后的对照表。

STEP 1 Verify the premise: is the lock actually held?
the backtrace only proves the task is INSIDE the acquire path;
it does NOT prove the lock is currently taken by someone else.
read the lock's state field to confirm.

held -> premise holds, proceed to STEP 2
not held -> the premise is wrong. Check, in order:
– is the lock address correct? (re-read dis)
– is the backtrace trustworthy? (see 9.2)
– if both are fine, this is not a lock problem
-> go to chapter 11

STEP 2 Narrow the candidate set
extract whatever locality information the lock exposes
(queue tail CPU, waiter count, etc.)
-> this narrows WHERE to look, never WHO holds it

STEP 3 Sweep per-CPU backtraces
crash> foreach bt (or bt -c 0 .. bt -c N)
look for a CPU that is NOT blocked on this lock,
but whose call path has already acquired it
-> the ONLY step that can identify the holder

STEP 4 Disassemble to confirm the acquisition order
crash> dis <func> <n>
find the point where this lock is held and another is requested

关于 STEP 1。 走到这里时,你已经从某个任务的 backtrace 得到了一条等待边,但 backtrace 反映的是任务当时在执行哪段代码,它不能证明锁此刻真的被别人拿着。二者对不上的情形有三种:锁刚被释放而等待者尚未退出自旋循环;backtrace 本身不可信(ARM64 跨栈失败或栈已损坏);从反汇编里取错了参数,读到的是另一把锁。

因此 STEP 1 的职责不是"顺手看一眼状态",而是在投入高成本推断之前验证前提——STEP 3 要扫描全部 CPU,花几秒确认前提成立是值得的。若锁确实空闲、地址无误、backtrace 可信,那么"这是个锁问题"的定性本身就错了。

关于 STEP 3。 这是唯一能定出持有者的一步,也是最耗时的:要找的不是"在等这把锁的 CPU",而是"已经拿到这把锁、正在做别的事的 CPU"。前者在 queued_spin_lock_slowpath 里,后者不在——它可能正在等另一把锁、等 I/O,或者正跑在一个长循环里。

前两步的各锁读法:

锁类型STEP 1:判"被持有"STEP 2:可用的定位信息
qspinlock(spinlock_t) locked 字节非零 tail_cpu – 1 得队尾 CPU;pending 非零表示快速路径有竞争者
qrwlock(rwlock_t) wlocked == 0xFF 为写锁持有;cnts >> 8 非零为读锁持有 wlocked == 0x01 表示写者已在排队;无队尾 CPU 信息
ticket lock(未启用 QUEUED_SPINLOCKS) owner != next 二者之差即等待者数量;无 CPU 信息
驱动自定义原语 须读该结构的状态字段,实测确定 视结构而定,可能没有

最后一行是常态而非例外。 遇到本篇未覆盖的锁时,STEP 1 的判据要从它自己的结构里找(通常是某个计数或标志字段),STEP 2 可能完全没有可用信息——此时直接跳到 STEP 3,扫描全部 CPU,代价是候选范围没有缩小。

4.5 架构差异如何改变"能拿到什么现场"

自旋锁的分析受架构影响,但真正需要关心的不是指令层面的差异,而是哪些差异会改变故障能否被发现、现场能否被采集。以下两条属于此列。

维度x86_64ARM64后果
自旋等待指令 PAUSE,持续占用 CPU WFE,进入低功耗等待 ARM64 自旋不烧 CPU,基于使用率的监控可能看不出异常
hard lockup 检测 PMU 驱动的 local APIC NMI,默认可用 需伪 NMI,条件苛刻(见下) 条件不满足时,关中断死锁不产生 vmcore

其余架构差异(内存序、释放时的唤醒机制、取地址与调用指令的写法)属于实现细节,不改变分析的可行性。其中反汇编相关的读法差异在第七章 7.3 节有说明。

ARM64 的 hard lockup 检测需要同时满足三个条件:

CONFIG_ARM64_PSEUDO_NMI=y
GICv3 (or later) interrupt controller
firmware support for interrupt priority masking

三者缺一,hard lockup 检测即不可用。其后果需分两种情形理解。

情形一:卡死发生在关中断的路径上。 此时无任何机制可以打断该 CPU——soft lockup 依赖的 watchdog hrtimer 本身要靠中断触发,中断已关则无法触发;伪 NMI 不可用,同样无从介入。系统静默卡死,不产生 vmcore。

情形二:卡死发生在中断仍开启的路径上。 watchdog hrtimer 可正常触发,soft lockup 能够捕获并产生 vmcore。

因此该类平台的可检测范围被压缩为情形二;情形一完全不可见,只能依靠 SysRq 等外部手段人工取现场。

分析 ARM64 vmcore 前值得先确认:

crash> sym gic_irq_nmi_setup # 存在该符号 -> 已编译进内核(GICv3 实现)
crash> log | grep -i "GICv3" # 确认中断控制器满足硬件条件
crash> log | grep -i "pseudo-NMI" # 有输出才说明运行时已启用
crash> p saved_command_line # 查 irqchip.gicv3_pseudo_nmi=1 是否存在

编译进内核与运行时启用是两回事,两项都要查。 该特性默认关闭,须显式加启动参数才生效。另需注意:irq_nmi_setup 是 struct irq_chip 的成员名而非全局符号,sym irq_nmi_setup 会报 not found,应查其 GICv3 实现 gic_irq_nmi_setup。

这条确认有个直接的推论价值:如果一个 ARM64 现场是 soft lockup 而非 hard lockup,说明卡住的路径中断仍是开的——这就排除了 spin_lock_irqsave 一类关中断持锁的嫌疑,嫌疑范围立刻缩小。第九章会用到这条推理。


五、死锁类型图谱

ABBA 死锁(最常见)

Task A: holds Lock_X —- waiting —-> Lock_Y
Task B: holds Lock_Y —- waiting —-> Lock_X

特征:两个或以上 D 状态任务,各自 backtrace 停在不同锁的获取路径,交叉追踪 owner 形成有向环。

自锁(Self Deadlock)

Task A: holds Lock_X —- (several call levels) —-> mutex_lock(Lock_X)

mutex 是非递归锁,二次加锁会被自己的 owner 永远阻塞。识别特征极其明确:从 mutex.owner 解出的 task_struct 地址,等于当前阻塞在该锁上的任务地址——自己等自己。

中断上下文参与的死锁

process context : spin_lock_irqsave(&Lock_X) /* IRQ disabled */
wants Lock_Y

hard IRQ : spin_lock(&Lock_Y) /* already holds Lock_Y */
wants Lock_X /* held by process ctx */

单 CPU 上,关中断后中断无法抢占,构成本 CPU 内的链路死锁;多 CPU 上则是跨 CPU 的 ABBA。特征是某 CPU 的中断上下文帧(do_IRQ / el1_irq)中持有 spinlock,另一 CPU 的进程路径在等同一把锁。

按 4.5 节的推论,这类死锁在无伪 NMI 的 ARM64 平台上通常拿不到 vmcore——关中断后 watchdog 也进不来。

锁顺序不一致(潜在死锁)

同一对锁在不同代码路径中以相反顺序加锁。它不是必然触发的死锁,但随并发度提高迟早发生。lockdep 在第一次违反依赖顺序时即告警,无需等到真正死锁——这是第十章的价值所在。

rwsem 写者优先导致的准死锁

Task A (writer): holds rwsem —- waiting —-> mutex_M
Task B (reader): holds mutex_M —- waiting —-> rwsem (down_read)

写者持有 rwsem 等 mutex;读者持有 mutex 尝试 down_read。由于 RWSEM_FLAG_WAITERS 已置位,rwsem 对新读者实施写者优先策略,读者被排队而非直接获取——于是双方互等。本质仍是 ABBA,只是两侧锁类型不同。

这类死锁容易被误判为"读锁怎么会阻塞"。 关键在于理解:有写者在排队时,读者不能插队,否则写者会被源源不断的读者饿死。这也是 3.3 节强调必须读 rwsem_waiter.type 的原因——不读 type,就分不清眼前是"两个写者互等"还是"读者被挡在门外"。


六、等待链重建:完整方法论

把前面各章的方法串成一条完整路径:

STEP 1 Enumerate stalled tasks
crash> ps | grep " UN "
crash> ps -m | grep " UN " (last-run delta, ordering only)
record: PID <-> task_struct address
|
v
STEP 2 Collect backtraces, extract WAIT edges
crash> foreach UN bt
blocking point tells you the lock TYPE:
__mutex_lock / rwsem_down_* -> PATH A (ch.3)
queued_spin_lock_slowpath -> PATH B (ch.4)
wait edge: TASK —- waits for —-> LOCK
|
v
STEP 3 Locate the lock address
global symbol: sym -m <module> | grep -i lock
embedded: dis <caller_func> <n>, read the first argument
x86_64: lea …, %rdi ARM64: adrp/add -> x0
|
v
STEP 4 Read HOLD edges (the hard part)
PATH A: struct mutex|rw_semaphore <addr>
p/x owner.counter ; holder = counter & ~0x7
validate: task <holder> <- must be a valid task
PATH B: struct raw_spinlock <- DEBUG owner field?
struct qspinlock <addr> <- locked byte, tail
then infer via foreach bt + dis
hold edge: LOCK —- held by —-> TASK
|
v
STEP 5 Build the graph, search for a cycle
nodes: tasks and locks
a cycle == deadlock confirmed
NO cycle == follow chains to their TERMINAL nodes, see ch.11
|
v
STEP 6 Verify at source level
crash> dis <func> <n>
confirm the two paths take the locks in opposite order

第五步是判定的分水岭。 追不出环时不要强行认定死锁——第十一章列出的几类现象都能造出"大片 D 状态"的假象。

第三步的 dis <func> <n> 中的 n 是反汇编的指令条数(不是行数或字节数)。加锁调用通常出现在函数序言之后不远处,12 是个够用的起手值;若没看到目标 call / bl,逐步加大到 30、60。


七、案例一:mutex ABBA 死锁(路线 A 全流程)

本章为教学构造。 模块名 demo_drv、符号名、地址与 PID 均为虚拟,位域取值按真实编码规则生成(owner & ~0x7 与对应 task 地址的引用关系自洽)。

之所以用构造案例演示完整流程,是因为真实死锁现场几乎总在某一步卡住——ARM64 的 bt 可能跨栈失败,CPU 停机失败会导致寄存器现场缺失,锁可能是嵌套结构里的匿名成员。这些困难对首次建立流程认知是干扰。第九章是真实生产现场,那里会展示上述每一种困难。

两类案例的分工:构造案例教方法,真实案例教边界。

7.1 现场

触发原因:hung_task 超时 panic。dmesg 摘录:

INFO: task worker-thread:4821 blocked for more than 120 seconds.
__schedule+0x2b9/0x860
__mutex_lock.isra.0+0x209/0x540
volume_lock_acquire+0x38/0x70 [demo_drv]

INFO: task worker-thread:4822 blocked for more than 120 seconds.
__mutex_lock.isra.0+0x209/0x540
io_lock_acquire+0x3c/0x80 [demo_drv]

7.2 STEP 1-2:枚举与取边

crash> ps -m | grep " UN "
[0 00:05:47.882] 4821 4815 0 ffff888207a3c000 UN worker-thread
[0 00:05:45.201] 4822 4815 1 ffff888207b48000 UN worker-thread
[0 00:05:41.094] 4823 4815 2 ffff888207c5a000 UN worker-thread

crash> bt 4821
#3 [ffff888207a3bd20] __mutex_lock at ffffffff81a58c30
#5 [ffff888207a3bda0] volume_lock_acquire at ffffffffc07834a8 [demo_drv]
#6 [ffff888207a3be20] flush_volume at ffffffffc07836c0 [demo_drv]

crash> bt 4822
#3 [ffff888207b48d20] __mutex_lock at ffffffff81a58c30
#5 [ffff888207b48da0] io_lock_acquire at ffffffffc078384c [demo_drv]
#6 [ffff888207b48e20] submit_io at ffffffffc078390a [demo_drv]

边(第一类):

PID 4821 (flush path) —- waits for —-> the mutex taken in volume_lock_acquire
PID 4822 (submit path) —- waits for —-> the mutex taken in io_lock_acquire

PID 4823 的 bt 显示它也在等第一把锁,是普通排队者,不在环上。

7.3 STEP 3:定位锁地址

模块的全局锁可直接查符号:

crash> sym -m demo_drv | grep -i mutex
ffffffffc0789040 (b) volume_mutex [demo_drv]
ffffffffc0789080 (b) io_mutex [demo_drv]

若锁嵌在某个结构体里、拿不到符号,就从调用帧反汇编,读第一个参数:

crash> dis volume_lock_acquire 12
ffffffffc0783490: push %rbp
ffffffffc0783491: mov %rsp,%rbp
ffffffffc07834a8: lea 0x4590(%rip),%rdi # ffffffffc0789040 <volume_mutex>
ffffffffc07834af: call mutex_lock_nested

x86_64 上第一个参数在 %rdi,lea 注释里的 # ffffffffc0789040 就是锁地址——crash 已经把 RIP 相对寻址算好并标注了符号名,不需要手工计算。

bt 输出中并存三类地址,须加区分。 以 7.2 节的帧为例:

#3 [ffff888207a3bd20] __mutex_lock at ffffffff81a58c30
└──────┬───────┘ └──────┬───────┘
栈帧地址 符号地址

栈帧地址(ffff888207a3bd20)是该帧在当前进程内核栈上的起始位置,属运行时坐标,随每次调用而变;用途是配合 bt -f 展开该帧的原始内容。

符号地址(ffffffff81a58c30)是该帧执行到的指令位置,属编译期坐标,固定不变;用途是 dis 反汇编或 sym 反查。

对象地址(ffffffffc0789040)则是 volume_mutex 这个 mutex 实例所在位置,位于模块 .bss 段,是 struct mutex 要读的目标。

三者可由前缀快速区分(参见第五篇 2.2 节):ffff8882… 属直接映射区(内核栈),ffffffff81… 属内核镜像,ffffffffc0… 属模块区。

ARM64 的读法不同: 取全局地址用 adrp + add 组合,首参在 x0,调用用 bl:

adrp x0, ffffffc010789000 <- page base
add x0, x0, #0x40 <- + offset
bl mutex_lock_nested

锁地址 = 页基址 + 偏移 = 0xffffffc010789040。

7.4 STEP 4:读第二类边

crash> p/x ((struct mutex *)0xffffffffc0789040)>owner.counter
$1 = 0xffff888207b48001

$1、$2 是 crash 的值历史编号,每执行一次 p 自增,可用 p $1 回看之前的结果。它们只是输出编号,与所读字段无关。

volume_mutex 的持有者 = 0xffff888207b48001 & ~0x7 = 0xffff888207b48000。验证一下:

crash> task 0xffff888207b48000 | head -2
PID: 4822 COMMAND: "worker-thread"

这一步验证不能省——若 task 报错说明解出的地址不是有效 task_struct,那是锁结构损坏而非死锁(见 3.4 节)。

同法读第二把:

crash> p/x ((struct mutex *)0xffffffffc0789080)>owner.counter
$2 = 0xffff888207a3c001

io_mutex 的持有者 = 0xffff888207a3c000 → PID 4821。

7.5 STEP 5:确认环

PID 4821 –holds–> io_mutex –blocks–> PID 4822
^ |
| |
+–blocked by– volume_mutex <–holds———+

cycle confirmed -> ABBA deadlock

7.6 STEP 6:反汇编验证加锁顺序

crash> dis flush_volume 60
ffffffffc07836c0: call io_lock_acquire <– 先加 io_mutex
ffffffffc07836d8: call volume_lock_acquire <– 后加 volume_mutex

crash> dis submit_io 60
ffffffffc0783910: call volume_lock_acquire <– 先加 volume_mutex
ffffffffc0783928: call io_lock_acquire <– 后加 io_mutex

两条路径加锁顺序完全相反,根因确认。修复方向:统一全局加锁顺序,或重新评估是否必须同时持有两把锁。


八、案例二:rwsem 死锁(与案例一的操作差异)

本章同为教学构造。

本案例与第七章同属路线 A:同样是两个任务互持对方所需的锁,同样按第六章的六步走。区别只在于其中一把锁是 rw_semaphore 而非 mutex,这导致 STEP 4(读持有边)的操作有所不同。

第七章已完整演示过六步流程,本章因此只展开受 rwsem 影响的步骤,其余步骤(枚举任务、取等待边、定位锁地址、确认环、反汇编验证)与第七章完全一致,不再重复。

以下"差异"均指相对于第七章 mutex 的操作差异,其根源是 3.3 节所述的 rwsem 三处结构特殊性。

8.1 现场概要

  • PID 5100(cache-flusher):持有 meta_mutex,以写者身份等待 cache_rwsem
  • PID 5101(meta-updater):持有 cache_rwsem 写锁,等待 meta_mutex

按第六章 STEP 1-2 枚举任务并取等待边(流程与第七章相同,完整 bt 输出格式见 7.2 节,此处不再重复),得到两个任务的阻塞点:

PIDCOMMAND阻塞点(栈顶在左)等待的锁
5100 cache-flusher rwsem_down_write_slowpath ← down_write ← cache_flush_begin cache_rwsem(写)
5101 meta-updater __mutex_lock ← mutex_lock_nested ← meta_lock_update meta_mutex

上表为摘要,非命令输出——foreach UN bt 实际会逐个任务打印完整帧列表。STEP 3(定位锁地址)同样与第七章一致,略过。

8.2 STEP 4 差异一:需先读 count 判定锁的持有形态

mutex 只需读 owner,rwsem 要先看 count 才知道是写锁还是读锁被持有:

crash> p/x ((struct rw_semaphore *)0xffffffffc0801200)>count.counter
$1 = 0x3

bit0 = 1 → 写者持有;bit1 = 1 → 有等待者。确认是写锁持有,owner 才有意义:

crash> p/x ((struct rw_semaphore *)0xffffffffc0801200)>owner.counter
$2 = 0xffff8882091a0001

持有者 = 0xffff8882091a0000 → PID 5101。

若 count 显示的是读者持有(如 0x300),owner 字段不含进程信息,只能改走遍历 backtrace 的路子(3.3 节特殊性二)。

8.3 STEP 4 差异二:遍历等待链时须同时读出 type

crash> list rwsem_waiter.list -s rwsem_waiter.task,rwsem_waiter.type \\
((struct rw_semaphore *)0xffffffffc0801200)>wait_list.next
ffff888209201e40
task = 0xffff888209201000 ← PID 5100
type = 1 ← RWSEM_WAITING_FOR_WRITE

这一步决定了问题的定性。 type = WRITE 说明 5100 是以写者身份排队,与 5101 构成两个写者互等的经典 ABBA;若 type = READ,则性质变成第五章讲的"写者优先导致读者排队",成因与修复方向都不同。

8.4 STEP 5 差异三:环的结构相同,但取边动作是两步而非一步

PID 5100 –holds–> meta_mutex –blocks–> PID 5101
^ |
| |
+–blocked by– cache_rwsem <–holds———+

环的结构与第七章无异,但取边的动作不同:meta_mutex 一步读 owner,cache_rwsem 要两步(先 count 定性、再 owner 取值)。这就是 rwsem 分析的全部额外成本。


九、案例三:回到 jbd2 现场(路线 B 与失效边界)

本章为真实生产现场,全部命令输出取自一份 4.19 ARM64 麒麟 vmcore(96 核、1TB 内存、连续运行 322 天)。与前两章的构造案例相比,这里的过程要曲折得多——多个步骤受阻,恰好把路线 B 的困难与边界完整暴露了出来。同时,第六篇留下的那个悬念在本章得到证实。

9.1 三轴确认

crash> sys
KERNEL: /usr/lib/debug/.../4.19.90-23.42.v2101.ky10.aarch64/vmlinux
DUMPFILE: vmcore [PARTIAL DUMP]
CPUS: 96
UPTIME: 322 days, 17:04:41
RELEASE: 4.19.90-23.42.v2101.ky10.aarch64
MACHINE: aarch64 (unknown Mhz)
MEMORY: 1024 GB
PANIC: "Kernel panic – not syncing: softlockup: hung tasks"

三项信息值得留意:KERNEL 路径位于 /usr/lib/debug/ 下,说明带 debuginfo,dis -l 可输出源码行号——这一点对本章的结论至关重要;UPTIME 322 天表明系统长期稳定后才出问题;PANIC 明确是 softlockup 而非 hard lockup。

确认锁的实现形态:

crash> struct qspinlock
struct qspinlock {
union {
atomic_t val;
struct {
u8 locked;
u8 pending;
};
struct {
u16 locked_pending;
u16 tail;
};
};
}
SIZE: 4

与 4.1 节的位域布局一致。该机 96 核,远低于 _Q_PENDING_BITS = 8 所对应的规模上限,标准位域表适用。

路线判定(按 4.2 节):

crash> struct raw_spinlock
struct raw_spinlock {
arch_spinlock_t raw_lock;
}
SIZE: 4

结构中只有 raw_lock 一个成员,SIZE 为 4——CONFIG_DEBUG_SPINLOCK 未开启(开启时会额外出现 magic、owner_cpu、owner,SIZE 相应增大)。判定为路线 B。

9.2 伪 NMI:编译了,但未启用

按 4.5 节,ARM64 的 hard lockup 检测依赖伪 NMI。逐项核实:

crash> sym irq_nmi_setup
symbol not found: irq_nmi_setup
possible alternatives:
ffff3ab9a9d99778 (t) gic_irq_nmi_setup

irq_nmi_setup 是 struct irq_chip 的成员名而非全局符号,查不到属正常。crash 提示的 gic_irq_nmi_setup 才是判断依据。

该符号存在,说明 CONFIG_ARM64_PSEUDO_NMI 已编译进内核。 硬件条件也满足:

crash> log | grep -i "GICv3"
[ 0.000000] GICv3: GIC: Using split EOI/Deactivate mode
[ 0.000000] GICv3: Distributor has no Range Selector support
[ 0.000000] GICv3: VLPI support, direct LPI support

GICv3 正常初始化,且采用 split EOI/Deactivate 模式——这正是伪 NMI 的前提之一。

但运行时未启用:

crash> log | grep -i "pseudo-NMI"
(无输出)

crash> p saved_command_line
saved_command_line = "BOOT_IMAGE=/vmlinuz-4.19.90-… root=… ro rd.lvm.lv=…
video=VGA-1:640×480-32@60me nomodeset rhgb quiet
smmu.bypassdev=0x1000:0x17 smmu.bypassdev=0x1000:0x15 cra"
...

内核真正启用伪 NMI 时会打印 Pseudo-NMIs enabled…;启动参数中也没有 irqchip.gicv3_pseudo_nmi=1。该特性默认关闭,须显式开启。

结论:这台机器不具备 hard lockup 检测能力,原因是"编译了但未启用"。 这个区别有实际价值——复现时只需增加一个启动参数即可获得该能力,无需重新编译内核。

由此得到一条推论:本例能触发 soft lockup,说明卡住的路径中断仍是开的。 这不仅排除了 spin_lock_irqsave 一类关中断持锁的嫌疑,也解释了这份 vmcore 何以存在——按 4.5 节的情形划分,若该路径关闭了中断,在这台无伪 NMI 的机器上不会有任何检测机制介入,也就不会有现场可供分析。

9.3 取等待边受阻:三种不同的失败

第六篇 5.6 节已分析过 bt 在这份现场上的困难。完整核实三颗 CPU:

crash> bt -c 79
PID: 2896966 TASK: ffff8b5408058380 CPU: 79 COMMAND: "jbd2/sdc1-8"
#0 [ffffab54bf4dfbf0] __crash_kexec at ffff3ab9a98cf8a0
#1 [ffffab54bf4dfd60] panic at ffff3ab9a9809cc4
#2 [ffffab54bf4dfe40] watchdog_timer_fn at ffff3ab9a9906878
#3 [ffffab54bf4dfea0] __hrtimer_run_queues at ffff3ab9a98aa070
#4 [ffffab54bf4dff20] hrtimer_interrupt at ffff3ab9a98aaf14
#5 [ffffab54bf4dff90] arch_timer_handler_phys at ffff3ab9aa0eec14
#6 [ffffab54bf4dffb0] handle_percpu_devid_irq at ffff3ab9a9889c8c
#7 [ffffab54bf4dfff0] generic_handle_irq at ffff3ab9a98820e8
#8 [ffffab54bf4e0010] __handle_domain_irq at ffff3ab9a9882a04

crash> bt -c 0
PID: 0 TASK: ffff3ab9aaf0f4c0 CPU: 0 COMMAND: "swapper/0"
#0 [ffff8b54fe24fe30] crash_save_cpu at ffff3ab9a98cfc44
#1 [ffff8b54fe24ffe0] handle_IPI at ffff3ab9a97b74f0
#2 [ffff8b54fe250050] gic_handle_irq at ffff3ab9a97a1740

crash> bt -c 95
PID: 2896829 TASK: ffff8b540805c180 CPU: 95 COMMAND: "jbd2/sdb1-8"
bt: WARNING: cannot determine starting stack frame for task ffff8b540805c180

CPU寄存器现场bt 结果受阻原因
79 有(panic 路径) 8 帧,止于 __handle_domain_irq unwinder 跨栈失败
0 3 帧,止于 gic_handle_irq unwinder 跨栈失败
95 cannot determine starting stack frame 未响应停机 IPI

CPU 0 的输出纠正了一个容易误判的地方。 它的栈顶是 crash_save_cpu——该 CPU 收到了停机 IPI 并完整执行了寄存器保存流程,crash_notes 有效。然而 dmesg 里写着:

crash> log | grep -i "failed to stop"
[27881836.838944] SMP: failed to stop secondary CPUs 0-78,80-95

crash> log | grep -i "stale\\|unreliable"
[27881836.848669] Some CPUs may be stale, kdump will be unreliable.

CPU 0 明明在 0-78 这个名单里。矛盾的根源在于该消息的打印方式。以下为 4.19 版本 arch/arm64/kernel/smp.c 的实现:

if (atomic_read(&waiting_for_crash_ipi) > 0)
pr_warn("SMP: failed to stop secondary CPUs %*pbl
",
cpumask_pr_args(&mask)); /* mask = 全部 secondary CPU */

打印的 mask 是"所有非本核 CPU",不是"实际失败的 CPU"。 计数器大于 0 仅说明至少有一颗未在超时内报到,消息却把 95 颗全部列出。

failed to stop secondary CPUs 给出的是候选名单,不是失败名单。 判断某颗 CPU 的现场是否可用,须逐个 bt -c N 实测,不能据这条消息推断。

该结论基于 4.19 的实现。其他内核版本的打印逻辑可能不同(较新版本有可能改为只输出实际超时的 CPU 子集),遇到此类消息时应回查目标内核对应位置的代码,或直接以逐 CPU 实测为准。

交叉推断在这份 vmcore 上确实失效,但主因是 unwinder 而非寄存器缺失。 即便寄存器齐全(如 CPU 0),也看不到中断之前该 CPU 在执行什么——而这恰恰是 STEP 3 推断持有者所需的信息。至于 CPU 95,它本身就是另一颗 soft lockup 的核(jbd2/sdb1-8),卡在自旋中无法响应 IPI,属于问题的症状而非独立故障。

改从内核自身打印的调用链取等待边:

crash> log | grep -A20 "Call trace"

el1_irq+0xb8/0x140
ext4_process_freed_data+0x258/0x530
ext4_journal_commit_callback+0x50/0x120
jbd2_journal_commit_transaction+0x164c/0x1a60
kjournald2+0xd0/0x2b8

ARM64 上 crash 的 unwinder 跨栈能力有限,bt 断在中断路径时,dmesg 中内核自己打印的 Call trace 往往更完整——那是内核 unwinder 的输出,与 crash 是两套独立实现。

9.4 核心验证:反汇编 +0x258

这是第六篇明确要求补上的一步。 第六篇当时提出"两块独立磁盘的 jbd2 卡在同一偏移,可能是自旋锁争抢",并强调必须反汇编确认。

先定位函数基址:

crash> sym ext4_process_freed_data
ffff3ab9a9b36798 (T) ext4_process_freed_data
/usr/src/debug/kernel-4.19.90/.../fs/ext4/mballoc.c: 2860

crash> whatis ext4_process_freed_data
void ext4_process_freed_data(struct super_block *, tid_t);

目标地址:

base = 0xffff3ab9a9b36798
target = base + 0x258 = 0xffff3ab9a9b369f0

crash 的 dis 输出中,符号后的偏移是十进制。 +0x258 对应输出里的 +600,换算时容易出错,建议直接按地址反汇编。

crash> dis -l 0xffff3ab9a9b369e0 16

./arch/arm64/include/asm/atomic_lse.h: 479
0xffff3ab9a9b369e0 <ext4_process_freed_data+584>: mov x0, x4
0xffff3ab9a9b369e4 <ext4_process_freed_data+588>: mov x2, #0x1
0xffff3ab9a9b369e8 <ext4_process_freed_data+592>: mov w30, w1
0xffff3ab9a9b369ec <ext4_process_freed_data+596>: .inst 0x88fe7c82 ; undefined
0xffff3ab9a9b369f0 <ext4_process_freed_data+600>: mov w0, w30

./include/asm-generic/qspinlock.h: 86
0xffff3ab9a9b369f4 <ext4_process_freed_data+604>: cbnz w0, 0xffff3ab9a9b36c70

fs/ext4/ext4.h: 1571
0xffff3ab9a9b369f8 <ext4_process_freed_data+608>: ldr x1, [x19,#1032]

fs/ext4/mballoc.c: 2820
0xffff3ab9a9b369fc <ext4_process_freed_data+612>: ldr w2, [x20,#48]
0xffff3ab9a9b36a00 <ext4_process_freed_data+616>: ldr w0, [x1,#700]
0xffff3ab9a9b36a04 <ext4_process_freed_data+620>: sub w0, w0, w2
0xffff3ab9a9b36a08 <ext4_process_freed_data+624>: str w0, [x1,#700]

./include/asm-generic/qspinlock.h: 101
0xffff3ab9a9b36a0c <ext4_process_freed_data+628>: ldr x0, [x19,#1032]
0xffff3ab9a9b36a10 <ext4_process_freed_data+632>: add x0, x0, #0x2a0
0xffff3ab9a9b36a14 <ext4_process_freed_data+636>: stlrb w23, [x0]

源码行号直接给出了答案。

+596 归属 atomic_lse.h:479(LSE 原子操作),+604 归属 qspinlock.h:86。后者对应的是:

/* include/asm-generic/qspinlock.h */
static __always_inline void queued_spin_lock(struct qspinlock *lock)
{
u32 val = atomic_cmpxchg_acquire(&lock->val, 0, _Q_LOCKED_VAL);
if (likely(val == 0))
return;
queued_spin_lock_slowpath(lock, val); /* <- cbnz 跳转目标 */
}

汇编与源码逐条对应:x2 = 1 是 _Q_LOCKED_VAL;.inst 0x88fe7c82 是 LSE 的比较交换指令;+600 把结果搬回 w0;+604 判非零则跳 slowpath。

.inst … undefined 不是内存损坏。 crash 内置的反汇编器版本较旧,不识别 ARMv8.1 的 LSE 原子指令(casal、ldadd 等),因此把机器码原样输出。需要解码时用 objdump -d –start-address=0xffff3ab9a9b369ec vmlinux 即可。遇到 undefined 先考虑反汇编器能力,而非数据损坏。

+0x258 是 spin_lock() 内联展开后、cmpxchg 刚返回的那条指令。第六篇的假设成立。

9.5 确定是哪把锁

+628: ldr x0, [x19,#1032] <- x0 = sb->s_fs_info,即 EXT4_SB(sb)
+632: add x0, x0, #0x2a0 <- 加固定偏移,无索引运算
+636: stlrb w23, [x0] <- qspinlock.h:101, queued_spin_unlock()

x19 是函数首参 struct super_block *,偏移 1032 处取出 EXT4_SB(sb),再加固定偏移 0x2a0(十进制 672)得到锁地址。

注意这里是纯固定偏移,不含任何索引计算。 这一点排除了 ext4_lock_group() 的可能——后者内部调用 bgl_lock_ptr(),返回 locks[group & (NR_BG_LOCKS-1)],是依赖 group 参数的动态地址,编译后必然包含掩码与索引运算。

实测确认该偏移对应的字段:

crash> struct ext4_sb_info -o | grep -B2 -A2 "672"
[656] struct ext4_group_info ***s_group_info;
[664] struct inode *s_buddy_cache;
[672] spinlock_t s_md_lock;
[680] unsigned short *s_mb_offsets;
[688] unsigned int *s_mb_maxs;

struct … -o 的偏移列是十进制,因此查 0x2a0 须换算为 672 再检索。

结论:卡在 ext4_sb_info->s_md_lock 上——这是每个 ext4 文件系统实例唯一的一把 mballoc 数据保护锁,不是 per-block-group 锁。

这里纠正一处极易发生的误判。 反汇编中 +608 处带有 fs/ext4/ext4.h:1571 的行号,该行正是 ext4_lock_group() 的定义位置,很容易据此认定卡在 group 锁上。但行号信息在内联展开后会出现映射偏差,不能单独作为锁身份的证据。

真正的判据有两条:其一,寻址方式是固定偏移而非动态索引;其二,struct ext4_sb_info -o 实测给出的字段名。凡是从行号推断出的对象身份,都应当用结构体偏移实测复核。

而 mballoc.c:2820(+612~+624)是临界区内的操作——从 x1+700 减去 x20+48 后写回,对应释放块时更新计数。

9.6 完整因果链

two jbd2 threads (sdb1 / sdc1) commit transactions
|
v
jbd2_journal_commit_transaction
|
v
ext4_journal_commit_callback -> ext4_process_freed_data
|
v
walk s_freed_data_list; for each freed extent:
spin_lock(&sbi->s_md_lock) <- STUCK HERE (+0x258)
update counters <- mballoc.c:2820
spin_unlock(&sbi->s_md_lock) <- +636 stlrb
|
v
each filesystem has exactly ONE s_md_lock
-> all mballoc operations on that fs serialize through it
|
v
spin exceeds watchdog threshold -> soft lockup on both CPUs

第六篇的假设得到证实:确实是自旋锁争抢。 但争抢的对象与原先的推测不同——不是 per-block-group 锁,而是 s_md_lock。

这个区别在故障机理上很关键。 per-block-group 锁有 NR_BG_LOCKS(通常 128)把,按 group 编号散列,竞争被稀释;而 s_md_lock 是每个文件系统唯一的一把,该文件系统上所有 mballoc 相关操作都要串行通过它,不存在任何稀释。

还有一点须澄清:sdb1 与 sdc1 是两个独立的文件系统,各自拥有独立的 ext4_sb_info,因而各自有独立的 s_md_lock。两颗 CPU 并非在争抢同一个锁对象。它们卡在同一偏移,是因为执行的是同一段代码路径,而非竞争同一对象。

因此现场的性质是:两个文件系统各自的 s_md_lock 同时出现长时间争抢。这提示压力来自共性因素而非单一磁盘——结合该机规模(96 核、1TB 内存、连续运行 322 天),待释放的 extent 链表可能极长,每个 extent 都要取一次 s_md_lock,而 96 核的并发规模会让这把单点锁成为明显瓶颈。

进一步的排查方向:确认 s_freed_data_list 的实际长度,以及同一文件系统上有多少 CPU 在并发执行 mballoc 路径。

9.7 本案例的方法论价值

其一,行号可以提示方向,但不能作为对象身份的最终判据。 第六篇提出"可能是自旋锁",本章通过 dis -l 的 qspinlock.h:86 确认了这一点——这部分行号证据是可靠的,因为它标注的正是 +604 那条 cbnz 指令本身。

但用行号判断是哪把锁则不可靠:+608 处的 ext4.h:1571 指向 ext4_lock_group(),据此会得出"per-block-group 锁"的结论,而 struct ext4_sb_info -o 实测表明该偏移是 s_md_lock。内联展开会使行号映射产生偏差,凡涉及对象身份的结论,都应以结构体偏移实测复核。

本例中真正指向正确答案的是寻址方式:固定偏移 add #0x2a0 排除了需要索引运算的 bgl_lock_ptr()。汇编的寻址形态有时比行号更可信。 与此同时,带 debuginfo 的 vmlinux 仍是必要条件——若只有裸符号,看到 .inst undefined 与几条 ldr/add,连"这是加锁路径"都难以断定。

其二,dmesg 消息的措辞需要核实其生成逻辑。 failed to stop secondary CPUs 0-78,80-95 看似列出了 95 颗失败的 CPU,实际是候选名单;CPU 0 的 bt 证明它保存成功。遇到关键判断依赖某条内核消息时,值得回查该消息的打印代码。

其三,工具能力是分析的边界条件。 ARM64 上 crash 的 unwinder 跨不过 IRQ 栈边界,这与寄存器是否可读无关。此时应及时转向 dmesg 的 Call trace,而非在失效的命令上反复尝试。同理,.inst undefined 是反汇编器的能力问题,换用 objdump 即可。

其四,对可复现的问题,应优先改变采集条件。 这份现场若能复现,两处调整可显著降低分析难度:开启 CONFIG_DEBUG_SPINLOCK 使 raw_spinlock 带上 owner 字段,分析路径由路线 B 转为路线 A;增加 irqchip.gicv3_pseudo_nmi=1 启动参数以获得 hard lockup 检测能力。二者的成本均低于在现有 vmcore 上进行交叉推断。


十、lockdep 告警的事后解读

启用 CONFIG_PROVE_LOCKING 时,内核在运行时维护全局锁依赖图。一旦新的加锁操作会形成依赖环,在第一次违反时即打印告警——不需要死锁真正发生。

若 dmesg 中有 lockdep 告警,优先读它。 它直接给出环的两条边和精确的代码偏移,比从锁结构反推快一个数量级——相当于把第六章的六步全部替你做完了。

crash> log | grep -A 60 "circular locking dependency"

典型告警的结构:

WARNING: possible circular locking dependency detected
——————————————————
net-rx-0/5200 is trying to acquire lock: <- 新请求的锁
ffffffffc0909080 (&drv->tx_lock){+.+.}-{2:2},
at: drv_process_rx+0x190/0x3a0 [netdrv]

but task is already holding: <- 当前已持有的锁
ffffffffc0909040 (&drv->rx_lock){+.+.}-{2:2},
at: drv_process_rx+0x30/0x3a0 [netdrv]

which lock already depends on the new lock.

the existing dependency chain (in reverse order) is:

-> #1 (&drv->tx_lock){+.+.}-{2:2}:
_raw_spin_lock_irqsave+0x48/0x90
drv_flush_tx+0x30/0x280 [netdrv]

-> #0 (&drv->rx_lock){+.+.}-{2:2}:
_raw_spin_lock_irqsave+0x48/0x90
drv_flush_tx+0x190/0x280 [netdrv]

读法: 上半部分的 trying to acquire 与 already holding 就是环的两条边;下半部分的依赖链给出对方路径的加锁位置。四个 +0x190/0x3a0 这样的偏移可直接用 dis 定位到具体指令。

{+.+.} 四字符是该锁的中断上下文使用历史。-{2:2} 是 lockdep 记录的该锁在 hardirq / softirq 上下文中的最大嵌套深度——注意这是历史最大值,不是本次加锁关了几层。

这些注记的确切语义随内核版本略有调整,遇到时以该内核 Documentation/locking/lockdep-design.rst 为准,或直接看告警下方 lockdep 自动打印的说明段落。分析时真正关键的是上半部分与两条依赖链——那才是环的直接证据,注记只是补充信息。


十一、D 状态海的辨别:并非全是死锁

大量 D 状态进程不等于存在环路死锁。第六章 STEP 5 追不出环时,应转入本章重新定性。

11.1 核心动作:找到终端节点

没有环,意味着等待链是一条条有终点的链而非首尾相接的圈。因此辨别的第一步不是继续找环,而是沿每条链走到底:

terminal node = the task at the end of a wait chain
that is NOT waiting on any lock

终端节点在等什么,决定了根因归属。 它与前面那些等待者之间隔了几把锁,只影响诊断步骤的长短,不改变根因。

Follow each chain to its terminal node, then ask:
what is that terminal task actually waiting on?

block device I/O -> storage subsystem
network / RPC -> NFS, iSCSI, remote storage
completion -> some other subsystem has not finished
a long-running loop -> not blocked at all, merely slow
RCU grace period -> different mechanism entirely, see 11.3

11.2 单点阻塞:直接型与经锁型

绝大多数"大片 D 状态但无环"的现场,都属于单点阻塞——所有等待最终收敛到同一个资源上。它有两种形态,根因相同,观察难度不同。

直接型:等待者与阻塞源之间没有锁。

Task A ──waits──> I/O
Task B ──waits──> I/O
Task C ──waits──> I/O

每个任务自己的 backtrace 就直接显示 io_schedule、blk_mq_submit_bio、submit_bio_noacct 等位置,再往下是驱动的超时等待。一眼可见,无需追踪。

经锁型:等待者与阻塞源之间隔着一把锁。

Task B ──waits──> Lock L ──held by──> Task A ──waits──> I/O
Task C ──waits──> Lock L (终端节点)
Task D ──waits──> Lock L

多数任务的 backtrace 显示 __mutex_lock,看起来像锁竞争;只有终端任务 A 的 backtrace 才显示真正的阻塞源。若只看前几个任务就下结论,会误判为锁问题。

这两种形态的根因是同一个,都是那个终端资源。 差别仅在于经锁型需要多走一跳:读锁的 owner,找到持有者,再看持有者在等什么。

锁在经锁型中的作用是放大器。 原本只有一个任务被 I/O 卡住,因为它持有锁,几十个任务跟着卡住。这解释了"一块盘变慢为何会导致整机 hang",也提示修复方向——除了处理 I/O 本身,还应评估该锁的持有范围是否过宽、是否有必要在持锁期间执行 I/O。

关于能否自行恢复。 这类形态下不存在互等的环,因此一旦终端阻塞解除,整条链会自然瓦解。但终端阻塞能否解除取决于那个外部资源本身——存储设备彻底失效或 NFS 服务端永久失联时,I/O 永不完成,锁永不释放,系统同样无法恢复。"无环"意味着不是死锁,不意味着能自愈。

11.3 RCU Stall:机制不同的一类

RCU stall 与前述形态有本质区别:它既不是等锁,也不是等设备,而是宽限期无法结束。

rcu: INFO: rcu_preempt self-detected stall on CPU
rcu: Tasks blocked on rcu_node (5 tasks):

某任务长时间未退出 rcu_read_lock() 临界区,或某 CPU 长期不经过静止点,导致所有依赖 synchronize_rcu 的操作被阻塞。

它不适用 11.1 的终端节点分析,因为"阻塞源"可能根本没有被阻塞——它可能正在长循环里执行,或者只是那颗 CPU 长期未经过静止点。沿等待链追踪不会指向它。

辨别方法: dmesg 中有 rcu stall 告警;或 backtrace 显示任务在 rcu_read_lock 保护区内执行了睡眠操作(schedule、wait_event)——那是 RCU 使用违规。

11.4 seqlock 读者空转:不表现为 D 状态

这是本章唯一不产生 D 状态的形态,但同样容易被当成"系统卡住"。

若某写者长时间持有 seqlock(seqcount 停在奇数),读者会持续重试而空转 CPU:

symptom : CPU utilization abnormally high
NO blocked tasks, NO D-state pile-up
cause : writer holds seqlock too long, readers keep retrying
check : seqcount value is ODD -> a writer is active
then locate that writer via its internal spinlock (路线 B)

特征是"CPU 高但无进程阻塞"——与死锁形态相反。按死锁思路去找 D 状态任务会一无所获。

11.5 辨别流程

Large number of D-state tasks, but STEP 5 found no cycle
|
v
Follow each chain to its TERMINAL node
(the task not waiting on any lock)
|
+– terminal waits on I/O / network / completion?
| -> single-point blocking (11.2)
| direct form : the tasks themselves show io_schedule etc.
| via-lock form: only the terminal task shows the real source
| root cause = the terminal resource, NOT the lock
|
+– terminal is in a long loop, not blocked?
| -> not a deadlock; a slow path holding the lock too long
|
+– dmesg shows rcu stall / task sleeps inside rcu_read_lock?
-> RCU stall (11.3); chain-following does not apply

High CPU but NO blocked tasks
-> seqcount odd, readers retrying? (11.4)

只有"找到环"是死锁的肯定判据。 本章各条都是排除项——沿链走到终端、看清终端在等什么,若终端等的是外部资源而非另一把锁,那么问题不在锁,而在那个资源。


十二、小结

锁是内核并发的咽喉,死锁是打在咽喉上的结。vmcore 是打结之后的静止画面——读它的方法是沿结构追链路,不是猜。

入口(第一章): 第五篇回答"这个值为什么是错的",第六篇回答"这件事跟谁有关",本篇回答**“他们卡在谁手里”**。当 foreach UN bt 的阻塞点收敛到同步原语上时,转入本篇。

底层逻辑(第二章): 死锁分析的本质是重建有向图并找环。图里只有两类边——等待边(task –waits–> lock)从 backtrace 得到,任何锁都一样;持有边(lock –held by–> task)只能从锁结构读,这才是全部困难所在。owner 字段之所以重要,正因为它是持有边的唯一直接来源;val 之所以要逐位拆,因为它是无 owner 的锁上唯一能挤出信息的地方。

"能否睡眠"这个分类维度是从可调试性倒推出来的:能睡 → 必须有等待队列 → 有链表可读、值得记 owner;不能睡 → 原地自旋 → 无需队列、记 owner 不划算。理解这条推导,遇到本篇没覆盖的锁类型也能判断该往哪查。

两条路线(第三、四章):

路线适用锁核心动作成本
路线 A mutex / rw_semaphore / rt_mutex owner & ~0x7 得持有者;list 遍历 wait_list 分钟级
路线 B spinlock_t / rwlock_t val 判定是否被持有;多 CPU backtrace 推断持有者 小时级,且可能失败

路线 A 有三处会失灵:rwsem 被读者持有、semaphore 无 owner、锁结构已损坏。转入路线 B 之前应先执行路线判定——struct <lock_type> 看结构里有没有持有者字段,几秒钟的检查可能省下数小时;该判定同样适用于驱动自定义原语,判据为字段的类型与语义(struct task_struct * 指针、PID 或 CPU 编号等),而非字段名称。

三个案例的分工:构造案例教方法,真实案例教边界。 第七章走通路线 A 的完整六步;第八章只展开 rwsem 相对第七章的三处操作差异(先读 count 定性、遍历时须读 waiter type、取持有边是两步而非一步);第九章是真实生产现场,用实测数据完成了第六篇留下的验证——dis -l 给出的 qspinlock.h:86 确证了卡点位于 spin_lock() 的快速路径上;而锁的身份则由 struct ext4_sb_info -o 实测确定为 s_md_lock——行号信息因内联展开存在映射偏差,一度指向 ext4_lock_group(),实测纠正了这一误判。

第九章同时展示了路线 B 的三类失效边界:crash 的 ARM64 unwinder 跨不过 IRQ 栈边界,即使寄存器齐全也看不到中断前的执行路径;failed to stop secondary CPUs 打印的是候选名单而非失败名单,据其推断会误判;反汇编器不识别 ARMv8.1 的 LSE 指令,输出 .inst undefined 易被误认为数据损坏。三者都不是数据缺失,而是工具能力的边界。

三条贯穿全篇的限定:

第一,所有位域与常量都是某组三轴取值下的实例。qspinlock 的 pending 宽度随 NR_CPUS 变、rwsem 的 count 编码在 5.3 前后完全不同、spinlock 有无 owner 取决于 CONFIG_DEBUG_SPINLOCK——动手前先 struct <type> 实测。

第二,对可复现的问题,应优先改变采集条件而非在现有 vmcore 上强推。开启 CONFIG_DEBUG_SPINLOCK 后 raw_spinlock 带上 owner 字段,分析路径由路线 B 转为路线 A,数小时的交叉推断变为一次直读;ARM64 上增加 irqchip.gicv3_pseudo_nmi=1 可获得 hard lockup 检测能力。二者的成本均低于交叉推断。

此外,带 debuginfo 的 vmlinux 对锁分析价值极高。第九章的结论完全建立在 dis -l 输出的源码行号上——若只有裸符号,.inst undefined 加几条 ldr/add 很难被认定为加锁路径。

第三,追不出环时不要强行认定死锁。此时的正确动作是沿每条等待链走到终端节点——那个不再等待任何锁的任务——再看它究竟在等什么。根因由终端节点的等待对象决定;等待者与终端之间隔没隔锁,只影响诊断要走几跳,不改变根因。

由此,第十一章的形态可归为两类:单点阻塞(终端在等 I/O、网络或 completion,其"直接型"与"经锁型"根因相同,锁在经锁型中只起放大作用),以及机制独立的两种——RCU stall 不适用链条追踪,seqlock 读者空转甚至不表现为 D 状态。需要注意,无环只说明不是死锁,不意味着系统能自愈:终端阻塞若来自永久失效的外部资源,链条同样不会瓦解。


下一篇《庖刃所及》进入驱动与模块崩溃的领域:当崩溃根源不在主线内核,而在那些未必拿得到源码的 .ko 里——如何在 vmcore 中把它们拎出来,是第八篇要解的题。


赞(0)
未经允许不得转载:网硕互联帮助中心 » 庖丁解 vmCore · 第七篇 · 结喉之处:锁与死锁的解剖
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!