Linux-内核编程第二部分-四-

197 阅读50分钟

Linux 内核编程第二部分(四)

原文:zh.annas-archive.org/md5/066F8708F0154057BE24B556F153766F

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:内核同步-第二部分

本章继续讨论上一章的话题,即内核同步和一般内核中处理并发的问题。我建议如果你还没有阅读上一章,那么先阅读上一章,然后再继续阅读这一章。

在这里,我们将继续学习有关内核同步和处理内核空间并发的广泛主题。与以往一样,这些材料针对内核和/或设备驱动程序开发人员。在本章中,我们将涵盖以下内容:

  • 使用 atomic_t 和 refcount_t 接口

  • 使用 RMW 原子操作符

  • 使用读写自旋锁

  • 缓存效应和伪共享

  • 使用每 CPU 变量的无锁编程

  • 内核内的锁调试

  • 内存屏障-简介

使用 atomic_t 和 refcount_t 接口

在我们简单的演示杂项字符设备驱动程序的(miscdrv_rdwr/miscdrv_rdwr.c)open方法(以及其他地方),我们定义并操作了两个静态全局整数gagb

static int ga, gb = 1;
[...]
ga++; gb--;

到目前为止,你应该明显意识到,我们操作这些整数的地方是一个潜在的错误,如果保持原样:它是共享的可写数据(在共享状态下),因此是一个关键部分,因此需要保护 并发访问。你明白了;因此,我们逐步改进了这一点。在上一章中,我们的ch12/1_miscdrv_rdwr_mutexlock/1_miscdrv_rdwr_mutexlock.c程序中,首先使用互斥锁来保护关键部分。后来,您了解到,使用自旋锁来保护非阻塞关键部分,例如这个,从性能上来说会(远远)优于使用互斥锁;因此,在我们的下一个驱动程序ch12/2_miscdrv_rdwr_spinlock/2_miscdrv_rdwr_spinlock.c中,我们使用了自旋锁:

spin_lock(&lock1);
ga++; gb--;
spin_unlock(&lock1);

这很好,但我们仍然可以做得更好!原来在内核中操作全局整数是如此普遍(考虑引用或资源计数器的增加和减少等),以至于内核提供了一类操作符称为refcount原子整数操作符或接口;这些操作符专门设计用于原子地(安全和不可分割地)操作只有整数

新的 refcount_t 与旧的 atomic_t 接口

在这个话题领域的开始,重要的是要提到这一点:从 4.11 内核开始,有一个新的更好的接口集被命名为refcount_t接口,用于内核空间对象的引用计数。它极大地改善了内核的安全性(通过大大改进的整数溢出IoF)和使用后释放UAF)保护以及内存排序保证,而旧的atomic_t接口缺乏)。refcount_t接口,就像 Linux 上使用的其他几种安全技术一样,起源于 The PaX Team 的工作- pax.grsecurity.net/(它被称为PAX_REFCOUNT)。

话虽如此,现实情况是(截至撰写本文时),旧的atomic_t接口在内核核心和驱动程序中仍然被广泛使用(它们正在逐渐转换,旧的atomic_t接口正在移动到新的refcount_t模型和 API 集)。因此,在这个话题中,我们同时涵盖了两者,指出差异,并提到哪些refcount_t API 在适用的地方取代了atomic_t API。将refcount_t接口视为(旧的)atomic_t接口的一种变体,专门用于引用计数。

atomic_t操作符和refcount_t操作符之间的一个关键区别是前者适用于有符号整数,而后者基本上设计为仅适用于unsigned int数量;更具体地说,这很重要,它仅在严格指定的范围内工作:1到**UINT_MAX-1**(或[1..INT_MAX]!CONFIG_REFCOUNT_FULL)。内核有一个名为CONFIG_REFCOUNT_FULL的配置选项;如果设置,它将执行(更慢和更彻底的)"完整"引用计数验证。这对安全性有益,但可能会导致性能略有下降(典型的默认情况是保持此配置关闭;这是我们的 x86_64 Ubuntu 客户机的情况)。

试图将refcount_t变量设置为0或负数,或设置为[U]INT_MAX或更高,是不可能的;这有助于防止整数下溢/上溢问题,从而在许多情况下防止使用后释放类错误!(好吧,这不是不可能的;它会通过WARN()宏触发(吵闹的)警告。)想一想,refcount_t变量只能用于内核对象引用计数,什么都不能用。

因此,这确实是所需的行为;引用计数器必须从一个正值开始(通常是1,当对象新实例化时),每当代码获取或获取引用时,它会增加(或添加到),并且每当代码放置或离开对象上的引用时,它会减少(或减去)。您应该仔细操作引用计数器(匹配您的获取和放置),始终保持其值在合法范围内。

相当令人费解的是,至少对于通用的与体系结构无关的 refcount 实现来说,refcount_t API 是在atomic_t API 集上内部实现的。例如,refcount_set() API - 用于将引用计数的值原子设置为传递的参数 - 在内核中是这样实现的:

// include/linux/refcount.h
/**
 * refcount_set - set a refcount's value
 * @r: the refcount
 * @n: value to which the refcount will be set
 */
static inline void refcount_set(refcount_t *r, unsigned int n)
{
    atomic_set(&r->refs, n); 
}

这是对atomic_set()的薄包装(我们很快会介绍)。这里的明显常见问题是:为什么要使用 refcount API?有几个原因:

  • 计数器在REFCOUNT_SATURATED值(默认设置为UINT_MAX)处饱和,并且一旦到达那里就不会再移动。这很关键:它避免了计数器的包装,这可能会导致奇怪和偶发的 UAF 错误;这甚至被认为是一个关键的安全修复(kernsec.org/wiki/index.php/Kernel_Protections/refcount_t)。

  • 一些较新的 refcount API 确实提供内存排序保证;特别是refcount_t API - 与其较老的atomic_t表亲相比 - 以及它们提供的内存排序保证在www.kernel.org/doc/html/latest/core-api/refcount-vs-atomic.html#refcount-t-api-compared-to-atomic-t中有清楚的文档(如果您对底层细节感兴趣,请查看)。

  • 此外,要意识到与先前提到的通用实现不同,依赖于体系结构的 refcount 实现可能会有所不同(如果存在的话;例如,x86 有,而 ARM 没有)。

内存排序到底是什么,它如何影响我们?事实上,这是一个复杂的话题,不幸的是,关于这一点的内部细节超出了本书的范围。了解基础知识是值得的:我建议您阅读Linux-Kernel Memory ModelLKMM),其中包括处理器内存排序等内容。我们在这里为您提供了关于这方面的良好文档:Linux-Kernel Memory Model 解释github.com/torvalds/linux/raw/master/tools/memory-model/Documentation/explanation.txt)。

更简单的 atomic_t 和 refcount_t 接口

关于atomic_t接口,我们应该提到所有以下atomic_t构造仅适用于 32 位整数;当然,现在 64 位整数已经很常见,64 位原子整数操作符也是可用的。它们通常在语义上与它们的 32 位对应物相同,不同之处在于名称(atomic_foo()变成atomic64_foo())。因此,64 位原子整数的主要数据类型称为atomic64_t(又名atomic_long_t)。另一方面,refcount_t接口适用于 32 位和 64 位整数。

以下表格显示了如何并排声明和初始化atomic_trefcount_t变量,以便您可以进行比较和对比:

(旧的)atomic_t(仅限 32 位)(新的)refcount_t(32 位和 64 位)
要包含的头文件<linux/atomic.h><linux/refcount.h>
声明和初始化一个变量static atomic_t gb = ATOMIC_INIT(1);static refcount_t gb = REFCOUNT_INIT(1);

表 17.1 - 旧的 atomic_t 与新的 refcount_t 接口用于引用计数:头文件和初始化

内核中可用的所有atomic_trefcount_tAPI 的完整集合非常庞大;为了在本节中保持简单和清晰,我们只列出了一些更常用的(32 位原子)和refcount_t接口(它们操作于通用的atomic_trefcount_t变量v):

操作(旧的)atomic_t 接口(新的)refcount_t 接口[范围:0 到[U]INT_MAX]
要包含的头文件<linux/atomic.h><linux/refcount.h>
声明和初始化一个变量static atomic_t v = ATOMIC_INIT(1);static refcount_t v = REFCOUNT_INIT(1);
原子性地读取v的当前值int atomic_read(atomic_t *v)unsigned int refcount_read(const refcount_t *v)
原子性地将v设置为值ivoid atomic_set(atomic_t *v, i)void refcount_set(refcount_t *v, int i)
原子性地将v值增加1void atomic_inc(atomic_t *v)void refcount_inc(refcount_t *v)
原子性地将v的值减少1void atomic_dec(atomic_t *v)void refcount_dec(refcount_t *v)
原子性地将i的值添加到vvoid atomic_add(i, atomic_t *v)void refcount_add(int i, refcount_t *v)
原子性地从v中减去i的值void atomic_sub(i, atomic_t *v)void refcount_sub(int i, refcount_t *v)
原子性地将i的值添加到v并返回结果int atomic_add_return(i, atomic_t *v)bool refcount_add_not_zero(int i, refcount_t *v)(不是精确匹配;将i添加到v,除非它是0。)
原子性地从v中减去i的值并返回结果int atomic_sub_return(i, atomic_t *v)bool refcount_sub_and_test(int i, refcount_t *r)(不是精确匹配;从v中减去i并测试;如果结果的引用计数为0,则返回true,否则返回false。)

表 17.2 - 旧的 atomic_t 与新的 refcount_t 接口用于引用计数:API

现在您已经看到了几个atomic_trefcount_t宏和 API;让我们快速检查一下它们在内核中的使用示例。

在内核代码库中使用 refcount_t 的示例

在我们关于内核线程的演示内核模块中(在ch15/kthread_simple/kthread_simple.c),我们创建了一个内核线程,然后使用get_task_struct()内联函数来标记内核线程的任务结构正在使用中。正如您现在猜到的那样,get_task_struct()例程通过refcount_inc()API 增加任务结构的引用计数器——一个名为usagerefcount_t变量:

// include/linux/sched/task.h
static inline struct task_struct *get_task_struct(struct task_struct *t) 
{
    refcount_inc(&t->usage);
    return t;
}

相反的例程put_task_struct()对引用计数执行后续减量。它内部使用的实际例程refcount_dec_and_test()测试新的 refcount 值是否已经降至0;如果是,则返回true,如果是这种情况,则意味着任务结构没有被任何人引用。对__put_task_struct()的调用将其释放:

static inline void put_task_struct(struct task_struct *t) 
{
    if (refcount_dec_and_test(&t->usage))
        __put_task_struct(t);
}

内核中另一个使用 refcounting API 的示例可以在kernel/user.c中找到(它有助于跟踪用户通过每个用户结构声明的进程、文件等的数量):

图 7.1 - 屏幕截图显示内核/user.c 中 refcount_t 接口的使用

查阅refcount_t API 接口文档(www.kernel.org/doc/html/latest/driver-api/basics.html#reference-counting);refcount_dec_and_lock_irqsave()返回true,如果能够将引用计数减少到0,则在禁用中断的情况下保留自旋锁,否则返回false

作为练习,将我们之前的ch16/2_miscdrv_rdwr_spinlock/miscdrv_rdwr_spinlock.c驱动程序代码转换为使用 refcount;它具有整数gagb,在读取或写入时,通过自旋锁进行保护。现在,将它们变成 refcount 变量,并在处理它们时使用适当的refcount_t API。

小心!不要让它们的值超出允许的范围,[0..[U]INT_MAX]!(请记住,当完整的 refcount 验证(CONFIG_REFCOUNT_FULL打开)时,范围为[1..UINT_MAX-1],当不是完整验证(默认)时,范围为[1..INT_MAX])。这样做通常会导致调用WARN()宏(此演示中的代码在我们的 GitHub 存储库中未包含,图 7.1中可见):

图 7.2 - (部分)屏幕截图显示当我们错误地尝试将 refcount_t 变量设置为<= 0 时,WARN()宏触发

内核有一个有趣且有用的测试基础设施,称为Linux 内核转储测试模块LKDTM);请参阅drivers/misc/lkdtm/refcount.c,了解在 refcount 接口上运行的许多测试用例,您可以从中学习...另外,您还可以通过内核的故障注入框架使用 LKDTM 来测试和评估内核对故障情况的反应(请参阅此处的文档:使用 Linux 内核转储测试模块(LKDTM)引发崩溃 - www.kernel.org/doc/html/latest/fault-injection/provoke-crashes.html#provoking-crashes-with-linux-kernel-dump-test-module-lkdtm)。

到目前为止,所有涵盖的原子接口都是针对 32 位整数进行操作的;那么 64 位整数呢?接下来就是。

64 位原子整数运算符

正如本主题开头提到的,我们迄今为止处理的atomic_t整数运算符都是针对传统的 32 位整数进行操作的(这个讨论不适用于较新的refcount_t接口;它们无论如何都是针对 32 位和 64 位数量进行操作的)。显然,随着 64 位系统成为现在的常态而不是例外,内核社区为 64 位整数提供了一套相同的原子整数运算符。区别如下:

  • 将 64 位原子整数声明为atomic64_t类型的变量(即atomic_long_t)。

  • 对于所有运算符,使用atomic64_前缀代替atomic_前缀。

因此,采用以下示例:

  • 使用ATOMIC64_INIT()代替ATOMIC_INIT()

  • 使用atomic64_read()代替atomic_read()

  • 使用atomic64_dec_if_positive()代替atomic64_dec_if_positive()

最近的 C 和 C++语言标准 - C11 和 C++11 - 提供了一个原子操作库,帮助开发人员实现原子性更容易,因为它有隐式的语言支持;我们不会在这里深入讨论这个方面。可以在这里找到参考(C11 也有几乎相同的等价物):en.cppreference.com/w/c/atomic

请注意,所有这些例程 - 32 位和 64 位的原子_operators - 都是与架构无关的。值得重申的关键一点是,对原子整数进行的任何操作都必须通过将变量声明为atomic_t并通过提供的方法来完成。这包括初始化甚至(整数)读取操作。

就内部实现而言,foo()原子整数操作通常是一个宏,变成内联函数,然后调用特定架构的arch_foo()函数。通常情况下,浏览官方内核文档中的原子操作符总是一个好主意(在内核源树中,它在这里:Documentation/atomic_t.txt;访问www.kernel.org/doc/Documentation/atomic_t.txt)。它将众多原子整数 API 整齐地分类为不同的集合。值得一提的是,特定架构的内存排序问题会影响内部实现。在这里,我们不会深入探讨内部情况。如果感兴趣,请参考官方内核文档网站上的这个页面www.kernel.org/doc/html/v4.16/core-api/refcount-vs-atomic.html#refcount-t-api-compared-to-atomic-t(此外,关于内存排序的细节超出了本书的范围;请查看内核文档www.kernel.org/doc/Documentation/memory-barriers.txt了解更多)。

我们没有尝试在这里展示所有原子和引用计数 API(这真的不必要);官方内核文档涵盖了它:

让我们继续讨论在驱动程序上工作时使用的典型构造 - 读取修改写入RMW)。继续阅读!

使用 RMW 原子操作符

还有一组更高级的原子操作符称为 RMW API 也可用。在其许多用途中(我们将在接下来的部分中列出),是对位进行原子 RMW 操作,换句话说,安全地、不可分割地执行位操作。作为操作设备或外围寄存器的设备驱动程序作者,这确实是您将发现自己使用的东西。

本节的材料假定您至少具有基本的访问外围设备(芯片)内存和寄存器的理解;我们在第三章中详细介绍了这一点,请确保在继续之前理解它。

经常需要对寄存器进行位操作(使用按位AND &和按位OR |是最常见的操作符),这是为了修改它的值,设置和/或清除其中的一些位。问题是,仅仅进行一些 C 操作来查询或设置设备寄存器是不够的。不,先生:不要忘记并发问题!继续阅读完整的故事。

RMW 原子操作——对设备寄存器进行操作

让我们先快速复习一些基础知识:一个字节由 8 位组成,从位0,即最低有效位LSB),到位7,即最高有效位MSB)。(这实际上在include/linux/bits.h中以BITS_PER_BYTE宏的形式正式定义,还有一些其他有趣的定义。)

一个寄存器基本上是外围设备中的一个小片内存;通常,它的大小,寄存器位宽,是 8、16 或 32 位之一。设备寄存器提供控制、状态和其他信息,并且通常是可编程的。实际上,这在很大程度上是你作为驱动程序作者要做的事情——适当地编程设备寄存器以使设备执行某些操作,并查询它。

为了充实这个讨论,让我们考虑一个假设的设备,它有两个寄存器:一个状态寄存器和一个控制寄存器,每个寄存器宽度为 8 位。(在现实世界中,每个设备或芯片都有一个数据表,其中提供了芯片和寄存器级硬件的详细规格;这对于驱动程序作者来说是一个必不可少的文档)。硬件人员通常设计设备的方式是将几个寄存器顺序地组合在一个更大的内存块中;这称为寄存器银行。通过拥有第一个寄存器的基地址和每个后续寄存器的偏移量,可以很容易地寻址任何给定的寄存器(在这里,我们不会深入探讨在诸如 Linux 等操作系统上寄存器如何被“映射”到虚拟地址空间)。例如,在头文件中可能像这样描述(纯粹是假设的)寄存器:

#define REG_BASE        0x5a00
#define STATUS_REG      (REG_BASE+0x0)
#define CTRL_REG        (REG_BASE+0x1)

现在,假设为了打开我们的虚构设备,数据表告诉我们可以通过将控制寄存器的第7位(MSB)设置为1来实现。正如每个驱动程序作者很快就会了解到的,修改寄存器有一个神圣的序列:

  1. 读取寄存器的当前值到一个临时变量中。

  2. 修改变量为所需的值。

  3. 变量写回寄存器。

这经常被称为RMW 序列;所以,很好,我们像这样编写(伪)代码:

turn_on_dev()
{
    u8 tmp;

    tmp = ioread8(CTRL_REG);  /* read: current register value into tmp */
    tmp |= 0x80;              /* modify: set bit 7 (MSB) */
    iowrite8(tmp, CTRL_REG);  /* write: new tmp value into register */
}

(顺便说一句,在 Linux 上用于MMIO——内存映射 I/O的实际例程是ioread[8|16|32]()iowrite[8|16|32]()。)

这里有一个关键点:这还不够好;原因是**并发,数据竞争!**想想看:一个寄存器(无论是 CPU 还是设备寄存器)实际上是一个全局共享的可写内存位置;因此,访问它构成了一个临界区,你必须小心保护它免受并发访问!如何做到这一点很容易;我们可以简单地使用自旋锁(至少目前是这样)。修改前面的伪代码以在临界区中插入spin_[un]lock()API 是微不足道的——RMW 序列。

然而,有一种更好的方法可以在处理整数等小量时实现数据安全;我们已经涵盖了它:原子操作符!然而,Linux 更进一步,为以下两种情况提供了一组原子 API:

  • 非 RMW 原子操作(我们之前看到的,在使用 atomic_t 和 refcount_t 接口部分)

  • 原子 RMW 操作;这些包括几种类型的操作符,可以分为几个不同的类别:算术、按位、交换(交换)、引用计数、杂项和屏障

让我们不要重复造轮子;内核文档(www.kernel.org/doc/Documentation/atomic_t.txt)中包含了所有所需的信息。我们将仅显示这份文件的相关部分,直接引用自Documentation/atomic_t.txt内核代码库。

// Documentation/atomic_t.txt
[ ... ]
Non-RMW ops:
  atomic_read(), atomic_set()
  atomic_read_acquire(), atomic_set_release()

RMW atomic operations:

Arithmetic:
  atomic_{add,sub,inc,dec}()
  atomic_{add,sub,inc,dec}_return{,_relaxed,_acquire,_release}()
  atomic_fetch_{add,sub,inc,dec}{,_relaxed,_acquire,_release}()

Bitwise:
  atomic_{and,or,xor,andnot}()
  atomic_fetch_{and,or,xor,andnot}{,_relaxed,_acquire,_release}()

Swap:
  atomic_xchg{,_relaxed,_acquire,_release}()
  atomic_cmpxchg{,_relaxed,_acquire,_release}()
  atomic_try_cmpxchg{,_relaxed,_acquire,_release}()

Reference count (but please see refcount_t):
  atomic_add_unless(), atomic_inc_not_zero()
  atomic_sub_and_test(), atomic_dec_and_test()

Misc:
  atomic_inc_and_test(), atomic_add_negative()
  atomic_dec_unless_positive(), atomic_inc_unless_negative()
[ ... ]

好了;现在您已经了解了这些 RMW(和非 RMW)操作符,让我们实际操作一下 - 我们将看看如何在下一步使用 RMW 操作符进行位操作。

使用 RMW 位操作符

在这里,我们将专注于使用 RMW 位操作符;其他操作留给您去探索(参考提到的内核文档)。因此,让我们再次考虑如何更有效地编写我们的伪代码示例。我们可以使用set_bit() API 在任何寄存器或内存项中设置(为1)任何给定的位:

void set_bit(unsigned int nr, volatile unsigned long *p);

这样原子地 - 安全地和不可分割地 - 将p的第nr位设置为1。(事实上,设备寄存器(可能还有设备内存)被映射到内核虚拟地址空间中,因此看起来就像是 RAM 位置 - 就像这里的地址p一样。这称为 MMIO,是驱动程序作者映射和处理设备内存的常见方式。)

因此,使用 RMW 原子操作符,我们可以安全地实现我们之前(错误地)尝试的操作 - 用一行代码打开我们的(虚构的)设备:

set_bit(7, CTRL_REG);

以下表总结了常见的 RMW 位原子 API:

RMW 位原子 API注释
void set_bit(unsigned int nr, volatile unsigned long *p);原子地设置(设置为1p的第nr位。
void clear_bit(unsigned int nr, volatile unsigned long *p)原子地清除(设置为0p的第nr位。
void change_bit(unsigned int nr, volatile unsigned long *p)原子地切换p的第nr位。
以下 API 返回正在操作的位(nr)的先前值
int test_and_set_bit(unsigned int nr, volatile unsigned long *p)原子地设置p的第nr位,返回先前的值(内核 API 文档位于www.kernel.org/doc/htmldocs/kernel-api/API-test-and-set-bit.html)。
int test_and_clear_bit(unsigned int nr, volatile unsigned long *p)原子地清除p的第nr位,返回先前的值。
int test_and_change_bit(unsigned int nr, volatile unsigned long *p)原子地切换p的第nr位,返回先前的值。

表 17.3 - 常见的 RMW 位原子 API

注意:这些原子 API 不仅仅是相对于它们运行的 CPU 核心是原子的,而且现在也是相对于所有/其他核心是原子的。实际上,这意味着如果您在多个 CPU 上并行执行原子操作,也就是说,如果它们(可能)竞争,那么这是一个关键部分,您必须用锁(通常是自旋锁)来保护它!

尝试一些这些 RMW 原子 API 将有助于建立您对使用它们的信心;我们将在接下来的部分中这样做。

使用位原子操作符 - 一个例子

让我们来看一个快速的内核模块,演示了 Linux 内核的 RMW 原子位操作符的用法(ch13/1_rmw_atomic_bitops)。您应该意识到这些操作符可以在任何内存上工作,无论是寄存器还是 RAM;在这里,我们在示例 LKM 中操作一个简单的静态全局变量(名为mem)。很简单;让我们来看一下:

// ch13/1_rmw_atomic_bitops/rmw_atomic_bitops.c
[ ... ]
#include <linux/spinlock.h>
#include <linux/atomic.h>
#include <linux/bitops.h>
#include "../../convenient.h"
[ ... ]
static unsigned long mem;
static u64 t1, t2; 
static int MSB = BITS_PER_BYTE - 1;
DEFINE_SPINLOCK(slock);

我们包括所需的头文件,并声明和初始化了一些全局变量(请注意我们的MSB变量如何使用BIT_PER_BYTE)。我们使用一个简单的宏,SHOW(),来显示带有printk的格式化输出。init代码路径是实际工作所在的地方:

[ ... ]
#define SHOW(n, p, msg) do {                                   \
    pr_info("%2d:%27s: mem : %3ld = 0x%02lx\n", n, msg, p, p); \
} while (0)
[ ... ]
static int __init atomic_rmw_bitops_init(void)
{
    int i = 1, ret;

    pr_info("%s: inserted\n", OURMODNAME);
    SHOW(i++, mem, "at init");

    setmsb_optimal(i++);
    setmsb_suboptimal(i++);

    clear_bit(MSB, &mem);
    SHOW(i++, mem, "clear_bit(7,&mem)");

    change_bit(MSB, &mem);
    SHOW(i++, mem, "change_bit(7,&mem)");

    ret = test_and_set_bit(0, &mem);
    SHOW(i++, mem, "test_and_set_bit(0,&mem)");
    pr_info(" ret = %d\n", ret);

    ret = test_and_clear_bit(0, &mem);
    SHOW(i++, mem, "test_and_clear_bit(0,&mem)");
    pr_info(" ret (prev value of bit 0) = %d\n", ret);

    ret = test_and_change_bit(1, &mem);
    SHOW(i++, mem, "test_and_change_bit(1,&mem)");
    pr_info(" ret (prev value of bit 1) = %d\n", ret);

    pr_info("%2d: test_bit(%d-0,&mem):\n", i, MSB);
    for (i = MSB; i >= 0; i--)
        pr_info(" bit %d (0x%02lx) : %s\n", i, BIT(i), test_bit(i, &mem)?"set":"cleared");

    return 0; /* success */
}

我们在这里使用的 RMW 原子操作符以粗体字突出显示。这个演示的一个关键部分是展示使用 RMW 位原子操作符不仅更容易,而且比在自旋锁的限制范围内手动执行 RMW 操作的传统方法更快。这是这两种方法的函数:

/* Set the MSB; optimally, with the set_bit() RMW atomic API */
static inline void setmsb_optimal(int i)
{
    t1 = ktime_get_real_ns();
    set_bit(MSB, &mem);
    t2 = ktime_get_real_ns();
    SHOW(i, mem, "set_bit(7,&mem)");
    SHOW_DELTA(t2, t1);
}
/* Set the MSB; the traditional way, using a spinlock to protect the RMW
 * critical section */
static inline void setmsb_suboptimal(int i)
{
    u8 tmp;

    t1 = ktime_get_real_ns();
    spin_lock(&slock);
 /* critical section: RMW : read, modify, write */
    tmp = mem;
    tmp |= 0x80; // 0x80 = 1000 0000 binary
    mem = tmp;
    spin_unlock(&slock);
    t2 = ktime_get_real_ns();

    SHOW(i, mem, "set msb suboptimal: 7,&mem");
    SHOW_DELTA(t2, t1);
}

我们在init方法中早期调用这些函数;注意我们通过ktime_get_real_ns()例程获取时间戳,并通过我们的convenient.h头文件中定义的SHOW_DELTA()宏显示所花费的时间。好了,这是输出:

图 7.3-来自我们的 ch13/1_rmw_atomic_bitops LKM 的输出截图,展示了一些原子 RMW 操作符的工作情况

(我在我的 x86_64 Ubuntu 20.04 虚拟机上运行了这个演示 LKM。)现代方法-通过set_bit() RMW 原子位 API-在这个样本运行中只需要 415 纳秒来执行;传统方法慢了大约 265 倍!代码(通过set_bit())也简单得多...

在与原子位操作符有些相关的地方,以下部分是对内核中用于搜索位掩码的高效 API 的非常简要的介绍-事实证明这是内核中一个相当常见的操作。

高效地搜索位掩码

有几种算法依赖于对位掩码进行快速搜索;几种调度算法(如SCHED_FIFOSCHED_RR,你在伴随指南Linux 内核编程-第十章CPU 调度器-第一部分第十一章CPU 调度器-第二部分中了解到)通常在内部需要这个。有效地实现这一点变得很重要(特别是对于操作系统级别的性能敏感代码路径)。因此,内核提供了一些 API 来扫描给定的位掩码(这些原型可以在include/asm-generic/bitops/find.h中找到):

  • unsigned long find_first_bit(const unsigned long *addr, unsigned long size): 在内存区域中查找第一个设置的位;返回第一个设置的位的位数,否则(没有设置位)返回@size

  • unsigned long find_first_zero_bit(const unsigned long *addr, unsigned long size): 在内存区域中查找第一个清除的位;返回第一个清除的位的位数,否则(没有清除的位)返回@size

  • 其他例程包括find_next_bit()find_next_and_bit()find_last_bit()

通过查看<linux/bitops.h>头文件,还可以发现其他非常有趣的宏,比如for_each_{clear,set}_bit{_from}()

使用读写自旋锁

想象一下内核(或驱动程序)代码的一部分,其中正在搜索一个大的全局双向循环链表(有几千个节点)。现在,由于数据结构是全局的(共享和可写),访问它构成了一个需要保护的临界区。

假设搜索列表是一个非阻塞操作的场景,你通常会使用自旋锁来保护临界区。一个天真的方法可能会建议根本不使用锁,因为我们只是读取列表中的数据,而不是更新它。但是,当然(正如你所学到的),即使是对共享可写数据的读取也必须受到保护,以防止同时发生的意外写入,从而导致脏读或不完整读取。

因此,我们得出结论,我们需要自旋锁;我们可以想象伪代码可能看起来像这样:

spin_lock(mylist_lock);
for (p = &listhead; (p = next_node(p)) != &listhead; ) {
    << ... search for something ... 
         found? break out ... >>
}
spin_unlock(mylist_lock);

那么问题是什么?当然是性能!想象一下在多核系统上,几个线程几乎同时到达这段代码片段;每个线程都会尝试获取自旋锁,但只有一个获胜的线程会获取它,遍历整个列表,然后执行解锁,允许下一个线程继续。换句话说,执行现在是串行化的,显然会显著减慢速度。但是没办法;还是有办法吗?

进入读-写自旋锁。使用这种锁定结构,要求所有执行对受保护数据的读取的线程都会请求读锁,而任何需要对列表进行写访问的线程都会请求独占写锁。只要没有写锁在起作用,读锁将立即授予任何请求的线程。实际上,这种结构允许所有读者并发访问数据,实际上根本不需要真正的锁定。只要只有读者,这是可以的。一旦有写入线程出现,它就会请求写锁。现在,正常的锁定语义适用:写入者必须等待所有读者解锁。一旦发生这种情况,写入者就会获得独占的写锁并继续进行。因此,现在,如果任何读者或写者尝试访问,它们将被迫等待直到写者解锁。

因此,对于那些数据访问模式中读取非常频繁而写入很少,并且关键部分是相当长的情况,读-写自旋锁是一种性能增强的锁。

读-写自旋锁接口

使用自旋锁后,使用读-写变体是很简单的;锁数据类型被抽象为rwlock_t结构(而不是spinlock_t),在 API 名称方面,只需用readwrite替换spin

#include <linux/rwlock.h>
rwlock_t mylist_lock;

读-写自旋锁的最基本 API 如下:

void read_lock(rwlock_t *lock);
void write_lock(rwlock_t *lock);

例如,内核的tty层有处理安全关注键SAK)的代码;SAK 是一种安全功能,是一种防止特洛伊木马式凭证黑客攻击的手段,通过终止与 TTY 设备关联的所有进程来实现。当用户按下 SAK 时,这将发生(www.kernel.org/doc/html/latest/security/sak.html)。在其代码路径中,它必须迭代所有任务,终止整个会话和打开 TTY 设备的任何线程。为此,它必须以读模式获取一个名为tasklist_lock的读写自旋锁。相关代码如下,其中tasklist_lock上的read_[un]lock()被突出显示:

// drivers/tty/tty_io.c
void __do_SAK(struct tty_struct *tty)
{
    [...]
    read_lock(&tasklist_lock);
    /* Kill the entire session */
    do_each_pid_task(session, PIDTYPE_SID, p) {
        tty_notice(tty, "SAK: killed process %d (%s): by session\n", task_pid_nr(p), p->comm);
        group_send_sig_info(SIGKILL, SEND_SIG_PRIV, p, PIDTYPE_SID);
    } while_each_pid_task(session, PIDTYPE_SID, p);
    [...]
    /* Now kill any processes that happen to have the tty open */
    do_each_thread(g, p) {
        [...]
    } while_each_thread(g, p);
    read_unlock(&tasklist_lock);

另外,在伴随指南Linux 内核编程-第六章,内核内部要点进程和线程 遍历任务列表部分中,我们做了类似的事情:我们编写了一个内核模块(github.com/PacktPublishing/Linux-Kernel-Programming/raw/master/ch6/foreach/thrd_showall/thrd_showall.c),遍历了任务列表中的所有线程,并输出了每个线程的一些细节。因此,现在我们了解了并发处理的情况,难道我们不应该使用这个锁-tasklist_lock-保护任务列表的读-写自旋锁吗?是的,但它没有起作用(insmod(8)失败,并显示消息thrd_showall: Unknown symbol tasklist_lock (err -2))。原因当然是,这个tasklist_lock变量没有被导出,因此对我们的内核模块不可用。

作为内核代码库中读-写自旋锁的另一个示例,ext4文件系统在处理其范围状态树时使用了一个。我们不打算在这里深入讨论细节;我们只会简单提到一个事实,即读-写自旋锁(在 inode 结构中,inode->i_es_lock)在这里被广泛用于保护范围状态树免受数据竞争的影响(fs/ext4/extents_status.c)。

内核源树中有许多类似的例子;网络堆栈中的许多地方,包括 ping 代码(net/ipv4/ping.c),都使用rwlock_t,路由表查找,邻居,PPP 代码,文件系统等等。

就像普通自旋锁一样,我们有典型的读写自旋锁 API 的变化:{read,write}_lock_irq{save}()与相应的{read,write}_unlock_irq{restore}(),以及{read,write}_{un}lock_bh()接口。请注意,即使是读取 IRQ 锁也会禁用内核抢占。

谨慎一些。

读写自旋锁存在问题。其中一个典型问题是,不幸的是,写者可能会饿死,当阻塞在多个读者上时。想想看:假设当前有三个读取线程持有读写锁。现在,一个写入者想要锁。它必须等到所有三个读者解锁。但如果在此期间,更多的读者出现了(这是完全可能的)?这对于写者来说是一场灾难,他现在必须等待更长的时间 - 实际上是挨饿。(可能需要仔细地检查或分析涉及的代码路径,以弄清楚是否确实是这种情况。)

不仅如此,缓存效应 - 也称为缓存乒乓 - 当多个位于不同 CPU 核心上的读取线程并行读取相同的共享状态时(同时持有读写锁)时,经常会发生,实际上我们在缓存效应和伪共享部分讨论了这一点。自旋锁的内核文档(www.kernel.org/doc/Documentation/locking/spinlocks.txt)也说了差不多的事情。以下是其中的一句引用:“注意!读写锁需要比简单自旋锁更多的原子内存操作。除非读取临界区很长,否则最好只使用自旋锁。”实际上,内核社区正在努力尽可能地删除读写自旋锁,将它们移动到更高级的无锁技术(如RCU - Read Copy Update)中。因此,滥用读写自旋锁是不明智的。

内核文档中有关自旋锁用法的整洁简单的文档(由 Linus Torvalds 本人编写),非常值得一读,可以在这里找到:www.kernel.org/doc/Documentation/locking/spinlocks.txt

读写信号量

我们之前提到过信号量对象(第六章,内核同步 - 第一部分,在信号量和互斥体部分),将其与互斥体进行对比。在那里,您了解到最好只使用互斥体。在这里,我们指出,在内核中,就像存在读写自旋锁一样,也存在读写信号量。用例和语义与读写自旋锁类似。相关的宏/API(在<linux/rwsem.h>中)是{down,up}_{read,write}_{trylock,killable}()。在struct mm_struct结构(它本身在任务结构中)中的一个常见示例是读写信号量:struct rw_semaphore mmap_sem;

结束这个讨论,我们只会简单提到内核中的其他相关同步机制。在用户空间应用程序开发中广泛使用的同步机制(我们特别考虑的是 Linux 用户空间中的 Pthreads 框架)是条件变量CV)。简而言之,它提供了两个或更多线程根据数据项的值或某些特定状态进行同步的能力。在 Linux 内核中,它的等效物被称为完成机制。请在内核文档中找到有关其用法的详细信息:www.kernel.org/doc/html/latest/scheduler/completion.html#completions-wait-for-completion-barrier-apis

序列锁主要用于大部分写情况(与适用于大部分读情况的读写自旋锁/信号量锁相对),在受保护变量的写远远超过读的情况下。你可以想象,这并不是一个非常常见的情况;使用序列锁的一个很好的例子是更新jiffies_64全局变量。

对于好奇的人,jiffies_64全局更新代码从这里开始:kernel/time/tick-sched.c:tick_do_update_jiffies64()。这个函数会判断是否需要更新 jiffies,如果需要,就会调用do_timer(++ticks);来实际更新它。与此同时,write_seq[un]lock(&jiffies_lock);API 提供了对大部分写关键部分的保护。

缓存效应和伪共享

现代处理器在内部使用多级并行缓存内存,以便在处理内存时提供非常显著的加速(我们在配套指南Linux 内核编程-第八章-模块作者的内核内存分配-第一部分-分配 slab 内存部分简要提到了这一点)。我们意识到,现代 CPU 实际上并不直接读写 RAM;当软件指示从某个地址开始读取 RAM 的一个字节时,CPU 实际上会从起始地址读取多个字节 - 整个缓存行的字节(通常为 64 字节)到所有 CPU 缓存(比如 L1、L2 和 L3:1、2 和 3 级)。这样,访问顺序内存的下几个元素会得到巨大的加速,因为首先会在缓存中检查(首先在 L1 中,然后在 L2 中,然后在 L3 中,缓存命中变得可能)。它之所以(要快得多)更快,原因很简单:访问 CPU 缓存内存通常需要 1 到几个(个位数)纳秒,而访问 RAM 可能需要 50 到 100 纳秒(当然,这取决于所涉及的硬件系统和你愿意花费的金额!)。

软件开发人员通过做以下事情来利用这种现象:

  • 将数据结构的重要成员放在一起(希望在一个缓存行内),并放在结构的顶部

  • 填充结构成员,以便我们不会超出缓存行(同样,这些点已经在配套指南Linux 内核编程-第八章-模块作者的内核内存分配-第一部分-数据结构-一些设计提示部分中涵盖了)

然而,存在风险,事情确实会出错。举个例子,考虑这样声明的两个变量:u16 ax = 1, bx = 2;u16表示无符号 16 位整数值)。

现在,因为它们被声明为相邻的,它们在运行时很可能会占用相同的 CPU 缓存行。为了理解问题是什么,让我们举个例子:考虑一个双核系统,每个核有两个 CPU 缓存,L1 和 L2,以及一个公共或统一的 L3 缓存。现在,一个线程T1正在处理变量ax,另一个线程T2正在并发地(在另一个 CPU 核心上)处理变量bx。所以,想一想:当运行在 CPU0上的线程T1从主内存(RAM)中访问ax时,它的 CPU 缓存将被axbx的当前值填充(因为它们在同一个缓存行内!)。同样地,当运行在 CPU1上的线程T2从 RAM 中访问bx时,它的 CPU 缓存也将被两个变量的当前值填充。图 7.4在概念上描述了这种情况:

图 7.4 - 当线程 T1 和 T2 并行处理两个相邻变量时,CPU 缓存内存的概念描述

到目前为止还好;但是如果T1执行一个操作,比如ax ++,与此同时,T2执行bx ++呢?那又怎样?(顺便说一句,您可能会想:为什么他们不使用锁?有趣的是,这与本讨论无关;因为每个线程正在访问不同的变量,所以不存在数据竞争。问题在于它们在同一个 CPU 高速缓存行中。)

这里的问题是高速缓存一致性。处理器和/或处理器与操作系统(这都是与体系结构相关的东西)将必须保持高速缓存和 RAM 相互同步或一致。因此,一旦T1修改ax,CPU 0的那个高速缓存行将被使无效,也就是说,CPU 0的高速缓存行将被刷新到 RAM 以更新 RAM 到新值,然后立即,RAM 到 CPU 1的高速缓存更新也必须发生以保持一切一致!

但是高速缓存行也包含bx,正如我们所说,bx也已经在 CPU 1上由T2修改。因此,几乎同时,CPU 1的高速缓存行将被刷新到 RAM,带有bx的新值,并随后更新到 CPU 0的高速缓存(与此同时,统一的 L3 高速缓存也将被读取/更新)。您可以想象,对这些变量的任何更新都将导致大量的高速缓存和 RAM 流量;它们会反弹。事实上,这经常被称为高速缓存乒乓!这种效果非常有害,会显著减慢处理速度。这种现象被称为错误共享

识别错误共享是困难的部分;我们必须寻找在共享高速缓存行上的变量,这些变量由不同的上下文(线程或其他)同时更新。

有趣的是,在内存管理层的一个关键数据结构的早期实现include/linux/mmzone.h:struct zone也遭受了同样的错误共享问题:两个相邻声明的自旋锁!这个问题已经被解决(我们在配套指南Linux 内核编程-第七章,内存管理内部-基础知识物理 RAM 组织/区域部分简要讨论了内存区域)。

如何解决这个错误的共享?很简单:只需确保变量之间的间距足够大,以确保它们不共享相同的高速缓存行(通常在变量之间插入虚拟填充字节以实现此目的)。还可以参考进一步阅读部分中关于错误共享的参考资料。

无锁编程与每 CPU 变量

正如您所了解的,当操作共享可写数据时,必须以某种方式保护临界区。锁定可能是实现此保护的最常见技术。然而,并非一切都很顺利,因为性能可能会受到影响。要了解原因,可以考虑一些与锁有关的类比:一个是漏斗,漏斗的茎口只宽到足以允许一个线程通过,不多。另一个是繁忙公路上的单个收费站或繁忙十字路口的交通灯。这些类比帮助我们可视化和理解为什么锁定可能导致瓶颈,在一些极端情况下会使性能变得非常缓慢。更糟糕的是,这些不利影响在高端多核系统上可能会被放大;实际上,锁定的扩展性并不好。

另一个问题是锁争用;特定锁被获取的频率是多少?在系统中增加锁的数量有利于降低两个或多个进程(或线程)之间对特定锁的争用。这被称为锁效率。然而,同样地,这并不可扩展到极大的程度:过一段时间后,在系统上拥有数千个锁(实际上是 Linux 内核的情况)并不是好消息-产生微妙的死锁条件的机会显著增加。

因此,存在许多挑战-性能问题、死锁、优先级反转风险、车队(由于锁定顺序,快速代码路径可能需要等待第一个较慢的代码路径,后者已经获取了快速代码路径也需要的锁),等等。在一个可扩展的内核中进一步发展,需要使用无锁算法及其在内核中的实现。这些已经导致了几种创新技术,其中包括每 CPU(PCP)数据、无锁数据结构(按设计)和 RCU。

在本书中,我们选择仅详细介绍每 CPU 作为无锁编程技术。关于 RCU(及其相关的按设计无锁数据结构)的细节超出了本书的范围。请参考本章的进一步阅读部分,了解有关 RCU、其含义以及在 Linux 内核中的使用的几个有用资源。

每 CPU 变量

顾名思义,每 CPU 变量通过为系统上的每个(活动的)CPU 分配一个副本来工作。实际上,通过避免在线程之间共享数据,我们摆脱了并发的问题领域,即临界区。使用每 CPU 数据技术,由于每个 CPU 都引用其自己的数据副本,运行在该处理器上的线程可以在没有竞争的情况下操纵它。 (这在某种程度上类似于局部变量;由于局部变量位于每个线程的私有堆栈上,它们不在线程之间共享,因此没有临界区,也不需要锁定。)在这里,锁定的需求也被消除了-使其成为一种无锁技术!

因此,想象一下:如果您在一个具有四个活动 CPU 核心的系统上运行,那么该系统上的每 CPU 变量本质上是一个四个元素的数组:元素0表示第一个 CPU 上的数据值,元素1表示第二个 CPU 核心上的数据值,依此类推。了解这一点,您会意识到每 CPU 变量在某种程度上也类似于用户空间 Pthreads 线程本地存储TLS)实现,其中每个线程自动获取标有__thread关键字的(TLS)变量的副本。在这里和每 CPU 变量中,显而易见:仅对小数据项使用每 CPU 变量。这是因为数据项会被复制,每个 CPU 核心有一个实例(在具有几百个核心的高端系统上,开销会增加)。我们在内核代码库中提到了一些每 CPU 使用的示例(在内核中的每 CPU 使用部分)。

现在,当使用每 CPU 变量时,您必须使用内核提供的辅助方法(宏和 API),而不是直接访问它们(就像我们在引用计数和原子操作符中看到的那样)。

使用每 CPU

让我们通过将讨论分为两部分来接近每 CPU 数据的辅助 API 和宏(方法)。首先,您将学习如何分配、初始化和随后释放每 CPU 数据项。然后,您将学习如何使用(读/写)它。

分配、初始化和释放每 CPU 变量

基本上有两种类型的每 CPU 变量:静态分配和动态分配。静态分配的每 CPU 变量是在编译时分配的,通常通过DEFINE_PER_CPUDECLARE_PER_CPU宏之一来实现。使用DEFINE允许您分配和初始化变量。以下是一个分配单个整数作为每 CPU 变量的示例:

#include <linux/percpu.h>
DEFINE_PER_CPU(int, pcpa);      // signature: DEFINE_PER_CPU(type, name)

现在,在一个具有四个 CPU 核心的系统上,初始化时概念上看起来是这样的:

图 7.5-在具有四个活动 CPU 的系统上对每 CPU 数据项的概念表示

(实际实现当然比这复杂得多;请参考本章的进一步阅读部分,了解更多内部实现。)

简而言之,使用每个 CPU 变量对于性能敏感的代码路径是有益的,因为:

  • 我们避免使用昂贵的、性能破坏的锁。

  • 访问和操作每个 CPU 变量保证保持在一个特定的 CPU 核心上;这消除了昂贵的缓存效应,如缓存乒乓和伪共享(在“缓存效应和伪共享”部分中介绍)。

可以通过alloc_percpu()alloc_percpu_gfp()包装宏动态分配每个 CPU 数据,只需将要分配为每个 CPU 的对象的数据类型传递给它,对于后者,还要传递gfp分配标志:

alloc_percpu_gfp;

底层的__alloc_per_cpu[_gfp]()例程通过EXPORT_SYMBOL_GPL()导出(因此只能在 LKM 以 GPL 兼容许可证发布时使用)。

正如你所学到的,资源管理的devm_*()API 变体允许你(通常在编写驱动程序时)方便地使用这些例程来分配内存;内核将负责释放它,有助于防止泄漏情况发生。devm_alloc_percpu(dev, type)宏允许你使用这个作为__alloc_percpu()的资源管理版本。

通过前面的例程分配的内存必须随后使用void free_percpu(void __percpu * __pdata) API 释放。

对每个 CPU 变量执行 I/O(读取和写入)

当然,一个关键的问题是你到底如何访问(读取)和更新(写入)每个 CPU 变量?内核提供了几个辅助例程来实现这一点;让我们举一个简单的例子来理解。我们定义一个单个整数每个 CPU 变量,然后在以后的某个时间点,我们想要访问并打印其当前值。你应该意识到,由于是每个 CPU,所以检索到的值将根据代码当前运行的 CPU 核心自动计算;换句话说,如果以下代码在核心1上运行,那么实际上将获取pcpa[1]的值(实际操作并非完全如此;这只是概念上的):

DEFINE_PER_CPU(int, pcpa);
int val;
[ ... ]
val = get_cpu_var(pcpa);
pr_info("cpu0: pcpa = %+d\n", val);
put_cpu_var(pcpa);

{get,put}_cpu_var()宏对我们允许安全地检索或修改给定每个 CPU 变量(其参数)的每个 CPU 值。重要的是要理解get_cpu_var()put_cpu_var()(或等效)之间的代码实际上是一个关键部分 - 一个原子上下文 - 其中内核抢占被禁用,任何类型的阻塞(或睡眠)都是不允许的。如果在这里做任何阻塞(睡眠)的操作,那就是内核错误。例如,看看如果你尝试通过get_cpu_var()/put_cpu_var()宏对内存进行分配会发生什么:

void *p;
val = get_cpu_var(pcpa);
p = vmalloc(20000);
pr_info("cpu1: pcpa = %+d\n", val);
put_cpu_var(pcpa);
vfree(p);
[ ... ]

$ sudo insmod <whatever>.ko
$ dmesg
[ ... ]
BUG: sleeping function called from invalid context at mm/slab.h:421
[67641.443225] in_atomic(): 1, irqs_disabled(): 0, pid: 12085, name:
thrd_1/1
[ ... ]
$

(顺便说一句,在关键部分内部调用printk()(或pr_<foo>())包装器是可以的,因为它们是非阻塞的。)问题在于vmalloc() API 可能是一个阻塞的;它可能会睡眠(我们在配套指南Linux 内核编程第九章模块作者的内核内存分配 - 第二部分理解和使用内核 vmalloc() API部分中详细讨论过),而在get_cpu_var()/put_cpu_var()对之间的代码必须是原子的和非阻塞的。

在内部,get_cpu_var()宏调用preempt_disable(),禁用内核抢占,而put_cpu_var()通过调用preempt_enable()来撤消这一操作。正如之前所见(在配套指南Linux 内核编程CPU 调度章节中),这可以嵌套,并且内核维护一个preempt_count变量来确定内核抢占是否实际上被启用或禁用。

总之,当使用这些宏时,你必须仔细匹配{get,put}_cpu_var()(例如,如果我们调用get宏两次,我们也必须调用相应的put宏两次)。

get_cpu_var()是一个lvalue,因此可以进行操作;例如,要增加每个 CPU 的pcpa变量,只需执行以下操作:

get_cpu_var(pcpa) ++;
put_cpu_var(pcpa);

您还可以(安全地)通过宏检索当前每 CPU 值:

per_cpu(var, cpu);

因此,要检索系统上每个 CPU 核心的每 CPUpcpa变量,请使用以下内容:

for_each_online_cpu(i) {
 val = per_cpu(pcpa, i);
    pr_info(" cpu %2d: pcpa = %+d\n", i, val);
}

顺便说一句,您可以始终使用smp_processor_id()宏来确定您当前运行的 CPU 核心;实际上,这正是我们的convenient.h:PRINT_CTX()宏的工作原理。

类似地,内核提供了用于处理需要为每个 CPU 的变量指针的例程,{get,put}_cpu_ptr()per_cpu_ptr()宏。当处理每个 CPU 数据结构时(而不仅仅是一个简单的整数),这些宏被广泛使用;我们安全地检索当前正在运行的 CPU 的结构的指针,并使用它(per_cpu_ptr())。

每 CPU - 一个示例内核模块

通过我们的示例每 CPU 演示内核模块的实际操作会有所帮助,以使用这个强大的功能(代码在这里:ch13/2_percpu)。在这里,我们定义并使用两个每 CPU 变量:

  • 一个静态分配和初始化的每 CPU 整数

  • 一个动态分配的每 CPU 数据结构

作为演示每 CPU 变量的有趣方式,让我们这样做:我们将安排我们的演示内核模块产生一对内核线程。让我们称它们为thrd_0thrd_1。此外,一旦创建,我们将利用 CPU 掩码(和 API)将我们的thrd_0内核线程关联到 CPU 0,将我们的thrd_1内核线程关联到 CPU 1(因此,它们将被调度在这些核心上运行;当然,我们必须在至少有两个 CPU 核心的 VM 上测试这段代码)。

以下代码片段说明了我们如何定义和使用每 CPU 变量(我们省略了创建内核线程和设置它们的 CPU 亲和性掩码的代码,因为它们与本章的覆盖范围无关;然而,浏览完整代码并尝试它是非常重要的!):

// ch13/2_percpu/percpu_var.c
[ ... ]
/*--- The per-cpu variables, an integer 'pcpa' and a data structure --- */
/* This per-cpu integer 'pcpa' is statically allocated and initialized to 0 */
DEFINE_PER_CPU(int, pcpa);

/* This per-cpu structure will be dynamically allocated via alloc_percpu() */
static struct drv_ctx {
    int tx, rx; /* here, as a demo, we just use these two members,
                   ignoring the rest */
    [ ... ]
} *pcp_ctx;
[ ... ]

static int __init init_percpu_var(void)
{
    [ ... ]
    /* Dynamically allocate the per-cpu structures */
    ret = -ENOMEM;
 pcp_ctx = (struct drv_ctx __percpu *) alloc_percpu(struct drv_ctx);
    if (!pcp_ctx) {
        [ ... ]
}

为什么不使用资源管理的devm_alloc_percpu()呢?是的,在适当的时候你应该使用;然而,在这里,因为我们不是在编写一个合适的驱动程序,我们没有一个struct device *dev指针方便使用,这是devm_alloc_percpu()所需的第一个参数。

顺便说一句,我在编写这个内核模块时遇到了一个问题;要设置 CPU 掩码(为每个内核线程更改 CPU 亲和性),内核 API 是sched_setaffinity()函数,但不幸的是,这个函数对我们来说是未导出的,因此我们无法使用它。因此,我们执行了一个绝对被认为是黑客行为的操作:通过kallsyms_lookup_name()(在定义了CONFIG_KALLSYMS时有效)获取不合作函数的地址,然后将其作为函数指针调用。这样做是有效的,但绝对不是编码的正确方式。

我们的设计思想是创建两个内核线程,并让它们分别操作每 CPU 数据变量。如果这些是普通的全局变量,这肯定构成了一个关键部分,我们当然需要一个锁;但在这里,正是因为它们是每 CPU,并且我们保证我们的线程在不同的核心上运行,我们可以同时使用不同的数据更新它们!我们的内核线程工作例程如下;它的参数是线程编号(01)。我们相应地分支并操作每 CPU 数据(我们的第一个内核线程将整数增加三次,而我们的第二个内核线程将其减少三次):

/* Our kernel thread worker routine */
static int thrd_work(void *arg)
{
    int i, val;
    long thrd = (long)arg;
    struct drv_ctx *ctx;
    [ ... ]

    /* Set CPU affinity mask to 'thrd', which is either 0 or 1 */
    if (set_cpuaffinity(thrd) < 0) {
        [ ... ]
    SHOW_CPU_CTX();

    if (thrd == 0) { /* our kthread #0 runs on CPU 0 */
        for (i=0; i<THRD0_ITERS; i++) {
            /* Operate on our perpcu integer */
 val = ++ get_cpu_var(pcpa);
            pr_info(" thrd_0/cpu0: pcpa = %+d\n", val);
            put_cpu_var(pcpa);

            /* Operate on our perpcu structure */
 ctx = get_cpu_ptr(pcp_ctx);
            ctx->tx += 100;
            pr_info(" thrd_0/cpu0: pcp ctx: tx = %5d, rx = %5d\n",
                ctx->tx, ctx->rx);
            put_cpu_ptr(pcp_ctx);
        }
    } else if (thrd == 1) { /* our kthread #1 runs on CPU 1 */
        for (i=0; i<THRD1_ITERS; i++) {
            /* Operate on our perpcu integer */
 val = -- get_cpu_var(pcpa);
            pr_info(" thrd_1/cpu1: pcpa = %+d\n", val);
           put_cpu_var(pcpa);

            /* Operate on our perpcu structure */
            ctx = get_cpu_ptr(pcp_ctx); ctx->rx += 200;
            pr_info(" thrd_1/cpu1: pcp ctx: tx = %5d, rx = %5d\n",
                ctx->tx, ctx->rx); put_cpu_ptr(pcp_ctx);        }}
    disp_vars();
    pr_info("Our kernel thread #%ld exiting now...\n", thrd);
    return 0;
}

运行时的效果很有趣;请参阅以下内核日志:

图 7.6 - 显示我们的 ch13/2_percpu/percpu_var LKM 运行时的内核日志的屏幕截图

图 7.6的最后三行输出中,您可以看到我们的每 CPU 数据变量在 CPU 0和 CPU 1上的值的摘要(我们通过我们的disp_vars()函数显示)。显然,对于每 CPUpcpa整数(以及pcp_ctx数据结构),值是不同的,正如预期的那样,没有显式锁定

刚刚演示的内核模块使用for_each_online_cpu(i)宏在每个在线 CPU 上显示每个 CPU 变量的值。接下来,如果您的虚拟机有 6 个 CPU,但希望其中只有两个在运行时处于“活动”状态,该怎么办?有几种安排的方法;其中一种是在启动时向 VM 的内核传递maxcpus=n参数-您可以通过查找/proc/cmdline来查看是否存在:

$ cat /proc/cmdline BOOT_IMAGE=/boot/vmlinuz-5.4.0-llkd-dbg root=UUID=1c4<...> ro console=ttyS0,115200n8 console=tty0  quiet splash 3 **maxcpus=2** 还要注意我们正在运行我们自定义的5.4.0-llkd-dbg调试内核。

内核中的每个 CPU 使用

每个 CPU 变量在 Linux 内核中被广泛使用;一个有趣的案例是在 x86 架构上实现current宏的情况(我们在伴随指南Linux 内核编程第六章内核内部要点-进程和线程使用 current 访问任务结构部分中介绍了使用current宏)。事实上,current经常被查找(和设置);将其作为每个 CPU 变量可以确保我们保持其无锁访问!以下是实现它的代码:

// arch/x86/include/asm/current.h
[ ... ]
DECLARE_PER_CPU(struct task_struct *, current_task);
static __always_inline struct task_struct *get_current(void)
{
    return this_cpu_read_stable(current_task);
}
#define current get_current()

DECLARE_PER_CPU()宏声明名为current_task的变量为struct task_struct *类型的每个 CPU 变量。get_current()内联函数在这个每个 CPU 变量上调用this_cpu_read_stable()助手,从而读取当前正在运行的 CPU 核上的current的值(阅读elixir.bootlin.com/linux/v5.4/source/arch/x86/include/asm/percpu.h#L383处的注释以了解这个例程的作用)。好吧,这很好,但一个常见问题:current_task每个 CPU 变量在哪里更新?想一想:内核必须在每当其上下文切换到另一个任务时更改(更新)current

这确实是这种情况;它确实在上下文切换代码(arch/x86/kernel/process_64.c:__switch_to();在elixir.bootlin.com/linux/v5.4/source/arch/x86/kernel/process_64.c#L504)中更新:

__visible __notrace_funcgraph struct task_struct *
__switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
    [ ... ]
 this_cpu_write(current_task, next_p);
    [ ... ]
}

接下来,一个快速实验来展示内核代码库中通过__alloc_percpu()使用每个 CPU:在内核源代码根目录中运行cscope -d(这假设您已经通过make cscope构建了cscope索引)。在cscope菜单中,在查找调用此函数的函数:提示下,键入__alloc_percpu。结果如下:

图 7.7 - 显示调用 __alloc_percpu() API 的内核代码的(部分)cscope -d 输出的屏幕截图

当然,这只是内核代码库中每个 CPU 使用的部分列表,仅跟踪通过__alloc_percpu()底层 API 的使用。搜索调用alloc_percpu[_gfp]()__alloc_percpu[_gfp]()的包装器)的函数会发现更多命中。

通过这些讨论,我们已经完成了关于内核同步技术和 API 的讨论,让我们通过学习一个关键领域来结束本章:在内核代码中调试锁定问题时的工具和提示!

内核中的锁调试

内核有几种方法来帮助调试与内核级锁定问题有关的困难情况,死锁是主要问题之一。

以防您还没有,确保您首先从上一章(第六章,内核同步-第一部分)中阅读了有关同步、锁定和死锁指南的基础知识,特别是独占执行和原子性Linux 内核中的并发问题部分。

在任何调试场景中,都有不同的调试发生的时间点,因此可能需要使用不同的工具和技术。非常广义地说,bug 可能会在软件开发生命周期SDLC)中的几个不同时间点被注意到和调试(实际上):

  • 开发期间

  • 发布前的开发(测试,质量保证QA)等)

  • 内部发布后

  • 发布后,在现场

一个众所周知且不幸的至理名言:bug 从开发中暴露出来的越远,修复的代价就越高!所以您确实希望尽早找到并修复它们!

由于本书专注于内核开发,我们将在这里专注于一些用于在开发时调试锁问题的工具和技术。

重要:我们期望您现在正在运行调试内核,即故意配置为开发/调试目的的内核。性能会受到影响,但没关系-我们现在是在找 bug!我们在伴随指南Linux 内核编程第五章编写您的第一个内核模块-LKMs 第二部分中介绍了典型调试内核的配置,并在这里提供了一个用于调试的示例内核配置文件:github.com/PacktPublishing/Linux-Kernel-Programming/raw/master/ch5/kconfigs/sample_kconfig_llkd_dbg.config。关于为锁调试配置调试内核的具体信息实际上在下面介绍。

配置调试内核以进行锁调试

由于与锁调试的相关性和重要性,我们将快速查看Linux 内核补丁提交清单文档(www.kernel.org/doc/html/v5.4/process/submit-checklist.html)中与我们讨论最相关的一个关键点,即启用调试内核(特别是用于锁调试):

// https://www.kernel.org/doc/html/v5.4/process/submit-checklist.html
[...]
12\. Has been tested with CONFIG_PREEMPT, CONFIG_DEBUG_PREEMPT, CONFIG_DEBUG_SLAB, CONFIG_DEBUG_PAGEALLOC, CONFIG_DEBUG_MUTEXES, CONFIG_DEBUG_SPINLOCK, CONFIG_DEBUG_ATOMIC_SLEEP, CONFIG_PROVE_RCU and CONFIG_DEBUG_OBJECTS_RCU_HEAD all simultaneously enabled. 
13\. Has been build- and runtime tested with and without CONFIG_SMP and CONFIG_PREEMPT.

16\. All codepaths have been exercised with all lockdep features enabled.
[ ... ]

尽管本书未涉及,但我不能不提到一个非常强大的动态内存错误检测器,称为内核地址 SANitizerKASAN)。简而言之,它使用基于编译时的仪器化动态分析来捕获常见的与内存相关的 bug(它适用于 GCC 和 Clang)。ASan地址 Sanitizer)由 Google 工程师贡献,用于监视和检测用户空间应用程序中的内存问题(在Linux 的系统编程实践书中详细介绍并与 valgrind 进行比较)。内核等效的 KASAN 自 4.0 内核以来已经适用于 x86_64 和 AArch64(从 4.4 Linux 开始)。可以在内核文档中找到有关详细信息(如何启用和使用它)(www.kernel.org/doc/html/v5.4/dev-tools/kasan.html#the-kernel-address-sanitizer-kasan);我强烈建议您在调试内核中启用它。

正如伴随指南Linux 内核编程第二章从源代码构建 5.x Linux 内核-第一部分中所述,我们可以根据我们的需求配置我们的 Linux 内核。在这里(在 5.4.0 内核源代码树的根目录中),我们执行make menuconfig并导航到Kernel hacking / Lock Debugging (spinlocks, mutexes, etc...)菜单(见图 7.8,在我们的 x86_64 Ubuntu 20.04 LTS 虚拟机上拍摄):

图 7.8-(截断)内核 hacking / Lock Debugging(spinlocks,mutexes,等...)菜单的屏幕截图,启用了我们调试内核所需的项目

图 7.8<Kernel hacking> Lock Debugging(spinlocks,mutexes,等...)菜单的(截断)屏幕截图,启用了我们调试内核所需的项目。

与交互式地逐个浏览每个菜单项并选择<帮助>按钮以查看其内容相比,获得相同的帮助信息的一个更简单的方法是查看相关的 Kconfig 文件(描述菜单)。在这里,它是lib/Kconfig.debug,因为所有与调试相关的菜单都在那里。对于我们的特殊情况,搜索menu "锁调试(自旋锁、互斥锁等...)"字符串,其中锁调试部分开始(见下表)。

以下表总结了每个内核锁调试配置选项帮助调试的内容(我们没有展示所有内容,对于其中一些内容,我们直接引用了lib/Kconfig.debug文件中的内容):

锁调试菜单标题它的作用
锁调试:证明锁定正确性(CONFIG_PROVE_LOCKING这是lockdep内核选项 - 打开它以始终获得锁正确性的滚动证明。任何与锁定相关的死锁的可能性甚至在实际发生之前就报告;非常有用!(稍后更详细地解释。)
锁使用统计(CONFIG_LOCK_STAT跟踪锁争用点(稍后更详细地解释)。
RT 互斥锁调试,死锁检测(CONFIG_DEBUG_RT_MUTEXES这允许自动检测和报告 rt 互斥锁语义违规和 rt 互斥锁相关的死锁(锁死)。”
自旋锁和rw-lock调试:基本检查(CONFIG_DEBUG_SPINLOCK打开此选项(与CONFIG_SMP一起)有助于捕获缺少自旋锁初始化和其他常见自旋锁错误。
互斥锁调试:基本检查(CONFIG_DEBUG_MUTEXES此功能允许检测和报告互斥锁语义违规。”
RW 信号量调试:基本检查(CONFIG_DEBUG_RWSEMS允许检测和报告不匹配的 RW 信号量锁定和解锁。
锁调试:检测错误释放活锁(CONFIG_DEBUG_LOCK_ALLOC此功能将检查内核是否通过任何内存释放例程(kfree(),kmem_cache_free(),free_pages(),vfree()等)错误释放任何持有的锁(自旋锁、读写锁、互斥锁或 RW 信号量),是否通过spin_lock_init()/mutex_init()等错误重新初始化活锁,或者是否在任务退出期间持有任何锁。”
原子段内睡眠检查(CONFIG_DEBUG_ATOMIC_SLEEP如果在这里选择 Y,各种可能会睡眠的例程在自旋锁被持有时、在 rcu 读端关键段内、在禁止抢占的段内、在中断内等情况下将变得非常嘈杂...
锁 API 启动时自检(CONFIG_DEBUG_LOCKING_API_SELFTESTS如果您希望内核在启动时运行简短的自检,请在此处选择 Y。自检检查调试机制是否检测到常见类型的锁定错误。(如果禁用锁调试,则当然不会检测到这些错误。)以下锁定 API 包括:自旋锁、读写锁、互斥锁和 RW 信号量。”
锁的折磨测试(CONFIG_LOCK_TORTURE_TEST此选项提供一个在内核锁原语上运行折磨测试的内核模块。如果需要,可以在运行的内核上构建内核模块进行测试。”(可以内联构建为Y,也可以作为模块外部构建为M。”

表 17.4 - 典型的内核锁调试配置选项及其含义

正如先前建议的,打开开发和测试过程中使用的调试内核中的所有或大部分锁调试选项是一个好主意。当然,预期的是,这样做可能会显著减慢执行速度(并使用更多内存);就像生活中一样,这是一个你必须决定的权衡:你可以在速度的代价下获得常见锁定问题、错误和死锁的检测。这是一个你应该更愿意做出的权衡,特别是在开发(或重构)代码时。

锁验证器 lockdep - 及早捕捉锁定问题

Linux 内核具有一个非常有用的功能,可以被内核开发人员充分利用:运行时锁定正确性或锁定依赖验证器;简而言之,lockdep。基本思想是:每当内核中发生任何锁定活动 - 获取或释放任何内核级别的锁,或涉及多个锁的任何锁定序列时,lockdep运行时就会发挥作用。

这是被跟踪或映射的(有关性能影响及其如何被缓解的更多信息,请参见下一段)。通过应用已知的正确锁定规则(在前一章的锁定指南和死锁部分中你已经得到了一些提示),lockdep然后对所做的正确性进行结论。

这是它的美妙之处,lockdep实现了 100%的数学证明(或闭合),证明了锁序列是正确的还是不正确。以下是来自内核文档对该主题的直接引用(www.kernel.org/doc/html/v5…

验证器在数学上实现了完美的“闭合”(锁定正确性的证明),即对于内核生命周期中至少发生一次的每个简单的、独立的单任务锁定序列,验证器都能以 100%的确定性证明,这些锁定序列的任何组合和时序都不会导致任何类型的锁相关死锁。

此外,lockdep通过发出WARN*()宏来警告您有关以下类别的锁定错误:死锁/锁倒置场景、循环锁依赖关系以及硬中断/软中断安全/不安全的锁定错误。这些信息非常宝贵;使用lockdep验证您的代码可以通过及早捕捉锁定问题来节省数百个被浪费的工作小时。(顺便说一下,lockdep跟踪所有锁及其锁定序列或“锁链”;这些可以通过/proc/lockdep_chains查看。)

关于性能缓解:你可能会想象,随着成千上万个锁实例在周围浮动,验证每个单个锁序列将会非常慢(实际上,它的算法时间复杂度是O(N²))。这根本行不通;因此,lockdep通过验证任何锁定场景(比如,在某个代码路径上,先获取锁 A,然后获取锁 B - 这被称为锁序列锁链仅一次,即第一次出现时。它通过维护每个锁链的 64 位哈希来实现这一点。

原始用户空间方法:一种非常原始的尝试检测死锁的方法是通过用户空间,只需使用 GNU ps(1);执行ps -LA -o state,pid,cmd | grep "^D"会打印出处于D(不可中断睡眠,TASK_UNINTERRUPTIBLE)状态的任何线程。这可能是由于死锁,但也可能不是;如果它持续了很长时间,那么它很可能是死锁。试一试!当然,lockdep是一个远远优越的解决方案。(请注意,这仅适用于 GNU ps,而不适用于轻量级的busybox ps。)

其他有用的用户空间工具是strace(1)ltrace(1) - 它们分别提供了由进程(或线程)发出的每个系统和库调用的详细跟踪;你可能能够捕捉到一个挂起的进程/线程,并查看它在哪里被卡住(使用strace -p PID对挂起的进程可能特别有用)。

另一个需要明确的要点是:lockdep发出关于(数学上)不正确的锁定的警告,即使在运行时实际上没有发生死锁!lockdep提供了证据表明确实存在可能在将来某个时刻导致错误(死锁、不安全的锁定等)的问题;它通常是完全正确的;认真对待并修复问题。 (再说一遍,通常情况下,软件世界中没有任何东西是 100%正确的 100%的时间:如果lockdep代码本身出现了错误怎么办?甚至还有一个CONFIG_DEBUG_LOCKDEP配置选项。最重要的是,我们作为人类开发人员必须仔细评估情况,检查是否存在错误的警告。)

接下来,lockdep基于锁类进行工作;这只是一个“逻辑”锁,而不是该锁的“物理”实例。例如,内核的打开文件数据结构struct file有两个锁——互斥锁和自旋锁——lockdep将每个锁都视为一个锁类。即使在运行时内存中存在几千个struct file实例,lockdep也只会将其跟踪为一个类。有关lockdep内部设计的更多细节,我们建议您参考官方内核文档(www.kernel.org/doc/html/v5.4/locking/lockdep-design.html)。

示例 - 使用 lockdep 捕获死锁错误

在这里,我们假设您现在已经构建并正在运行一个启用了lockdep的调试内核(详细描述在为锁调试配置调试内核部分)。验证它确实已启用:

$ uname -r
5.4.0-llkd-dbg

$ grep PROVE_LOCKING /boot/config-5.4.0-llkd-dbg
CONFIG_PROVE_LOCKING=y

$

好的!现在,让我们亲自体验一些死锁,看看lockdep将如何帮助您捕获它们。继续阅读!

示例 1 - 使用 lockdep 捕获自死锁错误

作为第一个例子,让我们回到我们的一个内核模块,这个模块是伴随指南Linux Kernel Programming - Chapter 6Kernel Internals Essentials – Processes and Threads部分的,Iterating over the task list部分,这里:github.com/PacktPublishing/Linux-Kernel-Programming/raw/master/ch6/foreach/thrd_showall/thrd_showall.c。在这里,我们循环遍历每个线程,从其任务结构中打印一些细节;关于这一点,这里有一个代码片段,我们从中获取线程的名称(记住它在任务结构的一个成员中叫做comm):

// ch6/foreach/thrd_showall/thrd_showall.c
static int showthrds(void)
{
    struct task_struct *g = NULL, *t = NULL; /* 'g' : process ptr; 't': thread ptr */
    [ ... ]
    do_each_thread(g, t) { /* 'g' : process ptr; 't': thread ptr */
        task_lock(t);
        [ ... ]
        if (!g->mm) {    // kernel thread
            snprintf(tmp, TMPMAX-1, " [%16s]", t->comm);
        } else {
            snprintf(tmp, TMPMAX-1, " %16s ", t->comm);
        }
        snprintf(buf, BUFMAX-1, "%s%s", buf, tmp);
        [ ... ]

这样做是有效的,但似乎有更好的方法:与其直接使用t->comm查找线程的名称(就像我们在这里做的那样),内核提供了{get,set}_task_comm()辅助例程来获取和设置任务的名称。因此,我们重写代码以使用get_task_comm()辅助宏;它的第一个参数是放置名称的缓冲区(预期您已为其分配了内存),第二个参数是要查询其名称的线程的任务结构的指针(以下代码片段来自这里:ch13/3_lockdep/buggy_thrdshow_eg/thrd_showall_buggy.c):

// ch13/3_lockdep/buggy_lockdep/thrd_showall_buggy.c
static int showthrds_buggy(void)
{
    struct task_struct *g, *t; /* 'g' : process ptr; 't': thread ptr */
    [ ... ]
    char buf[BUFMAX], tmp[TMPMAX], tasknm[TASK_COMM_LEN];
    [ ... ]
    do_each_thread(g, t) { /* 'g' : process ptr; 't': thread ptr */
        task_lock(t);
        [ ... ]
        get_task_comm(tasknm, t);
        if (!g->mm) // kernel thread
            snprintf(tmp, sizeof(tasknm)+3, " [%16s]", tasknm);
        else
            snprintf(tmp, sizeof(tasknm)+3, " %16s ", tasknm);
        [ ... ]

当编译并插入到我们的测试系统(一个虚拟机,谢天谢地)的内核中时,它可能会变得奇怪,甚至只是简单地挂起!(当我这样做时,我能够在系统完全无响应之前通过dmesg(1)检索内核日志。)

如果您的系统在插入此 LKM 时卡住了怎么办?嗯,这就是内核调试的困难所在!您可以尝试的一件事(在我在 x86_64 Fedora 29 VM 上尝试这个例子时对我有效)是重新启动卡住的 VM,并使用journalctl --since="1 hour ago"命令查看内核日志,利用 systemd 强大的journalctl(1)实用程序;您应该能够看到lockdep的 printk 输出。不幸的是,不能保证内核日志的关键部分在卡住时保存到磁盘,以便journalctl能够检索。这就是为什么使用内核的kdump功能 - 然后使用crash(8)对内核转储映像文件进行事后分析 - 可以成为救命稻草的原因(请参阅本章进一步阅读部分中有关使用kdumpcrash的资源)。

扫视内核日志,很明显:lockdep捕获到了(自身)死锁(我们在截图中展示了相关部分输出)。

图 7.9 - 展示我们的有 bug 的模块加载后的内核日志的(部分)截图;lockdep 捕获到了自身死锁!

尽管接下来有更多的细节(包括insmod(8)的内核堆栈的堆栈回溯 - 因为它是进程上下文,在这种情况下,寄存器值等等),但是我们在前面的图中看到的足以推断出发生了什么。显然,lockdep告诉我们insmod/2367 正在尝试获取锁:,接着是但任务已经持有锁:。接下来(仔细看图 7.9),insmod持有的锁是(p->alloc_lock)(暂时忽略后面的内容;我们很快会解释),实际尝试获取它的例程(在at:后面显示)是__get_task_comm+0x28/0x50。现在我们有了进展:让我们弄清楚在调用get_task_comm()时到底发生了什么;我们发现它是一个宏,是实际工作例程__get_task_comm()的包装器。它的代码如下:

// fs/exec.c
char *__get_task_comm(char *buf, size_t buf_size, struct task_struct *tsk)
{
    task_lock(tsk);
    strncpy(buf, tsk->comm, buf_size);
    task_unlock(tsk);
    return buf; 
}
EXPORT_SYMBOL_GPL(__get_task_comm);

啊,问题就在这里:__get_task_comm()函数尝试重新获取我们已经持有的同一个锁,导致(自身)死锁!我们在哪里获取它?回想一下,在我们(有 bug 的)内核模块进入循环后的第一行代码是我们调用task_lock(t),然后几行后,我们调用get_task_comm(),它在内部尝试重新获取同一个锁:结果就是自身死锁

do_each_thread(g, t) {   /* 'g' : process ptr; 't': thread ptr */
    task_lock(t);
    [ ... ]
    get_task_comm(tasknm, t);

此外,找到这个特定锁是很容易的;查找task_lock()例程的代码:

// include/linux/sched/task.h */
static inline void task_lock(struct task_struct *p)
{
    spin_lock(&p->alloc_lock);
}

所以,现在一切都说得通了;这是任务结构中名为alloc_lock的自旋锁,就像lockdep告诉我们的那样。

lockdep的报告中有一些令人困惑的标记。看看以下几行:

[ 1021.449384] insmod/2367 is trying to acquire lock:
[ 1021.451361] ffff88805de73f08 (&(&p->alloc_lock)->rlock){+.+.}, at: __get_task_comm+0x28/0x50
[ 1021.453676]
               but task is already holding lock:
[ 1021.457365] ffff88805de73f08 (&(&p->alloc_lock)->rlock){+.+.}, at: showthrds_buggy+0x13e/0x6d1 [thrd_showall_buggy]

忽略时间戳,在前面的代码块中看到的第二行最左边列中的数字是用于标识这个特定锁序列的 64 位轻量级哈希值。请注意,它与下一行中的哈希值完全相同;因此,我们知道它是同一个锁!{+.+.}是 lockdep 对这个锁获取的状态的表示(含义是:+表示在启用 IRQ 的情况下获取锁,.表示在禁用 IRQ 并且不在 IRQ 上下文中获取锁,等等)。这些在内核文档中有解释(www.kernel.org/doc/Documentation/locking/lockdep-design.txt);我们就到此为止。

Steve Rostedt 在 2011 年的 Linux Plumber's Conference 上做了一个关于解释lockdep输出的详细演示;相关幻灯片很有启发性,探讨了简单和复杂的死锁场景以及lockdep如何检测它们:

Lockdep: 如何阅读其神秘的输出 (blog.linuxplumbersconf.org/2011/ocw/sessions/153)。

修复它

现在我们理解了这里的问题,我们该如何解决呢?看到 lockdep 的报告(图 7.9)并解释它,很简单:(如前所述)由于在do-while循环的开始已经获取了名为alloc_lock的任务结构自旋锁(通过task_lock(t)),确保在调用get_task_comm()例程之前(它在内部获取并释放相同的锁),您解锁它,然后执行get_task_comm(),然后再次锁定它。

以下屏幕截图(图 7.10)显示了旧版本(ch13/3_lockdep/buggy_thrdshow_eg/thrd_showall_buggy.c)和我们代码的新版本之间的差异(通过diff(1)实用程序):

图 7.10 - (部分)屏幕截图显示了我们的演示 thrdshow LKM 的错误和修复版本之间的关键部分

很好;接下来是另一个例子 - 捕获 AB-BA 死锁!

示例 2 - 使用 lockdep 捕获 AB-BA 死锁

作为另一个例子,让我们看一个(演示)内核模块,它故意创建了一个循环依赖,最终会导致死锁。 代码在这里:ch13/3_lockdep/deadlock_eg_AB-BA。 我们基于之前的一个模块(ch13/2_percpu)创建了这个模块;正如您所记得的,我们创建了两个内核线程,并确保(通过使用一个被篡改的sched_setaffinity())每个内核线程在唯一的 CPU 核心上运行(第一个内核线程在 CPU 核心0上运行,第二个在核心1上运行)。

这样,我们就有了并发性。现在,在这些线程中,我们让它们使用两个自旋锁,lockAlockB。 理解我们有一个进程上下文,有两个或更多锁,我们记录并遵循锁定顺序规则:首先获取 lockA,然后获取 lockB。 很好;所以,一种不应该这样做的方式是:

kthread 0 on CPU #0                kthread 1 on CPU #1
  Take lockA                           Take lockB
     <perform work>                       <perform work>
                                          (Try and) take lockA
                                          < ... spins forever :
                                                DEADLOCK ... >
(Try and) take lockB
< ... spins forever : 
      DEADLOCK ... >

当程序(实际上是内核线程 1)忽略了锁定顺序规则(当lock_ooo模块参数设置为1时),这当然是经典的 AB-BA 死锁! 它发生了死锁。 这里是相关的代码(我们没有在这里显示整个程序;请克隆本书的 GitHub 存储库github.com/PacktPublishing/Linux-Kernel-Programming并自行尝试):

// ch13/3_lockdep/deadlock_eg_AB-BA/deadlock_eg_AB-BA.c
[ ... ]
/* Our kernel thread worker routine */
static int thrd_work(void *arg)
{
    [ ... ]
   if (thrd == 0) { /* our kthread #0 runs on CPU 0 */
        pr_info(" Thread #%ld: locking: we do:"
            " lockA --> lockB\n", thrd);
        for (i = 0; i < THRD0_ITERS; i ++) {
            /* In this thread, perform the locking per the lock ordering 'rule';
 * first take lockA, then lockB */
            pr_info(" iteration #%d on cpu #%ld\n", i, thrd);
            spin_lock(&lockA);
            DELAY_LOOP('A', 3); 
            spin_lock(&lockB);
            DELAY_LOOP('B', 2); 
            spin_unlock(&lockB);
            spin_unlock(&lockA);
        }

我们的内核线程0正确执行,遵循锁定顺序规则;与之前的代码相关的我们的内核线程1的代码如下:

   [ ... ]
   } else if (thrd == 1) { /* our kthread #1 runs on CPU 1 */
        for (i = 0; i < THRD1_ITERS; i ++) {
            /* In this thread, if the parameter lock_ooo is 1, *violate* the
 * lock ordering 'rule'; first (attempt to) take lockB, then lockA */
            pr_info(" iteration #%d on cpu #%ld\n", i, thrd);
            if (lock_ooo == 1) {        // violate the rule, naughty boy!
                pr_info(" Thread #%ld: locking: we do: lockB --> lockA\n",thrd);
                spin_lock(&lockB);
                DELAY_LOOP('B', 2);
                spin_lock(&lockA);
                DELAY_LOOP('A', 3);
                spin_unlock(&lockA);
                spin_unlock(&lockB);
            } else if (lock_ooo == 0) { // follow the rule, good boy!
                pr_info(" Thread #%ld: locking: we do: lockA --> lockB\n",thrd);
                spin_lock(&lockA);
                DELAY_LOOP('B', 2);
                spin_lock(&lockB);
                DELAY_LOOP('A', 3);
                spin_unlock(&lockB);
                spin_unlock(&lockA);
            }
    [ ... ]

构建并运行它,将lock_ooo内核模块参数设置为0(默认值);我们发现,遵守锁定顺序规则,一切正常:

$ sudo insmod ./deadlock_eg_AB-BA.ko
$ dmesg
[10234.023746] deadlock_eg_AB-BA: inserted (param: lock_ooo=0)
[10234.026753] thrd_work():115: *** thread PID 6666 on cpu 0 now ***
[10234.028299] Thread #0: locking: we do: lockA --> lockB
[10234.029606] iteration #0 on cpu #0
[10234.030765] A
[10234.030766] A
[10234.030847] thrd_work():115: *** thread PID 6667 on cpu 1 now ***
[10234.031861] A
[10234.031916] B
[10234.032850] iteration #0 on cpu #1
[10234.032853] Thread #1: locking: we do: lockA --> lockB
[10234.038831] B
[10234.038836] Our kernel thread #0 exiting now...
[10234.038869] B
[10234.038870] B
[10234.042347] A
[10234.043363] A
[10234.044490] A
[10234.045551] Our kernel thread #1 exiting now...
$ 

现在,我们将lock_ooo内核模块参数设置为1运行它,发现,如预期的那样,系统被锁定! 我们违反了锁定顺序规则,因此系统陷入了死锁! 这次,重新启动 VM 并执行journalctl --since="10 min ago"得到了 lockdep 的报告:

======================================================
WARNING: possible circular locking dependency detected
5.4.0-llkd-dbg #2 Tainted: G OE
------------------------------------------------------
thrd_0/0/6734 is trying to acquire lock:
ffffffffc0fb2518 (lockB){+.+.}, at: thrd_work.cold+0x188/0x24c [deadlock_eg_AB_BA]

but task is already holding lock:
ffffffffc0fb2598 (lockA){+.+.}, at: thrd_work.cold+0x149/0x24c [deadlock_eg_AB_BA]

which lock already depends on the new lock.
[ ... ]
other info that might help us debug this:

 Possible unsafe locking scenario:

       CPU0                    CPU1
       ----                    ----
  lock(lockA);
                               lock(lockB);
                               lock(lockA);
  lock(lockB);

 *** DEADLOCK ***

[ ... lots more output follows ... ]

lockdep报告非常惊人。 在句子“可能的不安全锁定场景:”之后,检查一下,它几乎精确地显示了运行时实际发生的情况 - CPU1 : lock(lockB); --> lock(lockA);out-of-orderooo)锁定顺序!由于lockA已经被 CPU 0上的内核线程占用,CPU 1上的内核线程永远旋转 - 这是 AB-BA 死锁的根本原因。

此外,非常有趣的是,模块插入后不久(lock_ooo设置为1),内核还检测到了软锁定错误。 printk 被定向到我们的控制台,日志级别为KERN_EMERG,这使我们能够看到这一点,尽管系统似乎已经挂起。 它甚至显示了问题的起源(再次强调,这个输出是在我的 x86_64 Ubuntu 20.04 LTS VM 上运行自定义 5.4.0 调试内核)的相关内核线程:

Message from syslogd@seawolf-VirtualBox at Dec 24 11:01:51 ...
kernel:[10939.279524] watchdog: BUG: soft lockup - CPU#0 stuck for 22s! [thrd_0/0:6734]
Message from syslogd@seawolf-VirtualBox at Dec 24 11:01:51 ...
kernel:[10939.287525] watchdog: BUG: soft lockup - CPU#1 stuck for 23s! [thrd_1/1:6735]

(FYI,检测到这一点并喷出前面的消息的代码在这里:kernel/watchdog.c:watchdog_timer_fn())。

另一个注意事项:/proc/lockdep_chains的输出也“证明”了错误的锁定顺序被采用(或存在):

$ sudo cat /proc/lockdep_chains
[ ... ]
irq_context: 0
[000000005c6094ba] lockA
[000000009746aa1e] lockB
[ ... ]
irq_context: 0
[000000009746aa1e] lockB
[000000005c6094ba] lockA

还要记住,lockdep仅在第一次违反任何内核锁的锁规则时报告一次。

lockdep - 注释和问题

让我们用一些关于强大的lockdep基础设施的要点来总结这一覆盖范围。

lockdep 注释

在用户空间,您可能熟悉使用非常有用的assert()宏。在那里,您断言一个布尔表达式,一个条件(例如,assert(p == 5);)。如果断言在运行时为真,则什么也不会发生,执行会继续;当断言为假时,进程将被中止,并且一个嘈杂的printf()会指示哪个断言以及它失败的位置。这允许开发人员检查他们期望的运行时条件。因此,断言可能非常有价值-它们有助于捕获错误!

类似地,lockdep允许内核开发人员通过lockdep_assert_held()宏在特定点断言锁已被持有。这称为lockdep 注释。宏定义如下所示:

// include/linux/lockdep.h
#define lockdep_assert_held(l) do { \
        WARN_ON(debug_locks && !lockdep_is_held(l)); \
    } while (0)

断言失败会导致警告(通过WARN_ON())。这非常有价值,因为它意味着尽管现在应该持有锁l,但实际上并没有。还要注意,这些断言只在启用锁调试时才起作用(这是内核内启用锁调试时的默认设置;只有在lockdep或其他内核锁定基础设施发生错误时才会关闭)。事实上,内核代码库在核心和驱动程序代码中都广泛使用lockdep注释。(还有一些形式为lockdep_assert_held*()lockdep断言的变体,以及很少使用的lockdep_*pin_lock()宏。)

lockdep 问题

在使用lockdep时可能会出现一些问题:

  • 重复加载和卸载模块可能导致lockdep的内部锁类限制超出(如内核文档中所解释的那样,加载x.ko内核模块会为其所有锁创建一组新的锁类,而卸载x.ko则不会删除它们;实际上是重用)。实际上,要么不要重复加载/卸载模块,要么重置系统。

  • 特别是在数据结构具有大量锁(例如结构数组)的情况下,未能正确初始化每个锁可能会导致lockdep锁类溢出。

debug_locks整数在禁用锁调试时设置为0(即使在调试内核上也是如此);这可能会导致出现以下消息:*WARNING* lock debugging disabled!! - possibly due to a lockdep warning。这甚至可能是由于lockdep之前发出警告而发生的。重新启动系统并重试。

尽管本书是基于 5.4 LTS 内核的,但在撰写时最近合并到 5.8 内核中的一个强大功能是内核并发性检查器KCSAN)。这是 Linux 内核的数据竞争检测器,通过编译时插装工作。您可以在这些 LWN 文章中找到更多详细信息:使用 KCSAN 查找竞争条件,LWN,2019 年 10 月(lwn.net/Articles/802128/)和并发错误应该害怕大坏数据竞争检测器(第一部分),LWN,2020 年 4 月(lwn.net/Articles/816850/)。

另外,值得一提的是,存在一些工具用于捕获用户空间应用程序中的锁定错误和死锁。其中包括著名的helgrind(来自 Valgrind 套件)、TSan线程检测器),它提供了编译时的仪器来检查多线程应用程序中的数据竞争,以及 lockdep 本身;lockdep 也可以在用户空间中使用(作为库)!此外,现代的[e]BPF 框架提供了deadlock-bpfcc(8)前端。它专门设计用于在给定运行进程(或线程)中找到潜在的死锁(锁定顺序倒置)。

锁定统计

锁定可能会争用,这是指当一个上下文想要获取锁,但它已经被占用,因此必须等待解锁发生。严重的争用可能会导致严重的性能瓶颈;内核提供了锁定统计,以便轻松识别严重争用的锁。通过打开CONFIG_LOCK_STAT内核配置选项来启用锁定统计(如果没有这个选项,在大多数发行版内核上,/proc/lock_stat条目将不存在)。

锁定统计代码利用了lockdep在锁定代码路径(__contended__acquired__released钩子)中插入钩子来在这些关键点收集统计信息。关于锁定统计的精心编写的内核文档(www.kernel.org/doc/html/latest/locking/lockstat.html#lock-statistics)传达了这些信息(以及更多)以及有用的状态图;请查阅。

查看锁定统计

一些快速提示和查看锁定统计信息的基本命令如下(当然,这假设CONFIG_LOCK_STAT已经打开):

做什么?命令
清除锁定统计sudo sh -c "echo 0 > /proc/lock_stat"
启用锁定统计sudo sh -c "echo 1 > /proc/sys/kernel/lock_stat"
禁用锁定统计sudo sh -c "echo 0 > /proc/sys/kernel/lock_stat"

接下来,一个简单的演示来查看锁定统计信息:我们编写一个非常简单的 Bash 脚本,ch13/3_lockdep/lock_stats_demo.sh(在本书的 GitHub 存储库中查看其代码)。它清除并启用锁定统计,然后简单地运行cat /proc/self/cmdline命令。这实际上会触发内核深处的一系列代码运行(主要在fs/proc内);需要查找几个全局的可写数据结构。这将构成一个关键部分,因此将会获取锁。我们的脚本将禁用锁定统计,然后使用 grep 命令查看一些锁定统计信息,过滤掉其余的部分:

egrep "alloc_lock|task|mm" /proc/lock_stat                                                                        

运行后,我们得到的输出如下(同样,在我们的 x86_64 Ubuntu 20.04 LTS VM 上运行我们的自定义 5.4.0 调试内核):

图 7.11 - 屏幕截图显示我们的 lock_stats_demo.sh 脚本运行,显示一些锁定统计信息

图 7.11中的输出在水平上相当长,因此换行。)显示的时间单位是微秒。class name字段是锁类;我们可以看到与任务和内存结构(task_structmm_struct)相关的几个锁!我们不会重复材料,而是建议您查阅锁定统计的内核文档,该文档解释了前述字段(con-bounceswaittime*等)以及如何解释输出。正如预期的那样,在图 7.11中,在这种简单情况下,以下内容:

  • 第一个字段class_name是锁类;这里看到了锁的(符号)名称。

  • 实际上没有锁的争用(字段 2 和 3)。

  • 等待时间(waittime*,字段 3 到 6)为 0。

  • acquisitions字段(#9)是锁定被获取(占用)的总次数;它是正数(甚至对于mm_struct信号量&mm->mmap_sem*,它甚至超过了 300)。

  • 最后的四个字段,10 到 13,是累积锁持有时间统计(holdtime-{min|max|total|avg})。同样,在这里,您可以看到 mm_struct mmap_sem* 锁的平均持有时间最长。

  • (请注意,任务结构的自旋锁命名为alloc_lock也被占用;我们在示例 1 - 使用 lockdep 捕获自死锁错误部分遇到了它)。

可以通过 sudo grep ":" /proc/lock_stat | head 查找系统上争用最激烈的锁。当然,您应该意识到这是上次重置(清除)锁统计信息时的情况。

请注意,由于锁调试被禁用,锁统计信息可能会被禁用;例如,您可能会遇到这种情况:

$ sudo cat /proc/lock_stat
lock_stat version 0.4
*WARNING* lock debugging disabled!! - possibly due to a lockdep warning

这个警告可能需要您重新启动系统。

好了,您离成功不远了!让我们以对内存屏障的简要介绍结束本章。

内存屏障 - 介绍

最后但同样重要的是,让我们简要讨论另一个问题 - 内存屏障。这是什么意思?有时,程序流对人类程序员来说变得不可知,因为微处理器、内存控制器和编译器可以重新排序内存读取和写入。在大多数情况下,这些“技巧”保持良性并且被优化。但是有些情况 - 通常跨硬件边界,例如多核系统上的 CPU 核心、CPU 到外围设备,以及反之亦然的UniProcessorUP) - 在这些情况下,这种重新排序不应该发生;必须遵守原始和预期的内存加载和存储顺序。内存屏障(通常是嵌入在*mb*()宏中的机器级指令)是一种抑制这种重新排序的方法;它是一种强制 CPU/内存控制器和编译器按照所需的顺序对指令/数据进行排序的方法。

可以通过使用以下宏将内存屏障放入代码路径中:#include <asm/barrier.h>

  • rmb(): 将读(或加载)内存屏障插入指令流中

  • wmb(): 将写(或存储)内存屏障插入指令流中

  • mb(): 通用内存屏障;直接引用内存屏障的内核文档(www.kernel.org/doc/Documentation/memory-barriers.txt)上的话,"通用内存屏障保证在屏障之前指定的所有 LOAD 和 STORE 操作将在系统的其他组件方面发生在屏障之后指定的所有 LOAD 和 STORE 操作之前。"

内存屏障确保在执行前面的指令或数据访问之前,后续的指令不会执行,从而保持顺序。在某些(罕见)情况下,DMA 可能是其中之一,驱动程序作者使用内存屏障。在使用 DMA 时,重要的是阅读内核文档(www.kernel.org/doc/Documentation/DMA-API-HOWTO.txt)。它提到了内存屏障的使用位置以及不使用它们的危险;有关此内容的更多示例,请参见以下内容。

由于内存屏障的放置通常对我们中的许多人来说是一个相当令人困惑的事情,我们建议您参考为您编写驱动程序的处理器或外围设备的相关技术参考手册,以获取更多详细信息。例如,在树莓派上,SoC 是 Broadcom BCM2835 系列;参考其外围设备手册 - BCM2835 ARM Peripherals 手册(www.raspberrypi.org/app/uploads/2012/02/BCM2835-ARM-Peripherals.pdf),第 1.3 节,正确内存排序的外围设备访问注意事项 - 有助于弄清何时以及何时不使用内存屏障。

在设备驱动程序中使用内存屏障的示例

举个例子,以 Realtek 8139“快速以太网”网络驱动程序为例。为了通过 DMA 传输网络数据包,必须首先设置 DMA(传输)描述符对象。对于这个特定的硬件(NIC 芯片),DMA 描述符对象定义如下:

//​ drivers/net/ethernet/realtek/8139cp.c
struct cp_desc {
    __le32 opts1;
    __le32 opts2;
    __le64 addr;
};

DMA 描述符对象,被命名为struct cp_desc,有三个“单词”。每个单词都必须初始化。现在,为了确保 DMA 控制器正确解释描述符,通常至关重要的是看到对 DMA 描述符的写入与驱动程序作者的意图相同的顺序。为了保证这一点,使用了内存屏障。事实上,相关的内核文档 - 动态 DMA 映射指南www.kernel.org/doc/Documentation/DMA-API-HOWTO.txt)告诉我们确保这确实是这种情况。因此,例如,当设置 DMA 描述符时,您必须将其编码如下,以在所有平台上获得正确的行为:

desc->word0 = address;
wmb();
desc->word1 = DESC_VALID;

因此,看看实践中如何设置 DMA 传输描述符(由 Realtek 8139 驱动程序代码,如下):

// 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();
    [...]

根据芯片的数据表要求,驱动程序要求将单词txd->opts2txd->addr存储到内存中,然后存储txd->opts1单词。由于这些写入的顺序很重要,驱动程序使用wmb()写内存屏障。 (另外,FYI,RCU 当然是适当内存屏障的用户,以强制执行内存排序。)

此外,对于单个变量,使用READ_ONCE()WRITE_ONCE()绝对保证编译器和 CPU 会执行你的意图。它将排除所需的编译器优化,使用所需的内存屏障,并在多个核上的多个线程同时访问所涉及的变量时保证缓存一致性。

有关详细信息,请参阅内核文档中关于内存屏障的部分(www.kernel.org/doc/Documentation/DMA-API-HOWTO.txt)。大部分情况下,这些都是在幕后处理的;对于驱动程序作者来说,只有在执行操作,如设置 DMA 描述符或启动和结束 CPU 到外围设备(反之亦然)的通信时,才可能需要内存屏障。

最后一件事 - 一个(不幸的)常见问题:使用volatile关键字会神奇地使并发问题消失吗?当然不会。volatile关键字只是指示编译器禁用围绕该变量的常见优化(此代码路径之外的事物也可能修改标记为volatile的变量),仅此而已。在处理 MMIO 时,这通常是必需的和有用的。关于内存屏障,有趣的是,编译器不会重新排序对于其他volatile变量标记的变量的读取或写入。然而,原子性是一个单独的构造,不能通过使用volatile关键字来保证。

总结

嗯,你知道吗!恭喜你,你做到了,你完成了这本书!

在本章中,我们继续了上一章的内容,继续学习有关内核同步的知识。在这里,您学会了如何通过atomic_t和更新的refcount_t接口更有效地和安全地对整数进行锁定。在其中,您了解了典型的 RMW 序列如何在驱动程序作者的常见活动中被原子化和安全地使用 - 更新设备的寄存器。然后介绍了读者-写者自旋锁,这是一个有趣且有用的内容,尽管有一些注意事项。您将看到,由于不幸的缓存副作用,很容易错误地产生性能问题,包括查看伪共享问题以及如何避免它。

开发者的福音——无锁算法和编程技术——然后详细介绍了 Linux 内核中的每 CPU 变量。重要的是要学会如何谨慎地使用这些技术(尤其是更高级的形式,如 RCU)。最后,您将了解内存屏障是什么,它们通常在哪里使用。

您在 Linux 内核(以及相关领域,如设备驱动程序)中的长期工作之旅现在已经认真开始了。请注意,没有不断的动手实践和实际操作这些材料,成果很快就会消失……我敦促您与这些主题和其他主题保持联系。随着您的知识和经验的增长,为 Linux 内核(或任何开源项目)做出贡献是一项高尚的努力,您最好能够承担起这项任务。

问题

最后,这里有一些问题供您测试对本章材料的了解:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/questions。您会发现一些问题的答案在书的 GitHub 存储库中:github.com/PacktPublishing/Linux-Kernel-Programming/tree/master/solutions_to_assgn

进一步阅读

为了帮助您深入学习这个主题,我们在本书的 GitHub 存储库中提供了一个相当详细的在线参考和链接列表(有时甚至包括书籍)。进一步阅读文档在这里可用:github.com/PacktPublishing/Linux-Kernel-Programming/raw/master/Further_Reading.md