7Linux 内核设计与实现(第3版)

内核同步

并发控制

原子操作自旋锁信号量互斥锁

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第7章 内核同步(Kernel Synchronization)

一、导读

1.1 本章学习目标

理解并发(concurrency)在 Linux 内核中的来源:中断、进程调度、多核并行

掌握临界区(critical section)和竞态条件(race condition)的概念

深入理解原子操作(atomic operations)的实现原理和使用场景

掌握自旋锁(spinlock)的工作机制、适用场景及注意事项

理解信号量(semaphore)和互斥锁(mutex)的区别及使用场景

深入了解 RCU(Read-Copy-Update)的设计思想和实现原理

掌握禁止抢占(preempt_disable)的作用和使用方法

了解死锁的产生条件及内核中的死锁避免策略

1.2 为什么本章如此重要

并发是 Linux 内核面临的核心挑战之一。在一个运行着数百个进程、数十个设备驱动、运行在多核处理器上的系统中,大量代码路径可能同时访问共享数据。如果不对这些访问进行同步,就会导致数据损坏、逻辑错误甚至系统崩溃。内核同步机制的正确使用,是编写可靠内核代码的基础。

内核同步的复杂性在于其运行环境的多样性。同一段代码可能在中断上下文中执行(不能睡眠)、在进程上下文中执行(可以睡眠)、在单核上执行(只需禁止抢占)、在多核上执行(需要真正的锁)。选择正确的同步机制需要考虑:临界区的长度、是否在中断上下文中、是否需要睡眠、读写的频率比等多个因素。

RCU 是 Linux 内核中最重要的同步创新之一。它允许读取操作完全不加锁,在配置管理等读多写少的场景中提供了接近零开销的读取性能。理解 RCU 的原理和使用方法,对于阅读现代 Linux 内核代码至关重要。

1.3 核心问题引导

内核中的并发来源有哪些?为什么单核系统也需要同步?

原子操作是如何在硬件层面保证不可中断的?x86 上的 lock 前缀起什么作用?

自旋锁和互斥锁的核心区别是什么?如何根据临界区长度选择?

为什么在中断上下文中不能使用互斥锁?

RCU 的"读端完全无锁"是如何实现的?写入者如何保证不会破坏读取者的操作?

什么是"宽限期"(grace period)?RCU 如何检测宽限期的结束?

死锁的四个必要条件是什么?内核中如何避免死锁?

preempt_disable() 和 spin_lock() 有什么区别?它们分别防止什么类型的并发?


二、核心概念详解

7.1 原子操作

原子操作是在硬件层面保证不可被中断的操作。在 x86 架构上,通过在指令前添加 lock 前缀来实现——该前缀使得 CPU 在执行该指令期间锁定总线或缓存行,阻止其他 CPU 核心访问同一内存位置。

内核提供了两类原子操作:原子整数操作和原子位操作。原子整数操作通过 atomic_t 类型和相关函数实现:

c
atomic_t counter;
atomic_set(&counter, 0);       // 设置为 0
atomic_inc(&counter);          // 原子加 1
atomic_dec(&counter);          // 原子减 1
atomic_read(&counter);         // 原子读取
atomic_add_return(5, &counter); // 原子加 5 并返回结果

原子操作是最轻量的同步机制,没有锁的开销(不需要保存状态、不需要等待),但功能有限——只能执行简单的加减和位操作,不能保护复杂的临界区。原子操作常用于实现引用计数、统计计数等简单场景。

7.2 自旋锁

自旋锁(Spinlock)是一种忙等待锁。当一个线程尝试获取已被占用的自旋锁时,它会在一个循环中不断检查锁是否可用("自旋"),而不是进入睡眠。自旋锁的实现非常紧凑,在单核系统上仅通过禁止抢占来实现,在多核系统上通过原子操作和忙等待来实现。

c
DEFINE_SPINLOCK(my_lock);
spin_lock(&my_lock);    // 获取自旋锁
// 临界区
spin_unlock(&my_lock);  // 释放自旋锁

自旋锁适用于短临界区(通常只有几条指令),因为自旋等待的开销小于上下文切换的开销。但如果临界区过长,持有锁的线程可能因为时间片用完而被调度出去,导致其他等待的线程无限自旋,浪费 CPU 资源。

自旋锁有几个重要的变体:spin_lock_irq() 在获取锁的同时禁止本地中断,防止中断处理程序与当前代码竞争;spin_lock_irqsave() 除了禁止中断外,还保存之前的中断状态,在释放锁时恢复;spin_lock_bh() 禁止软中断(下半部),防止下半部与当前代码竞争。

7.3 信号量与互斥锁

信号量(Semaphore)是一种睡眠锁。当线程无法获取信号量时,它会将自身放入等待队列并进入睡眠状态,让出 CPU 给其他线程。信号量维护一个计数器,表示可用资源的数量。down() 操作将计数器减 1,如果计数器变为负数则睡眠;up() 操作将计数器加 1,如果等待队列中有线程则唤醒它。

互斥锁(Mutex)是信号量的特化版本,具有更严格的语义:同一时刻只有一个线程持有锁;只有锁的持有者才能释放锁;不允许在中断上下文中使用;不允许递归获取。这些限制使得互斥锁可以实现更完善的调试功能,如锁的所有权跟踪、死锁检测、持有者退出时的自动释放等。

c
DEFINE_MUTEX(my_mutex);
mutex_lock(&my_mutex);    // 获取互斥锁(可能睡眠)
// 临界区(可以睡眠)
mutex_unlock(&my_mutex);  // 释放互斥锁

互斥锁适用于长临界区或临界区内可能需要睡眠的场景(如等待 I/O 完成、分配内存等)。与自旋锁相比,互斥锁的获取和释放开销更大(涉及进程调度),但不会在等待时浪费 CPU。

7.4 RCU(Read-Copy-Update)

RCU 是 Linux 内核中最精巧的同步机制之一。它的核心思想是:将读-写冲突的解决从读取端移到写入端。读取操作完全不加锁,写入操作通过"复制-修改-替换-延迟释放"的方式避免与读取操作冲突。

RCU 的基本工作流程:读取者通过 rcu_read_lock() 标记进入 RCU 读端临界区(实际上只是禁止抢占,没有原子操作或内存屏障);读取共享数据的指针;写入者创建数据的副本,修改副本,然后用原子操作将指针指向新副本;写入者调用 synchronize_rcu() 等待所有已开始的读取操作完成(等待一个宽限期);宽限期结束后,安全地释放旧数据。

RCU 的关键在于宽限期(Grace Period)的检测。宽限期是指所有 CPU 都至少经历了一次上下文切换(或等效的 quiescent state)的时间段。这保证了所有在 rcu_read_lock() 之前开始的读取操作都已完成。内核通过跟踪每个 CPU 的调度状态来检测宽限期。

RCU 适用于读多写少的场景,如路由表、进程列表、配置参数等。在这些场景中,读取操作的频率远高于写入操作,RCU 的零开销读取带来了显著的性能提升。


三、深入分析与讨论

3.1 死锁的产生与避免

死锁是指两个或多个线程互相等待对方释放资源,导致所有线程都无法继续。死锁的四个必要条件(Coffman 条件):互斥(资源不能共享)、持有并等待(持有资源的同时等待其他资源)、不可抢占(资源只能由持有者释放)、循环等待(存在等待环路)。

内核中避免死锁的策略包括:锁排序(Lock Ordering)——为所有锁定义全局的获取顺序,所有代码都按相同顺序获取锁,消除循环等待;超时锁——使用 mutex_trylock() 或 spin_trylock() 尝试获取锁,失败时不等待而是执行替代路径;锁依赖检测——内核内置的 lockdep(Lock Dependency Validator)工具在运行时跟踪锁的获取顺序,检测潜在的死锁模式。

lockdep 是内核中一个强大的调试工具。它通过注解(lockdep_init_map)跟踪每个锁的类别和获取顺序,构建锁依赖图。当检测到可能的死锁路径时(即使在实际运行中尚未发生),lockdep 会发出警告。这使得开发者可以在测试阶段发现潜在的死锁问题。

3.2 每 CPU 变量与同步优化

每 CPU 变量(Per-CPU Variable)是一种避免同步的技术。内核为每个 CPU 核心创建一份变量的副本,每个 CPU 只访问自己的副本,因此不存在并发访问的问题。每 CPU 变量通过 DEFINE_PER_CPU() 定义,通过 get_cpu_var() 或 this_cpu_ptr() 访问。

每 CPU 变量广泛用于统计计数、调度器运行队列等场景。例如,每个 CPU 维护自己的运行队列,调度操作不需要与其他 CPU 同步。统计计数使用每 CPU 变量避免了原子操作的开销。读取全局统计时,只需将所有 CPU 的副本求和。


四、实践案例

4.1 案例一:使用 lockdep 检测锁依赖问题

在开发内核模块时,启用 CONFIG_PROVE_LOCKING 配置选项,使 lockdep 工具在运行时检测锁的获取顺序。编写一个模块,以不同顺序获取两把锁(在 A 函数中先获取 lock1 再获取 lock2,在 B 函数中先获取 lock2 再获取 lock1)。加载模块后触发两个函数的执行,观察 lockdep 的死锁警告输出。

4.2 案例二:RCU 在进程链表遍历中的应用

内核中遍历进程列表(for_each_process())使用 RCU 保护。分析内核源码中 for_each_process() 的实现,理解它如何使用 rcu_read_lock() 保护遍历过程。比较使用 RCU 保护与使用读写锁保护的遍历在性能上的差异。


五、常见误区与注意事项

误区:自旋锁总是比互斥锁快。自旋锁的获取和释放操作确实更快(不涉及进程调度),但如果临界区较长或锁竞争激烈,自旋等待会浪费大量 CPU。在临界区可能睡眠时,必须使用互斥锁。

误区:RCU 读取不需要任何保护。RCU 读取需要 rcu_read_lock() 禁止抢占,确保读取过程中不会被调度走。如果读取过程中被抢占,宽限期的计算就会出错。

误区:原子操作可以替代锁。原子操作只能保证单个操作的原子性,不能保护多个操作的序列。如果需要"检查然后修改"(check-then-act)的复合操作,仍然需要锁。

注意:在中断上下文中不能使用可能睡眠的同步机制。mutex_lock()、down()、kmalloc(GFP_KERNEL) 等都可能睡眠,在中断上下文中使用会导致内核崩溃。应使用 spin_lock_irqsave()、atomic_t、kmalloc(GFP_ATOMIC) 等。

注意:锁的粒度影响性能和正确性。锁粒度过粗(如全局锁)限制并发性能;锁粒度过细(如每个元素一把锁)增加管理开销和死锁风险。需要根据实际访问模式选择合适的粒度。


六、本章小结

本章详细讲解了 Linux 内核中的同步机制。并发来源于中断、进程调度和多核并行。原子操作是最轻量的同步机制,适用于简单的计数操作。自旋锁适用于短临界区,通过忙等待避免上下文切换的开销。互斥锁适用于长临界区或需要睡眠的场景,通过睡眠等待避免 CPU 浪费。RCU 是读多写少场景的最优选择,读取操作完全无锁,写入操作通过延迟释放保证正确性。每 CPU 变量通过消除共享来避免同步。选择正确的同步机制需要综合考虑临界区长度、上下文类型、读写频率等因素。lockdep 工具帮助在开发阶段发现潜在的死锁问题。


七、思考题与练习

编写一个内核模块,演示竞态条件:两个内核线程同时修改一个共享变量,观察数据损坏的现象。然后使用互斥锁修复问题。

分析自旋锁在单核系统和多核系统上的实现差异。为什么单核系统只需要禁止抢占?

设计一个使用 RCU 保护的数据结构(如链表),实现无锁读取和安全的写入操作。

解释为什么在中断上下文中不能使用互斥锁。如果确实需要在中断处理中访问共享资源,应该怎么做?

研究 lockdep 的工作原理。它是如何跟踪锁依赖关系的?它能检测哪些类型的问题?

比较 per-cpu 变量和原子操作在统计计数场景下的性能差异。

分析一个死锁场景:两个进程分别持有 A 锁和 B 锁,并尝试获取对方的锁。说明如何通过锁排序避免这种死锁。

研究 seqlock 的设计思想和使用场景。它与读写锁和 RCU 有什么区别?

7/10内核同步
0%