Linux 内核之旅(二十九):内核并发与同步机制全解

基于 v4.11.6 源码:从内存屏障、spinlock、mutex 到 RCU 的实现与用例

Posted by pandaychen on September 12, 2026

0x00 前言

本文代码基于 v4.11.6 版本,架构为x86_64

内核是一个天生高度并发的程序,同一份数据结构变量(task_structdentrysk_buff、路由表等)可能被多个 CPU 上运行的进程上下文、软中断、硬中断同时访问。如何在保证正确性的前提下把同步开销降到最低,是内核设计的核心命题之一。本文系统梳理下内核中常见的并发与同步机制,覆盖如下场景:

  • 基石:内存屏障、原子操作、抢占/中断控制
  • 自旋类(不可睡眠):spinlock(qspinlock)、rwlock(qrwlock)、seqlock
  • 睡眠类:semaphoremutexrwsem
  • 无锁/读侧优化:RCU(含 SRCU)、Per-CPU 变量
  • 进程休眠原语:等待队列(wait queue)

提示:本文讨论到的若干机制在 4.11 版本之后有重写(例如 wait_queue_t 在 4.13 改名 wait_queue_entry_trwsem 在 5.3 大改等等) 凡涉及版本差异处均显式标注,正文严格以 4.11.6 为准

0x01 并发的根源与内核上下文

三个并发来源

来源 说明 在单核(UP)是否存在
SMP 多核 多个 CPU 真正并行访问同一数据
内核抢占(CONFIG_PREEMPT 一个进程在内核态执行时被更高优先级进程抢占
中断/软中断 硬中断、软中断、tasklet 打断当前执行流

这解释了为什么内核锁总要区分如下三个典型场景:

  • 关不关抢占
  • 关不关中断
  • 关不关下半部

因为竞争对手可能来自另一个 CPU,也可能来自本 CPU 上打断本端的那个中断处理程序

执行上下文与「能否睡眠」

由于内核代码运行在若干上下文中,是否允许睡眠(调用 schedule() 主动让出)是选锁的第一判断要素,参考下文(注意一些禁止的场景):

flowchart TD
    subgraph proc [进程上下文 process context]
        SYS["系统调用/内核线程<br/>可睡眠 可抢占"]
    end
    subgraph atomic [原子上下文 atomic context 禁止睡眠]
        SIRQ["软中断 softirq/tasklet"]
        HIRQ["硬中断 hardirq"]
        SPIN["持有 spinlock 期间"]
    end
    SYS -->|"可用: mutex/rwsem/semaphore<br/>wait_event/RCU/spinlock"| OK1["可睡 + 不可睡都行"]
    SIRQ -->|"只能用: spinlock/rwlock<br/>seqlock/RCU/per-cpu/atomic"| OK2["禁止 mutex/semaphore/wait_event"]
    HIRQ --> OK2
    SPIN --> OK2

这里简单小结下,只要处在”原子上下文”(硬/软中断、持自旋锁、显式关抢占),就绝不能调用任何可能睡眠的函数mutex_lockkmalloc(GFP_KERNEL)copy_from_usermsleep 等)。违反会触发 scheduling while atomic 或死锁问题

那么什么叫可能睡眠呢?

todo

并发的本质:交错,而不一定是并行

先厘清一个常被混淆的点:并发(concurrency)不等于并行(parallelism)

  • 并行:两个执行流在两个 CPU 上同一时刻真的一起跑——只有 SMP 多核才有。
  • 并发:两个执行流的操作在时间上可以交错(interleave),且它们访问了共享数据——不要求同一时刻

数据竞争(race)的成立需要三者同时满足:1、 多个执行流的操作可交错 2、 访问同一份数据 3、至少一方是写。注意第一条只要求”可交错”,而”真并行”只是产生交错的一种方式而已。

这就正面回答了一个疑问:一个进程在内核态执行时被更高优先级进程抢占,为什么和并发有关?

设想进程 A 在内核态正往一个全局链表插入节点,做到一半(比如新节点的 next 已接好、但前驱的 next 还没更新)时,被更高优先级的进程 B 抢占。B 也进内核、也去操作同一条链表——它看到的是一个处于中间态、自相矛盾的链表,于是链表被写坏。整个过程发生在同一个 CPU 上,全程没有任何”并行”,但因为 A 的操作被 B 交错了,竞争照样发生。

sequenceDiagram
    participant A as 进程A(内核态)
    participant L as 全局链表(共享)
    participant B as 进程B(更高优先级)
    A->>L: 开始插入:接好新节点 next
    Note over A,L: 临界区做到一半,处于中间态
    B-->>A: 时钟中断触发调度,抢占!(同一CPU)
    B->>L: 也来操作同一链表 → 读到自相矛盾的中间态
    Note over L: 链表被写坏(即使从未"并行")

所以抢占(以及中断)是”单核也存在的并发来源”:它们把本来顺序执行的代码变成了可被交错的代码。这正是前表中”内核抢占 / 中断在 UP 单核依然存在”的真正含义——把”并发”理解成”交错”而非”并行”,整张表就通了。

内核如何堵住这个窗口:把插入放进临界区

回到那个”插一半被抢占”的例子。先看内核链表插入的真身 __list_addinclude/linux/list.h),可以清楚看到你说的那个中间态窗口:

static inline void __list_add(struct list_head *new,
                              struct list_head *prev,
                              struct list_head *next)
{
    next->prev = new;                 /* STEP1 */
    new->next  = next;                /* STEP2 */
    new->prev  = prev;                /* STEP3 到这里:new 已"半接入",但 prev 还没指向它 */
    WRITE_ONCE(prev->next, new);      /* STEP4 直到这一步,从 prev 前向遍历才看得到 new */
}

在 STEP1/2/3 与 STEP4 之间,正是你描述的”新节点 next 已接好、但前驱 next 还没更新”的不一致中间态:从 prev 往后遍历还看不到 new,而 new->next 却已指入链表。注意 __list_add 本身不做任何加锁/关抢占——它假设调用者已经在临界区里。

内核堵住这个窗口的办法是:任何会被并发访问的链表,其插入都必须在锁的保护下进行,而这把锁会顺带关掉抢占。以等待队列为例(kernel/sched/wait.c):

void add_wait_queue(wait_queue_head_t *q, wait_queue_t *wait)
{
    unsigned long flags;
    wait->flags &= ~WQ_FLAG_EXCLUSIVE;
    spin_lock_irqsave(&q->lock, flags);   /* 进入临界区 */
    __add_wait_queue(q, wait);            /* 内部就是 list_add → __list_add */
    spin_unlock_irqrestore(&q->lock, flags);
}

spin_lock(乃至 spin_lock_irqsave)的第一件事就是关抢占include/linux/spinlock_api_smp.hkernel/locking/spinlock.c):

static inline void __raw_spin_lock(raw_spinlock_t *lock)
{
    preempt_disable();                    /* ★ 关键:持锁期间本 CPU 不可被抢占 */
    spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);
    LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);
}

/* irqsave 版还会先关中断,把三个来源一次性堵死 */
static inline unsigned long __raw_spin_lock_irqsave(raw_spinlock_t *lock)
{
    unsigned long flags;
    local_irq_save(flags);                /* 关中断:挡住硬中断里的并发访问 */
    preempt_disable();                    /* 关抢占:挡住被高优先级进程抢占 */
    /* ... 拿锁 ... */
    return flags;
}

于是那三个”半更新时被打断”的隐患被逐一封死:

  • 被更高优先级进程 B 抢占preempt_disable 让本 CPU 在持锁期间不会触发调度,B 根本无法在 A 的 ①②③④ 之间被切进来——A 一口气做完四步、spin_unlock 才重新开抢占。
  • 被本 CPU 硬中断打断irqsave 关中断,中断处理程序也进不来(否则中断里若也来抢同一把锁,还会自死锁,见 0x03 / 0x0E 案例 5)。
  • 被另一个 CPU 并行访问:另一个 CPU 上的写者会在 spin_lock 处自旋等待,直到 A spin_unlock,从而看不到中间态。

一句话:__list_add 只管”怎么改指针”,”改的时候不许别人插进来”这件事交给外面的锁;而锁通过 preempt_disable / local_irq_save 把抢占和中断这两个”单核并发来源”一并关掉。

例外:如果读者是无锁的(RCU)怎么办?

上面的锁只解决了”写者 vs 写者 / 中断”的互斥;但读者若也想无锁(不加锁、不关抢占地遍历链表),光靠 preempt_disable 就不够了——因为无锁读者可能恰好在写者做 ①②③④ 的中途读到半成品。内核对这类场景改用 list_add_rcuinclude/linux/rculist.h):

static inline void __list_add_rcu(struct list_head *new,
                                  struct list_head *prev, struct list_head *next)
{
    new->next = next;                              /* 先把 new 内部指针备好 */
    new->prev = prev;
    rcu_assign_pointer(list_next_rcu(prev), new);  /* 带写屏障地"发布"前向指针 */
    next->prev = new;                              /* 反向指针后补(读者只走前向) */
}

两点关键区别:

  • 调整赋值顺序,保证发布 prev->next 是最后一步,且这一步用 rcu_assign_pointer(内含 smp_wmb),它保证”new 的内部字段先于 ‘prev->next 指向 new’ 对其他 CPU 可见”;
  • 于是无锁读者要么看到”还没接入 new“,要么看到”已完全接入且内容就绪的 new“,永远不会看到半成品。写者之间仍需持锁互斥(list_add_rcu 注释明确要求调用者持锁),回收还要等宽限期——这正是 0x0F “链表:RCU 保护的双向链表” 一节的机制来源。

并发来源 → 防御机制的对应关系

不同来源的竞争对手,需要不同的”防御动作”,这直接决定了内核锁的形态:

竞争对手来自 要做的防御 对应手段
另一个 CPU(SMP) 真正的互斥 + 保证内存可见性/顺序 spinlock 的自旋、atomicLOCK 前缀、内存屏障
本 CPU 被抢占的另一进程 关抢占,堵住”临界区中途被换出” preempt_disable()(spinlock 已内含)
本 CPU 的软中断/下半部 关下半部 local_bh_disable() / spin_lock_bh()
本 CPU 的硬中断 关中断 local_irq_save() / spin_lock_irqsave()

关键洞察:一把 spin_lock_irqsave() 其实是”三合一”——它同时做了 ① 对其他 CPU 自旋互斥(防 SMP)② preempt_disable(防抢占)③ local_irq_save(防中断)。所以你选 spinlock 的哪个变体,本质是在回答“我的竞争对手可能来自上表哪几行”:只跟别的进程争 → spin_lock(已含关抢占);还会被软中断碰 → spin_lock_bh;还会被硬中断碰 → spin_lock_irqsave。多关一层是正确但更贵,少关一层就留下竞争窗口。

反过来,这张表也解释了为什么 spin_lock 一定要内含 preempt_disable:若持锁期间不关抢占,就正好落回上一节”进程 A 插链表插一半被 B 抢占”的陷阱——锁本身就失去意义了。

应用视角 vs 内核视角:为什么内核的并发控制”更难”

站在应用开发者视角,并发控制大体是”用 mutex / 读写锁 / 原子变量保护共享变量和数据结构,避免多线程读写冲突”。内核侧的目标相同(保护共享数据一致性),但有几个本质差异,使得”选哪把锁”复杂得多:

维度 应用视角 内核视角
执行流类型 基本同质:都是线程/协程,都能睡、都可被调度 异构:进程上下文、软中断、硬中断、NMI,各自能力不同
能否睡眠 几乎总能睡(阻塞一个线程无妨) 硬约束:中断上下文、持自旋锁时绝不能睡
竞争对手 主要是”线程 vs 线程” 还有”进程 vs 中断”“中断 vs 中断”“CPU0 软中断 vs CPU1 软中断”
对象生命周期 多由 GC / 智能指针兜底 无 GC,并发控制还要额外保证”读的对象不会在脚下被释放”
内存序 语言/运行时大多隐藏了屏障 常需显式 READ_ONCE/smp_wmb/rcu_assign_pointer
出错代价 通常是单个进程 hang / 崩溃 可能整机死锁、panic(内核自己就是运行时)

把这几条落到实操,内核选锁其实要连问两个问题

  1. “我这段代码运行在什么上下文?”——决定我能不能睡(进程上下文可用 mutex/rwsem;中断上下文或持自旋锁时只能用 spinlock/RCU/atomic)。
  2. “我的竞争对手来自哪个来源?”——决定我要额外关掉什么(关抢占 / 关下半部 / 关中断,即上一节的对应表)。

两个问题的答案叠加,才唯一确定该用哪把锁。这就是为什么应用侧”无脑上 mutex”在内核里行不通——不是不想,而是①当前上下文可能根本不允许睡眠,②竞争对手可能是一个你无法”加锁”的中断处理程序(对中断只能”关”,不能”锁”)。

此外还有一个应用侧很少触及的维度——存在性(existence):应用里对象常有 GC 保证”只要还有人引用就不释放”;内核没有 GC,”我正在读的这个 task_struct / dentry 会不会在我读的过程中被别人释放”本身就是一个并发问题,需要 RCU、引用计数(get_task_struct)、existence lock 等专门手段来解决(详见 0x0C、0x0E)。这是内核并发控制比应用侧多出来的一整类工作。

执行上下文与「能否睡眠」

内核代码运行在若干上下文中,是否允许睡眠(调用 schedule() 主动让出)是选锁的第一判据:

flowchart TD
    subgraph proc [进程上下文 process context]
        SYS["系统调用/内核线程<br/>可睡眠 可抢占"]
    end
    subgraph atomic [原子上下文 atomic context 禁止睡眠]
        SIRQ["软中断 softirq/tasklet"]
        HIRQ["硬中断 hardirq"]
        SPIN["持有 spinlock 期间"]
    end
    SYS -->|"可用: mutex/rwsem/semaphore<br/>wait_event/RCU/spinlock"| OK1["可睡 + 不可睡都行"]
    SIRQ -->|"只能用: spinlock/rwlock<br/>seqlock/RCU/per-cpu/atomic"| OK2["禁止 mutex/semaphore/wait_event"]
    HIRQ --> OK2
    SPIN --> OK2

判断口诀:只要处在”原子上下文”(硬/软中断、持自旋锁、显式关抢占),就绝不能调用任何可能睡眠的函数mutex_lockkmalloc(GFP_KERNEL)copy_from_usermsleep 等)。违反会触发 scheduling while atomic 或死锁

copy_process中的 list_add_tail 与list_add_tail_rcu

先给结论:list_add_taillist_add_tail_rcu 的差别不在”写者要不要加锁”(两者都要、且都不自带锁),而在”这条链表有没有无锁读者。先看这几个原语的实现(list_add_tail/list_add_tail_rcu 只是尾插的封装,真正干活的是 __list_add/__list_add_rcu):

static inline void list_add_rcu(struct list_head *new, struct list_head *head)
{
	__list_add_rcu(new, head, head->next);
}

static inline void list_add_tail_rcu(struct list_head *new,
					struct list_head *head)
{
	__list_add_rcu(new, head->prev, head);
}

static inline void list_add_tail(struct list_head *new, struct list_head *head)
{
	__list_add(new, head->prev, head);
}

//https://elixir.bootlin.com/linux/v4.11.6/source/include/linux/list.h#L55
static inline void __list_add(struct list_head *new,
			      struct list_head *prev,
			      struct list_head *next)
{
	if (!__list_add_valid(new, prev, next))
		return;

	next->prev = new;
	new->next = next;
	new->prev = prev;
	WRITE_ONCE(prev->next, new);
}

#define list_next_rcu(list)	(*((struct list_head __rcu **)(&(list)->next)))

static inline void __list_add_rcu(struct list_head *new,
		struct list_head *prev, struct list_head *next)
{
	if (!__list_add_valid(new, prev, next))
		return;

	new->next = next;
	new->prev = prev;
	rcu_assign_pointer(list_next_rcu(prev), new);   //?
	next->prev = new;
}

问题 1:__list_add__list_add_rcu 的区别;为何一个用 WRITE_ONCE、一个用 rcu_assign_pointer

把两者并排看(忽略 __list_add_valid 的 debug 校验),差异集中在如下三点:

维度 __list_add(普通) __list_add_rcu(RCU)
发布前初始化 new next->prevnew->nextnew->prev new->nextnew->prev
发布 prev->next 那步 WRITE_ONCE(仅保证单指针原子写) rcu_assign_pointer(= smp_store_release,带写屏障
next->prev(后继反向指针) 在发布之前 挪到发布之后
跨 CPU 写序保证 有:初始化先于发布可见
对读者的假设 读者持同一把外部锁 读者仅 rcu_read_lock 无锁遍历
删除配对 list_del list_del_rcu + 宽限期回收

todo

1、赋值顺序。两者的共同点是:都保证”发布 prev->next = new“这一步之前,new 自身的 next 已就绪。因为无锁前向读者一旦经 prev->next 到达 new,会立刻解引用 new->next 继续走,故 new->next 必须先备好。差异点是:RCU 版把”后继的反向指针 next->prev = new“挪到了发布之后,因为 RCU 读者只走前向 ->next、从不读 ->prev,这个反向指针晚更新对读者无影响

2、发布那一步的”内存序强度”,这正是 WRITE_ONCE VS rcu_assign_pointer 的核心

  • WRITE_ONCE(prev->next, new):只是一条”不可被编译器拆分/优化掉”的原子单指针写。它保证这条指针写本身是一次完整 store(服务于 list_empty()READ_ONCE(head->next) 的无锁判空),但不提供任何跨 CPU 的写序保证:在弱序架构(如ARM等)上,别的 CPU 可能先看到 prev->next = new、后看到 new->next = next
  • rcu_assign_pointer(list_next_rcu(prev), new):等价于 smp_store_release()。它插入写屏障,保证”初始化 new 各字段(乃至节点承载的数据)”这些先前的写,一定先于”发布 prev->next = new“对其他 CPU 可见

3、对读者的隐含要求(发布-订阅配对)

  • 非 RCU 链表:读者与写者持同一把外部锁。读者拿锁的 acquire 语义与写者 unlock 的 release 语义配对,读者进临界区自然能看到写者的全部写。没有无锁读者能观察到中间态,故每次插入无需额外写屏障,WRITE_ONCE 足矣
  • RCU 链表:读者只在 rcu_read_lock() 下无锁遍历(rcu_read_lock 本质是 preempt_disable不含与写者配对的内存屏障)。因此写者必须自己在发布点给出屏障,rcu_assign_pointer(发布)正是与读者侧 list_for_each_entry_rcu/rcu_dereference(订阅,依赖序读)配对的另一半。这就是 RCU “publish-subscribe” 的全部要义(详见章节 0x0C
sequenceDiagram
    participant W as 写者 list_add_tail_rcu
    participant M as 内存 prev->next 与 new
    participant R as 无锁读者 list_for_each_entry_rcu
    W->>M: new->next = next;new->prev = prev(先备好 new 自身)
    Note over W,M: rcu_assign_pointer = smp_wmb + 发布
    W->>M: 写屏障 → 发布 prev->next = new
    R->>M: rcu_dereference 读 prev->next,看到 new
    Note over R: 依赖序保证:看到 new 指针 → 必看到已初始化的 new->next
    R->>M: 继续走 new->next(安全,绝不读到半成品)

外部调用方的差异

  • list_add_tail / list_del:用于”从不被无锁遍历”的链表(所有读者与写者持同一把锁)
  • list_add_tail_rcu / list_del_rcu + call_rcu/synchronize_rcu:用于”存在 rcu_read_lock 下无锁遍历”的链表;删除只摘链,须过宽限期再释放(见 0x0F、0x0E 案例 2)
  • 共同点:两者都不自带锁,写者之间的互斥一律由调用方的外部锁负责(下文)

调用侧copy_process的实现如下:

//https://elixir.bootlin.com/linux/v4.11.6/source/kernel/fork.c#L1491
static __latent_entropy struct task_struct *copy_process(
					unsigned long clone_flags,
					unsigned long stack_start,
					unsigned long stack_size,
					int __user *child_tidptr,
					struct pid *pid,
					int trace,
					unsigned long tls,
					int node)
{
    ......
    write_lock_irq(&tasklist_lock);

	/* CLONE_PARENT re-uses the old parent */
	if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {
		p->real_parent = current->real_parent;
		p->parent_exec_id = current->parent_exec_id;
	} else {
		p->real_parent = current;
		p->parent_exec_id = current->self_exec_id;
	}

	spin_lock(&current->sighand->siglock);

	/*
	 * Copy seccomp details explicitly here, in case they were changed
	 * before holding sighand lock.
	 */
	copy_seccomp(p);

	/*
	 * Process group and session signals need to be delivered to just the
	 * parent before the fork or both the parent and the child after the
	 * fork. Restart if a signal comes in before we add the new process to
	 * it's process group.
	 * A fatal signal pending means that current will exit, so the new
	 * thread can't slip out of an OOM kill (or normal SIGKILL).
	*/
	recalc_sigpending();
	if (signal_pending(current)) {
		retval = -ERESTARTNOINTR;
		goto bad_fork_cancel_cgroup;
	}
	if (unlikely(!(ns_of_pid(pid)->nr_hashed & PIDNS_HASH_ADDING))) {
		retval = -ENOMEM;
		goto bad_fork_cancel_cgroup;
	}

	if (likely(p->pid)) {
		ptrace_init_task(p, (clone_flags & CLONE_PTRACE) || trace);

		init_task_pid(p, PIDTYPE_PID, pid);
		if (thread_group_leader(p)) {
			init_task_pid(p, PIDTYPE_PGID, task_pgrp(current));
			init_task_pid(p, PIDTYPE_SID, task_session(current));

			if (is_child_reaper(pid)) {
				ns_of_pid(pid)->child_reaper = p;
				p->signal->flags |= SIGNAL_UNKILLABLE;
			}

			p->signal->leader_pid = pid;
			p->signal->tty = tty_kref_get(current->signal->tty);
			/*
			 * Inherit has_child_subreaper flag under the same
			 * tasklist_lock with adding child to the process tree
			 * for propagate_has_child_subreaper optimization.
			 */
			p->signal->has_child_subreaper = p->real_parent->signal->has_child_subreaper ||
							 p->real_parent->signal->is_child_subreaper;
			list_add_tail(&p->sibling, &p->real_parent->children);
			list_add_tail_rcu(&p->tasks, &init_task.tasks);
			attach_pid(p, PIDTYPE_PGID);
			attach_pid(p, PIDTYPE_SID);
			__this_cpu_inc(process_counts);
		} else {
			current->signal->nr_threads++;
			atomic_inc(&current->signal->live);
			atomic_inc(&current->signal->sigcnt);
			list_add_tail_rcu(&p->thread_group,
					  &p->group_leader->thread_group);
			list_add_tail_rcu(&p->thread_node,
					  &p->signal->thread_head);
		}
		attach_pid(p, PIDTYPE_PID);
		nr_threads++;
	}

	total_forks++;
	spin_unlock(&current->sighand->siglock);
	syscall_tracepoint_update(p);
	write_unlock_irq(&tasklist_lock);
    ......
}

问题 2:copy_process 为何两种混用(而非只用一种)

先回答”怎么加锁“的问题,观察到(list_add_tail/list_add_tail_rcu 实现里确实没有额外加锁):

  • 这几处list_add_*插入函数,全部位于 write_lock_irq(&tasklist_lock) -----> write_unlock_irq(&tasklist_lock) 之间。tasklist_lock 是一把全局 rwlock_t,写模式独占 → 它负责所有写者之间的互斥(另一个 CPU 上并发 fork/exit 的写者会在 write_lock 处等待)。这正印证 0x01 章节的结论,即list_add* 系列不自带锁,锁是调用方的事,插入原语只负责”内存序正确的挂链”
  • 为什么用 _irq 变体?:tasklist_lock 会在中断相关路径以读模式被访问,且要堵住”改到一半被本 CPU 中断/抢占”的窗口,故写侧必须使用 write_lock_irq 关中断 + 关抢占( 0x01章节的结论:”把插入放进临界区”把三个并发来源一并封死)
  • 读者侧分两类:需要稳定视图的读者用 read_lock(&tasklist_lock);无锁遍历的读者只用 rcu_read_lock()不需要碰 tasklist_lock
  • 后文会提到,tasklist_lockrwlock_t类型(0x05章节:读写自旋锁)

再回答”为何不统一用一种“,取决于每条链表各自的读者纪律

链表 节点 / 头 读者及其纪律 插入原语 为何
子进程链表 p->sibling / real_parent->children do_waitdo_wait_threadlist_for_each_entry(p, &tsk->children, sibling),在 read_lock(&tasklist_lock) 下遍历(非 RCU list_add_tail 只有持锁读者,无无锁读者,普通版足矣
全局任务链表 p->tasks / init_task.tasks for_each_processnext_task = list_entry_rcu(...tasks...),在 rcu_read_lock无锁遍历 list_add_tail_rcu 有无锁 RCU 读者,必须带发布屏障
线程组链表 p->thread_group next_thread/while_each_thread = list_entry_rcu(...thread_group...) list_add_tail_rcu 同上
线程链表 p->thread_node / signal->thread_head for_each_thread = list_for_each_entry_rcu(...thread_head, thread_node) list_add_tail_rcu 同上

结论:同一个 copy_process 把新任务挂进多条链表,而这些链表的”读者纪律”不同children 只有持锁读者(do_wait)→ 普通 list_add_tailtasks / thread_group / thread_node 都有 rcu_read_lock 下的无锁遍历者(for_each_process / next_thread / for_each_thread)→ 必须 list_add_tail_rcu。反证两个方向:

  • children 也换成 _rcu正确但浪费:多一次无谓的写屏障(读者根本不走 RCU)
  • tasks/thread_* 换成非 _rcu是 bug:弱序架构上无锁读者可能读到”指针已发布、new->next 却还没就绪”的半成品,遍历语义不确定

所以一个明确的结论是,选普通版还是 _rcu 版,看的是「读者要不要无锁」,而不是「写者要不要加锁」。写者两种情况下都在 tasklist_lock

对本文主题的指导意义

  1. 锁与原语分工list_add* 只管”怎么正确地改指针(含无锁读者所需的内存序)”,”改的时候不许别的写者插进来”永远交给外层锁(此处 tasklist_lock)。呼应 0x01”把插入放进临界区”
  2. 选 RCU 与否看读者、不看写者:这是选型最易搞反的点——决定用不用 _rcu 的唯一依据是”这条链表有没有 rcu_read_lock 下的无锁遍历者”
  3. 一把锁保护多个异构结构tasklist_lock 一个临界区里同时维护了”进程树(children,锁读)”与”任务/线程链表(tasks/thread_*,RCU 读)”——同一份写侧互斥,服务于纪律不同的多类读者。这正是内核”一锁多表”的真实形态
  4. 发布-订阅是 RCU 的地基rcu_assign_pointer(写发布)↔ list_for_each_entry_rcu/rcu_dereference(读订阅)的配对贯穿 0x0C、0x0F 与本例;WRITE_ONCE 只是其”去掉屏障”的退化版,适用于无无锁读者的场景

todo

同步原语选型总表

先给出一些总结性结论:

原语 可否睡眠(持有时) 典型场景 开销/特点
原子操作 atomic_t 不睡 计数器、标志位 最轻,无临界区
spinlock 持有时禁抢占,不可睡 短临界区、可能被中断访问 忙等,临界区必须短
rwlock 同上 读多写少的短临界区 读者并发,写者独占
seqlock 读侧不阻塞写侧 读极多、写极少(时间、路由) 读者可能重试,无写者饥饿
semaphore 可睡 遗留代码、计数信号量 已被 mutex 取代
mutex 可睡 一般互斥、长临界区 有乐观自旋,进程上下文
rwsem 可睡 读多写少的长临界区 mmap_sem
RCU 读侧几乎零开销 读极多、指针发布/回收 写者延迟回收
Per-CPU —— 每 CPU 独立数据、统计 消除共享,靠关抢占
等待队列 可睡 阻塞等待某条件成立 配合条件谓词

下面各个章节自底向上展开

0x02 基石:内存屏障与原子操作

在深入学习内核的并发机制之前,必须先理解两个更底层的问题:一、编译器/CPU 会乱序,以及二、普通读写不是原子的。锁的本质上是在这两个基石之上构建的

编译器屏障与 READ_ONCE/WRITE_ONCE

barrier() 只阻止编译器跨越它重排内存访问,不生成任何 CPU 指令。READ_ONCE()/WRITE_ONCE()include/linux/compiler.h)强制通过一次内存访问读/写变量,防止编译器把它优化进寄存器或拆分访问:

#define READ_ONCE(x)  __READ_ONCE(x, 1)

//https://elixir.bootlin.com/linux/v4.11.6/source/include/linux/compiler.h#L314
#define __READ_ONCE(x, check)						\
({									\
	union { typeof(x) __val; char __c[1]; } __u;			\
	if (check)							\
		__read_once_size(&(x), __u.__c, sizeof(x));		\
	else								\
		__read_once_size_nocheck(&(x), __u.__c, sizeof(x));	\
	__u.__val;							\
})
#define READ_ONCE(x) __READ_ONCE(x, 1)

#define WRITE_ONCE(x, val) \
({                          \
    union { typeof(x) __val; char __c[1]; } __u = { .__val = (val) }; \
    __write_once_size(&(x), __u.__c, sizeof(x)); \
    __u.__val;              \
})

一个经典误区是,先置 flag 再读数据的忙等循环

/* 错误:编译器可能把 flag 缓存进寄存器,永远读不到更新 */
while (!flag) ;
use(data);

必须写成 while (!READ_ONCE(flag)) ;

CPU 内存屏障

在多 CPU 下,即使编译器不乱序,CPU 和缓存也会让另一个 CPU 观察到的写顺序与程序顺序不同。因此内核提供(include/asm-generic/barrier.harch/x86/include/asm/barrier.h):

作用
smp_mb() 全屏障,读写都不越过
smp_wmb() 写屏障,屏障前的写先于屏障后的写可见
smp_rmb() 读屏障,屏障前的读先于屏障后的读完成
smp_store_release() / smp_load_acquire() release/acquire 语义,成对使用

经典的发布数据范式(这正是 rcu_assign_pointer 的底层实现):

/* 生产者 */
obj->a = 1;
obj->b = 2;
smp_wmb();            /* 保证上面的初始化先于指针发布可见 */
WRITE_ONCE(gp, obj);  /* 发布指针 */

/* 消费者 */
p = READ_ONCE(gp);
if (p) {
    smp_rmb();        /* 保证先读到指针,再读它指向的内容 */
    use(p->a, p->b);
}

关键认知:屏障解决的是”可见性/顺序”,不是”互斥” ,它不能替代锁,只是无锁编程和锁实现的构件和基础

//https://elixir.bootlin.com/linux/v4.11.6/source/include/linux/rcupdate.h#L594
#define rcu_assign_pointer(p, v)					      \
({									      \
	uintptr_t _r_a_p__v = (uintptr_t)(v);				      \
									      \
	if (__builtin_constant_p(v) && (_r_a_p__v) == (uintptr_t)NULL)	      \
		WRITE_ONCE((p), (typeof(p))(_r_a_p__v));		      \
	else								      \
		smp_store_release(&p, RCU_INITIALIZER((typeof(p))_r_a_p__v)); \
	_r_a_p__v;							      \
})

todo

原子操作与引用计数

atomic_t / atomic64_tinclude/linux/atomic.harch/x86/include/asm/atomic.h)在 x86 上用 lock 前缀指令实现,无需临界区即可完成不可分割的读-改-写

static __always_inline void atomic_inc(atomic_t *v)
{
    asm volatile(LOCK_PREFIX "incl %0" : "+m" (v->counter));
}

cmpxchg(ptr, old, new) 是无锁编程和几乎所有锁(qspinlock、mutex、rwsem)的核心,只有当 *ptr == old 时才写入 new 并返回旧值

todo:cmpxchg和atomic_inc的关系是什么?

在内核中,引用计数场景常见于 refcount_t(v4.11 新引入,include/linux/refcount.h),相比裸 atomic_t 增加了溢出/UAF 防护;面向对象生命周期管理则用 krefinclude/linux/kref.h),典型模式 kref_get / kref_put(&obj->ref, release_fn)

0x03 抢占与中断控制

自旋锁的语义高度依赖「关抢占/关中断」,先单独讲清楚。

三对开关

操作 关闭了什么 头文件
preempt_disable() / preempt_enable() 内核抢占(本 CPU 不会被调度走) include/linux/preempt.h
local_irq_save(flags) / local_irq_restore(flags) 本 CPU 的硬中断 include/linux/irqflags.h
local_bh_disable() / local_bh_enable() 本 CPU 的软中断/下半部 include/linux/bottom_half.h

preempt_count(存于 thread_info)用不同 bit 段同时记录抢占计数、软中断计数、硬中断计数等,关联查询函数如in_interrupt()in_atomic()

//https://elixir.bootlin.com/linux/v4.11.6/source/arch/x86/include/asm/thread_info.h#L55
struct thread_info {
	unsigned long		flags;		/* low level flags */
};

todo

为什么 spinlock 有那么多变体

考虑一个数据既被进程上下文访问、又被中断处理程序访问的场景:如果进程上下文持有 spinlock 时被同 CPU 的中断打断,而中断里又去抢同一把锁,由于 spinlock 是忙等且当前 CPU 不会释放,就会自锁死。因此可以按照「竞争对手来自哪个上下文」挑选spinlock的变体:

spinlock变体 额外动作 何时用
spin_lock()/spin_unlock() 只关抢占 只在进程上下文访问的数据
spin_lock_bh()/spin_unlock_bh() 关抢占 + 关软中断 数据也被软中断访问(如网络)
spin_lock_irqsave() 关抢占 + 关硬中断(保存 flags) 数据也被硬中断访问
flowchart LR
    Q{"临界区数据被谁访问?"}
    Q -->|"仅进程上下文"| A["spin_lock"]
    Q -->|"进程 + 软中断"| B["spin_lock_bh"]
    Q -->|"进程 + 硬中断"| C["spin_lock_irqsave"]

0x04 自旋锁 spinlock

用法与语义

自旋锁是忙等锁:拿不到锁的 CPU 原地打转(cpu_relax())而不睡眠。因此:

  • 临界区必须极短(不能睡眠、不能调用可能睡眠的函数)
  • 持锁期间关抢占,避免持锁者被调度走导致其他 CPU 长时间空转
spinlock_t lock;
spin_lock_init(&lock);
spin_lock(&lock);
/* 极短临界区 */
spin_unlock(&lock);

todo

x86_64 的实现:qspinlock

x86_64 在4.11.6版本默认启用队列自旋锁 qspinlockARCH_USE_QUEUE_SPINLOCK)。spinlock 底层实现 ticket spinlock,而是基于 MCS 的 qspinlock

调用链如下(include/linux/spinlock.hinclude/asm-generic/qspinlock.hkernel/locking/qspinlock.c):

spin_lock
  → raw_spin_lock
  → __raw_spin_lock
  → do_raw_spin_lock → arch_spin_lock
  → queued_spin_lock                (fast path)
      → queued_spin_lock_slowpath   (contended)

fast path 只是一次 cmpxchg

static __always_inline void queued_spin_lock(struct qspinlock *lock)
{
    u32 val;
    val = atomic_cmpxchg_acquire(&lock->val, 0, _Q_LOCKED_VAL);
    if (likely(val == 0))
        return;                 /* 无竞争,直接拿到 */
    queued_spin_lock_slowpath(lock, val);
}

qspinlock 的 32val 划分为三段:(tail, pending, locked)。竞争升级路径:

  1. 锁空闲 → 直接置 locked(fast path)
  2. 锁被占但队列空 → 设置 pending 位,自旋等 locked 清零(第一个等待者的快速通道,避免立即排队的开销)
  3. 已有等待者 → 取本 CPU 的 per-CPU mcs_spinlock 节点入队,在自己的节点上自旋(arch_mcs_spin_lock_contended)。前驱释放时只唤醒后继一个 CPU
flowchart TD
    START["queued_spin_lock"] --> FAST{"val == 0?"}
    FAST -->|是| GOT["cmpxchg 置 locked<br/>拿到锁"]
    FAST -->|否| SLOW["queued_spin_lock_slowpath"]
    SLOW --> PEND{"队列为空?"}
    PEND -->|是| SETP["设 pending 位<br/>自旋等 locked 清零"]
    PEND -->|否| MCS["取 per-cpu mcs 节点入队<br/>在本节点自旋"]
    SETP --> GOT
    MCS --> HEAD{"成为队头?"}
    HEAD -->|是| GOT
    HEAD -->|否| MCS

为什么用 MCS 队列

  • ticket lock 下所有等待 CPU 自旋在同一个 cache line 上,每次释放都造成全体 cache 失效(cache-line bouncing)
  • MCS 让每个等待者自旋在自己的本地节点上,扩展性在大核数下显著更好

spin_lock内核用例

  1. 进程 — sighand->siglock:保护进程信号相关字段。信号既可能在进程上下文发送,也可能在中断/定时器上下文发送,所以用的是关中断变体。见 kernel/signal.c 中大量 spin_lock_irqsave(&sighand->siglock, flags)

  2. VFS — dentry->d_lock:保护单个 dentry 的字段(d_countd_subdirsd_flags 等),是极短临界区的典型。见 fs/dcache.cspin_lock(&dentry->d_lock)。它常与 seqlock(rename_lock)、RCU 配合,构成 0x0E 的综合案例。

0x05 读写自旋锁 rwlock

用法

允许多个读者并发、写者独占,适合读多写少的短临界区:

rwlock_t lock;
read_lock(&lock);   /* 多读者可同时进入 */
/* 只读临界区 */
read_unlock(&lock);

write_lock(&lock);  /* 写者独占 */
/* 读写临界区 */
write_unlock(&lock);

同样有 read_lock_irqsave / write_lock_bh 等变体,选取规则同 spinlock

内核实现:qrwlock

x86_64 默认使用队列读写锁 qrwlockinclude/asm-generic/qrwlock.hkernel/locking/qrwlock.c)。arch_rwlock_t 内含一个 atomic_t cnts(低 8 位记录写者状态,其余记录读者计数)与一把内部 arch_spinlock_t wait_lock

  • 读者的 fast path:atomic_add_return_acquire(_QR_BIAS, &cnts),若无写者则直接进入
  • 写者:cmpxchg 抢占写标志;争用时经 queued_write_lock_slowpath 用内部 qspinlock 排队,并等所有读者退出

qrwlock 相比旧 rwlock 的关键改进是写者优先,避免持续到来的读者把写者饿死

注意:rwlock 是自旋锁,读者临界区同样不能睡眠。需要在可睡眠的长临界区做读写并发的场景,建议使用rwsem(下文)

内核用例:tasklist_lock(全局进程链表)

在内核实现中,全局进程链表用读写锁保护(kernel/fork.c 定义 DEFINE_RWLOCK(tasklist_lock)):

  • 遍历进程树、发送信号等读操作用 read_lock(&tasklist_lock)(可多核并发)
  • fork/exit 修改链表用 write_lock_irq(&tasklist_lock)

这正是读多(各种查询遍历)写少(进程创建销毁)的教科书场景

//https://elixir.bootlin.com/linux/v4.11.6/source/include/linux/sched/task.h#L20
extern rwlock_t tasklist_lock;

todo

0x06 顺序锁 seqlock / seqcount

回顾下,在open系统调用的rcu-walk模式时

todo

动机与用法

对于读极多、写极少且读者不希望阻塞写者的数据(如系统时间),rwlock 的读者仍要做原子加减,仍有 cache 竞争。seqlock 让读者完全不写共享状态,代价是读到”半更新”数据时需要重试

seqlock_t sl;
/* 写者:更新序号(奇数=写进行中) */
write_seqlock(&sl);
/* 修改数据 */
write_sequnlock(&sl);

/* 读者:读前后各取一次序号,若变化或为奇则重试 */
unsigned seq;
do {
    seq = read_seqbegin(&sl);
    /* 读数据到局部变量 */
} while (read_seqretry(&sl, seq));

原理(include/linux/seqlock.h):写者进入时把序号 +1(变为奇数),退出时再 +1(变为偶数),并有 smp_wmb 保证数据更新与序号更新的顺序。读者在临界区前后各读一次序号:若不相等(期间有写者)或为奇数(正有写者在写),说明读到的可能是撕裂数据,重试

sequenceDiagram
    participant R as 读者
    participant S as seqcount
    participant W as 写者
    R->>S: seq = read_seqbegin (读到偶数)
    R->>R: 拷贝数据到局部变量
    W->>S: write_seqlock (seq -> 奇数)
    W->>W: 修改共享数据
    W->>S: write_sequnlock (seq -> 偶数)
    R->>S: read_seqretry 比较 seq
    Note over R: seq 已变化 -> 丢弃结果, 重试

适用性约束:读者临界区里读到的数据可能是不一致的中间态,所以读者只能拷贝、不能带副作用地使用(不能据此解引用可能失效的指针)。纯值语义的数据(时间戳、坐标)最合适

内核用例

  1. 时间子系统jiffies/timekeeper 用 seqlock 保护,读时间的路径极热且不能阻塞时钟中断更新。见 kernel/time/timekeeping.ctk_core.seqkernel/time/jiffies.c

todo

  1. VFS — rename_lockfs/dcache.c__cacheline_aligned_in_smp DEFINE_SEQLOCK(rename_lock);。目录项 RCU-walk 查找时,并发 rename 可能让哈希链遍历”走错链”而漏掉目标(false-negative);rename_lock 提供一个全局序号,读者发现期间发生过 rename 就整体重试。每个 dentry 另有 d_seq(seqcount)保护其 name/parent/inode 三元组的原子快照——见下文综合案例

todo

0x07 信号量 semaphore

用法与定位

semaphore 是一个可睡眠的计数信号量include/linux/semaphore.hkernel/locking/semaphore.c):

//https://elixir.bootlin.com/linux/v4.11.6/source/include/linux/semaphore.h#L16
struct semaphore {
    raw_spinlock_t      lock;
    unsigned int        count;
    struct list_head    wait_list;
};

void down(struct semaphore *sem);   /* count-- ,为 0 则睡眠等待 */
void up(struct semaphore *sem);     /* count++ ,唤醒一个等待者 */

down() 拿内部 raw_spinlock 保护 count:若 count > 0 直接减一返回;否则把自己挂到 wait_listschedule() 睡眠。up() 若有等待者则唤醒队首

定位:当count 初值为 1 时退化为「二值信号量」即互斥。但对纯互斥场景,mutex 更优(有 owner 追踪、乐观自旋、lockdep 支持),所以新代码互斥一律用 mutex,semaphore 现已属遗留。计数信号量(count > 1,限制并发数)的场景也大多被其他机制替代

todo:和system 信号量的区别

内核用例:console_sem

内核控制台仍用信号量串行化输出:kernel/printk/printk.c 中的 console_sem,配套 console_lock()/console_unlock() 即封装 down(&console_sem)/up(&console_sem)。这是理解 semaphore 现实用途的直观例子

todo

0x08 互斥锁 mutex

用法

mutex 是进程上下文的睡眠互斥锁,是内核中最常用的互斥原语

struct mutex m;
mutex_lock(&m);
/* 可以较长、可以睡眠的临界区 */
mutex_unlock(&m);

规则:只能在进程上下文使用(会睡眠),加锁者必须是解锁者(有 owner 概念),不可递归

todo

数据结构:单 atomic_long_t owner

版本说明:4.11.6版本中, mutex rework 把旧的 atomic_t count + 非原子 owner 合并为单个 atomic_long_t owner。参考(include/linux/mutex.h):

//https://elixir.bootlin.com/linux/v4.11.6/source/include/linux/mutex.h#L53
struct mutex {
    atomic_long_t       owner;      /* 指向 owner task_struct,低位存标志 */
    spinlock_t          wait_lock;
#ifdef CONFIG_MUTEX_SPIN_ON_OWNER
    struct optimistic_spin_queue osq; /* MCS 乐观自旋队列 */
#endif
    struct list_head    wait_list;
    ......
};

owner 指针低位(task_struct 按 L1 cache 对齐,低位为 0 可复用)存两个标志(kernel/locking/mutex.c):

#define MUTEX_FLAG_WAITERS  0x01  /* 有等待者,unlock 必须唤醒 */
#define MUTEX_FLAG_HANDOFF  0x02  /* unlock 需把锁直接交给队首(防饥饿) */

三条路径:fast → 乐观自旋 → 慢路径

flowchart TD
    LOCK["mutex_lock"] --> FAST{"__mutex_trylock<br/>owner==0 ?"}
    FAST -->|成功| GOT["cmpxchg 写入 current<br/>拿到锁"]
    FAST -->|失败| SPIN{"mutex_optimistic_spin<br/>owner 仍在 CPU 上运行?"}
    SPIN -->|是, 自旋等待| FAST
    SPIN -->|owner 睡了/需调度| SLOW["__mutex_lock_common 慢路径"]
    SLOW --> ADD["加入 wait_list<br/>设 MUTEX_FLAG_WAITERS"]
    ADD --> LOOP{"__mutex_trylock(first)"}
    LOOP -->|成功| GOT
    LOOP -->|失败| SLEEP["schedule_preempt_disabled 睡眠<br/>队首设 MUTEX_FLAG_HANDOFF"]
    SLEEP --> LOOP
  1. fast path__mutex_trylockatomic_long_cmpxchg_acquireowner0 换成 current,成功即拿锁

  2. 乐观自旋(optimistic spinning)mutex_optimistic_spinkernel/locking/mutex.c)。核心洞察是:如果锁的持有者当前正在另一 CPU 上运行,那么它很可能马上就会释放锁,此时自旋等待比睡眠再唤醒(涉及两次上下文切换)更划算。自旋者通过 osq_lock(MCS 队列)排队,保证同一时刻只有一个自旋者竞争,并用 mutex_spin_on_owner 持续检查 owner 是否还在运行、是否 need_resched

  3. 慢路径 __mutex_lock_common:自旋无望则真正入睡——加入 wait_list,设 MUTEX_FLAG_WAITERS,循环 __mutex_trylock 失败就 schedule_preempt_disabled()。为防止乐观自旋者持续插队饿死队首,队首等待者会设置 MUTEX_FLAG_HANDOFF,迫使 unlock 把锁直接移交给它:

/* __mutex_lock_common 主循环(简化自 v4.11.6) */
for (;;) {
    if (__mutex_trylock(lock, first))   /* first: 是否已是队首, 处理 handoff */
        goto acquired;
    if (unlikely(signal_pending_state(state, task))) { ret = -EINTR; goto err; }
    spin_unlock_mutex(&lock->wait_lock, flags);
    schedule_preempt_disabled();        /* 睡眠 */
    spin_lock_mutex(&lock->wait_lock, flags);
    if (!first && __mutex_waiter_is_first(lock, &waiter)) {
        first = true;
        __mutex_set_flag(lock, MUTEX_FLAG_HANDOFF);  /* 我成了队首,要求直接移交 */
    }
}

内核用例

  1. VFS — inode->i_rwsem(对比引出):4.5 起 inode->i_mutex 已升级为 rwsem(下一节),说明连”目录/文件操作串行化”这种典型 mutex 场景,都在往”读写分离”演进。

  2. 子系统级互斥:内核里保护某模块全局状态的锁绝大多数是 mutex,如各类 xxx_mutex(设备探测、netlink、cgroup 等)。凡是”进程上下文、临界区里可能睡眠或较长”的互斥,答案基本都是 mutex

todo

0x09 读写信号量 rwsem

用法与 vs rwlock

rw_semaphoreinclude/linux/rwsem.h)是可睡眠的读写锁:多读者并发、写者独占,但持有期间允许睡眠

struct rw_semaphore rwsem;
down_read(&rwsem);   /* 读者,可多个 */
/* 可睡眠的只读临界区 */
up_read(&rwsem);

down_write(&rwsem);  /* 写者独占 */
up_write(&rwsem);

rwlock_t 的根本区别:

  rwlock_t(qrwlock) rw_semaphore
持有时能否睡眠 不能(自旋)
上下文 任意(含中断) 仅进程上下文
争用行为 忙等 睡眠
临界区长度 极短 可长

4.11 的 rwsem 结构含 atomic_long_t count(编码读者数/写者状态)、wait_listwait_lock,以及写者的乐观自旋支持(osq + owner,类似 mutex,kernel/locking/rwsem-xadd.c)。

版本提示:rwsem 在 5.3 有一次大重写(读写状态合并进单一 count、引入更完善的自旋/偷锁策略)。4.11 是重写前实现,正文以 4.11 为准。

源码用例:mm->mmap_sem

进程地址空间用 mm->mmap_seminclude/linux/mm_types.hstruct mm_struct)这把 rwsem 保护 VMA 红黑树。这是 rwsem 最著名的用例:

  • 缺页异常 do_page_faultdown_read(&mm->mmap_sem):只是查询 VMA,多个线程的缺页可并发(读者),且缺页处理里可能睡眠(分配页、读磁盘),所以必须是可睡眠的 rwsem 而非自旋 rwlock。
  • mmap/munmap/brk 修改地址空间 → down_write(&mm->mmap_sem):独占。

这解释了多线程程序里”缺页可并发、但 mmap 会串行化”的行为本质。相关代码见 mm/memory.cmm/mmap.c

0x0A Per-CPU 型变量

思想:用空间替换共享

最快的同步是没有共享。Per-CPU 变量为每个 CPU 分配一份独立副本,各 CPU 只访问自己的那份,从根本上消除跨 CPU 竞争与 cache bouncing。适合统计计数、每 CPU 缓存/队列等

DEFINE_PER_CPU(int, my_counter);        /* 定义 */

/* 访问:关抢占以保证"取本 CPU id"和"访问副本"之间不被迁移 */
int *p = get_cpu_var(my_counter);       /* = this_cpu_ptr + preempt_disable */
(*p)++;
put_cpu_var(my_counter);                /* preempt_enable */

/* 或用 this_cpu_* 原子式访问(自身保证本 CPU 原子) */
this_cpu_inc(my_counter);

相关内核实现代码:

//https://elixir.bootlin.com/linux/v4.11.6/source/include/linux/percpu-defs.h#L262
/*
 * Must be an lvalue. Since @var must be a simple identifier,
 * we force a syntax error here if it isn't.
 */
#define get_cpu_var(var)						\
(*({									\
	preempt_disable();						\
	this_cpu_ptr(&var);						\
}))


/*
 * The weird & is necessary because sparse considers (void)(var) to be
 * a direct dereference of percpu variable (var).
 */
#define put_cpu_var(var)						\
do {									\
	(void)&(var);							\
	preempt_enable();						\
} while (0)

要点(include/linux/percpu-defs.hinclude/asm-generic/percpu.h):

  • per_cpu_ptr(ptr, cpu):拿指定 CPU 的副本(如汇总统计时遍历所有 CPU)
  • this_cpu_ptr(ptr) / this_cpu_inc():获取/修改本 CPU 副本
  • 陷阱:用 this_cpu_ptr 拿到指针后如果发生抢占/迁移,指针就指向了”原来那个 CPU”的副本。所以要么用带抢占保护的 get_cpu_var/this_cpu_*,要么自己 preempt_disable

内核用例

  1. 协议栈 SNMP 统计/proc/net/snmp 里的海量计数(收发包、错误数)都是 per-CPU 的 MIB,收发热路径上用 __SNMP_INC_STATS/SNMP_INC_STATSinclude/net/snmp.h)只改本 CPU 计数,读取 /proc 时才 per_cpu_ptr 遍历求和。若这些计数用全局原子变量,收包路径的 cache 竞争会摧毁性能

  2. 调度器 runqueues:每 CPU 一个运行队列 DEFINE_PER_CPU_SHARED_ALIGNED(struct rq, runqueues)kernel/sched/core.c),本 CPU 调度只操作本地 rq

  3. 内存统计 vmstatvm_event_states、per-zone/per-cpu 计数(mm/vmstat.c

0x0B 等待队列 wait queue

用途

当内核代码需要阻塞等待某个条件成立(数据到达、缓冲区可用、子进程退出)时,用等待队列把当前进程挂起,待条件满足时由另一方唤醒。这是”睡眠—唤醒”机制的通用基础设施

版本提示:4.11 类型名是 wait_queue_head_t(队列头)与 wait_queue_t(队列项),4.13 才分别保留 wait_queue_head_t 与改名 wait_queue_entry_t。本文用 4.11 名字。

用法:条件谓词 + 唤醒

DECLARE_WAIT_QUEUE_HEAD(wq);
/* 等待方:wait_event 会循环检查 condition,为假则睡眠 */
wait_event_interruptible(wq, condition);
/* 唤醒方:改变条件后唤醒 */
condition = true;
wake_up_interruptible(&wq);

wait_event 系列(include/linux/wait.h)的核心是一个带条件的循环,展开后本质是 prepare_to_wait / schedule / finish_wait

/* 手写版,等价于 wait_event 的展开 */
DEFINE_WAIT(wait);
for (;;) {
    prepare_to_wait(&wq, &wait, TASK_INTERRUPTIBLE); /* 挂入队列,置睡眠态 */
    if (condition)          /* 关键:必须先置态再判条件,避免丢唤醒 */
        break;
    if (signal_pending(current)) { ... }
    schedule();             /* 让出 CPU */
}
finish_wait(&wq, &wait);    /* 出队,置 TASK_RUNNING */

todo

为什么要”先置睡眠态、再判条件”:如果先判条件(为假)再准备睡眠,中间条件可能恰好被满足并发出唤醒,而此时你还没进入队列/睡眠态,唤醒就丢了(lost wakeup),导致永久睡眠。这个顺序是等待队列正确性的核心

唤醒相关:

  • wake_up() 唤醒队列上所有非独占等待者 + 一个独占等待者;
  • add_wait_queue_exclusive() 标记独占等待者,用于避免惊群(thundering herd):如 accept 多个进程等同一 listen socket,只唤醒一个。

内核典型用例

  1. 管道 pipe:读端在数据为空时 wait_event(等 pipe->wait),写端写入后 wake_up_interruptible。见 fs/pipe.c

  2. socket 阻塞收发sk->sk_wq 等待队列,sk_wait_data 等数据到达,协议栈收到数据后 sk->sk_data_ready 唤醒。见 net/core/sock.c

  3. wait4/waitpid:父进程用 __wait_event 等子进程状态变化

0x0C RCU:读-拷贝-更新

前面在深入分析open内核实现时,讨论过RCU-walk机制。RCU(Read-Copy-Update)是内核为读极多、写极少场景设计的同步机制,读侧几乎零开销、无锁、无等待。它是理解现代内核可扩展性的关键

三条读侧约束 + 发布/订阅

读者用 rcu_read_lock()/rcu_read_unlock() 界定读侧临界区(include/linux/rcupdate.h)。在非抢占内核里,rcu_read_lock() 实际就是 preempt_disable()没有任何原子操作、没有等待,这就是读侧零开销的来源

todo:为什么是preempt_disable,作用?

rcu_read_lock();
p = rcu_dereference(gp);        /* 订阅:安全读取被 RCU 保护的指针 */
if (p)
    do_something(p->field);     /* 保证在本临界区内 p 不会被释放 */
rcu_read_unlock();

发布用 rcu_assign_pointer(gp, p)(内含 smp_wmb/release,保证 p 指向内容的初始化先于指针可见,正是 0x02 提到的发布范式);订阅用 rcu_dereference(保证依赖顺序)

todo

写侧:宽限期与两种回收

写者要更新时的模式是:分配新副本 → 初始化 → rcu_assign_pointer 发布 → 等所有”旧读者”离开 → 释放旧副本。”等旧读者离开”就是宽限期(grace period)

  • 同步等待:synchronize_rcu() 阻塞直到宽限期结束(写者可睡眠时用)
  • 异步回调:call_rcu(&p->rcu, free_fn) 注册回调,宽限期后由内核调用(不能睡眠或不想阻塞时用);便捷宏 kfree_rcu(p, rcu)
sequenceDiagram
    participant W as 写者
    participant G as 全局指针 gp
    participant R1 as 读者A(旧)
    participant R2 as 读者B(新)
    W->>W: newp = 拷贝并修改 oldp
    W->>G: rcu_assign_pointer(gp, newp)
    Note over R1: 仍在使用 oldp (进入宽限期前)
    R2->>G: rcu_dereference -> 读到 newp
    W->>W: synchronize_rcu() 等待
    Note over R1: rcu_read_unlock 离开临界区
    W->>W: 所有旧读者退出 -> 宽限期结束
    W->>W: kfree(oldp) 安全回收

宽限期的定义:一段时间,长到足以让”宽限期开始时正处在读侧临界区的所有读者”都退出。判定依据是静止状态(quiescent state, QS),即每个 CPU 经历一次上下文切换/空闲/用户态,就说明它已不在任何 RCU 读侧临界区(因为读侧禁抢占)。所有 CPU 都报告过 QS,宽限期即结束

宽限期与读/写端的时间线关系

rcu-grace-period.drawio

todo

顺序锁+RCU机制的配合

Tree RCU 状态机(追踪)

大核数下若用单一全局状态记录 QS 会成为瓶颈, 内核提供了使用 Tree RCUkernel/rcu/tree.c):把 CPU 组织成 rcu_node 树,QS 信息逐层向上汇报,rcu_state 为根

时钟中断 rcu_check_callbacks
  → 记录本 CPU 是否处于 QS(用户态/空闲/上下文切换即 QS)
  → rcu_process_callbacks(RCU_SOFTIRQ 软中断)
      → note_gp_changes         (感知宽限期开始/结束)
      → rcu_check_quiescent_state → rcu_report_qs_rnp / rcu_report_qs_rsp
          (QS 逐层上报 rcu_node → rcu_state)
      → 宽限期结束后 → rcu_do_batch → 执行到期的 call_rcu 回调

synchronize_rcu 内部即 wait_rcu_gp:注册一个回调并睡眠,等回调被调用(宽限期结束)时被唤醒

RCU的变体

  1. SRCU(Sleepable RCU):普通 RCU 读侧禁抢占、不可睡眠。SRCU(kernel/rcu/srcu.c)允许读侧睡眠,代价是每个 SRCU 域需独立的 struct srcu_struct,读侧 srcu_read_lock 返回一个 index、srcu_read_unlock 传回。适合读侧可能阻塞的场景

  2. list/hlist RCU 遍历:内核大量链表用 RCU 保护遍历(include/linux/rculist.h):list_for_each_entry_rcuhlist_for_each_entry_rcu;修改用 list_add_rcu/list_del_rcu(删除只摘链、kfree 推迟到宽限期后)。哈希表常用 hlist_nullsinclude/linux/rculist_nulls.h)以应对遍历中元素被移动到别的桶

内核典型用例

  1. 协议栈 — TCP 连接查找:established 连接哈希表 ehashhlist_nulls + RCU,收包软中断里 __inet_lookup_establishednet/ipv4/inet_hashtables.c)在 rcu_read_lock() 下无锁查找 socket,避免每包都抢全局锁,这是高并发连接性能的关键

  2. 进程 — pid/task 查找find_task_by_vpid 路径在 RCU 读侧遍历 pid 哈希(kernel/pid.c),task_struct 的释放经 call_rcu 延迟(delayed_put_task_struct),保证读者手中的指针在临界区内有效

  3. VFS — RCU-walk 路径查找:见 0x10 综合案例(并在 0x0E 案例 6 概览其四原语协作)

todo

0x0D 番外

决策速查

flowchart TD
    Q0{"能否睡眠?<br/>(进程上下文)"}
    Q0 -->|"不能<br/>(中断/持自旋锁)"| A1{"读多写少?"}
    Q0 -->|"能"| B1{"读多写少?"}
    A1 -->|否| SP["spinlock"]
    A1 -->|是, 短临界区| RW["rwlock / seqlock"]
    A1 -->|"是, 读极多写极少"| RCU["RCU / seqlock"]
    B1 -->|否| MX["mutex"]
    B1 -->|是| RS["rwsem"]
    B1 -->|"读极多, 指针发布回收"| RCU2["RCU"]

常见问题

  1. 原子上下文里睡眠:在 spinlock 内、软/硬中断里调用 mutex_lockkmalloc(GFP_KERNEL)copy_from_user。后果:BUG: scheduling while atomic 或死锁。持自旋锁时分配内存要用 GFP_ATOMIC

  2. 锁序死锁:两处以不同顺序获取同两把锁(A→B 与 B→A)。规范:全局固定加锁顺序,或用 spin_lock_nested;开发期开启 CONFIG_PROVE_LOCKING(lockdep)自动检测

  3. 中断变体选错:数据被硬中断访问却用了 spin_lock() 而非 spin_lock_irqsave() → 同 CPU 中断重入自锁死

  4. Per-CPU 未关抢占this_cpu_ptr 拿指针后被迁移到别的 CPU,改错副本。用 get_cpu_var/this_cpu_* 或显式 preempt_disable

  5. RCU 读侧睡眠rcu_read_lock() 后禁抢占,其中不可睡眠、不可阻塞;需要睡眠请用 SRCU

  6. seqlock 读侧滥用:读到的可能是撕裂数据,只能拷贝值、不能据此做不可逆操作或解引用可能失效的指针

0x0E 综合案例:原语协作(组合)范式

真实内核代码几乎总是多种原语配合:RCU 定”生死存续”、引用计数保”跨临界区长期持有”、spinlock 串行化写者、屏障保证发布顺序等等

本节列举若干真实用例

case1:RCU + 引用计数 —— 查找对象并安全”带走”

场景(非常经典的例子):通过 struct pid 找到对应的 task_struct,并希望在函数返回之后继续使用它(比如后续给它发信号)。见 kernel/pid.c

/* 读侧内层:仅在 RCU 临界区(或持 tasklist_lock)内有效 */
struct task_struct *pid_task(struct pid *pid, enum pid_type type)
{
    struct task_struct *result = NULL;
    if (pid) {
        struct hlist_node *first;
        /* rcu_dereference_check: 带依赖序地读链表首节点;
         * lockdep 断言:调用者要么持 rcu_read_lock,要么持 tasklist_lock */
        first = rcu_dereference_check(hlist_first_rcu(&pid->tasks[type]),
                                      lockdep_tasklist_lock_is_held());
        if (first)
            result = hlist_entry(first, struct task_struct, pids[(type)].node);
    }
    return result;   /* 出了保护区,这个指针就不保证有效 */
}

/* 调用层:把"临界区内有效"升级为"跨临界区长期有效" */
struct task_struct *get_pid_task(struct pid *pid, enum pid_type type)
{
    struct task_struct *result;
    rcu_read_lock();                 /* STEP1: 进入 RCU 读侧:保证 result 在本区间内不被 kfree */
    result = pid_task(pid, type);
    if (result)
        get_task_struct(result);     /* STEP2: 关键:还在临界区内就把引用计数 +1 */
    rcu_read_unlock();               /* STEP3: 离开临界区:此后 result 靠引用计数(而非RCU)存活 */
    return result;                   /* 调用者用完必须 put_task_struct() 配平 */
}

//get_task_struct:本质是累加引用计数
#define get_task_struct(tsk) do { atomic_inc(&(tsk)->usage); } while(0)

协作分析

  • RCU 保证的只是”读侧临界区内 result 不会被回收”(对象的释放要等宽限期,详见 0x0C章节)。一旦 rcu_read_unlock(),宽限期随时可能结束、对象随时可能被 free
  • 所以必须在 rcu_read_unlock() 之前get_task_struct()(本质是 atomic_inc(&t->usage),见 include/linux/sched/task.h)。若把顺序颠倒成”先 unlock 再 get”,中间就有一个窗口:对象已被释放而你正要去 atomic_inc 它 → use-after-free
  • 小结:RCU 负责”我能安全地读到并触碰它”(瞬时存在性),引用计数负责”我能把它带出临界区长期持有”(长期存在性)。这是内核最常见的”查找 + 带走”范式

case2:RCU 读 + spinlock 写 + call_rcu 回收(existence lock 三段式)

case 1 是读侧,那么写侧如何与之配合?以进程/pid hash表的增删为例(kernel/pid.ckernel/exit.c):

如下attach_pid的实现(在copy_process函数中被调用,会使用全局rwlock_t tasklist_lock进行写保护)

extern rwlock_t tasklist_lock;

//https://elixir.bootlin.com/linux/v4.11.6/source/kernel/pid.c#L388
/* 写侧-加入:必须在 tasklist_lock 写锁保护下调用 */
void attach_pid(struct task_struct *task, enum pid_type type)
{
    struct pid_link *link = &task->pids[type];
    /* hlist_add_head_rcu: 内含发布语义(smp)把新节点挂链,读者可无锁并发遍历 */
    hlist_add_head_rcu(&link->node, &link->pid->tasks[type]);
}

/* 写侧-摘除并回收(简化自 release_task) */
void release_task(struct task_struct *p)
{
    write_lock_irq(&tasklist_lock);   /* STEP1: 写者互斥:防并发增删撕裂链表 */
    __exit_signal(p);                 /*    内部 __unhash_process → detach_pid(→hlist_del_rcu)/list_del_rcu */
    write_unlock_irq(&tasklist_lock);
    ......
    call_rcu(&p->rcu, delayed_put_task_struct); /* STEP2: 延迟回收:等宽限期后才真正 put/free */
}

static void delayed_put_task_struct(struct rcu_head *rhp)
{
    struct task_struct *tsk = container_of(rhp, struct task_struct, rcu);
    ......
    put_task_struct(tsk);             /* STEP3:宽限期已过,读者都退出,安全递减最终引用 */
}

协作分析(三段式闭环)

  • 写者之间tasklist_lock(rwlock,参考章节 0x05)互斥,保证链表结构不被并发增删撕裂
  • 读者(case1 的 pid_taskfor_each_process)用 RCU 无需tasklist_lock,靠 hlist_add_head_rcu/list_del_rcu 的发布/摘链语义看到一致的链
  • list_del_rcu 只把节点摘链,不释放内存;真正释放推迟到 call_rcu 回调(宽限期后)。这确保”删除时可能正在遍历的读者”手里的 next 指针仍指向有效内存。这被称为 existence lock:RCU 让对象在”所有可能持有它的读者”退出前不消失
  • 与case1 正好配对:读者要长期持有 → 在 RCU 内 get_task_struct;写者要删除 → list_del_rcu + call_rcu(put)引用计数与 RCU 回收共同决定对象何时真正被 free

小结下,case1(读侧)与case2(写侧)围绕 pid 哈希/task_struct 的协作时序如下:

sequenceDiagram
    participant Rd as 读者 get_pid_task
    participant H as pid 哈希链
    participant Wr as 写者 release_task
    Rd->>Rd: rcu_read_lock
    Rd->>H: pid_task: rcu_dereference 查到 task
    Rd->>Rd: get_task_struct 引用计数+1
    Rd->>Rd: rcu_read_unlock
    Note over Rd: 此后靠引用计数持有 task, 与RCU无关
    Wr->>Wr: write_lock tasklist_lock
    Wr->>H: list_del_rcu 摘链(不释放内存)
    Wr->>Wr: write_unlock tasklist_lock
    Wr->>Wr: call_rcu(delayed_put_task_struct)
    Note over Wr: 等宽限期(所有RCU读者退出)后
    Wr->>Wr: put_task_struct 递减最终引用
    Note over Rd,Wr: 读者引用计数 + RCU宽限期 共同决定何时真正 free

case3:原子操作 + spinlock (引用降到 0 才拿锁)

场景:引用计数递减是热路径,若每次 put 都先 spin_lock 再判零,锁竞争会很重。iput()atomic_dec_and_lock 把”递减 + 判零 + 按需拿锁”合成一个原子决策(fs/inode.c):

//https://elixir.bootlin.com/linux/v4.11.6/source/fs/inode.c#L1527
void iput(struct inode *inode)
{
    if (!inode)
        return;
    BUG_ON(inode->i_state & I_CLEAR);
retry:
    /* atomic_dec_and_lock: 原子递减 i_count
     *   - 递减后 != 0: 不拿锁,直接返回(快路径,绝大多数 put)
     *   - 递减后 == 0: 拿住 i_lock 并返回真(慢路径,进入销毁)*/
    if (atomic_dec_and_lock(&inode->i_count, &inode->i_lock)) {
        if (inode->i_nlink && (inode->i_state & I_DIRTY_TIME)) {
            atomic_inc(&inode->i_count);
            inode->i_state &= ~I_DIRTY_TIME;
            spin_unlock(&inode->i_lock);
            mark_inode_dirty_sync(inode);
            goto retry;
        }
        iput_final(inode);            /* 持 i_lock 进入最终销毁 */
    }
}

todo

协作分析

  • 原子操作负责”绝大多数无竞争的快路径”:普通 put 只是一条 lock dec,完全无锁
  • spinlock 只在”引用归零、要销毁、需要与其他访问者互斥”的稀有慢路径才出现。atomic_dec_and_locklib/dec_and_lock.c)内部保证”判零”和”拿锁”这两步对外是一个原子决策,避免”刚判零、锁还没拿、别人又 iget 复活了”的竞争
  • 进阶:lockrefinclude/linux/lockref.h)把一个 spinlock 和一个 count 打包进同一个机器字,lockref_get_not_zero 先用 cmpxchg 无锁地”计数 +1(若非 0)”,失败才退回拿 spinlock。dcache 用它让 dget/dput 在无竞争时完全无锁。此封装正是 VFS RCU-walk(下文介绍)中 lockref_get_not_zero 的底层实现

todo

另一端(取引用 igrab:与”放”动作的 iput 对应的是”拿”动作的 igrab。实现如下,同样用 i_lock 守护”计数 + 状态”的一致性(fs/inode.c):

//igrab 的作用是安全地获取并增加一个现有 inode 的引用计数
//https://elixir.bootlin.com/linux/v4.11.6/source/fs/inode.c#L1218
struct inode *igrab(struct inode *inode)
{
    spin_lock(&inode->i_lock);
    /* 只有 inode 未进入销毁流程时才允许 +1;
     * i_lock 保证这里读到的 i_state 与 iput 里的"判零/置 I_FREEING"互斥 */
    if (!(inode->i_state & (I_FREEING | I_WILL_FREE))) {
        __iget(inode);             /* = atomic_inc(&inode->i_count) */
        spin_unlock(&inode->i_lock);
    } else {
        spin_unlock(&inode->i_lock);
        inode = NULL;              /* 正在被释放,拿不到,返回 NULL */
    }
    return inode;
}

读/写两端协作iput(写/放)用 atomic_dec_and_lock 在归零时拿 i_lock 进入销毁并置 I_FREEINGigrab(读/拿)拿 i_lock 后先查 I_FREEING/I_WILL_FREE 再决定能否 __iget同一把 i_lock 把”最后一次 put 的销毁决定”与”新的 get”串起来,杜绝”计数刚归零、正要销毁,却被 igrab 复活”的竞争

case4:等待队列 + spinlock —— 防丢唤醒(lost wakeup)

在前文(0x0B章节),提到”必须先置睡眠态再判条件”,其正确性还依赖等待队列内建的一把 spinlockinclude/linux/wait.hkernel/sched/wait.c):

struct __wait_queue_head {
    spinlock_t        lock;          /* 队列自带的锁 */
    struct list_head  task_list;
};

/* 等待方:入队与置睡眠态在同一把锁下 */
void prepare_to_wait(wait_queue_head_t *q, wait_queue_t *wait, int state)
{
    unsigned long flags;
    wait->flags &= ~WQ_FLAG_EXCLUSIVE;
    spin_lock_irqsave(&q->lock, flags);
    if (list_empty(&wait->task_list))
        __add_wait_queue(q, wait);   /* 挂入队列 */
    set_current_state(state);        /* 置睡眠态(内含屏障) */
    spin_unlock_irqrestore(&q->lock, flags);
}

/* 唤醒方:遍历唤醒也拿同一把 q->lock */
void __wake_up(wait_queue_head_t *q, unsigned int mode, int nr, void *key)
{
    unsigned long flags;
    spin_lock_irqsave(&q->lock, flags);
    __wake_up_common(q, mode, nr, 0, key);
    spin_unlock_irqrestore(&q->lock, flags);
}

完整的等待端(读者)wait_event 展开等价于下面的循环,把”入队 + 置睡眠态 + 判条件 + 睡眠”串起来:

#define wait_event(wq, condition)                            \
do {                                                         \
    DEFINE_WAIT(__wait);                                     \
    for (;;) {                                               \
        prepare_to_wait(&wq, &__wait, TASK_UNINTERRUPTIBLE); \
        if (condition)          /* 先置睡眠态,再判条件 */     \
            break;                                           \
        schedule();             /* 让出CPU,被 wake_up 唤醒后重判 */ \
    }                                                        \
    finish_wait(&wq, &__wait);  /* 出队 + 置 TASK_RUNNING */ \
} while (0)

todo

协作分析

  • 等待方展开顺序是:prepare_to_wait(入队 + 置睡眠态)→ 判条件 → schedule;唤醒方是:改条件 → wake_up
  • 若无协作会 lost wakeup:等待方判条件为假、还没睡下时,唤醒方改了条件并 wake_up(此刻等待方尚未入队/尚未置睡眠态)→ 唤醒落空 → 永久睡眠
  • q->lock 保证”入队 + 置睡眠态”与”遍历唤醒”两段互斥set_current_statewake_up 内部的屏障(见 0x02)再保证”置睡眠态”先于”判条件”、”改条件”先于”读睡眠态”。二者合起来堵住丢唤醒窗口
  • 注意分工:条件变量本身(如 pipe 的可读字节数)通常另有一把锁(pipe->mutex)保护;等待队列的 q->lock 只保护队列结构。两把锁各管一摊,配合才安全

case5:spin_lock_irqsave + 唤醒 (中断上下文与进程上下文协作)

场景:一个动作在中断上下文完成(如 DMA 完成中断),需要唤醒在进程上下文睡眠等待的线程。completion 是这个范式的标准封装(kernel/sched/completion.c):

//https://elixir.bootlin.com/linux/v4.11.6/source/kernel/sched/completion.c#L18
/* 通知方:可能在硬中断里被调用 */
void complete(struct completion *x)
{
    unsigned long flags;
    /* irqsave: 关本CPU中断,防"进程上下文持 x->wait.lock 时被同CPU中断重入抢同一把锁"自锁死 */
    spin_lock_irqsave(&x->wait.lock, flags);
    x->done++;                               /* 更新条件 */
    __wake_up_locked(&x->wait, TASK_NORMAL, 1); /* 已持锁,唤醒一个等待者 */
    spin_unlock_irqrestore(&x->wait.lock, flags);
}

/* 等待方:进程上下文 */
//https://elixir.bootlin.com/linux/v4.11.6/source/kernel/sched/completion.c#L113
void wait_for_completion(struct completion *x)
{
    /* 内部 spin_lock_irq(&x->wait.lock); 若 x->done==0 则挂入 x->wait 睡眠,
     * 被 complete() 唤醒后 x->done-- 返回 */
    wait_for_common(x, MAX_SCHEDULE_TIMEOUT, TASK_UNINTERRUPTIBLE);
}

static inline long __sched
__wait_for_common(struct completion *x,
		  long (*action)(long), long timeout, int state)
{
	might_sleep();

	spin_lock_irq(&x->wait.lock);
	timeout = do_wait_for_common(x, action, timeout, state);
	spin_unlock_irq(&x->wait.lock);
	return timeout;
}

协作分析

  • 这是”中断上下文 ↔ 进程上下文”经由一把锁 + 一个条件(x->done)+ 一个等待队列协作的范式,与case4 同源,但强调跨上下文
  • 为什么必须使用 irqsave 而非普通 spin_lock(基于0x03章节的规则):complete() 可能在硬中断里调用;若进程上下文在 wait_for_completion 里持有 x->wait.lock 时被同 CPU 的中断打断,中断里的 complete() 又来抢同一把锁 → 忙等且当前 CPU 不释放 → 自锁死irqsave 在持锁期间关本 CPU 中断,杜绝这种重入
  • 同理,sighand->siglock0x04章节引用)用 spin_lock_irqsave,因为信号可能在定时器中断等中断上下文里投递

另一端(等待)wait_for_completion 的核心是 do_wait_for_common(简化自 kernel/sched/completion.c):

static long __sched
do_wait_for_common(struct completion *x,
                   long (*action)(long), long timeout, int state)
{
    if (!x->done) {
        DECLARE_WAITQUEUE(wait, current);
        __add_wait_queue_tail_exclusive(&x->wait, &wait); /* 独占入队,避免惊群 */
        do {
            if (signal_pending_state(state, current)) { /* ... 处理信号 */ }
            __set_current_state(state);        /* 置睡眠态 */
            spin_unlock_irq(&x->wait.lock);    /* 放锁再睡 */
            timeout = action(timeout);         /* 通常是 schedule_timeout */
            spin_lock_irq(&x->wait.lock);      /* 醒来重新拿锁 */
        } while (!x->done && timeout);         /* 复检条件:防丢唤醒/伪唤醒 */
        __remove_wait_queue(&x->wait, &wait);
        if (!x->done)
            return timeout;
    }
    x->done--;                                 /* 消费一次完成事件 */
    return timeout ?: 1;
}

读/写两端协作:等待方持 x->wait.lockx->done、入队、置睡眠态后放锁schedule;通知方(前面的 complete)持同一把 x->wait.lockx->done++__wake_up_locked。二者靠”放锁前已置睡眠态并入队”避免丢唤醒(与案例 4 同理),只是这里的条件被封装成了 x->done 计数,即complete 递增、等待方递减消费

case6:RCU + seqcount + rename_lock + d_lock(四套原语协作)

dcache 的 RCU-walk 路径查找是”多原语协作”的经典范例

  • RCU 保证 dentry 不被释放
  • 每 dentry 的 d_seq(seqcount) 保证 name/parent/inode 三元组的原子快照
  • 全局 rename_lock(seqlock) 兜底检测 rename
  • d_lock(spinlock) 只在最后取引用时短暂持有

四者按”访问频率”分层付费

原语 职责
1 RCU 遍历期间对象不被 free
2 d_seq seqcount 单个 dentry 字段快照一致
3 rename_lock seqlock 全局 rename 兜底重试
4 d_lock spinlock + lockref 收尾取引用转 ref-walk

case7:双重检查锁定 + 屏障(延迟初始化)

内核存在如下场景,某个昂贵对象只需初始化一次,但初始化点会被多个 CPU 并发触及。若每次都拿锁判断会拖慢已初始化后的热路径。经典解法是 double-checked locking,即无锁快检 → 未命中才拿锁 → 锁内再检一次 → 初始化并带屏障发布。内核页表按需分配的 __pte_alloc 就是此例(mm/memory.c):

//https://elixir.bootlin.com/linux/v4.11.6/source/mm/memory.c#L614
int __pte_alloc(struct mm_struct *mm, pmd_t *pmd, unsigned long address)
{
    spinlock_t *ptl;
    pgtable_t new = pte_alloc_one(mm, address);   /* 先在锁外分配、清零、初始化 */
    if (!new)
        return -ENOMEM;

    /* 关键屏障:保证"页表页的初始化"先于"指针被挂入 pmd"对其他CPU可见;
     * 否则无锁遍历页表的 CPU 可能看到指针却读到未初始化内容 */
    smp_wmb();

    ptl = pmd_lock(mm, pmd);                       /* 拿页表锁 */
    if (likely(pmd_none(*pmd))) {                  /* 再检一次:别的CPU是否已经装好了? */
        atomic_long_inc(&mm->nr_ptes);
        pmd_populate(mm, pmd, new);                /* 发布:把新页表挂入 pmd */
        new = NULL;
    }
    spin_unlock(ptl);
    if (new)                                       /* 输给了别人,释放自己多分配的那份 */
        pte_free(mm, new);
    return 0;
}

协作分析

  • 第一次检查(这里体现为”多个 CPU 都可能进来分配”)在锁外,让绝大多数已装好的情况走无锁快路径。第二次检查 pmd_none(*pmd) 在锁内,防止两个 CPU 同时判空后重复安装——谁先装好,后来者就 pte_free 掉自己那份
  • smp_wmb() 缺一不可pmd_populate 是”发布指针”,若初始化对新页表内容的写没有先于发布可见,另一个无锁遍历页表的 CPU 就可能顺着指针读到垃圾。这正是 0x02 的发布范式(等价于 rcu_assign_pointer 里的写屏障)
  • 通用形态(用 mutex 时):if (!READ_ONCE(p)) { mutex_lock(&m); if (!p) { q = init(); smp_store_release(&p, q); } mutex_unlock(&m); }——快检用 READ_ONCE、发布用 smp_store_release,是同一套”检查-锁-复检-带屏障发布”的骨架

另一端(无锁读者):与 __pte_alloc 的”发布”配对的是无锁遍历页表的读者(缺页/GUP 快路径)。它顺着 pgd → pud → pmd → pte 的指针链读取、不加页表锁

/* 读者:无锁 pointer chasing(简化) */
pmd_t *pmd = pmd_offset(pud, addr);
if (pmd_none(*pmd))            /* 读到"发布后的指针" */
    return NULL;
pte = pte_offset_map(pmd, addr); /* 依赖"先读指针,再读其内容"的数据依赖序 */

读/写两端配对:写者 __pte_allocsmp_wmb() 保证”页表内容初始化”先于”指针发布”;读者靠 数据依赖序(load 指针 → load 指针指向的内容天然有序,仅 alpha 需 smp_read_barrier_depends)保证不会看到指针却读到发布前的垃圾。通用 mutex-DCL 的读者端则是那句 if (READ_ONCE(p)) return p; 快检,与 smp_store_release 配对

0x0F 常见数据结构的并发控制

同一种数据结构,配不同的锁策略就得到不同的并发能力。本节介绍内核中典型的三类数据结构:链表 / 哈希表 / 红黑树,分读、写两端并发安全性讨论

链表:RCU 保护的双向链表(读无锁、写串行 + 延迟回收)

以内核网络设备链表为例,即 net->dev_base_head 是 RCU 链表的典型(net/core/dev.c):

/* 读端:无锁遍历(调用者持 rcu_read_lock) */
//https://elixir.bootlin.com/linux/v4.11.6/source/include/linux/netdevice.h#L2343
#define for_each_netdev_rcu(net, d) \
    list_for_each_entry_rcu(d, &(net)->dev_base_head, dev_list)

struct net_device *dev_getbyhwaddr_rcu(struct net *net, unsigned short type,
                                       const char *ha)
{
    struct net_device *dev;
    for_each_netdev_rcu(net, dev)          /* rcu_dereference 跟 next 指针 */
        if (dev->type == type && !memcmp(dev->dev_addr, ha, dev->addr_len))
            return dev;
    return NULL;
}

//https://elixir.bootlin.com/linux/v4.11.6/source/include/linux/netdevice.h#L2337
//全局锁
extern rwlock_t				dev_base_lock;		/* Device list lock */

/* 写端:加入(持 RTNL + dev_base_lock)*/
//https://elixir.bootlin.com/linux/v4.11.6/source/net/core/dev.c#L226
static int list_netdevice(struct net_device *dev)
{
    struct net *net = dev_net(dev);
    ASSERT_RTNL();                          /* 写者之间用 RTNL 互斥 */
    write_lock_bh(&dev_base_lock);          /* 只用来挡住老式 read_lock 纯读者 */
    list_add_tail_rcu(&dev->dev_list, &net->dev_base_head); /* 发布语义挂链 */
    hlist_add_head_rcu(&dev->index_hlist, dev_index_hash(net, dev->ifindex));
    write_unlock_bh(&dev_base_lock);
    return 0;
}

/* 写端:删除(只摘链;调用者随后须过一个 RCU 宽限期才能 free/复用 dev) */
//https://elixir.bootlin.com/linux/v4.11.6/source/net/core/dev.c#L245
static void unlist_netdevice(struct net_device *dev)
{
    ASSERT_RTNL();
    write_lock_bh(&dev_base_lock);
    list_del_rcu(&dev->dev_list);           /* 只摘链,不释放 */
    hlist_del_rcu(&dev->index_hlist);
    write_unlock_bh(&dev_base_lock);

	dev_base_seq_inc(dev_net(dev));
}

todo

协作分析

  • 读端零锁list_for_each_entry_rcurcu_dereferencenext,配合 rcu_read_lock 保证遍历期间节点不被 free
  • 写端两把锁各司其职RTNL(大互斥)串行化写者之间dev_base_lock(rwlock)只挡住”用 read_lock(&dev_base_lock) 的老式纯读者”。挂/摘链用 list_add_tail_rcu/list_del_rcu 提供内存序
  • 回收unlist_netdevice 只摘链,源码注释明确要求调用者”过一个 RCU 宽限期后才能释放/复用 dev”(参考章节 0x0E case2 的 existence lock 同构)

哈希表:hlist_nulls + per-bucket spinlock + RCU(TCP 连接表)

TCP established 连接表 ehash 要在收包软中断里被高频无锁查找、又要被建立连接/拆散连接等并发修改(net/ipv4/inet_hashtables.c):

//https://elixir.bootlin.com/linux/v4.11.6/source/net/ipv4/inet_hashtables.c#L267
/* 读端:软中断里无锁查找 socket(调用者持 rcu_read_lock)*/
struct sock *__inet_lookup_established(struct net *net,
                struct inet_hashinfo *hashinfo, ...)
{
    unsigned int slot = hash & hashinfo->ehash_mask;
    struct inet_ehash_bucket *head = &hashinfo->ehash[slot];
    const struct hlist_nulls_node *node;
    struct sock *sk;
begin:
    sk_nulls_for_each_rcu(sk, node, &head->chain) {   /* RCU 遍历该桶 */
        if (sk->sk_hash != hash) continue;
        if (likely(INET_MATCH(sk, net, acookie, saddr, daddr, ports, dif))) {
            if (unlikely(!atomic_inc_not_zero(&sk->sk_refcnt)))
                goto out;                     /* 已在销毁,放弃 */
            if (unlikely(!INET_MATCH(sk, net, acookie, saddr, daddr, ports, dif))) {
                sock_gen_put(sk); goto begin; /* 拿到引用后复检,防窗口内被改 */
            }
            goto found;
        }
    }
    /* nulls 值 != 起始 slot:说明遍历中元素被移到别的桶,整体重试 */
    if (get_nulls_value(node) != slot)
        goto begin;
out:   sk = NULL;
found: return sk;
}

/* 写端:插入(拿该桶的 per-bucket spinlock) */
//https://elixir.bootlin.com/linux/v4.11.6/source/net/ipv4/inet_hashtables.c#L392
bool inet_ehash_insert(struct sock *sk, struct sock *osk)
{
    struct hlist_nulls_head *list;
    struct inet_ehash_bucket *head;
    spinlock_t *lock;

    sk->sk_hash = sk_ehashfn(sk);
    head = inet_ehash_bucket(hashinfo, sk->sk_hash);
    list = &head->chain;
    lock = inet_ehash_lockp(hashinfo, sk->sk_hash);   /* 按 hash 取该桶的锁 */
    spin_lock(lock);
    if (osk)                                  /* 需替换(如 TIMEWAIT)时先原子摘除 */
        sk_nulls_del_node_init_rcu(osk);
    __sk_nulls_add_node_rcu(sk, list);        /* 发布语义挂入桶 */
    spin_unlock(lock);
    return true;
}
struct inet_hashinfo {
	/* This is for sockets with full identity only.  Sockets here will
	 * always be without wildcards and will have the following invariant:
	 *
	 *          TCP_ESTABLISHED <= sk->sk_state < TCP_CLOSE
	 *
	 */
	struct inet_ehash_bucket	*ehash;
	spinlock_t			*ehash_locks;   //每个bucket一个spin_lock
    ......
}


//https://elixir.bootlin.com/linux/v4.11.6/source/include/net/inet_hashtables.h#L163
static inline spinlock_t *inet_ehash_lockp(
	struct inet_hashinfo *hashinfo,
	unsigned int hash)
{
	return &hashinfo->ehash_locks[hash & hashinfo->ehash_locks_mask];
}

协作分析

  • 读端零锁 + 引用 + 复检rcu_read_lock + sk_nulls_for_each_rcu 无锁遍历;命中后 atomic_inc_not_zero(&sk->sk_refcnt) 拿引用、再 INET_MATCH 复检一次(对象可能在拿引用的窗口内被改),失败重试(这正是 0x0E章节中case1 的”RCU + 引用计数 + 复检”组合)
  • 写端 per-bucket 锁inet_ehash_lockp 按 hash 选一把桶级 spinlock,不同桶的插入/删除可并行,只与同桶写者之间互斥,读者完全不碰这把锁
  • hlist_nulls 为何必要:RCU 无锁遍历时,元素可能从桶 A 被删除并重新插入桶 B;普通 hlist 会顺着它的新 next 走到别的桶从而漏查。hlist_nullsinclude/linux/rculist_nulls.h)让每条链的结尾是一个编码了 slot 号的特殊 nulls 指针,遍历到尾部比较 get_nulls_value(node) != slot,不一致就说明”串桶”了,整体重试(这是 hashtable + RCU 机制的经典配套)

红黑树:rbtree + 外部锁(进程地址空间 VMA 树)

红黑树的插入/删除要做树旋转,牵动多个节点指针,无法像链表那样用 RCU 让读者永远看到一致视图,因此内核 rbtree 一律用外部锁保护。进程 VMA 树 mm->mm_rbmm->mmap_sem(rwsem,关联章节 0x09)保护(mm/mmap.c):

/* 读端:查找覆盖 addr 的 VMA(调用者持 down_read(&mm->mmap_sem);省略 vmacache 快查) */
struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr)
{
    struct rb_node *rb_node = mm->mm_rb.rb_node;
    struct vm_area_struct *vma = NULL;
    while (rb_node) {
        struct vm_area_struct *tmp =
            rb_entry(rb_node, struct vm_area_struct, vm_rb);
        if (tmp->vm_end > addr) {
            vma = tmp;
            if (tmp->vm_start <= addr) break;    /* 命中 */
            rb_node = rb_node->rb_left;
        } else {
            rb_node = rb_node->rb_right;
        }
    }
    return vma;
}

/* 写端:插入新 VMA(调用者持 down_write(&mm->mmap_sem)) */
void __vma_link_rb(struct mm_struct *mm, struct vm_area_struct *vma,
                   struct rb_node **rb_link, struct rb_node *rb_parent)
{
    rb_link_node(&vma->vm_rb, rb_parent, rb_link); /* 挂上新节点 */
    vma->rb_subtree_gap = 0;
    vma_gap_update(vma);
    vma_rb_insert(vma, &mm->mm_rb);                /* 旋转 + 着色 rebalance */
}

find_vma的调用方加锁情况(down_read/up_read):

static noinline void
__do_page_fault(struct pt_regs *regs, unsigned long error_code,
		unsigned long address)
{
	struct vm_area_struct *vma;
	struct task_struct *tsk;
	struct mm_struct *mm;
    ......
    if (unlikely(!down_read_trylock(&mm->mmap_sem))) {
		if ((error_code & PF_USER) == 0 &&
		    !search_exception_tables(regs->ip)) {
			bad_area_nosemaphore(regs, error_code, address, NULL);
			return;
		}
retry:  
		down_read(&mm->mmap_sem);
	} else {
		might_sleep();
	}

	vma = find_vma(mm, address);
	if (unlikely(!vma)) {
		bad_area(regs, error_code, address);
		return;
	}
    ......
	if (unlikely(expand_stack(vma, address))) {
		bad_area(regs, error_code, address);
		return;
	}

good_area:
	if (unlikely(access_error(error_code, vma))) {
		bad_area_access_error(regs, error_code, address, vma);
		return;
	}

	/*
	 * If for any reason at all we couldn't handle the fault,
	 * make sure we exit gracefully rather than endlessly redo
	 * the fault.  Since we never set FAULT_FLAG_RETRY_NOWAIT, if
	 * we get VM_FAULT_RETRY back, the mmap_sem has been unlocked.
	 */
	fault = handle_mm_fault(vma, address, flags);
	major |= fault & VM_FAULT_MAJOR;
    ......

	up_read(&mm->mmap_sem);

    ......
}

__vma_link_rb的调用方加锁情况(写锁)


todo

协作分析

  • 为什么不用无锁/RCU?:插入/删除的旋转会同时改动祖先、父、子的多个指针,无锁读者可能落在一个自洽性被破坏的中间态(甚至走进环)。rbtree 因此需要一把真正的锁把读者挡在树外
  • 读多写少 → rwsem:查 VMA(缺页、/proc/<pid>/maps)远多于改地址空间(mmap/munmap)。mmap_sem 这把 rwsem 让读者 down_read 并发、写者 down_write 独占,且持有期间可睡眠(缺页里要分配页、读盘)。这正是选 rwsem 而非自旋 rwlock 的原因(关联章节0x09),rwlock要求临界区不可以睡眠
  • 对照 seqlock:只读值语义的结构可用 seqlock 让读者重试;但 find_vma 返回的是要继续解引用的指针,不适合 seqlock”可能读到撕裂数据”的模型,故用可睡眠的 rwsem

小结

能容忍”摘链后延迟回收”的链式结构(list/hlist)最适合 RCU 读无锁;需要旋转/再平衡的结构(rbtree)必须用外部锁;哈希表则用”每桶一把小锁 + RCU 读 + nulls 防串桶”兼顾并发与正确

数据结构 锁策略 读端 写端
链表(dev list) RCU + RTNL + dev_base_lock list_for_each_entry_rcu(无锁) list_add/del_rcu + 宽限期回收
哈希表(TCP ehash) RCU + per-bucket spinlock + hlist_nulls sk_nulls_for_each_rcu + 引用 + 复检 桶锁 + __sk_nulls_add_node_rcu
红黑树(VMA) rwsem(mmap_sem find_vmadown_read vma_rb_insertdown_write

0x10 综合案例:一次 VFS 路径查找中的并发协同

前文提到过,RCU-walk(LOOKUP_RCU)是多种原语协同的典范。当解析路径 /a/b/c 时,内核不取任何 dentry 引用计数、不加 d_lock,全程在 RCU + seqcount 保护下”无锁”走完,失败才回退到 ref-walk

核心是 __d_lookup_rcufs/dcache.c),内核使用了顺序锁配合rcu机制实现。这段代码浓缩了内核并发设计哲学为最热的读路径(路径查找每秒百万次)榨干每一分开销,只在真正需要修改时才付出锁的代价

//https://elixir.bootlin.com/linux/v4.11.6/source/fs/dcache.c#L2064
struct dentry *__d_lookup_rcu(const struct dentry *parent,
                const struct qstr *name, unsigned *seqp)
{
    ......
    hlist_bl_for_each_entry_rcu(dentry, node, head, d_hash) {  /* RCU 遍历哈希链 */
        unsigned seq;
seqretry:
        seq = raw_seqcount_begin(&dentry->d_seq);   /* 取该 dentry 的 seqcount 快照 */
        if (dentry->d_parent != parent)
            continue;
        if (d_unhashed(dentry))
            continue;
        *seqp = seq;
        if (dentry->d_name.hash_len != hashlen)
            continue;
        if (dentry_cmp(dentry, str, hashlen_len(hashlen)) != 0)
            continue;
        return dentry;
    }
    return NULL;
}

多层保护各司其职:

机制 在此处的职责
rcu_read_lock(调用方 link_path_walk 持有) 保证遍历期间 dentry 不被释放(释放经 call_rcu
hlist_bl_for_each_entry_rcu RCU 安全遍历哈希桶链
每 dentry 的 d_seq(seqcount) 原子快照 name/parent/inode 三元组;被 d_move 改动则读者重试
全局 rename_lock(seqlock) 检测遍历期间是否发生 rename,若”走错链”漏查则整体重试回退
dentry->d_lock(spinlock) 到达目标、要真正取引用(lockref_get_not_zero)转入 ref-walk 时才短暂持有
flowchart TD
    START["path_lookupat LOOKUP_RCU"] --> RL["rcu_read_lock"]
    RL --> WALK["link_path_walk 逐段解析"]
    WALK --> LOOKUP["__d_lookup_rcu:<br/>hlist RCU 遍历 + d_seq 快照"]
    LOOKUP --> CHK{"d_seq / rename_lock<br/>校验通过?"}
    CHK -->|是| NEXT["前进到下一段"]
    CHK -->|否| FALL["unlazy_walk 失败<br/>回退 ref-walk (-ECHILD)"]
    NEXT --> DONE{"到达目标?"}
    DONE -->|否| WALK
    DONE -->|是| GRAB["d_lock + lockref 取引用<br/>结束 RCU"]

上面贴出的__d_lookup_rcu的实现主要依靠顺序锁与rcu来实现无锁访问,这里再补充下__d_lookup_rcu的两处调用场景:

场景一:lookup_fast

//https://elixir.bootlin.com/linux/v4.11.6/source/fs/namei.c#L1537
static int lookup_fast(struct nameidata *nd,
		       struct path *path, struct inode **inode,
		       unsigned *seqp)
{
	struct vfsmount *mnt = nd->path.mnt;
	struct dentry *dentry, *parent = nd->path.dentry;
	int status = 1;
	int err;

	/*
	 * Rename seqlock is not required here because in the off chance
	 * of a false negative due to a concurrent rename, the caller is
	 * going to fall back to non-racy lookup.
	 */
	if (nd->flags & LOOKUP_RCU) {
		unsigned seq;
		bool negative;
        //外侧无任何锁
		dentry = __d_lookup_rcu(parent, &nd->last, &seq);
		if (unlikely(!dentry)) {
			if (unlazy_walk(nd))
				return -ECHILD;
			return 0;
		}

		......
	} else {
		dentry = __d_lookup(parent, &nd->last);
		if (unlikely(!dentry))
			return 0;
		status = d_revalidate(dentry, nd->flags);
	}
	......

	path->mnt = mnt;
	path->dentry = dentry;
	err = follow_managed(path, nd);
	if (likely(err > 0))
		*inode = d_backing_inode(path->dentry);
	return err;
}

__d_lookup的实现如下,可以看到,对比与__d_lookup_rcu,在rcu_read_lock() 的临界区内调用自旋锁(spin_lock)。此动作不仅是合法的,而且在 Linux 内核(dcache 模块中)是一种非常经典的设计模式:RCU 负责保护对象存在,自旋锁负责保护对象状态

todo

//https://elixir.bootlin.com/linux/v4.11.6/source/fs/dcache.c#L2203
struct dentry *__d_lookup(const struct dentry *parent, const struct qstr *name)
{
	unsigned int hash = name->hash;
	struct hlist_bl_head *b = d_hash(hash);
	struct hlist_bl_node *node;
	struct dentry *found = NULL;
	struct dentry *dentry;

	rcu_read_lock();
	
	hlist_bl_for_each_entry_rcu(dentry, node, b, d_hash) {

		if (dentry->d_name.hash != hash)
			continue;

		spin_lock(&dentry->d_lock);
		if (dentry->d_parent != parent)
			goto next;
		if (d_unhashed(dentry))
			goto next;

		if (!d_same_name(dentry, parent, name))
			goto next;

		dentry->d_lockref.count++;
		found = dentry;
		spin_unlock(&dentry->d_lock);
		break;
next:
		spin_unlock(&dentry->d_lock);
 	}
 	rcu_read_unlock();

 	return found;
}

场景二:lookup_slow

//https://elixir.bootlin.com/linux/v4.11.6/source/fs/namei.c#L1638
/* Fast lookup failed, do it the slow way */
static struct dentry *lookup_slow(const struct qstr *name,
				  struct dentry *dir,
				  unsigned int flags)
{
	struct dentry *dentry = ERR_PTR(-ENOENT), *old;
	struct inode *inode = dir->d_inode;
	DECLARE_WAIT_QUEUE_HEAD_ONSTACK(wq);

	inode_lock_shared(inode);
	/* Don't go there if it's already dead */
	if (unlikely(IS_DEADDIR(inode)))
		goto out;
again:
	dentry = d_alloc_parallel(dir, name, &wq);
	if (IS_ERR(dentry))
		goto out;
	if (unlikely(!d_in_lookup(dentry))) {
		if (!(flags & LOOKUP_NO_REVAL)) {
			int error = d_revalidate(dentry, flags);
			if (unlikely(error <= 0)) {
				if (!error) {
					d_invalidate(dentry);
					dput(dentry);
					goto again;
				}
				dput(dentry);
				dentry = ERR_PTR(error);
			}
		}
	} else {
		old = inode->i_op->lookup(inode, dentry, flags);
		d_lookup_done(dentry);
		if (unlikely(old)) {
			dput(dentry);
			dentry = old;
		}
	}
out:
	inode_unlock_shared(inode);
	return dentry;
}
struct dentry *d_alloc_parallel(struct dentry *parent,
				const struct qstr *name,
				wait_queue_head_t *wq)
{
	unsigned int hash = name->hash;
	struct hlist_bl_head *b = in_lookup_hash(parent, hash);
	struct hlist_bl_node *node;
	struct dentry *new = d_alloc(parent, name);
	struct dentry *dentry;
	unsigned seq, r_seq, d_seq;

	if (unlikely(!new))
		return ERR_PTR(-ENOMEM);

retry:
	rcu_read_lock();
	seq = smp_load_acquire(&parent->d_inode->i_dir_seq) & ~1;
	r_seq = read_seqbegin(&rename_lock);
    //call __d_lookup_rcu
	dentry = __d_lookup_rcu(parent, name, &d_seq);
	if (unlikely(dentry)) {
		if (!lockref_get_not_dead(&dentry->d_lockref)) {
			rcu_read_unlock();
			goto retry;
		}
		if (read_seqcount_retry(&dentry->d_seq, d_seq)) {
			rcu_read_unlock();
			dput(dentry);
			goto retry;
		}
		rcu_read_unlock();
		dput(new);
		return dentry;
	}
	if (unlikely(read_seqretry(&rename_lock, r_seq))) {
		rcu_read_unlock();
		goto retry;
	}
	hlist_bl_lock(b);
	if (unlikely(parent->d_inode->i_dir_seq != seq)) {
		hlist_bl_unlock(b);
		rcu_read_unlock();
		goto retry;
	}
	/*
	 * No changes for the parent since the beginning of d_lookup().
	 * Since all removals from the chain happen with hlist_bl_lock(),
	 * any potential in-lookup matches are going to stay here until
	 * we unlock the chain.  All fields are stable in everything
	 * we encounter.
	 */
	hlist_bl_for_each_entry(dentry, node, b, d_u.d_in_lookup_hash) {
		if (dentry->d_name.hash != hash)
			continue;
		if (dentry->d_parent != parent)
			continue;
		if (!d_same_name(dentry, parent, name))
			continue;
		hlist_bl_unlock(b);
		/* now we can try to grab a reference */
		if (!lockref_get_not_dead(&dentry->d_lockref)) {
			rcu_read_unlock();
			goto retry;
		}

		rcu_read_unlock();
		/*
		 * somebody is likely to be still doing lookup for it;
		 * wait for them to finish
		 */
		spin_lock(&dentry->d_lock);
		d_wait_lookup(dentry);
		/*
		 * it's not in-lookup anymore; in principle we should repeat
		 * everything from dcache lookup, but it's likely to be what
		 * d_lookup() would've found anyway.  If it is, just return it;
		 * otherwise we really have to repeat the whole thing.
		 */
		if (unlikely(dentry->d_name.hash != hash))
			goto mismatch;
		if (unlikely(dentry->d_parent != parent))
			goto mismatch;
		if (unlikely(d_unhashed(dentry)))
			goto mismatch;
		if (unlikely(!d_same_name(dentry, parent, name)))
			goto mismatch;
		/* OK, it *is* a hashed match; return it */
		spin_unlock(&dentry->d_lock);
		dput(new);
		return dentry;
	}
	rcu_read_unlock();
	/* we can't take ->d_lock here; it's OK, though. */
	new->d_flags |= DCACHE_PAR_LOOKUP;
	new->d_wait = wq;
	hlist_bl_add_head_rcu(&new->d_u.d_in_lookup_hash, b);
	hlist_bl_unlock(b);
	return new;
mismatch:
	spin_unlock(&dentry->d_lock);
	dput(dentry);
	goto retry;
}
EXPORT_SYMBOL(d_alloc_parallel);