Linux内核并发控制:锁的使用与选择
在多线程、多进程的并发环境中,数据竞争是一个常见且棘手的问题。当多个进程或线程同时访问和修改共享数据时,就可能导致数据不一致和各种错误。下面我们将深入探讨硬件中断与数据竞争、锁的使用准则、死锁问题以及如何选择合适的锁。
硬件中断与数据竞争
想象这样一个场景:进程P1正在执行驱动的读取方法代码,进入了临界区(在时间t2和t3之间)。然而,此时一个硬件中断触发了(在同一个CPU上)。在Linux操作系统中,硬件(外设)中断具有最高优先级,默认情况下会抢占任何代码(包括内核代码)。这意味着进程P1至少会被暂时搁置,失去处理器,中断处理代码将抢占并运行。
关键问题在于:中断处理代码(无论是硬中断上半部、所谓的小任务还是软中断下半部)是否正在共享和操作与被中断的进程上下文相同的可写共享数据?如果是,那么就会出现数据竞争问题;如果不是,那么被中断的代码相对于中断代码路径就不是临界区。
大多数设备驱动都会处理中断,因此驱动开发者有责任确保进程上下文和中断代码路径之间不共享全局或静态数据(即不共享临界区)。如果确实存在共享情况,就必须采取措施保护这些数据,防止数据竞争和可能的损坏。
锁的使用准则与死锁
锁是一种复杂的同步机制,如果使用不当,可能会导致性能问题和各种错误,如死锁、循环依赖和中断不安全的锁定等。以下是一些关键的锁使用准则:
1.
锁定粒度
:锁和解锁之间的“距离”(即临界区的长度)不应过粗(临界区过长),而应足够“精细”。在大型项目中,锁的数量过多或过少都有问题。锁太少会导致性能问题,因为相同的锁会被反复使用,容易产生高度竞争;锁太多虽然对性能有好处,但不利于复杂度控制。此外,在代码库中有多个锁时,必须清楚每个锁保护的是哪个共享数据对象。
2.
锁顺序
:锁必须始终以相同的顺序获取,并且这个顺序应该被记录下来,项目中的所有开发者都要遵循。错误的锁顺序通常会导致死锁。
3.
避免递归锁定
:尽可能避免递归锁定。
4.
防止饥饿
:确保一旦获取了锁,就能够“足够快”地释放它。
5.
保持简单
:尽量避免复杂性和过度设计,特别是在涉及锁的复杂场景中。
常见死锁场景
死锁是指系统无法继续前进,应用程序或内核组件似乎会无限期挂起。以下是一些常见的死锁场景:
-
简单情况 - 单锁,进程上下文
:尝试两次获取同一个锁,会导致自我死锁。
-
简单情况 - 多锁(两个或更多),进程上下文
:例如,在CPU 0上,线程A获取锁A,然后想要获取锁B;同时,在CPU 1上,线程B获取锁B,然后想要获取锁A。这会导致所谓的AB - BA死锁,还可能扩展为AB - BC - CA的循环依赖(A - B - C锁链),同样会导致死锁。
-
复杂情况 - 单锁,进程和中断上下文
:在中断上下文中获取锁A。如果在另一个核心上发生中断,并且处理程序试图获取锁A,就会导致死锁。因此,在中断上下文中获取的锁必须在禁用中断的情况下使用。
-
更复杂情况 - 多锁,进程和中断(硬中断和软中断)上下文
:在简单情况下,遵循锁顺序准则通常就足够了,但在复杂情况下,即使是经验丰富的开发者也可能会陷入困境。幸运的是,Linux内核的运行时锁依赖验证器lockdep可以检测到每一种死锁情况。
除了死锁,活锁也是一种危险的情况。活锁类似于死锁,但参与任务的状态是运行而不是等待。例如,中断“风暴”可能会导致活锁,现代网络驱动通过在中断负载下关闭中断并采用一种称为New API; Switching Interrupts (NAPI)的轮询技术来缓解这种影响。
互斥锁和自旋锁的选择
Linux内核中有两种主要类型的锁:互斥锁和自旋锁。它们的关键区别在于“失败者”(未能获取锁的线程)如何等待解锁。
-
互斥锁
:失败者线程会被休眠,即通过睡眠来等待。当获胜者(持有锁的线程)解锁时,内核会唤醒所有失败者,它们再次竞争锁。因此,互斥锁和信号量有时也被称为睡眠锁。
-
自旋锁
:失败者不会睡眠,而是通过自旋等待锁被解锁。概念上,看起来像这样:
while (locked) ;
但这只是概念上的表示,实际上现代自旋锁的实现代码经过了高度优化,并且是特定于架构的。自旋锁只在多核(SMP)系统上有意义,在这种系统中,当获胜者线程在运行临界区代码时,失败者会在其他CPU核心上自旋等待。
理论上的锁选择
自旋锁的开销比互斥锁小。对于互斥锁,失败者线程需要进入睡眠状态,这会调用schedule()函数,导致处理器进行上下文切换。因此,互斥锁加锁和解锁操作的最小“成本”是在给定机器上进行两次上下文切换的时间。
假设t_locked表示在临界区花费的时间(t3 - t2),t_ctxsw表示上下文切换的时间。如果t_locked < 2 * t_ctxsw,即临界区花费的时间小于两次上下文切换的时间,使用互斥锁会带来过多的开销,此时使用自旋锁更合适。
实践中的锁选择
在实践中,精确测量每个临界区的上下文切换时间和花费的时间是不现实的。实际上,可以根据以下规则选择锁:
-
临界区在原子(中断)上下文或不能睡眠的进程上下文中运行
:使用自旋锁。
-
临界区在进程上下文中运行,并且在临界区中需要睡眠
:使用互斥锁。
由于自旋锁的开销较低,只要临界区不阻塞(睡眠),也可以在进程上下文中使用自旋锁。
互斥锁的使用
互斥锁也称为可睡眠或阻塞的互斥锁,用于在临界区可以睡眠(阻塞)的进程上下文中。它们不能在任何原子或中断上下文(如顶半部、小任务或软中断底半部等)、内核定时器或不允许阻塞的进程上下文中使用。
初始化互斥锁
互斥锁“对象”在内核中表示为struct mutex数据结构。可以使用DEFINE_MUTEX()宏静态初始化互斥锁,也可以使用mutex_init()函数动态初始化。例如:
#include <linux/mutex.h>
struct mutex mymtx;
// 静态初始化
DEFINE_MUTEX(mymtx);
// 动态初始化
struct mydrv_priv {
// 其他成员
struct mutex mymtx; /* 保护对mydrv_priv的访问 */
// 其他成员
};
static int init_mydrv(struct mydrv_priv *drvctx)
{
// 其他初始化代码
mutex_init(drvctx->mymtx);
// 其他初始化代码
}
将锁变量作为被保护的(父)数据结构的成员是Linux中常见且巧妙的模式,这种方法可以避免命名空间污染,并且明确指出哪个互斥锁保护哪个共享数据项。
综上所述,在Linux内核编程中,正确使用锁是确保代码正确性和性能的关键。通过遵循锁的使用准则,了解不同类型锁的特点和适用场景,开发者可以有效地避免数据竞争和死锁问题,提高代码的健壮性和可维护性。以下是一个简单的流程图,展示了锁选择的基本逻辑:
graph TD;
A[临界区运行上下文] --> B{是否为原子或不能睡眠的上下文};
B -- 是 --> C[使用自旋锁];
B -- 否 --> D{临界区是否需要睡眠};
D -- 是 --> E[使用互斥锁];
D -- 否 --> F[可使用自旋锁];
通过这个流程图,可以更直观地理解在不同情况下如何选择合适的锁。在实际编程中,还需要根据具体的应用场景和性能需求进行综合考虑。
Linux内核并发控制:锁的使用与选择(续)
自旋锁的使用
自旋锁适用于临界区在原子(中断)上下文或不能睡眠的进程上下文中运行的情况。与互斥锁不同,自旋锁不会让线程进入睡眠状态,而是让“失败者”线程在其他CPU核心上自旋等待锁被释放。
初始化自旋锁
在Linux内核中,自旋锁使用
spinlock_t
类型来表示。可以使用
DEFINE_SPINLOCK
宏静态初始化自旋锁,也可以使用
spin_lock_init
函数动态初始化。以下是示例代码:
#include <linux/spinlock.h>
// 静态初始化
spinlock_t my_spinlock = __SPIN_LOCK_UNLOCKED(my_spinlock);
// 动态初始化
spinlock_t another_spinlock;
spin_lock_init(&another_spinlock);
获取和释放自旋锁
获取自旋锁使用
spin_lock
函数,释放自旋锁使用
spin_unlock
函数。在中断上下文中使用自旋锁时,需要额外注意禁用中断,以避免死锁。以下是在中断处理程序中使用自旋锁的示例:
#include <linux/spinlock.h>
#include <linux/interrupt.h>
spinlock_t my_spinlock;
irqreturn_t my_irq_handler(int irq, void *dev_id) {
unsigned long flags;
spin_lock_irqsave(&my_spinlock, flags);
// 临界区代码
spin_unlock_irqrestore(&my_spinlock, flags);
return IRQ_HANDLED;
}
static int __init my_module_init(void) {
spin_lock_init(&my_spinlock);
// 注册中断处理程序
return 0;
}
static void __exit my_module_exit(void) {
// 注销中断处理程序
}
module_init(my_module_init);
module_exit(my_module_exit);
在上述代码中,
spin_lock_irqsave
函数用于获取自旋锁并保存当前中断状态,同时禁用中断;
spin_unlock_irqrestore
函数用于释放自旋锁并恢复之前保存的中断状态。
锁的性能考量
在选择锁时,除了考虑临界区的上下文和是否需要睡眠外,还需要考虑锁的性能。以下是一些性能相关的要点:
-
上下文切换开销
:互斥锁的加锁和解锁操作涉及上下文切换,会带来一定的开销。如果临界区执行时间较短,使用互斥锁可能会导致性能下降。
-
自旋开销
:自旋锁在等待锁释放时会让线程自旋,这会消耗CPU资源。如果临界区执行时间较长,自旋锁会浪费大量的CPU时间。
-
多核系统的优势
:自旋锁在多核(SMP)系统上有优势,因为在其他CPU核心上自旋等待不会影响当前核心的执行。而在单核系统上,自旋锁的自旋操作会导致CPU空转,降低系统性能。
锁的使用示例总结
为了更清晰地展示在不同场景下如何选择和使用锁,下面通过一个表格进行总结:
| 场景 | 锁的选择 | 初始化方法 | 获取和释放方法 | 注意事项 |
| ---- | ---- | ---- | ---- | ---- |
| 临界区在进程上下文,可睡眠 | 互斥锁 |
DEFINE_MUTEX
或
mutex_init
|
mutex_lock
和
mutex_unlock
| 不能在原子或中断上下文使用 |
| 临界区在原子或不能睡眠的上下文 | 自旋锁 |
DEFINE_SPINLOCK
或
spin_lock_init
|
spin_lock
和
spin_unlock
(中断上下文使用
spin_lock_irqsave
和
spin_unlock_irqrestore
) | 避免在单核系统上长时间自旋 |
实际应用中的锁选择流程
在实际应用中,可以按照以下流程选择合适的锁:
1. 确定临界区的运行上下文,判断是否为原子或不能睡眠的上下文。
2. 如果是原子或不能睡眠的上下文,选择自旋锁;如果是进程上下文,继续下一步。
3. 判断在临界区中是否需要睡眠。如果需要睡眠,选择互斥锁;如果不需要睡眠,可以选择自旋锁以减少开销。
以下是对应的mermaid流程图:
graph LR;
A[开始] --> B[确定临界区运行上下文];
B --> C{是否为原子或不能睡眠的上下文};
C -- 是 --> D[选择自旋锁];
C -- 否 --> E{临界区是否需要睡眠};
E -- 是 --> F[选择互斥锁];
E -- 否 --> G[可选择自旋锁];
D --> H[结束];
F --> H;
G --> H;
总结
在Linux内核编程中,正确处理并发问题是至关重要的。通过合理选择和使用互斥锁和自旋锁,可以有效地避免数据竞争和死锁问题,提高代码的性能和健壮性。在实际应用中,需要根据临界区的运行上下文、是否需要睡眠以及性能需求等因素综合考虑,选择最合适的锁。同时,遵循锁的使用准则,如锁顺序、避免递归锁定等,也是确保代码正确性的关键。希望通过本文的介绍,能帮助开发者更好地理解和应用Linux内核中的锁机制。
超级会员免费看
5万+

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



