Linux内核中的互斥锁:原理、应用与变体
1. 互斥锁在驱动中的应用示例
在Linux内核编程中,我们之前创建了一个简单的杂项字符设备驱动
miscdrv_rdwr
,同时编写了一个用户空间实用程序
rdwr_test_secret.c
用于读写设备驱动内存中的“秘密”数据。然而,该项目存在一个严重问题,即未对共享(全局)可写数据进行并发访问保护,忽略了关键部分,这在实际应用中可能导致数据损坏。
为了修正这个问题,我们复制了该驱动代码,命名为
ch12/1_miscdrv_rdwr_mutexlock/1_miscdrv_rdwr_mutexlock.c
,并对部分代码进行重写,核心是使用互斥锁保护所有关键部分。以下是新旧版本代码差异的操作步骤:
$ cd <lkp2e-book-repo>/ch12/1_miscdrv_rdwr_mutexlock
$ diff -u <lkp-p2...>/ch12/miscdrv_rdwr/miscdrv_rdwr.c \
miscdrv_rdwr_mutexlock.c > miscdrv_rdwr.patch
$ cat miscdrv_rdwr.patch
在新版本驱动中,我们做了如下改进:
- 引入互斥锁头文件:
+#include <linux/mutex.h> // mutex lock, unlock, etc
#include "../../convenient.h"
-
定义并初始化互斥锁变量
lock1来保护全局整数ga和gb:
+DEFINE_MUTEX(lock1); /* this mutex lock is meant to protect the integers ga
and gb */
-
在全局“驱动上下文”数据结构
drv_ctx中声明互斥锁lock来保护该数据结构的成员,并在模块初始化代码中初始化:
+ struct mutex lock; // this mutex protects this data structure
};
+ mutex_init(&ctx->lock);
+ /* Retrieve the device pointer for this device */
+ ctx->dev = llkd_miscdev.this_device;
+
+ /* Initialize the "secret" value :-) */
- strlcpy(ctx->oursecret, "initmsg", 8);
- dev_dbg(ctx->dev, "A sample print via the dev_dbg(): driver
initialized\n");
+ strscpy(ctx->oursecret, "initmsg", 8);
需要注意的是,在模块初始化代码中,
strscpy()
操作虽然处理的是共享可写数据,但由于初始化代码只在单一上下文(通常是
insmod(8)
进程)中运行,不存在并发问题,因此无需使用互斥锁保护。同样,局部变量由于位于进程或线程的内核模式栈中,每个进程上下文都有其独立实例,也不需要保护。
在清理代码路径(由
rmmod(8)
进程上下文调用)中,必须销毁互斥锁:
-static void __exit miscdrv_rdwr_exit(void)
+static void __exit miscdrv_exit_mutexlock(void)
{
+ mutex_destroy(&lock1);
+ mutex_destroy(&ctx->lock);
misc_deregister(&llkd_miscdev);
- pr_info("LLKD misc (rdwr) driver deregistered, bye\n");
+ pr_info("LKP2E misc driver %s deregistered, bye\n", llkd_miscdev.name);
}
2. 驱动方法中的互斥锁使用及潜在问题
在驱动的
open
方法中,旧版本对全局整数
ga
和
gb
的操作没有保护,而新版本在互斥锁的保护下进行操作:
+ mutex_lock(&lock1);
+ ga++; gb--;
+ mutex_unlock(&lock1);
+
+ dev_info(dev, " filename: \"%s\"\n"
[ ... ]
然而,在
mutex_unlock()
之后的
dev_info()
函数中,我们读取了全局整数
ga
和
gb
的值。在并发情况下,即使是读取共享可写数据,如果不进行排他访问(通常通过加锁),也可能存在安全风险,可能导致脏读或撕裂读问题。虽然在大多数现代处理器上,读写单个整数项往往是原子操作,但我们仍应保护共享可写数据的读写操作。
此外,在代码片段中,我们从打开文件结构(
filp
指针)读取数据时没有进行保护,这也是一个潜在的问题。目前,我们将这些标记为潜在缺陷,后续会以更高效的方式处理。
在驱动的
read
方法中,我们使用驱动上下文结构的互斥锁保护关键部分。以下是部分代码示例(图12.8展示了
read()
方法的差异):
graph TD;
A[开始] --> B[获取互斥锁];
B --> C[执行copy_to_user()];
C --> D[执行dev_info()];
D --> E[释放互斥锁];
E --> F[结束];
在上述代码差异中,我们在
copy_to_user()
例程之前获取互斥锁,因为
copy_to_user()
是一个阻塞操作(可能会休眠),使用互斥锁保护可能阻塞的关键部分是其主要用例之一。但我们在
dev_info()
调用之后才释放互斥锁,这是因为
dev_info()
打印了三个变量的值,其中
secret_len
是局部变量不需要保护,而
ctx->tx
和
ctx->rx
位于全局驱动上下文结构中,即使只是读取也需要保护,以防止脏读或撕裂读。
3. 互斥锁API变体
除了之前介绍的可中断变体,互斥锁API还有
trylock
、
killable
和
io
变体。
3.1 互斥锁
trylock
变体
trylock
变体实现了忙等待语义,即测试互斥锁的可用性,如果可用则获取锁并继续执行关键部分代码;如果不可用则不等待,执行其他工作并重试。这是一种非阻塞的互斥锁变体,其工作流程如下:
graph TD;
A[开始] --> B[测试锁可用性];
B -- 可用 --> C[获取锁并执行关键部分];
B -- 不可用 --> D[执行其他工作];
D --> B;
C --> E[释放锁];
E --> F[结束];
其API如下:
int __sched mutex_trylock(struct mutex *lock);
返回值含义:
- 返回值为1表示成功获取锁。
- 返回值为0表示锁当前被占用。
需要注意的是,不要使用
mutex_trylock()
API 来判断互斥锁的状态,这是不安全的。同时,
trylock
变体只能在进程上下文中工作,锁必须由持有上下文通过
mutex_unlock()
释放。
3.2 互斥锁可中断和可杀死变体
mutex_lock_interruptible()
API 用于驱动或模块愿意响应任何(用户空间)信号中断的情况,若被中断,它返回
-ERESTARTSYS
告知内核VFS层进行信号处理,用户空间系统调用将失败,
errno
设置为
EINTR
。例如,在
delete_module(2)
系统调用(
rmmod
调用)的模块处理代码中:
// kernel/module.c
[ ... ]
SYSCALL_DEFINE2(delete_module, const char __user *, name_user,
unsigned int, flags)
{
struct module *mod;
[ ... ]
if (!capable(CAP_SYS_MODULE) || modules_disabled)
return -EPERM;
[ ... ]
if (mutex_lock_interruptible(&module_mutex) != 0)
return -EINTR;
mod = find_module(name);
[ ... ]
out:
mutex_unlock(&module_mutex);
return ret;
}
如果驱动只愿意被致命信号中断,则使用
mutex_lock_killable()
API,其签名与可中断变体相同。
3.3 互斥锁I/O变体
mutex_lock_io()
API 与
mutex_lock()
API 语法相同,唯一区别是内核认为等待线程的等待时间与等待I/O相同。
4. 信号量与互斥锁的区别
Linux内核提供了信号量对象,以及对(二进制)信号量的常规操作:
- 通过
down[_interruptible]()
(及其变体)API 获取信号量锁。
- 通过
up()
API 释放信号量锁。
信号量和互斥锁概念上相似,但实际上有很大不同,主要区别如下表所示:
| 比较项 | 互斥锁 | 信号量 |
| ---- | ---- | ---- |
| 获取和释放次数 | 只能获取和释放一次 | 可以多次获取和释放 |
| 用途 | 保护关键部分免受同时访问 | 作为一种机制,向等待任务发出某个里程碑已达成的信号 |
| 所有权 | 有锁的所有权概念,只有所有者上下文可以解锁 | 二进制信号量没有所有权概念 |
一般来说,信号量是一种较旧的实现,建议使用互斥锁代替。
5. 优先级反转和RT - 互斥锁
使用任何类型的锁时,都要小心设计和编码,以防止死锁情况发生。除了死锁,使用互斥锁还可能出现优先级反转问题,无界优先级反转情况可能非常危险,会导致产品的最高优先级线程长时间无法使用CPU。
用户空间的 Pthreads 互斥锁实现有优先级继承(PI)语义,在 Linux 内核中,Ingo Molnar 提供了基于 PI - futex 的 RT - 互斥锁(实时互斥锁),当
CONFIG_RT_MUTEXES
配置选项启用时可用。RT - 互斥锁与“常规”互斥锁语义相似,提供了初始化、加锁、解锁和销毁的API。其主要用于内部实现 PI futex,内核锁定自测试代码和 I2C 子系统也直接使用 RT - 互斥锁。
6. 互斥锁的内部设计
Linux 内核在可能的情况下尝试实现快速路径方法。当锁没有竞争(即初始处于解锁状态)时,会采用快速路径,几乎立即锁定锁。如果互斥锁已被锁定,内核通常使用“中间路径”的乐观自旋实现,使其更像混合(互斥锁/自旋锁)锁类型。如果以上都不可行,则采用“慢速路径”,尝试获取锁的进程上下文可能进入睡眠状态。更多关于互斥锁内部实现的详细信息可在官方内核文档中查看:https://docs.kernel.org/locking/mutex-design.html。
另一种互斥锁是
WW - 互斥锁
,主要用于图形子系统和部分 DMA 操作。在检测到死锁时,持有保留时间最长的“最老”任务将获得优先级,通过让“年轻”任务释放其持有的
WW
锁来解决死锁。与常规互斥锁相比,
WW - 互斥锁
在内核中的使用较少。
此外,LDV(Linux Driver Verification)项目有关于 Linux 模块(主要是驱动)和内核核心编程方面的有用“规则”,例如“锁定互斥锁两次或在未先锁定的情况下解锁”规则,我们可以从中看到内核驱动中互斥锁双重获取尝试导致(自我)死锁的实际示例及后续修复。
现在我们已经了解了互斥锁的使用,接下来可以继续学习内核中另一种常见的锁——自旋锁。
Linux内核中的互斥锁:原理、应用与变体
7. 互斥锁使用注意事项
在使用互斥锁时,有许多细节需要注意,以确保代码的正确性和性能。以下是一些关键的注意事项:
- 避免死锁 :死锁是使用锁时最常见的问题之一。为了避免死锁,需要确保锁的获取和释放顺序一致。例如,如果线程A先获取锁1再获取锁2,那么线程B也应该按照相同的顺序获取锁。
-
减少锁的持有时间
:尽量缩短锁的持有时间,以减少其他线程的等待时间,提高并发性能。例如,在前面的
read方法中,我们可以考虑在dev_info()之前释放互斥锁,只要确保需要保护的数据在释放锁之前已经使用完毕。 -
正确处理异常情况
:在获取锁之后,如果发生异常,需要确保锁被正确释放。可以使用
try - catch块或类似的机制来处理异常情况。
8. 互斥锁与其他同步机制的比较
除了互斥锁和信号量,内核中还有其他同步机制,如自旋锁、读写锁等。以下是互斥锁与其他常见同步机制的比较:
| 同步机制 | 特点 | 适用场景 |
| ---- | ---- | ---- |
| 互斥锁 | 可睡眠,适用于可能阻塞的关键部分 | 保护可能需要长时间执行的代码段,如文件操作、内存分配等 |
| 自旋锁 | 忙等待,不会睡眠 | 保护短小的关键部分,如计数器更新、标志位设置等 |
| 读写锁 | 允许多个读者同时访问,但写者需要独占访问 | 适用于读多写少的场景,如缓存管理、配置文件读取等 |
9. 互斥锁在实际项目中的应用案例
为了更好地理解互斥锁的应用,我们来看一个实际项目中的案例。假设我们有一个多线程的文件系统驱动,多个线程可能同时访问文件系统的元数据。为了保护元数据的一致性,我们可以使用互斥锁。
#include <linux/mutex.h>
// 定义互斥锁
DEFINE_MUTEX(fs_metadata_lock);
// 读取文件系统元数据的函数
void read_fs_metadata() {
mutex_lock(&fs_metadata_lock);
// 读取元数据的代码
// ...
mutex_unlock(&fs_metadata_lock);
}
// 写入文件系统元数据的函数
void write_fs_metadata() {
mutex_lock(&fs_metadata_lock);
// 写入元数据的代码
// ...
mutex_unlock(&fs_metadata_lock);
}
在这个案例中,我们使用互斥锁保护了文件系统元数据的读写操作,确保了数据的一致性。
10. 互斥锁的性能优化
在高并发场景下,互斥锁的性能可能成为瓶颈。为了优化互斥锁的性能,可以考虑以下几点:
- 减少锁的粒度 :将大的关键部分拆分成多个小的关键部分,每个部分使用独立的锁,从而减少锁的竞争。
- 使用无锁算法 :在某些情况下,可以使用无锁算法来替代互斥锁,提高并发性能。例如,使用原子操作来更新计数器。
- 使用读写锁 :如果关键部分的读操作远远多于写操作,可以考虑使用读写锁来提高并发性能。
11. 总结
互斥锁是 Linux 内核中一种重要的同步机制,用于保护关键部分免受并发访问的影响。通过本文的介绍,我们了解了互斥锁的基本概念、API 变体、与信号量的区别、优先级反转问题以及内部设计等方面的知识。
在使用互斥锁时,需要注意避免死锁、减少锁的持有时间、正确处理异常情况等问题。同时,要根据具体的应用场景选择合适的同步机制,如自旋锁、读写锁等。通过合理使用互斥锁和其他同步机制,可以提高内核代码的并发性能和稳定性。
希望本文能够帮助你更好地理解和使用 Linux 内核中的互斥锁,在实际项目中能够正确地应用这些知识,编写出高效、稳定的内核代码。
超级会员免费看

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



