Linux内核同步技术:中断处理、锁机制与并发控制
1. 中断处理与下半部机制
1.1 中断处理要点总结
在Linux系统中,中断处理程序必须是非阻塞的,并且要迅速完成任务。一般来说,要求在100微秒内完成。如果处理程序中有大量工作要做,可能会导致延迟。为了解决这个问题,现代操作系统通常将中断处理分为上半部和下半部。
上半部(硬中断处理程序)在硬件中断触发时几乎立即被调用,驱动开发者应在此完成最基本的必要工作,并尽量缩短处理时间。如果还有更多工作需要执行,可以注册并调用下半部,这是一个延迟执行的函数,会在稍后运行。
实际上,下半部是通过“小任务(tasklets)”实现的,而小任务又是基于内核的低级软中断技术构建的。小任务或软中断会在上半部完成后立即运行。
1.2 上半部和下半部的执行环境差异
上半部(硬中断)在当前CPU上会禁用所有中断,并在所有CPU上禁用正在处理的中断;而下半部处理程序则在所有中断启用的情况下运行。需要注意的是,下半部机制(软中断和小任务)也是在中断上下文(而非进程上下文)中运行的,因此和上半部一样,不能执行任何可能阻塞的操作。
硬中断处理程序不会并行运行(即不可重入),小任务同样不会并行运行,这使得小任务的使用更加简单。而软中断则可以在其他核心上并行运行。
1.3 线程化中断处理程序
现在,许多类型的驱动程序(特别是那些性能要求不是极高的慢速设备驱动)都很流行使用线程化的中断处理程序。在这种情况下,中断处理程序实际上是一个内核线程(采用SCHED_FIFO调度策略,实时优先级为50),因此允许在其中发出阻塞调用,并且消除了上半部和下半部的区分。
2. 下半部与锁机制
2.1 临界区竞争问题及解决方法
当驱动程序的软中断或小任务的临界区与上半部(硬中断)中断处理程序在不同核心上执行时产生竞争,可以使用普通的自旋锁来保护临界区。
当驱动程序的软中断或小任务的临界区与进程上下文的代码路径产生竞争时,可以使用 spin_lock_bh() 函数。该函数会先禁用本地处理器的下半部,然后获取自旋锁,从而保护临界区。对应的解锁API是 spin_unlock_bh() 。
2.2 不同类型自旋锁的使用情况
| 自旋锁类型 | 使用场景 | 特点 |
|---|---|---|
spin_lock()/spin_unlock() | 保护进程上下文中的临界区,当没有硬件中断需要处理或中断不产生竞争时使用;也可用于保护上半部和下半部处理程序之间的临界区 | 最简单、开销最低 |
spin_lock_irq()/spin_unlock_irq() | 当硬件中断起作用且有影响时使用,此时进程和中断上下文可能会产生竞争(即共享全局可写数据) | 中等开销,禁用中断和内核抢占 |
spin_lock_bh()/spin_unlock_bh() | 保护进程上下文和下半部之间的临界区,内部会禁用/启用本地核心的下半部 | - |
spin_lock_irqsave()/spin_unlock_irqrestore() | 这是使用自旋锁最安全的方式,与中等开销的自旋锁功能类似,但会保存和恢复CPU状态,确保不会意外覆盖之前的中断掩码设置 | 最强形式,相对高开销 |
2.3 自旋锁的内部实现
自旋锁的内部实现通常是与架构相关的代码,通常由原子机器语言指令组成,这些指令在微处理器上执行速度非常快。例如,在流行的x86[_64]架构上,自旋锁最终归结为对自旋锁结构的一个成员执行原子测试并设置的机器指令(通常通过 cmpxchg 机器语言指令实现)。在许多ARM机器上,通常是 wfe (等待事件)和 SEV (设置事件)机器指令在实现中起核心作用。作为内核或驱动开发者,在使用自旋锁时应仅使用暴露的API(和宏)。
2.4 实时Linux中的自旋锁变化
在实时Linux(RTL)中,当线程持有禁用中断/抢占的自旋锁时,无法被抢占,这与实时内核的要求相冲突。因此,当RTL启用时,自旋锁实际上会被重新实现为“可睡眠的自旋锁”,通过用 rt-mutex 锁替换自旋锁,使临界区可以睡眠,从而实现可抢占。
3. 锁机制的常见错误与准则
3.1 常见错误
- 未识别临界区 :
- 像
i++或i--这样的简单增量/减量操作也可能是临界区。 - 即使只是读取共享数据,如果满足临界区的两个条件(可能并行执行且操作共享可写数据),也属于临界区,不进行保护可能会导致脏读、撕裂读、数据不一致或损坏。
- 像
- 死锁 :这是一种无法继续前进的情况。为避免死锁,需要仔细设计锁机制,并遵循以下关键规则:
- 记录并始终遵循锁的获取顺序规则。
- 不要尝试重新获取已经持有的锁。
- 只释放当前持有的锁。
- 防止饥饿现象。
3.2 锁使用准则
- 尽量避免使用锁 :
- 这并不意味着完全不使用全局变量,而是要设计一个整体架构,尽可能避免写线程与其他对共享可写数据的读写访问并发运行。
- 如果使用共享可写数据,例如在全局结构中,尽量将所有(或尽可能多的)整数成员设置为
refcount_t或atomic_t。 - 必要时考虑内存屏障。
- 使用无锁技术。
- 如果必须使用锁,按以下顺序进行 :
- 尝试使用无锁技术,如每个CPU变量和RCU。
- 如果无法使用无锁技术,则使用普通锁:
- 互斥锁(Mutex):在进程上下文中,当临界区较长,且/或临界区需要或可能发生阻塞I/O(睡眠)时使用。
- 自旋锁(Spinlock):在任何原子上下文(如中断处理)中,当临界区较短且必须是非阻塞(不允许睡眠)时使用;也可在进程上下文中没有阻塞操作的临界区使用。如果互斥锁和自旋锁都适用,优先选择自旋锁,因为它通常能提供更好的性能,并且对临界区有更严格的规则要求。
-
refcount_t用于整数操作(atomic_t原子操作符是其旧接口)。 - 使用内核的RMW位操作符来操作位。
- 读写自旋锁(逐渐被RCU取代)。
- 始终牢记锁的顺序 :始终以相同的顺序获取锁,并记录和严格遵循该顺序,这有助于防止死锁(释放锁的顺序并不重要)。
- 锁数据而非代码 :
- 尽可能采用更细粒度的锁。
- 从更深层次来说,要通过仔细研究要保护的数据结构(甚至是其中的成员)来设计锁机制,明确说明如何保护其免受并发访问,即采用以数据为中心的方法,而不是在代码中随意添加互斥锁或自旋锁,直到看起来“可行”为止。
- 保持临界区简短 :临界区的长度(锁和解锁之间的代码路径)会影响性能,应尽量缩短。可以使用
criticalstat eBPF工具来检查和报告遇到的长原子临界区。 - 防止饥饿 :确保每个线程都有机会访问资源。
- 考虑缓存效应和内存屏障 :注意虚假共享和缓存行抖动问题。
- 使用调试内核 :运行所有测试用例时,调试内核必须启用“lockdep”,启用锁统计信息也有助于找出热点。
- 保持锁机制简单 :尽量简化锁机制,避免不必要的复杂性。
4. 练习题解答
4.1 练习题2解答
判断一段代码是否为临界区,需要看它是否满足两个条件:可能并行执行且操作共享可写数据。如果代码(如 t1 和 t2 之间的代码)可以并行运行,并且操作共享可写数据(如全局变量 mydrv ),那么它就是临界区,不应该在没有任何显式保护的情况下运行。
4.2 练习题3解答
对于内核模块的 init 和 cleanup 方法,虽然它们操作共享可写数据,但由于这些方法只会执行一次,不满足可能并行执行的条件,因此不构成临界区,不需要特殊保护。
5. 总结
理解并发及其相关问题对于软件专业人员至关重要。本文介绍了中断处理、下半部机制、锁机制等关键概念,以及如何处理硬件中断和并发问题。同时,还指出了锁机制的常见错误和使用准则,帮助开发者更好地进行内核编程。虽然已经介绍了很多内核并发的知识,但仍有许多概念和技术需要进一步学习。
6. 后续待学习内容
- 使用
atomic_t和refcount_t接口 - 使用RMW原子操作符
- 使用读写自旋锁
- 理解CPU缓存基础知识、缓存效应和虚假共享
- 使用每个CPU变量和RCU进行无锁编程
- 内核中的锁调试
- 引入内存屏障
在后续的学习中,我们将深入探讨这些内容,进一步提升对内核同步和并发控制的理解和应用能力。
7. 相关资源
8. 使用 atomic_t 和 refcount_t 接口
在之前的简单杂项字符设备驱动程序中,我们定义并操作了两个静态全局整数 ga 和 gb :
static int ga, gb = 1;
ga++; gb--;
在多线程或多处理器环境中,这些简单的操作可能会引发数据竞争问题。为了解决这个问题,可以使用 atomic_t 和 refcount_t 接口。
atomic_t 是内核提供的一种原子类型,用于执行原子操作,确保操作的原子性,避免数据竞争。例如:
#include <linux/atomic.h>
atomic_t ga = ATOMIC_INIT(1);
atomic_t gb = ATOMIC_INIT(1);
atomic_inc(&ga);
atomic_dec(&gb);
refcount_t 是专门用于引用计数的类型,它基于 atomic_t 实现,提供了更方便的引用计数操作。例如:
#include <linux/refcount.h>
refcount_t rc;
refcount_set(&rc, 1);
refcount_inc(&rc);
if (refcount_dec_and_test(&rc)) {
// 引用计数减为 0,进行相应处理
}
8.1 使用 atomic_t 和 refcount_t 的优势
- 原子性 :确保操作在多线程环境下的原子性,避免数据竞争。
- 性能 :原子操作通常比锁机制更高效,因为它们不需要上下文切换。
- 简化代码 :提供了简洁的接口,使代码更易于理解和维护。
8.2 操作步骤
- 包含必要的头文件:
#include <linux/atomic.h>或#include <linux/refcount.h>。 - 定义
atomic_t或refcount_t变量,并进行初始化。 - 使用相应的原子操作函数进行操作。
9. 使用 RMW 原子操作符
RMW(Read-Modify-Write)原子操作符用于在一个原子操作中完成读取、修改和写入操作,避免了中间状态的可见性问题。内核提供了一系列的 RMW 原子操作符,例如:
#include <linux/atomic.h>
atomic_t value = ATOMIC_INIT(0);
atomic_add_return(10, &value); // 原子地将 value 加 10 并返回新值
9.1 RMW 原子操作符的应用场景
- 计数器 :在多线程环境下对计数器进行安全的增减操作。
- 状态标志 :原子地修改状态标志,确保状态的一致性。
9.2 操作步骤
- 包含
<linux/atomic.h>头文件。 - 定义
atomic_t变量并初始化。 - 使用相应的 RMW 原子操作符进行操作。
10. 使用读写自旋锁
读写自旋锁允许多个读者同时访问共享资源,但在写操作时会独占资源,以保证数据的一致性。读写自旋锁适用于读多写少的场景。
10.1 读写自旋锁的使用示例
#include <linux/rwlock.h>
rwlock_t my_rwlock;
rwlock_init(&my_rwlock);
// 读者操作
read_lock(&my_rwlock);
// 读取共享资源
read_unlock(&my_rwlock);
// 写者操作
write_lock(&my_rwlock);
// 修改共享资源
write_unlock(&my_rwlock);
10.2 操作步骤
- 包含
<linux/rwlock.h>头文件。 - 定义
rwlock_t变量并初始化。 - 根据操作类型(读或写)使用相应的锁函数进行加锁和解锁操作。
11. 理解 CPU 缓存基础知识、缓存效应和虚假共享
11.1 CPU 缓存基础知识
CPU 缓存是位于 CPU 和主存之间的高速存储器,用于减少 CPU 访问主存的时间。缓存通常分为多级,如 L1、L2 和 L3 缓存。
11.2 缓存效应
- 缓存命中 :当 CPU 需要的数据已经在缓存中时,称为缓存命中,此时可以快速获取数据。
- 缓存缺失 :当 CPU 需要的数据不在缓存中时,称为缓存缺失,需要从主存中读取数据,会导致性能下降。
11.3 虚假共享
虚假共享是指多个线程同时访问不同但相邻的内存地址,这些地址可能位于同一个缓存行中。当一个线程修改了缓存行中的数据时,会导致其他线程的缓存行失效,需要重新从主存中读取数据,从而影响性能。
11.4 避免虚假共享的方法
- 数据对齐 :将数据按照缓存行大小进行对齐,避免多个线程访问同一个缓存行。
- 填充 :在数据结构中添加填充字节,确保不同的数据位于不同的缓存行中。
12. 锁调试与内存屏障
12.1 锁调试
内核提供了 lockdep 工具用于锁调试,可以帮助检测锁的使用错误,如死锁、重复加锁等。在调试内核时,需要启用 lockdep 功能。
12.2 内存屏障
内存屏障用于确保内存操作的顺序和可见性,避免编译器和处理器对内存操作进行重排序。内核提供了多种内存屏障函数,例如:
#include <linux/kernel.h>
smp_mb(); // 全内存屏障,确保读写操作的顺序
smp_rmb(); // 读内存屏障,确保读操作的顺序
smp_wmb(); // 写内存屏障,确保写操作的顺序
12.3 操作步骤
- 锁调试:在编译内核时启用
lockdep选项,运行程序并观察lockdep输出的调试信息。 - 内存屏障:在需要确保内存操作顺序的地方调用相应的内存屏障函数。
13. 总结
本文深入介绍了 Linux 内核中的同步技术,包括中断处理、下半部机制、各种锁机制(自旋锁、互斥锁、读写自旋锁)、原子操作( atomic_t 和 refcount_t )、RMW 原子操作符、CPU 缓存效应、虚假共享、锁调试和内存屏障等内容。通过合理使用这些技术,可以有效地解决内核编程中的并发问题,提高系统的性能和稳定性。
13.1 关键技术总结
| 技术 | 作用 |
|---|---|
| 中断处理与下半部机制 | 提高中断处理效率,避免长耗时操作影响系统响应 |
| 自旋锁 | 适用于短临界区,避免上下文切换开销 |
| 互斥锁 | 适用于长临界区,允许线程睡眠 |
| 读写自旋锁 | 适用于读多写少的场景,允许多个读者同时访问 |
| atomic_t 和 refcount_t | 提供原子操作,避免数据竞争 |
| RMW 原子操作符 | 实现原子的读-改-写操作 |
| CPU 缓存优化 | 避免虚假共享,提高缓存命中率 |
| 锁调试 | 检测锁的使用错误,如死锁 |
| 内存屏障 | 确保内存操作的顺序和可见性 |
13.2 操作建议
- 在设计内核代码时,尽量采用无锁技术,如原子操作和每个 CPU 变量。
- 对于必须使用锁的情况,根据临界区的长度和性质选择合适的锁类型。
- 始终遵循锁的获取顺序,避免死锁。
- 注意 CPU 缓存效应,避免虚假共享。
- 使用调试工具进行锁调试,确保锁的正确使用。
- 在需要确保内存操作顺序的地方使用内存屏障。
通过掌握这些技术和方法,可以更好地进行 Linux 内核编程,提高系统的并发性能和稳定性。
超级会员免费看
5万+

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



