内核同步:原子性、临界区与数据竞争
1. 原子性与内核空间实现
在现代微处理器中,通常只有单条机器语言指令,或者对处理器总线宽度内对齐的基本数据类型的读写操作,才能保证原子性。在用户空间,想要让几行 C 代码实现真正的原子性是不可能的,不过可以通过构建一个采用 SCHED_FIFO 任务调度策略和实时(RT)优先级为 99 的用户线程,来接近原子性。这样,当该线程要运行时,除了硬件中断或异常外,几乎没有其他因素能抢占它。
而在内核空间,我们可以编写真正具有原子性的代码,常用的方法是使用自旋锁(spinlocks)。
2. 临界区要点总结
2.1 临界区定义
临界区是可能并发执行的代码路径,能并行运行,且会对共享可写数据(即共享状态)进行读写操作。
2.2 临界区特性
- 需要保护 :由于操作共享可写数据,临界区必须免受并行和并发的影响,需以串行、互斥的方式运行。
- 原子执行 :在原子非阻塞上下文(包括任何中断上下文)中运行时,必须保证其原子性,即不可分割地执行到完成,不被中断。保护后,在“解锁”前可安全访问共享状态。
2.3 识别与保护
- 识别 :仔细审查代码,确保不遗漏临界区。任何全局或静态变量通常是明显的标志,但在可能并发的代码路径中,任何共享状态(如硬件寄存器、邮箱等)都可能是临界区。
- 保护 :可通过多种技术实现,常见的是锁定,此外还有原子操作符和无锁编程技术。
2.4 常见错误
- 仅保护写操作 :必须同时保护对共享可写数据的读操作,否则可能导致撕裂或脏读。例如,在 32 位系统上读写 64 位数据项时,操作不是原子的,若一个线程在读,另一个线程同时在写,可能会出现问题。
- 使用错误的锁 :必须使用相同的(正确的)锁来保护给定的数据项。例如,若使用锁 A 保护全局数据结构 X,那么每次访问该结构时都必须使用锁 A。
- 未保护临界区 :会导致数据竞争,其结果取决于运行时环境和时间,是一种难以发现、重现、确定根源和修复的缺陷。
2.5 例外情况
- 处理局部变量 :局部变量分配在线程的私有栈(或中断上下文中的本地 IRQ 栈)上,本质上是安全的。
- 处理序列化代码中的共享可写数据 :例如内核模块的 init 和 cleanup 方法,它们只会串行执行一次。
- 处理真正的常量和只读共享数据 :但不要被 C 语言的 const 关键字误导。
2.6 锁定复杂性
锁定本质上很复杂,必须仔细设计和实现锁定方案,避免死锁。
3. 数据竞争的正式定义
3.1 内存一致性模型
内存(一致性)模型为多并发加载/存储操作时的内存一致性概念提供了更正式的方法,Linux 内核有自己的内存模型,即 Linux-Kernel 内存模型(LKMM)。
3.2 LKMM 中的内存访问类型
| 访问类型 | 描述 | 示例 |
|---|---|---|
| 普通访问 | 通过 C 语言语句对内存进行的典型访问 |
i ++
或
y = 42 - x;
|
| 标记访问 | 旨在隐式保证原子性的“特殊”访问 |
- 加载:
READ_ONCE(x);
- 存储:
WRITE_ONCE(y, 42-x);
|
3.3 数据竞争的定义
当满足以下条件时,会发生数据竞争:
1. 两个内存访问访问同一位置。
2. 至少有一个是存储操作。
3. 至少有一个是普通访问。
4. 它们发生在不同的 CPU 上(或同一 CPU 上的不同线程中)。
5. 它们并发执行。
4. 数据竞争的检测与避免
4.1 标记访问的使用
标记访问虽能保证原子加载和存储,但不保证内存顺序,且会阻止像 KCSAN 这样的工具检测数据竞争,因此大多数情况下应继续使用普通 C 访问。
4.2 数据竞争检测工具
Kernel Concurrency Sanitizer(KCSAN)可用于检测数据竞争,建议学习如何使用它。
5. Linux 内核中的并发问题
5.1 并发问题来源
- 对称多处理器(SMP)系统 :多个用户空间进程可同时执行驱动的代码,操作共享可写数据,可能导致数据竞争。即使是单核心系统,未来也可能向多核发展,且单核心系统也存在并发问题。
- 可抢占内核 :在临界区内,内核可能抢占当前进程并切换到另一个进程,导致数据竞争。
- 阻塞 I/O :在临界区内遇到阻塞调用时,当前进程会进入睡眠状态,若此时另一个进程执行相同代码路径,可能导致数据竞争。
- 硬件中断 :硬件中断优先级高,会抢占当前代码执行。若中断处理代码和被中断的进程上下文操作相同的共享可写数据,会导致数据竞争。
5.2 并发问题示例
graph TD;
A[进程 P1 进入临界区] --> B{是否发生硬件中断};
B -- 是 --> C[中断处理代码尝试获取相同锁];
C --> D[数据竞争];
B -- 否 --> E[进程 P1 正常执行];
5.3 关键要点
识别和保护临界区是确保数据安全的关键,虽然并发问题看似复杂,但保护临界区的锁定 API 并不难学习使用。
6. 锁定准则与死锁问题
6.1 锁定准则
- 锁定粒度 :临界区的长度应适中,既不能过粗(过长),也不能过细。在大型项目中,锁的数量过多或过少都有问题。锁太少会导致性能问题,因为相同的锁会被频繁使用,容易产生竞争;锁太多虽然有利于性能,但会增加复杂度。开发者需要清楚每个锁保护的共享数据对象,通常可以将保护数据结构的锁作为成员放在数据结构内部。此外,长时间的原子临界区可能会导致高延迟和性能瓶颈,可使用如 criticalstat[-bpfcc] 等工具来检测和报告。
- 锁的释放规则 :只有持有锁的“所有者”才能释放(解锁)它,尝试释放未持有的锁或在持有锁时重新获取它被视为错误。虽然递归锁定可以解决部分问题,但通常不被推荐。
- 锁的顺序 :锁的获取顺序至关重要,必须确保在整个代码中按照相同的顺序获取锁,否则可能导致死锁。而锁的释放顺序则无关紧要,但必须释放所有持有的锁,以防止饥饿。
- 避免复杂性 :尽量避免复杂的锁定场景,保持简单性。
6.2 常见死锁场景
6.2.1 单锁,进程上下文
- 自我死锁 :尝试两次获取同一把锁会导致自我死锁。例如,持有锁时再次尝试获取该锁,由于锁已被锁定,需要等待解锁,但自己又持有锁无法解锁,从而陷入死循环。
6.2.2 多锁,进程上下文
- AB - BA 死锁 :线程 A 在 CPU 0 上获取锁 A 后想获取锁 B,同时线程 B 在 CPU 1 上获取锁 B 后想获取锁 A,双方相互等待,导致死锁。这种情况可以扩展为 AB - BC - CA 等更复杂的循环依赖。
6.2.3 单锁,进程和中断上下文
- 自旋锁死锁 :进程 P1 在 CPU 核心 0 上获取锁 A 后,驱动的硬件中断发生,中断处理代码尝试获取同一把锁 A。由于锁 A 已被进程 P1 锁定,中断处理代码会等待解锁,但进程 P1 又因中断正在运行而无法解锁,最终导致死锁。解决方法是在获取锁时禁用本地核心的所有硬件中断,确保进程上下文在执行临界区时不被中断,执行完后再重新启用中断。
6.2.4 更复杂的情况
涉及多个锁以及进程和中断(硬中断和软中断)上下文的情况更为复杂,这里暂不详细展开。
6.2.5 死锁解决建议
在简单情况下,遵循锁的顺序准则通常足以避免死锁。例如,在代码中始终按照预先定义的顺序获取锁。但随着情况变得复杂,即使是经验丰富的开发者也可能会遇到问题。不过,Linux 内核的运行时锁依赖验证器 lockdep 可以捕获几乎所有的死锁情况。
7. 总结
7.1 关键知识点回顾
- 原子性在用户空间难以保证,在内核空间可通过自旋锁实现。
- 临界区是操作共享可写数据的并发代码路径,必须进行识别和保护,避免常见错误。
- 数据竞争的发生条件由 LKMM 定义,可通过合适的访问方式和工具进行检测和避免。
- Linux 内核中的并发问题源于 SMP 系统、可抢占内核、阻塞 I/O 和硬件中断,需要正确使用锁定技术来解决。
- 锁定准则包括锁定粒度、锁的释放和顺序等,违反这些准则可能导致死锁。
7.2 重要性强调
理解和掌握内核同步的相关知识对于内核和驱动开发者至关重要。正确处理临界区和避免数据竞争、死锁等问题,能够确保系统的稳定性和性能。在实际开发中,要仔细设计和实现锁定方案,充分利用现有的工具和技术,不断提高代码的质量和可靠性。
7.3 未来展望
随着计算机系统的不断发展,内核同步技术也将不断演进。开发者需要持续关注新的技术和方法,以应对日益复杂的并发场景和性能需求。例如,未来可能会出现更高效的锁定机制和更强大的检测工具,帮助开发者更好地解决内核同步问题。
以下是一个总结内核同步关键流程的 mermaid 流程图:
graph LR;
A[识别临界区] --> B[选择锁定方式];
B --> C{是否为原子上下文};
C -- 是 --> D[使用自旋锁并禁用中断];
C -- 否 --> E[使用合适的锁];
D --> F[执行临界区代码];
E --> F;
F --> G[释放锁];
G --> H[检查是否有数据竞争];
H -- 是 --> I[使用 KCSAN 检测];
H -- 否 --> J[结束];
I --> J;
通过这个流程图,可以清晰地看到内核同步的主要步骤,从识别临界区开始,到选择合适的锁定方式,执行临界区代码,释放锁,最后检查是否存在数据竞争。这个流程有助于开发者在实际开发中遵循正确的步骤,确保内核代码的正确性和稳定性。
超级会员免费看
1681

被折叠的 条评论
为什么被折叠?



