内核同步 - 第二部分
在多线程和多核环境中,内核同步是确保系统稳定运行的关键。本文将深入探讨内核同步的几个重要方面,包括锁依赖报告、锁依赖注解、锁依赖已知问题、内核锁统计以及内存屏障。
锁依赖报告
锁依赖(lockdep)报告是一个强大的工具,它能够检测并报告内核中的锁使用问题。当检测到“可能的循环锁依赖”时,报告的初始部分会提示我们。接着,它会展示运行时实际发生的情况。
例如,线程 0(表示为 thrd_0/0/3578,包含线程名和 PID)尝试获取锁(64 位哈希值 ffffffffc06c80b8,符号名为 lockB),但该线程已经持有了另一个锁。报告还会显示锁在同一模块的同一函数中被持有,这清晰地表明了一个循环(AB - BA)死锁场景。此外,报告还会展示导致死锁的内核调用栈,帮助我们明确是哪些代码路径引发了死锁。
报告还会通过一个典型场景展示死锁的根本原因,例如在 CPU 核心 0 上的内核线程先获取了 lockA,而在核心 1 上的另一个内核线程先获取了 lockB,然后两个线程又分别尝试获取对方持有的锁,从而导致了 AB - BA 循环死锁。这种情况可能会导致软锁定(soft lockup)错误,由内核看门狗检测到。
需要注意的是,锁依赖报告只会在首次违反任何内核锁规则时进行报告。
锁依赖注解
在用户空间,我们熟悉使用
assert()
宏来检查运行时条件。类似地,锁依赖提供了
lockdep_assert_held()
宏,允许内核开发者断言在特定点持有某个锁,这被称为锁依赖注解。
以下是相关宏的定义:
// include/linux/lockdep.h
#define lockdep_assert(cond) \
do { WARN_ON(debug_locks && !(cond)); } while (0)
[ … ]
#define lockdep_assert_held(l) \
lockdep_assert(lockdep_is_held(l) != LOCK_STATE_NOT_HELD)
当断言失败时,会通过
WARN_ON()
发出警告,这意味着本应持有该锁,但实际上并没有。这些断言仅在启用锁调试时生效,内核代码库中广泛使用了锁依赖注解。
对于 RCU 读侧锁,
rcu_read_lock_held()
等 API 可以用于检查是否处于 RCU 读侧临界区。
锁依赖已知问题
在使用锁依赖时,可能会遇到以下几个已知问题:
1.
重复加载和卸载模块
:重复加载和卸载内核模块可能会导致锁依赖的内部锁类限制被超出。因为加载模块会为其所有锁创建新的锁类,而卸载模块时这些锁类不会被移除,而是被重用。因此,应避免重复加载和卸载模块,否则可能需要重置系统。
2.
锁初始化问题
:当数据结构包含大量锁时,如果未能正确初始化每个锁,可能会导致锁依赖的锁类溢出。
3.
误报问题
:锁依赖通过开发者编写的注解来定义“规则”,当检测到规则未被遵循时会生成警告。但这种方法有时会产生误报。不过,产生误报总比没有任何提示而导致系统真正死锁要好。解决方法是“修复锁的使用,而不是修复锁依赖的误报”。
4.
锁调试禁用
:当锁调试被禁用时,
debug_locks
整数会被设置为 0,可能会出现“
WARNING
lock debugging disabled!!”的警告。这可能是由于之前锁依赖发出的警告导致的,此时需要重启系统并重新尝试。
内核锁统计
锁可能会被竞争,即一个上下文想要获取锁,但锁已经被占用,需要等待解锁。严重的锁竞争会导致性能瓶颈,内核提供了详细的锁统计信息,帮助我们识别竞争激烈的锁,这在性能分析时非常有用。
要启用锁统计,需要开启
CONFIG_LOCK_STAT
内核配置选项。如果未选择该选项,
/proc/lock_stat
伪文件将不存在。
锁统计代码利用了锁依赖在锁代码路径中插入的钩子(
__contended
、
__acquired
和
__released
)来收集关键数据。相关的内核文档通过状态图详细介绍了这些信息。
以下是查看和操作内核锁统计的命令:
| 操作 | 命令(以 root 权限运行) |
| ---- | ---- |
| 清除锁统计 |
echo 0 > /proc/lock_stat
|
| 启用锁统计收集 |
echo 1 > /proc/sys/kernel/lock_stat
|
| 禁用锁统计收集 |
echo 0 > /proc/sys/kernel/lock_stat
|
| 查看当前锁统计 |
cat /proc/lock_stat
|
下面是一个简单的 Bash 脚本示例,用于演示如何使用锁统计:
clear_lock_stats() {
echo 0 > /proc/lock_stat
}
enable_lock_stats() {
echo 1 > /proc/sys/kernel/lock_stat
}
disable_lock_stats() {
echo 0 > /proc/sys/kernel/lock_stat
}
view_lock_stats() {
cat /proc/lock_stat
}
脚本首先禁用锁统计,然后运行一个测试用例,即加载之前的
deadlock_eg_AB - BA
模块(但不会导致循环死锁)。在插入模块之前,脚本会清除并启用锁统计,完成后禁用锁统计并将结果写入文件
lockstats.txt
。
解释锁统计结果时,需要注意以下几点:
1. 大多数锁统计列仅适用于锁名后面带有冒号
:
的行,没有冒号的行通常表示锁竞争点。
2. 显示的时间单位是微秒,第一列的类名是锁类,即锁的符号名。
3.
contentions
:需要等待的锁竞争次数。
4.
con - bounces
:涉及跨 CPU 数据的锁竞争次数,这是一种开销,实际的锁获取次数可能会更少。
5. 锁竞争点出现在两条短分隔线
-----
之间,其行内容与列标题不匹配,格式为锁名、
con - bounces
和调用点。
6. 接下来是各种锁的等待时间(
waittime*
)。
7.
acquisitions
:锁被获取的总次数,为正数。
8. 最后四列是累积的锁持有时间统计(
holdtime - {min|max|total|avg}
)。
需要注意的是,锁统计可能会因锁调试被禁用而失效,此时可能会看到警告信息,需要重启系统。
内存屏障
有时,微处理器、内存控制器和编译器可能会对内存读写进行重新排序,这在大多数情况下可以优化性能,但在某些情况下,需要确保内存加载和存储序列按照程序员的意图执行,这时就需要使用内存屏障。
内存屏障通常通过
*mb*()
宏实现,用于抑制这种重新排序,强制 CPU、内存控制器和编译器按照期望的顺序处理指令和数据。
可以使用以下宏在代码路径中插入内存屏障,需要包含
<asm/barrier.h>
头文件:
-
rmb()
:在指令流中插入读(或加载)内存屏障。
-
wmb()
:在指令流中插入写(或存储)内存屏障。
-
mb()
:通用内存屏障,确保在屏障之前指定的所有加载和存储操作在系统的其他组件看来,会在屏障之后的所有加载和存储操作之前发生。
在某些情况下,如使用 DMA 时,驱动作者可能会使用内存屏障。例如,在 Raspberry Pi 上,参考 Broadcom BCM2835 系列的外设手册可以帮助确定何时使用内存屏障。
设备驱动中使用内存屏障的示例
以 Realtek 8139 “快速以太网”网络驱动为例,在通过 DMA 传输网络数据包时,需要设置 DMA 传输描述符对象。
以下是 DMA 描述符对象的定义:
// drivers/net/ethernet/realtek/8139cp.c
struct cp_desc {
__le32 opts1;
__le32 opts2;
__le64 addr;
};
为了确保 DMA 控制器正确解释描述符,需要使用内存屏障来保证写入操作的顺序。例如,设置 DMA 描述符时:
desc->word0 = address;
wmb();
desc->word1 = DESC_VALID;
在实际的驱动代码中,也使用了
wmb()
写内存屏障来保证写入顺序:
// drivers/net/ethernet/realtek/8139cp.c
[ ... ]
static netdev_tx_t cp_start_xmit([...])
{
[ ... ]
len = skb->len;
mapping = dma_map_single(&cp->pdev->dev, skb->data, len, PCI_DMA_TODEVICE);
[ ... ]
struct cp_desc *txd;
[ ... ]
txd->opts2 = opts2;
txd->addr = cpu_to_le64(mapping);
wmb();
opts1 |= eor | len | FirstFrag | LastFrag;
txd->opts1 = cpu_to_le32(opts1);
wmb();
[...]
综上所述,内核同步涉及多个方面,包括锁依赖、锁统计和内存屏障等,正确使用这些技术可以提高系统的稳定性和性能。
内核同步 - 第二部分
内存屏障的应用场景分析
内存屏障在多种场景下发挥着重要作用,下面通过流程图进一步展示其常见应用场景。
graph LR
A[内存屏障应用场景] --> B[跨硬件边界]
A --> C[原子操作]
A --> D[外设访问]
A --> E[硬件中断]
B --> B1[多核系统CPU核心间]
D --> D1[CPU与外设DMA通信]
- 跨硬件边界 :在多核系统中,不同的 CPU 核心可能会并行执行任务,对共享内存的访问可能会出现乱序。例如,一个核心正在写入数据,而另一个核心可能会在数据未完全写入时就尝试读取。内存屏障可以确保每个核心看到的内存操作顺序是一致的,避免数据不一致的问题。
- 原子操作 :原子操作通常需要保证操作的完整性,即操作要么全部完成,要么完全不执行。内存屏障可以防止编译器和处理器对原子操作进行重排序,确保原子操作的正确性。
- 外设访问 :当 CPU 与外设进行通信时,特别是通过 DMA 进行数据传输时,内存屏障非常重要。如前面提到的 Realtek 8139 网络驱动,正确使用内存屏障可以确保 DMA 控制器正确解释描述符,保证数据的正确传输。
- 硬件中断 :在处理硬件中断时,内存屏障可以确保中断处理程序看到的内存状态是一致的。例如,在中断处理程序中访问共享数据时,使用内存屏障可以防止数据的不一致性。
锁统计与性能优化
通过内核锁统计,我们可以深入了解系统中锁的使用情况,从而进行性能优化。以下是一个简单的性能优化流程:
graph LR
A[开始] --> B[启用锁统计]
B --> C[运行测试用例]
C --> D[查看锁统计结果]
D --> E{是否存在高竞争锁}
E -- 是 --> F[分析高竞争锁代码]
E -- 否 --> G[结束]
F --> H[优化锁使用]
H --> C
-
启用锁统计
:开启
CONFIG_LOCK_STAT内核配置选项,并使用echo 1 > /proc/sys/kernel/lock_stat命令启用锁统计收集。 -
运行测试用例
:选择具有代表性的测试用例,让系统在正常运行过程中产生锁竞争。例如,可以运行之前提到的
deadlock_eg_AB - BA模块。 -
查看锁统计结果
:使用
cat /proc/lock_stat命令查看锁统计信息,并分析哪些锁的竞争比较激烈。 - 分析高竞争锁代码 :对于竞争激烈的锁,深入分析其所在的代码,找出可能导致竞争的原因。例如,是否存在不必要的锁持有时间过长,或者是否可以使用更细粒度的锁。
- 优化锁使用 :根据分析结果,对锁的使用进行优化。例如,可以减少锁的持有时间,或者将大锁拆分为多个小锁。
- 重复测试 :优化后再次运行测试用例,查看锁统计结果,验证优化效果。
锁依赖与代码审查
锁依赖不仅可以帮助我们发现死锁和其他锁相关的问题,还可以作为代码审查的重要工具。在代码审查过程中,可以关注以下几个方面:
1.
锁依赖注解的使用
:检查代码中是否正确使用了锁依赖注解,如
lockdep_assert_held()
等。确保注解能够准确反映锁的使用规则。
2.
锁的初始化
:检查所有锁是否都被正确初始化,特别是在数据结构包含大量锁的情况下。避免因锁初始化问题导致锁依赖的锁类溢出。
3.
锁的顺序
:确保锁的获取顺序是一致的,避免出现循环死锁的情况。例如,在多个线程中,都应该按照相同的顺序获取锁。
4.
RCU 读侧锁
:如果代码中使用了 RCU 读侧锁,检查是否正确使用了
rcu_read_lock_held()
等 API 来检查是否处于 RCU 读侧临界区。
总结
内核同步是操作系统开发中的一个重要领域,涉及到锁依赖、锁统计和内存屏障等多个方面。通过合理使用这些技术,可以提高系统的稳定性和性能。
锁依赖可以帮助我们检测死锁和其他锁相关的问题,但在使用过程中需要注意可能出现的已知问题。锁统计可以让我们了解系统中锁的竞争情况,从而进行性能优化。内存屏障则可以确保内存操作的顺序,避免因内存重排序导致的问题。
在实际开发中,我们应该充分利用这些技术,进行代码审查和性能优化,确保系统的可靠性和高效性。同时,对于不同的硬件平台和应用场景,需要根据具体情况选择合适的同步机制和优化策略。
总之,内核同步是一个复杂而重要的领域,需要我们不断学习和实践,以应对各种挑战。
超级会员免费看

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



