65、Linux内核中的互斥锁:原理、应用与变体

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 内核中的互斥锁,在实际项目中能够正确地应用这些知识,编写出高效、稳定的内核代码。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值