Skip to the content.

lockdep 实现

struct held_lock {
	/*
	 * One-way hash of the dependency chain up to this point. We
	 * hash the hashes step by step as the dependency chain grows.
	 *
	 * We use it for dependency-caching and we skip detection
	 * passes and dependency-updates if there is a cache-hit, so
	 * it is absolutely critical for 100% coverage of the validator
	 * to have a unique key value for every unique dependency path
	 * that can occur in the system, to make a unique hash value
	 * as likely as possible - hence the 64-bit width.
	 *
	 * The task struct holds the current hash value (initialized
	 * with zero), here we store the previous hash value:
	 */
	u64				prev_chain_key;
	unsigned long			acquire_ip;
	struct lockdep_map		*instance;
	struct lockdep_map		*nest_lock;
#ifdef CONFIG_LOCK_STAT
	u64 				waittime_stamp;
	u64				holdtime_stamp;
#endif
	/*
	 * class_idx is zero-indexed; it points to the element in
	 * lock_classes this held lock instance belongs to. class_idx is in
	 * the range from 0 to (MAX_LOCKDEP_KEYS-1) inclusive.
	 */
	unsigned int			class_idx:MAX_LOCKDEP_KEYS_BITS;
	/*
	 * The lock-stack is unified in that the lock chains of interrupt
	 * contexts nest ontop of process context chains, but we 'separate'
	 * the hashes by starting with 0 if we cross into an interrupt
	 * context, and we also keep do not add cross-context lock
	 * dependencies - the lock usage graph walking covers that area
	 * anyway, and we'd just unnecessarily increase the number of
	 * dependencies otherwise. [Note: hardirq and softirq contexts
	 * are separated from each other too.]
	 *
	 * The following field is used to detect when we cross into an
	 * interrupt context:
	 */
	unsigned int irq_context:2; /* bit 0 - soft, bit 1 - hard */
	unsigned int trylock:1;						/* 16 bits */

	unsigned int read:2;        /* see lock_acquire() comment */
	unsigned int check:1;       /* see lock_acquire() comment */
	unsigned int hardirqs_off:1;
	unsigned int references:12;					/* 32 bits */
	unsigned int pin_count;
};

核心: 根据每个任务实际执行过的锁嵌套关系,动态建立一张“锁类别有向图”;每次准备增加一条新边前,检查它是否会让图形成可导致阻塞的环。

它不是观察“哪个线程正在等待哪个线程”,而是在验证整个内核已经暴露出来的锁顺序规则。

三个核心对象

锁实例:struct lockdep_map

每个受 lockdep 跟踪的锁都会嵌入一个 dep_maplockdep_map 保存锁的 key、名字和 class 缓存:

struct lockdep_map {
	struct lock_class_key *key;
	struct lock_class *class_cache[];
	const char *name;
	/* ... */
};

锁类别:struct lock_class

lockdep 图中的节点不是具体的锁地址,而是 lock class:

struct lock_class {
	struct list_head locks_after;
	struct list_head locks_before;

	const struct lockdep_subclass_key *key;
	unsigned long usage_mask;
	/* ... */
};

例如:

struct inode {
	spinlock_t i_lock;
};

系统中可能有几十万个 inode 和几十万个 i_lock 实例,但这些锁通常属于同一个 class:

inode->i_lock class

这样 lockdep 能从不同锁实例的执行历史中归纳出同一类锁的通用嵌套规则。这也是 lockdep 能“举一反三”的根本原因。

当前任务持锁栈:struct held_lock

每个任务都有:

current->held_locks[];
current->lockdep_depth;
current->curr_chain_key;

栈中每一项是一个 struct held_lock,记录具体锁实例、所属 class、获取位置、读写模式、trylock、IRQ 上下文等信息。它表达的是:

当前任务已经获取:A -> B -> C

一次加锁发生了什么

以:

spin_lock(&lock);

为例,核心调用关系是:

spin_lock()
  -> spin_acquire(&lock->dep_map, ...)
     -> lock_acquire()
        -> __lock_acquire()

对于普通 spinlock,lockdep 检查发生在真正尝试获取底层锁之前:

spin_acquire(&lock->dep_map, ...);
LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);

所以它有机会在 CPU 真正自旋卡住之前报警。

__lock_acquire() 的主要步骤如下。

查找或注册 lock class

class = lock->class_cache[subclass];

if (!class)
	class = register_lock_class(lock, subclass, 0);

register_lock_class() 根据 lock key + subclass 查找或者建立全局 lock_class

构造 held_lock

它先在当前任务持锁栈的尾部构造一个临时记录:

hlock->class_idx    = class_idx;
hlock->instance     = lock;
hlock->irq_context  = task_irq_context(curr);
hlock->trylock      = trylock;
hlock->read         = read;
hlock->hardirqs_off = hardirqs_off;

这里不仅记录“是什么锁”,还记录:

记录 IRQ 使用属性

mark_usage() 会为 class 设置 usage bits,例如:

曾在 hardirq 中获取
曾在 softirq 中获取
曾在 IRQ enabled 时获取
曾以 read 模式获取

例如,同一个锁曾经在进程上下文、IRQ enabled 时获取,后来又观察到它在 hardirq 中被获取,那么就存在:

进程持有 A
    ↓ 被硬中断打断
硬中断再次获取 A

lockdep 因此报告 inconsistent lock state。

最关键的算法:加边前进行可达性搜索

假设当前任务已经持有 A,现在准备获取 B

spin_lock(&A);
spin_lock(&B);

lockdep 将这个事实表示成一条有向边:

A -> B

它的含义是:曾经观察到持有 A 时获取 B。

边由 struct lock_list 表示,同时存入:

A.locks_after  : B
B.locks_before : A

但是在增加 A -> B 前,lockdep 会先反向提问:

从 B 出发,通过已有依赖图,是否已经能走到 A?

也就是:

path_exists(B, A) ?

如果存在,就意味着加入新边后会形成:

A -> B -> ... -> A

核心代码是:

/* Adding prev -> next: search from next and see whether prev is reachable. */
ret = check_noncircular(next, prev, trace);

搜索使用 BFS:

候选新边:prev -> next
搜索方向:next -> ... -> prev
找到:形成环,报警
没找到:加入新边

使用 BFS 而不是 DFS,主要是为了找到最短的循环路径,方便输出容易理解的报告。

ABBA 是怎么被发现的

假设路径一执行过:

spin_lock(&A);
spin_lock(&B);

图中产生:

A -> B

之后路径二执行:

spin_lock(&B);
spin_lock(&A);

在第二个 spin_lock(&A) 之前,lockdep 准备增加:

B -> A

它先从新锁 A 开始搜索,发现已有:

A -> B

因此:

已有:A -> B
待加:B -> A
结果:A -> B -> A

于是立即输出:

WARNING: possible circular locking dependency detected

此时不要求两条路径同时执行、两个 CPU 真的互相等待或者系统真的已经卡住。它只需要分别观察到构成死锁的局部锁顺序。

多级环也一样:

曾观察:A -> B
曾观察:B -> C
现在要加:C -> A

从 A 出发能够经过 B 到达 C,所以 lockdep 判断加入 C -> A 会形成环。

为什么不是简单地“有环就报警”

读写锁带来了更复杂的问题。lockdep 将锁获取模式区分为:

E:exclusive writer
S:shared reader
R:recursive reader
N:non-recursive locker

某些读锁之间不会互相阻塞,所以依赖图里出现形式上的环,并不一定真的能够构造出等待环。因此每条依赖边还带有 ERENSRSN 等类型。

BFS 只沿着可能产生实际阻塞的 strong dependency path 搜索。所以现代 lockdep 的逻辑更准确地说是:

检测由冲突锁模式组成的强依赖环,而不是检测任意有向环。

为什么开销还能接受:chain cache

如果每次加锁都扫描持锁栈并进行图搜索,开销会非常大。lockdep 为当前锁序列计算一个增量 hash:

初始值
  hash(A)
  hash(hash(A), B)
  hash(hash(hash(A), B), C)

核心函数是:

iterate_chain_key(chain_key, hlock_id);

锁链中的 class 和读写模式都会参与 chain_key 的构造:

A(write) -> B(read) -> C(write)

然后:

chain cache 命中
    -> 这个完整锁链以前已经验证过
    -> 跳过昂贵的依赖更新和图搜索

chain cache 未命中
    -> 执行完整验证
    -> 验证通过后加入 cache

因此 lockdep 的性能模型大致是:

第一次看到某种锁链:昂贵
之后重复同一锁链:hash 查询,便宜很多

解锁如何处理

解锁的核心调用路径是:

lock_release()
  -> __lock_release()

lockdep 在 current->held_locks[] 中找到对应的锁实例,然后恢复:

curr->lockdep_depth = i;
curr->curr_chain_key = hlock->prev_chain_key;

如果释放的是栈顶锁,直接完成。如果是非 LIFO 解锁,例如:

持有栈:A -> B -> C
释放:B
剩余:A -> C

lockdep 会先截断到 A,再把 C 重新“获取”一次,从而重建正确的 held-lock 栈和 chain hash。

整体流程

             当前任务 held_locks[]
                     │
                     │ 正在持有 A,现在获取 B
                     ▼
              产生候选依赖 A -> B
                     │
        ┌────────────┴────────────┐
        │                         │
        ▼                         ▼
  检查 class usage          从 B 开始 BFS
  IRQ/softirq/wait          能否到达 A?
        │                         │
        │                   ┌─────┴─────┐
        │                   │           │
        │                  能          不能
        │                   │           │
        │                   ▼           ▼
        │                报警      加入 A -> B
        │                               │
        └───────────────────────────────┘
                                        │
                                        ▼
                              更新 chain cache
                                        │
                                        ▼
                              压入 held_locks[]

最终可以把 lockdep 看成三个层次:

局部事实:某任务持有 A 时获取了 B
                  ↓
全局模型:锁 class 依赖图中加入 A -> B
                  ↓
全局推理:新边是否闭合出可阻塞的强依赖环

它最巧妙的地方不是 BFS 本身,而是:

把不同 CPU、不同时刻、不同任务观察到的局部锁顺序,合并成一张全局 class 依赖图,从而在真正需要同时满足复杂时序之前,就推导出潜在死锁。

相关源码入口

本站所有文章转发 CSDN 将按侵权追究法律责任,其它情况随意。