Linux内核编程——内核同步(上)

211 阅读1小时+

在前一章和之前的一章(分别是第10章和第11章)中,你已经学习了关于Linux操作系统上CPU(或任务)调度的很多内容。在本章和接下来的章节中,我们将深入探讨内核同步这一有时复杂的主题。

任何熟悉多线程环境编程的开发者都知道,当两个或多个线程(通常是代码路径)可能操作一个共享的可写数据项时,就需要进行同步。如果没有同步(或互斥)来访问共享数据,它们可能会发生竞争条件,也就是说,结果是不可预测的。这被称为数据竞争。(事实上,数据竞争甚至可以在多个单线程进程操作任何类型的共享内存对象,或者可能发生中断时发生。)纯代码本身从来不是问题,因为它的权限是读+执行(r-x);在多个CPU核心上同时读取和执行代码不仅完全可以且是安全的,而且是积极鼓励的(因为它可以带来更好的吞吐量,这也是为什么多线程是个好主意)。然而,一旦你开始处理共享可写数据,就必须非常小心了(这就是为什么多线程和并发往往是复杂的主题!)。

关于并发及其通过同步控制的讨论是多种多样的,尤其是在像Linux内核(及相关驱动程序/模块)这样丰富而复杂的软件中,我们在本书中正是处理这一问题。因此,为了方便起见,我们将这个大主题分成两章,本章和下一章。

本章将涵盖以下内容:

  • 临界区、独占执行和原子性
  • Linux内核中的并发问题
  • 使用互斥锁还是自旋锁?何时使用哪种
  • 使用互斥锁
  • 使用自旋锁
  • 锁与中断
  • 锁定——常见错误和指南

技术要求

我假设你已经完成了在线章节《内核工作空间设置》,并适当地准备了一个运行Ubuntu 22.04 LTS(或更高稳定版本,或最近的Fedora发行版)的虚拟机(VM),并安装了所有必需的包。如果没有,我强烈建议你先完成这些准备工作。

为了最大限度地利用本书,我强烈建议你首先设置工作空间环境,包括克隆本书的GitHub代码库,并以动手实践的方式进行操作。该代码库可以在这里找到:github.com/PacktPublis…

在本章和下一章中,我们将不时提到一些简单的设备驱动示例。如果你不熟悉基本的Linux驱动程序概念,虽然这不是必需的前提条件,但我再次建议你阅读《Linux内核编程——第二部分——字符设备驱动和内核同步》一书;本书的第一章已经对此进行了讲解:第1章,编写一个简单的misc字符设备驱动(这本第二部分书实际上是本书的配套卷;此外,电子书可以免费下载)。

完成这些准备工作后,系好安全带,让我们开始探讨这一重要且有趣的主题吧!

临界区、独占执行和原子性

假设你正在为一个多核系统编写软件(如今,大多数嵌入式项目甚至系统都是多核的)。正如我们在引言中提到的,运行多个代码路径并行不仅是安全的,而且是可取的(否则花这些钱干什么,对吧?)。另一方面,在并发(并行和同时)代码路径中,只要有任何共享可写数据(也称为共享状态)的访问,就要求你保证在任何给定时刻,只有一个线程可以操作这些数据!这一点非常重要。为什么?想一想:如果你允许多个并发代码路径并行操作共享可写数据,你就会招来麻烦:数据可能会被损坏(产生“数据竞争”)。接下来的章节,在覆盖一些关键点之后,将通过几个伪代码示例清楚地说明数据竞争的概念(如果你愿意,也可以看看图12.6)。

什么是临界区?

接下来的几点非常重要,请仔细阅读。

临界区是必须满足以下两个条件的代码路径:

  • 条件一:该代码路径可能是并发的,也就是说,它有可能并行执行。
  • 条件二:它操作(读取和/或写入)共享可写数据(即共享状态)。

因此,临界区,按定义,需要避免并行执行。

换句话说,临界区是必须独占执行的代码片段,有时甚至是原子性的。

所谓独占执行,我们指的是在任何给定时刻,只有一个线程在执行临界区的代码;也就是说,它是单独执行的(序列化的,而不是并行化的)。这是出于数据安全的原因。

“原子性”一词意味着某个操作是不可分割的;在这里,它意味着能够在没有中断的情况下执行直到完成。

如果两个或更多线程可以并发执行临界区的代码,那就是一个错误或缺陷;这种情况通常被称为竞态条件或数据竞争。

识别并保护临界区免受同时执行(避免数据竞争)是确保软件正确性的隐式要求,你——作为设计者/架构师/开发者——必须确保这一点。学习如何保护临界区(相对)容易;但正确识别每一个临界区是一项你必须掌握的技能。我们将通过以下的“练习”来帮助你。

练习1 在以下用户模式Pthreads(伪)代码片段中,你可以假设该函数代码可以并行执行(由多个线程执行),那么在时间t1和t2之间的代码区域(注释中显示)是否构成临界区?

void qux(int factor)
{
    int nok;
    [ … ]
    //------------------------ t1
    nok += 10*PI;
    //------------------------ t2
    printf("..."); [ … ]
}

解答1 好的,我会帮助你解决这个练习!问问自己,什么构成临界区?当以下两个条件得到满足时,就是临界区:代码路径可能并行执行,并且它操作共享可写数据。那么,这段代码(t1和t2之间的行)是否满足这两个前提条件呢?它可以并行执行(如明确说明),但它并不操作共享可写数据(变量nok不是共享的,它是一个局部变量,因此每个执行此代码路径的线程会在其堆栈上得到该变量的副本)。所以答案显然是否定的,它不是一个临界区。换句话说,它可以并行执行——任意数量的实例——而不需要任何显式保护。

接下来是另两个小练习。

练习2和3 在以下内核模块(伪)代码片段中,你可以假设代码可以并行执行(通过多个用户模式线程切换到内核空间执行这些代码路径,并在进程上下文中运行它们),那么在时间t1和t2之间的代码区域(注释中显示)是否构成临界区?

static struct quux_drvctx {
        ... 
} *mydrv;

write_quux()      /*  驱动写方法   */
{
    [ … ]
    //-------------------- t1
    mydrv->sensor2 = 1;
    mydrv->hw = hw_zoom;
    //-------------------- t2
               [ … ]
}

static int glob;

static __init my_kmod_init(void) /* 初始化方法 */
{
        [ … ]
        //-------------------- t1
    glob += 14;
    pr_info(...);
        //-------------------- t2
        [ … ]
}
[ … ]
module_init(my_kmod_init);
module_exit(my_kmod_cleanup);

(解答可以在本章的“解答”部分找到;请在查看解答前尝试自行解决。)

现在让我们回顾一下原子性的关键概念:原子操作是不可分割的操作。在任何现代处理器上,通常有两种操作被认为是原子的;也就是说,它们将执行完成而不被中断:

  • 执行单条机器语言指令。
  • 读取或写入与处理器字长对齐的原始数据类型(通常为32位或64位);因此,在64位系统上读取或写入32位或64位整数是保证原子的。线程读取该变量时永远不会看到中间的、破损的或脏的数据;它们要么看到旧值,要么看到新值。另一方面,在32位处理器上操作64位数据项并不保证原子性,可能导致读取(或写入)破损或脏数据。

一句忠告:在这里小心为上!已经有研究表明,在现代硬件处理器上使用高度优化的编译器时,即便是这种“真理”——即对齐的原始数据类型的加载/存储(即读取/写入)在处理器的字长内始终是原子的——也可能不成立!编译器现在可以采用加载/存储撕裂技术等;可以阅读这篇精彩文章:《谁怕优化编译器?》,LWN,2019年7月:lwn.net/Articles/79…(以及本章的进一步阅读部分)。

因此,如果你有一些操作共享(全局或静态)可写数据的代码行,在没有显式同步机制的情况下,它们无法保证独占执行。请注意,有时,运行临界区代码时既需要原子性,也需要独占性,但并非每次都如此;让我们进一步探讨这一点。

当临界区的代码在一个安全可以休眠、可能会阻塞的进程上下文中运行时(例如,通过用户应用程序对驱动程序进行典型的文件操作(打开、读取、写入、ioctl、mmap等),或者在内核线程或工作队列的执行路径中),不要求临界区代码必须是原子的,但它确实需要是独占的。然而,当其代码在一个非阻塞的原子上下文中运行时(例如在硬件中断中:硬中断、任务或软中断),它必须既是原子的,又是独占的(我们将在“互斥锁还是自旋锁?什么时候使用哪个”部分进一步讨论这些点)。

一个概念性的例子有助于澄清问题。假设三个线程(来自用户空间的应用程序)几乎同时发出open()和read()系统调用,从而在多核系统上同时操作你的驱动程序(作为内核模块或内核内部实现)。(回想一下,Linux是一个单片内核;当一个进程或线程发出系统调用时,它会切换到内核模式,并在进程上下文中运行相应的内核/驱动程序代码路径。)

如果没有任何干预,它们很可能会并行执行临界区的代码,从而并行操作共享可写数据(发生数据竞争!),并且很可能会破坏数据!现在,让我们来看一个概念图,看看在临界区的代码路径中非独占执行是如何错误的(我们在这里甚至不讨论原子性):

image.png

如前面的图所示,在你的设备驱动程序中,在其(假设是)读取方法内,你让它运行一些代码以执行任务(从硬件读取一些数据)。让我们更深入地分析这个图,看看在不同时间点对数据的访问情况:

  • 从时间 t0 到 t1:此时访问的是本地变量数据,或者根本没有数据访问。这是并发安全的,不需要保护,可以并行运行(因为每个线程有自己的私有栈)。
  • 从时间 t1 到 t2:此时访问的是全局/静态共享可写数据。这并不是自动并发安全的;它是一个临界区(如图12.1所示,它确实满足了临界区的两个条件)。因此,它必须受到并发访问的保护。它必须独占执行(单独执行,恰好一个线程一次,且是序列化的),并且可能需要原子性。
  • 从时间 t2 到 t3:此时访问的是本地变量数据,或者根本没有数据访问。这是并发安全的,不需要保护,可以并行运行(因为每个线程有自己的私有栈)。

在本书中,结合我们到目前为止涵盖的内容和你的知识,我们假设你已经意识到需要同步临界区;我们不会再详细讨论这一点。如果你有兴趣深入了解,可以参考我之前的书《Hands-On System Programming with Linux》(Packt,2018年10月),该书详细讲解了这些内容(特别是第15章,使用Pthreads进行多线程编程第二部分——同步)。

因此,知道了这一点,我们现在可以重新阐述临界区的概念,并提到何时会出现这种情况。临界区是必须按以下方式运行的代码:

  • (始终)独占执行:单独执行(序列化)
  • (在原子上下文中)原子执行:不可分割地执行,直到完成,且没有中断

在接下来的部分,我们将讨论一个经典的场景——全局整数的递增。

经典案例——全局 i++

想象一下这个经典的例子:在一个并发代码路径中,全局整数 i 正在被递增,在这个路径中,多个线程可以同时执行。对于计算机硬件和软件的初步理解可能会让你认为这个操作显然是原子的。然而,现实是,现代硬件和软件(编译器和操作系统)比你想象的要复杂得多,因此会引入各种看不见的(对应用开发者而言)性能优化。

我们不会在这里深入探讨太多细节,但现实是,现代处理器非常复杂:在它们为提高性能所采用的众多技术中,一些技术包括超标量和超流水线执行,以便并行执行多个独立指令,并行执行不同指令的各个部分,进行即时指令和/或内存重排序,复杂的层级内存缓存(在处理器缓存中),加载/存储撕裂等!我们将在第13章《内核同步 - 第二部分》中详细讨论这些内容,具体包括“理解CPU缓存基础、缓存效应和虚假共享”以及“引入内存屏障”部分。关于这些有趣话题的几篇论文(和书籍)可以在进一步阅读部分找到。

所有这些都使得问题比表面上看起来更复杂。让我们继续讨论在可能并发的代码路径中的经典 i++

static int i = 5;
[ ... ]
foo()
{
    [ ... ]
    i++;     /* 这安全吗?是的,如果这段代码路径是独占的或原子的...
               * 否则,如果这段代码路径不是独占的,i++ 真的原子吗?? */
}

那么,这个递增操作安全吗?简短的回答是“不安全”,你必须保护它。为什么?因为它是一个临界区——我们正在访问(读取/写入)共享的可写数据,而且这个操作发生在一个可能并发的代码路径中!更详细的回答是,它确实取决于:

  • foo() 函数的代码是否被保证独占执行。
  • 这里的递增操作是否真正原子(不可分割);如果是,那么在并行执行的情况下 i++ 不会有问题——如果不是,那它会有问题!

假设包含 i++ 的代码路径不是独占的——也就是说,其他线程可以并行执行这段代码路径 foo()——为了让它正常工作,我们就要求 i++ 操作必须真正原子。作为一个简单的高级语言操作,它在第一眼看起来可能是原子的,但它真的原子吗?我们怎么知道这是否成立?有两件事决定了这一点:

  1. 处理器的指令集架构(ISA),它决定了(在多个低级相关的方面)当这个操作运行时执行的机器指令。
  2. 编译器,它将高级语言源代码转换为汇编代码(然后汇编器生成最终在处理器上执行的机器代码)。

如果ISA中包含使用单条机器指令执行整数递增操作的功能,并且编译器有智能并且在适当的时候使用它,那么它就是原子的——是安全的,通常不需要显式保护(比如锁)。否则,它就不安全,需要锁定!那么,如何知道你机器上的编译器是否满足这个条件呢?

尝试这个操作:打开浏览器,访问这个很棒的编译器探索网站:godbolt.org/。选择C语言,然后在左侧窗格中声明全局整数 i,并在一个函数中递增它。在右侧窗格中使用适当的编译器和编译器选项进行编译。你将看到C语言 i++ 语句生成的实际机器代码。如果它确实是单条机器指令,那么它就是安全和原子的;如果不是,它将需要锁定。通常,在基于CISC的机器(如x86[_64])上,编译器优化级别为2及以上会使代码原子化,但在基于RISC的机器(如基于ARM的机器)上情况不一定如此。

总的来说,你会发现你无法确定;实际上,你不能假设任何事情——你必须假设 i++ 操作是不安全的,也就是说,它是非原子的,默认情况下必须保护它!这可以通过以下截图看到:

image.png

前面的截图清楚地显示了这一点:左侧和右侧窗格中的浅色/黄色背景区域分别是我们著名的 i++ 语句的C源代码和编译器生成的相应汇编代码(基于x86_64的ISA和特定编译器的优化级别)。(供参考,在图12.2中,我通过取消选中右侧窗格设置齿轮中的Intel汇编语法,将汇编语法从Intel更改为更通用的AT&T格式)。通常情况下,如果没有优化,i++ 会变成三条机器指令。这正是我们预期的:它对应于i的取值或加载(从内存到寄存器),递增操作,以及存储(从寄存器到内存)!现在(至少在这种情况下,未进行优化时)这不是原子的;因此,完全有可能在其中一条机器指令执行后,控制单元干预并切换指令流到另一个位置。这甚至可能导致另一个进程或线程被上下文切换进来(除非你在周围使用了锁)!

好消息是,通过在“编译器选项...”窗口中快速设置-O2优化,i++ 变成了只有一条机器指令——真正的原子操作!(不过并不总是显示如此)。然而,我们无法提前预测这些事情;有一天,你的代码可能会在一个相当低端的ARM(RISC)系统上执行,这样就增加了i++ 需要多条机器指令的可能性。你现在可能在想——为了修复这个问题,使用类似互斥锁(或者甚至自旋锁)来锁定 i++ 似乎并不高效;你是对的。我们将在第13章《内核同步 - 第二部分》中,在“使用 atomic_trefcount_t 接口”部分专门介绍一种针对整数操作的优化锁技术。

现代语言提供了原子操作符;对于C/C++来说,这相对较新(从2011年起);ISO C++11和ISO C11标准提供了现成的内建原子变量来实现这一点。通过简单的谷歌搜索你可以快速找到相关信息。现代的glibc也在使用这些原子操作符。例如,如果你在用户空间处理信号,你应该知道使用 volatile sig_atomic_t 数据类型在信号处理程序中安全地访问和/或更新整数。这在内核中怎么办?在下一章,你将学习到Linux内核对此关键问题的解决方案。

请早做了解:Linux内核当然是一个并发环境;多个执行线程在多个CPU核心上并行运行。更重要的是,即使在单处理器(UP/单CPU)系统上,硬件中断、陷阱、故障、异常和软件信号的存在也可能导致数据完整性问题(数据竞争)。不用说,在代码路径中需要保护的点、临界区的保护远比说起来容易;使用锁定技术以及其他同步原语和技术来识别和保护临界区是至关重要的,这也是本章及下一章的核心内容。

概念——锁

我们需要同步是因为,如果没有任何干预,线程可能会并发执行临界区,在临界区中共享可写数据(共享状态)正在被操作。为了避免这些临界区中的并发,我们需要消除并行性;我们需要将临界区中的代码流序列化。

为了强制使代码路径序列化,一个常见的技术是使用锁。基本上,锁通过保证在任何给定时刻,只有一个执行线程可以“获取”或拥有锁来工作;一旦获取,只有这个线程可以继续执行——我们称之为“赢家”。这些概念稍后会有详细扩展。因此,使用锁来保护代码中的临界区将实现我们所追求的目标——独占执行临界区的代码(并且也可能是原子性的;稍后会详细讲解)。请查看这个图(图12.3):

image.png

图12.3展示了一种修复之前提到的情况(图12.1)的方法:使用锁来保护临界区!那么,锁(和解锁)如何在概念上工作呢?

锁的基本原理是,当发生争用时——即当多个竞争线程(假设是n个线程)尝试获取锁(通过概念上的LOCK操作)时——只有一个线程会成功。这个线程被视为“赢家”或“所有者”,它将锁API视为非阻塞调用,因此继续运行并愉快地执行临界区的代码(临界区实际上是锁和解锁操作之间的代码!)。

那么,n-1个“失败”线程会发生什么呢?它们(可能)会将锁API视为阻塞调用;它们实际上会等待。等待什么?当然是UNLOCK操作,这个操作(必须由)锁的所有者(“赢家”线程)在完成临界区的工作后执行!一旦解锁,剩余的n-1个线程现在会竞争下一个“赢家”位置;当然,最终会有一个线程胜出并继续向前执行。在此期间,n-2个失败者将继续等待(新的)赢家的解锁。这个过程会重复,直到所有n个线程(最终顺序地)获取锁。

现在,锁的工作是有效的,但——这应该是相当直观的——它会导致(相当高的!)开销,因为它消除了并行性并序列化了执行流程!为了帮助你更好地理解这个情况,可以想象一个漏斗,漏斗的窄颈部分就是临界区,在这里每次只有一个线程能通过。所有其他线程都会被堵住,等待解锁;锁定会造成瓶颈。以下图示旨在展示这一类比:

image.png

另一个常被提到的锁的物理类比是高速公路,多个车道合并成一条非常繁忙且堵车的车道,可能是一个设计不佳的收费站。同样,平行性——车子(线程)与不同车道上的其他车子(CPU)并行行驶——被丧失,需要序列化行为;车子被迫排成一队一个接一个。

因此,作为软件架构师,我们必须尽量设计出最小化锁定的产品/项目。虽然在大多数现实世界的项目中,完全消除全局变量是不切实际的,但优化和最小化它们的使用是必要的。我们稍后会深入讨论这一点,包括一些非常有趣的无锁编程技术。

另一个关键点:一个新手程序员可能天真地认为对共享可写数据对象进行读取是完全安全的,因此不需要显式保护;(除非是对齐的原始数据类型,其大小在处理器总线的宽度内)这种想法是不正确的。这种情况可能会导致所谓的脏读或撕裂读,即即使另一个写线程同时在写数据,可能已经过时和/或不一致的数据也会被读取。

教训很明确:你需要保护所有对共享可写数据的访问,无论是读取还是写入,尤其是在可能并发的代码路径中,实际上就是在所有的临界区中。

既然我们在讨论原子性,正如我们刚刚学到的,在典型的现代微处理器上,唯一保证原子性的操作是单条机器语言指令,或者(如我们所学的那样)读取/写入处理器总线宽度内的对齐原始数据类型。那么,如何标记几行C代码以确保它们真正原子化呢?在用户空间,这甚至是不可能的(我们可以接近,但无法保证原子性)。

在用户空间应用程序中,如何“接近”原子性呢?你可以始终构建一个用户线程,采用SCHED_FIFO任务调度策略和99的实时(RT)优先级。这样,当它想运行时,除了硬件中断/异常,几乎没有什么能抢占它。(旧的音频子系统实现就大量依赖于这一语义。)

在内核空间,我们可以编写真正原子性的代码。具体怎么做?简短的回答是,我们使用自旋锁来实现!我们稍后会详细学习自旋锁。

临界区——关键点总结

让我们总结一下关于临界区的一些关键点。仔细阅读这些内容,随时保持它们在手边,并确保在实践中使用它们:

  1. 临界区是一个可能并发的代码路径,它可以并行执行,并且操作(读取和/或写入)共享可写数据(即共享状态)。

  2. 由于它操作共享可写数据,临界区:

    • 需要防止并行和并发的访问(即,它必须独占执行/序列化/以互斥的方式执行)。
    • 在原子非阻塞上下文中执行时(包括任何中断上下文),必须保证它是原子执行的:不可分割地执行,直到完成,且没有中断。
  3. 一旦得到保护,就可以安全地访问共享状态,直到“解锁”。

  4. 每个代码库中的临界区都必须被识别和保护:

    • 识别临界区至关重要! 仔细检查你的代码,确保不漏掉它们。(任何全局或静态变量都是典型的红旗;但不仅仅是这些,任何共享状态——硬件寄存器、邮箱等——在可能并发的代码路径中都可以是临界区。)
  5. 保护临界区可以通过各种技术实现;一种非常常见的技术是锁(我们将在接下来的部分详细讨论)。还有原子操作符和无锁编程技术,我们将在下一章中探讨。

  6. 一个常见的错误是只保护写操作共享可写数据的临界区;你还必须保护读取共享可写数据的临界区。否则,你会面临撕裂或脏读的风险!为了帮助阐明这一点,想象一下在一个32位系统上读取和写入一个无符号64位数据项;在这种情况下,操作无法原子化(每次读取/写入都需要两条加载/存储操作)。那么,如果在一个线程读取数据项的值时,另一个线程同时在写这个数据呢?写线程会对访问采取某种“锁”,但是因为你认为读取是安全的,读取线程没有采取锁;由于不幸的时序巧合,最终可能会进行部分/撕裂/脏读。我们将在接下来的部分以及下一章中学习如何使用各种锁技术来解决这些问题。

  7. 另一个致命错误是没有使用相同(正确的)锁来保护给定的数据项。例如,如果你使用锁A来保护全局数据结构X,那么每次访问它时都必须使用锁A;使用锁B并不起作用。这比看起来要复杂,因为大型项目(如Linux内核)可能有成千上万个锁!

  8. 如果没有保护临界区,就会导致数据竞争,这是一种结果——共享数据的实际值——是“竞态”的情况,这意味着它会根据运行时情况和时序的不同而变化。这是一个缺陷,一个 bug(这种 bug 一旦进入“生产环境”,是极其难以察觉、重现、确定根本原因并修复的)。我们将在下一章的“内核中的锁调试”部分详细讨论一些强大的工具来帮助你解决这些问题;到时候一定要阅读!

  9. 例外情况:在以下情况下,你是安全的(隐式的,不需要显式保护):

    • 当你操作的是局部变量时。它们被分配在线程的私有栈上(或者在中断上下文中,分配在本地IRQ栈上),因此,从定义上讲,它们是安全的。
    • 当你在无法在其他上下文中运行的代码中操作共享可写数据时;即,它本质上是序列化的。在我们的上下文中,内核模块的初始化和清理方法符合这一条件(它们在insmod(或modprobe)和rmmod时只执行一次,且是顺序执行的)。
    • 当你操作的是完全常量且只读的共享数据时(不过,不要让C语言的const关键字误导你!)。
  10. 锁定本质上是复杂的;你必须仔细思考、设计和实现锁定方案,同时避免死锁。我们将在“锁定——常见错误和指南”部分更详细地讨论这一点。

数据竞争——更正式的定义

在多个并发的加载/存储操作存在的情况下,内存一致性的重要概念通过内存(一致性)模型提供了更正式的定义;它“建模”了系统内存行为,预测当代码在该系统上执行时,加载(从内存读取)操作可能会得到哪些值。Linux内核就有这样一个模型,它被称为Linux内核内存模型(LKMM)。

深入探讨其细节在此并不是必需的,但这确实是一些引人入胜的内容;你可以查看官方内核文档中的explanation.txt文档:链接

(我强烈建议你至少阅读前三个部分:INTRODUCTIONBACKGROUNDA SIMPLE EXAMPLE。内存排序的概念也在LKMM中有涉及。)

LKMM提供了两种对内存的访问方式:

  • 普通访问:这些是通过C语言语句对内存进行的典型访问,例如 i++y = 42 - x;

  • 标记访问:这些是“特殊”访问;它们被设计并实现为隐式保证原子性:

    • 通过 READ_ONCE() 宏进行的读取(例如 READ_ONCE(x);
    • 通过 WRITE_ONCE() 宏进行的写入(例如 WRITE_ONCE(y, 42 - x);

(实际上,不仅仅是这些宏保证原子性;atomic_*()refcount_*()smp_load_acquire() 以及类似的宏也属于标记访问类别。)

好了,现在进入关键点:根据LKMM,什么构成数据竞争

来自相同 explanation.txt 文档的 PLAIN ACCESSES AND DATA RACES 部分(链接)详细介绍了这一点。最好的方式是直接引用文档中的一段内容:

“数据竞争”发生在以下两次内存访问时:

  1. 它们访问相同的位置,
  2. 至少有一次是存储操作,
  3. 至少有一次是普通访问,
  4. 它们发生在不同的CPU上(或同一CPU上的不同线程),
  5. 它们并发执行。

在文献中,当两个访问满足上述第1和第2条时,我们说它们“冲突”。我们进一步指出,如果它们满足第1到第4条,那么这两个访问就是“竞态候选者”。因此,两个竞态候选者是否在给定的执行中真正发生竞态,取决于它们是否并发执行。[ … ]

这太棒了!通过LKMM清晰且正式地定义了什么构成数据竞争。

你可能会想,这些知识应该可以用来编写工具来检测数据竞争,对吧?这正是内核并发检查器(KCSAN)所做的!(Marco Elver是KCSAN的当前维护者。)事实上,《Linux内核调试》一书的第8章锁调试部分详细介绍了如何使用KCSAN(包括示例)。我强烈建议你学习如何使用KCSAN。

Marco Elver在2020年8月的Linux Plumbers Conference上做了一个名为《Linux内核中的数据竞争检测》的演讲(链接),在其中的幻灯片中,他解释了LKMM定义的数据竞争:

image.png

正如你所看到的,在图12.5的右侧是两个线程并发运行的示例(在不同的CPU核心上),它们正在操作相同的内存对象(相同的位置,共享可写数据)。左侧的红色叉号表示数据竞争,绿色勾号表示是安全的(中间的行——第3和第5行——是“可能的”;当严格解释时,它们发生竞态)。

这可能会让你思考:“为什么不总是使用标记访问,从而避免数据竞争呢?”请千万不要这样做。标记访问是内核代码内部使用的,或者当你知道可能会发生数据竞争但不太关心时使用(一个典型的例子是网络驱动程序的统计代码,它递增一个计数器,但没有显式的锁定原语;在这里,数据竞争被认为并不太重要)。不过,尤其对于模块/驱动程序开发者来说,关键点是:使用标记访问实际上会阻止像KCSAN这样的工具捕捉数据竞争。对于大多数情况,继续使用普通的C语言访问是非常重要的。

另外,需要注意的是——尽管标记访问保证了原子加载和存储,但它们并不保证内存排序;这必须通过内存屏障来实现(下一章将对此有所阐述)。现在,我们将进入另一个关键领域——在使用Linux操作系统时识别并发性和临界区。

Linux内核中的并发问题

识别内核代码中的临界区至关重要;如果你看不见它,怎么保护它呢?以下是一些指南,帮助你作为一个新兴的内核/驱动程序开发者,识别可能出现并发问题——从而出现临界区——的地方:

  • 对称多处理器(SMP)系统的存在CONFIG_SMP=y
  • 可抢占内核的存在CONFIG_PREEMPTION=y
  • 阻塞I/O
  • 硬件中断(无论是在SMP系统还是单处理器(UP)系统上)

这些是需要理解的关键点,我们将在本节中逐一讨论。

多核SMP系统与数据竞争

第一个点是显而易见的;请看图12.6中显示的伪代码:

image.png

这与我们在图12.1和图12.3中展示的情况类似。如图所示,从时间t2到时间t3,函数可以并行执行,并且它正在操作一些全局共享的可写数据,因此这是一个临界区。

现在,设想一个有四个CPU核心的系统(一个SMP/多核系统);两个用户空间进程,P1(运行在CPU 0上)和P2(运行在CPU 2上),可以并发打开设备文件并同时发出read()系统调用。此时,两个进程将同时执行驱动程序的read“方法”,从而同时操作共享可写数据!正如我们所知,t2到t3之间的代码是临界区,而我们违反了基本的独占规则——临界区必须在任何时刻只能由一个线程执行——因此我们很可能会破坏数据、应用程序,甚至更严重的后果。

换句话说,这种做法可能导致数据竞争;根据精确的时序巧合,错误(bug)可能会发生,也可能不会发生。这种不确定性——精确的时序巧合——正是使得发现和修复此类错误极其困难的原因(它可能会逃脱你的测试努力)。

遗憾的是,这句格言完全正确:测试可以检测到错误的存在,而无法检测到错误的缺失。 更糟糕的是,如果你的测试未能捕捉到数据竞争(和bug),它们就会在生产环境中肆意横行。

你可能会争辩说,既然你的产品是一个在单个CPU核心上运行的小型嵌入式系统(UP),那么关于控制并发性(通常通过锁定)的话题对你不适用。我们不同意这一点:几乎所有现代产品,如果还没有,都会在下一代阶段转向多核(也许是在其下一代阶段)。更重要的是,甚至UP系统也有并发问题,正如我们将很快探讨的那样。

可抢占内核、阻塞I/O和数据竞争

假设你在一个已配置为可抢占的Linux内核上运行你的内核模块或驱动程序(即,CONFIG_PREEMPT 开启;我们在第10章《CPU调度器 - 第1部分》中讨论了这个话题)。再次参考图12.6,考虑一个进程P1,它在进程上下文中运行驱动程序的读取方法代码,并在处理全局数组。现在,当它处于临界区(t2到t3之间)时,如果内核抢占了进程P1并将上下文切换到另一个进程P2,而P2恰好等待执行这段代码路径,会发生什么?这很危险;它可能会导致数据竞争。这种情况甚至可能在单个CPU(UP)系统上发生!(注意,尽管我们在这里使用“进程”一词,但它与“线程”是可以互换的。)

另一个类似的场景(同样,这种情况也可能发生在单核(UP)或多核系统上):进程P1正在通过驱动程序方法的临界区(t2到t3之间运行;再次见图12.6)。这次,如果在临界区内,它遇到了一个阻塞调用怎么办?

阻塞调用是一个会导致调用进程上下文被挂起、等待某个事件发生的函数;当该事件发生时,内核(或底层驱动程序)将“唤醒”任务,并从它离开的地方恢复执行。

这也被称为I/O阻塞,是非常常见的;许多API(包括一些用户空间的库和系统调用,以及一些内核API)天生就是阻塞的。在这种情况下,进程P1实际上会被上下文切换出CPU并进入休眠状态,这意味着schedule()代码会运行,并将它加入到等待队列中。在这段时间内,在P1被切换回来之前,如果另一个进程P2被调度运行,会发生什么?如果那个进程也在运行这段特定的代码路径,会发生什么?想想看——首先,P2现在操作的数据可能处于不完全更新的中间状态。此外,等P1回来时,共享数据可能已经“在它底下”发生了变化,从而导致各种错误;再次发生数据竞争,一个bug!

硬件中断和数据竞争

最后,设想这种情况:进程P1无辜地运行驱动程序的读取方法代码;因此,它进入了临界区(t2到t3之间;再次见图12.6)。它有所进展,但接着,硬件中断在同一个CPU核心上触发!(你可以在《Linux内核编程 - 第二部分》附带的书中详细了解硬件中断及其处理方式)。在Linux操作系统中,硬件中断具有最高优先级;默认情况下,它们会抢占所有代码(包括内核代码)。因此,进程(或线程)P1将至少暂时被搁置,失去处理器,因为中断处理代码路径将无疑抢占它并执行。

你可能会问,既然如此,又有什么关系呢?的确,这是一个完全常见的现象!在现代系统中,硬件中断触发非常频繁,实际上(并且字面上)会中断所有类型的任务上下文(在终端中运行vmstat 3;在系统列下,显示的in列显示了你系统中在过去1秒钟内触发的硬件中断数量!)。

在这里,关键问题是:中断处理代码(无论是硬中断ISR,还是“上半部分”,或所谓的tasklet或软中断“下半部分”)是否共享并操作与其刚才中断的进程上下文相同的共享可写数据?

如果是这样,那么我们有问题了——数据竞争!如果不是,那么你的中断代码就不是与中断代码路径相关的临界区,这就没问题。事实上,大多数设备驱动程序都会处理硬件中断;因此,作为驱动程序作者(你!),有责任确保没有全局或静态数据——实际上,没有临界区——在进程上下文和中断上下文代码路径之间共享。如果它们共享(有时确实会发生),你必须以某种方式保护这些数据,防止数据竞争和可能的损坏(这通常通过使用自旋锁来实现;别担心,我们将会讨论它)。

这些场景可能会让你觉得,保护这些并发问题真的非常具有挑战性;面对临界区的存在,以及刚才讨论的各种可能的并发问题(多核(或SMP)、内核抢占、阻塞I/O和硬件中断),你究竟该如何实现数据安全呢?好消息是,这其实相当简单;保护临界区的实际(锁定)API并不难学习使用。再次强调,识别——并能够保护——临界区是关键所在。

不再废话,接下来让我们开始深入探讨保护临界区的主要同步技术——锁定。

锁定指南与死锁

锁定本身是一个复杂的问题;它容易导致复杂的互锁情况。如果对它的理解不够深入,可能会导致性能问题和bug——死锁、循环依赖、中断不安全的锁定等等。以下是一些锁定指南,对于确保在使用锁定时代码的正确性至关重要:

锁定粒度

锁和解锁之间的“距离”——实际上是临界区的长度——不应过于粗糙(即,临界区太长);它应该“足够细”。下面的几点将对此进行扩展。

在这里你需要小心。当你在大型项目中工作时,保持锁的数量过少是一个问题,过多也是如此!锁的数量太少会导致性能问题(因为相同的锁被反复使用,通常会导致竞争激烈)。
拥有很多锁实际上对性能有好处,但对控制复杂性不利。这也引出了另一个关键点,作为开发者你需要理解:在代码库中有很多锁时,你必须清楚地知道每个锁保护的是哪个共享数据对象。如果你使用,比如说,lockA 来保护 mystructX,但在一个远离的代码路径(可能是一个中断处理程序)中,你忘记了这一点并使用了另一个锁lockB来保护同一个结构体,这就完全没有意义!现在,这些事情听起来可能很明显,但(正如有经验的开发者所知),在足够的压力和复杂性下,连显而易见的事情也不总是那么显而易见!

尽量保持平衡。在大型项目中,使用一个锁来保护一个全局(共享)数据结构是典型做法。现在,为锁命名可能会成为一个大问题!这就是为什么我们通常将保护数据结构的锁放在其中,作为一个成员变量。

长时间的原子临界区可能会导致高延迟,并成为性能瓶颈,特别是在对时间敏感的实时系统中。有几个工具(处于不同的稳定性状态)可以帮助你了解这些临界区在哪里(以及它们存在的时长)。其中一个来自eBPF稳定版本的工具是名为criticalstat[-bpfcc]的工具。它可以检测并报告长时间的临界区(它也会有用地显示内核栈跟踪,从而显示其来源),以何时禁用抢占和/或中断为标准,检测长时间的等待。

criticalstat实用程序的手册页可以在这里找到:manpages.ubuntu.com/manpages/fo…;这是它的“示例”页面:github.com/iovisor/bcc…。(Ftrace也有跟踪器可以捕捉长时间的抢占/中断关闭时间。)

锁定的基本规则

  • 只有“所有者”——即当前持有锁的线程——才能释放(解锁)它;试图释放你没有持有的锁,或者在锁被持有时重新获取它,都会被认为是bug(后者可以通过使用递归锁绕过;不过请看下一点)。
  • 尽量避免递归锁定;内核社区一般不推荐使用递归锁。
  • 锁定顺序至关重要,它能大大减少死锁的发生。你必须确保在整个代码中按相同的顺序获取锁;这些“锁定顺序规则”应该文档化,并由所有开发人员遵循(注释锁也是有用的;下一章中会讨论关于lockdep的内容)。错误的锁定顺序通常会导致死锁。
  • 另一方面,释放锁的顺序并不重要(当然,你必须在某个时刻释放所有持有的锁,以免导致饥饿)。
  • 小心避免饥饿;确保一旦锁被获取,它确实“足够快”地释放。

简洁性是关键

尽量避免复杂性或过度设计,特别是涉及锁的复杂场景。

死锁问题

在讨论锁定时,死锁的问题会出现。死锁是指无法取得任何进展;换句话说,应用程序进程/线程和/或内核组件似乎永远挂起。虽然我们不打算深入探讨死锁的详细内容,但我会简要提及一些常见的死锁场景:

简单情况,单锁,进程上下文
尝试两次获取相同的锁被认为是缺陷,结果会导致自死锁。想想看;当持有锁时,你试图重新获取它。现在,由于锁已被锁定,你必须等待直到它被解锁。但你已经持有锁并等待(而且只有你能解锁它),因此无法解锁它;结果就是(自)死锁!递归锁定可以解决这个问题,但通常它是禁用的,并且使用它是不推荐的。

简单情况,多个(两个或更多)锁,进程上下文
我们通过一个示例来研究这个问题:

  • 在CPU 0上,线程A获取锁A,然后想要获取锁B。
  • 同时,在CPU 1上,线程B获取锁B,然后想要获取锁A。

因此,每个线程都在等待另一个线程,永远无法继续……结果是经典的循环死锁,通常称为AB-BA死锁。其示意图如下(时间轴垂直向下):

CPU 0 : 线程A              CPU 1 : 线程B
...                                 ...
获取锁A                           获取锁B
...                                 ...
尝试获取锁B                       尝试获取锁A
等待锁B … … ...                  等待锁A … … ...
  … 永远等待 … … ...             … 永远等待 … … ...

这可以一直延续;例如,AB-BC-CA循环依赖(A-B-C锁链)会导致死锁。

复杂情况,单锁,进程和中断上下文
我们通过一个示例来研究这个问题(这个情况实际上是自旋锁的情形;我们很快会深入讨论):

  • 进程P1,运行你的驱动程序的读取方法(假设在CPU 0上),获取锁A。
  • 一毫秒后,驱动程序的硬件中断在相同的核心上发生,因此驱动程序的中断处理程序立即抢占P1。现在,如果中断上下文尝试获取相同的锁A,会发生什么?由于锁A当前被进程上下文P1持有,因而中断上下文必须等待解锁;但P1已经持有锁并等待重新获得CPU,而中断的执行会一直阻止它,从而导致无法执行解锁,最终导致中断上下文也永远等待...再次,结果是(自)死锁!

这个问题的解决方法当然是,在获取锁时禁用(屏蔽)所有硬件中断;这样进程上下文就不能被中断,它会运行临界区直到完成。换句话说,这样就能确保它是原子的。当它执行解锁时,这个操作会重新启用所有本地核心的中断,一切恢复正常!因此,在中断(或更广义的原子)上下文中获取的锁必须始终在禁用中断的情况下使用。(如何做到这一点?使用自旋锁;我们当然会在接下来讨论自旋锁时更详细地讲解这些方面。)

更复杂的情况,多个锁,进程和中断(硬中断和软中断)上下文(我们在这里不进一步展开;你可以在即将到来的“锁定与中断”部分找到详细内容。)

在较简单的情况下,始终遵循锁定顺序指南是足够的:始终按良好的顺序获取锁(我们将在下一章的“使用互斥锁”部分提供内核代码的示例)。然而,正如你可能已经开始意识到的那样,事情会变得非常复杂,复杂的死锁场景甚至可以绊倒经验丰富的开发者。幸运的是,lockdep ——Linux内核的运行时锁依赖验证器——可以捕获(几乎)所有的死锁情况!(别担心,我们会深入讨论lockdep,下一章会详细讲解;只需按部就班地进行)。当我们讨论自旋锁时(在“使用自旋锁”部分),我们会遇到类似之前提到的进程和/或中断上下文的场景;需要使用哪些精确的自旋锁API(以避免死锁等问题)会在那里明确说明。

实际上,即使是活锁情况也可能像死锁一样致命!活锁本质上是一种概念上类似于死锁的情况;只是参与任务的状态是运行中的,而不是等待中的。例如,一个中断“风暴”(成百上千的硬件中断——及其相关的软中断——成批发生,需要迅速处理,给系统带来压力)可能会导致活锁;现代网络驱动通过关闭中断(在中断负载下)并采用一种称为新API(NAPI)的轮询技术来缓解这种影响,在适当时重新打开中断。(嗯,事情比这更复杂,但我们在这里就不展开讨论了。)

对于那些没有“与世隔绝”的人,你会知道Linux内核有两种主要类型的锁:互斥锁和自旋锁。实际上,还有更多类型,包括其他同步(和“无锁”编程)技术,这些都会在本章及下一章中讲解。

内核文档中提到三种内核中实现的锁类别:睡眠锁、CPU局部锁和自旋锁。睡眠锁包括(RT)互斥锁、信号量及其变种。自旋锁包括自旋锁、读写自旋锁及其变种。所谓的“局部锁”通常用于实时(RT)应用,尽管非RT应用也包括用于锁调试。

现在,让我们深入探讨互斥锁和自旋锁到底是什么意思,并在什么情况下该使用哪种锁!

互斥锁还是自旋锁?什么时候使用哪一个

学习如何使用互斥锁和自旋锁的确切语义是相当简单的(内核API的适当抽象使典型的驱动程序开发者或模块作者更加容易理解)。在这种情况下,关键问题是一个概念性的问题:这两种锁类型到底有什么区别?更具体来说,在什么情况下应该使用哪种锁?你将在本节中学习这些问题的答案。

以我们之前的驱动程序读取方法伪代码(图12.6)为基础,假设三个线程——tA、tB和tC——在并行运行(在SMP系统上),它们通过这段代码。我们将通过在临界区开始前(时间t2)获取锁,在临界区代码路径结束后(时间t3)释放锁,从而解决并发问题,同时避免任何数据竞争。让我们通过一个图(图12.7)再次查看伪代码,这次使用锁来确保代码的正确性:

image.png

当三个线程尝试同时获取锁时,锁API的语义保证其中只有一个线程能够成功获取锁。假设tB(线程B)获得了锁:它现在是“赢家”或“所有者”线程。这意味着线程tA和tC是“失败者”;它们做什么呢?它们会等待解锁!

当“赢家”(tB)完成临界区并解锁时,之前的失败者之间的竞争重新开始;其中一个将成为下一个赢家,过程会重复进行。(需要理解的是,图12.7中显示的“API”只是伪代码,并不是实际的(解)锁API;我们将在接下来的两个主要部分中讨论这些API。)

互斥锁和自旋锁之间的关键区别在于失败者等待解锁事件的方式。对于互斥锁,失败的线程会被挂起;也就是说,它们通过休眠来等待(实际上,当它们尝试获取互斥锁且该锁已被锁定时,它们会将其视为一个阻塞调用;它们会被调度或上下文切换出CPU——它们现在处于“休眠”状态)。当赢家执行解锁时,内核会唤醒所有的失败者线程,它们重新运行,并再次竞争锁。(实际上,互斥锁和信号量有时被称为睡眠锁。)

然而,对于自旋锁,没有休眠的问题;失败的线程通过“自旋”在锁上等待,直到锁被解锁。从概念上讲,它看起来像这样:

while (locked) ;

请注意,这只是一个概念性示例。想一想——这实际上是轮询(polling)。然而,作为一个优秀的程序员,你会理解轮询通常被认为是一个不好的做法。那么,为什么自旋锁会以这种方式工作呢?实际上,它并不完全是这样;这里只是为了概念说明而呈现的方式。正如你很快会理解的那样,自旋锁只有在多核(SMP)系统上才真正有意义。在这种系统中,当赢家线程在运行临界区代码时,失败的线程通过在其他CPU核心上自旋来等待!实际上,在实现层面,用于实现现代自旋锁的代码是高度优化的(并且是架构特定的),并不是通过简单地“自旋”来实现的(例如,许多ARM架构的自旋锁实现使用了事件等待(WFE)机器指令,使CPU能够在低功耗状态下进行优化等待)。有关互斥锁和自旋锁在内核内部实现的更多资源,请参见进一步阅读部分。

选择使用哪种锁——理论上

自旋锁的实现并不是我们在这里关注的重点;我们关注的是自旋锁比互斥锁的开销更低。这是怎么回事呢?其实很简单:为了使互斥锁工作,失败的线程必须进入休眠状态(然后在解锁时被唤醒)。为了实现这一点,内部会调用 schedule() 函数,这意味着失败的线程将互斥锁API视为一个阻塞调用!调用调度器最终会导致线程被上下文切换出CPU。相反,当拥有锁的线程最终解锁时,失败的线程必须被唤醒;它们中的一个将成为下一个“赢家”,并被上下文切换回处理器。

因此,互斥锁/解锁操作的最小“成本”是执行两次上下文切换的时间(具体实现取决于机器)。通过重新审视图12.7,我们可以确定在临界区中花费的时间(“锁定”代码路径);即,t_locked = t3 - t2

假设 t_ctxsw 代表上下文切换所需的时间。正如我们所学,互斥锁/解锁操作的最小成本是两次上下文切换(第一次是“进入休眠”,第二次是“被唤醒”):2 * t_ctxsw

现在,假设以下表达式成立: t_locked < 2 * t_ctxsw

换句话说,假设在临界区内花费的时间少于两次上下文切换的时间。在这种情况下,使用互斥锁就是错误的,因为这会带来过高的开销;更多的时间用来执行元工作,而不是实际的工作——这种现象被称为“抖动”。正是这种使用场景——非常短的临界区——在像Linux这样的现代操作系统中非常常见,这也促使我们更倾向于使用自旋锁而不是互斥锁。因此,总结来说,当你有短小的、不阻塞的临界区时,选择自旋锁;当临界区较长且(可能)阻塞时,选择互斥锁。为什么强调“阻塞”?以下部分——实际内容——将明确说明这一点;继续阅读吧!

选择使用哪种锁——实践中

虽然在理论上,t_locked < 2 * t_ctxsw 的“规则”可能很好,但等等:你真的需要精确地测量上下文切换时间和每个临界区中花费的时间吗?当然不——那是不现实且过于学究的。

从实际角度来看,可以这样理解:互斥锁通过让失败的线程进入休眠状态来工作(直到解锁发生);而自旋锁则不这样做(失败的线程“自旋”)。回想一下我们在Linux内核中的一个“黄金规则”:内核不能在任何类型的原子上下文中休眠(调用 schedule())。因此,我们不能在中断上下文中使用互斥锁,或者在任何不安全休眠的上下文中使用它;然而,使用自旋锁是可以的。(回想一下,阻塞API是通过调用 schedule() 使调用上下文进入休眠的。)总结如下:

  • 如果临界区运行在原子上下文(例如中断上下文)中,或者在进程上下文中但不能休眠,使用自旋锁。
  • 如果临界区运行在进程上下文中,并且临界区内可能发生休眠或阻塞I/O,使用互斥锁。

当然,使用自旋锁的开销被认为比使用互斥锁低;因此,只要临界区不阻塞(休眠),你甚至可以在进程上下文中使用自旋锁(例如我们假设的驱动程序的读取方法)。

[1] 上下文切换所花费的时间是有差异的;它在很大程度上取决于硬件和操作系统的质量。一些之前(2018年9月)的测量显示,固定CPU时,上下文切换的时间大约为1.2到1.5微秒,而不固定CPU时大约为2.2微秒(eli.thegreenplace.net/2018/measur…)。

硬件和Linux操作系统已经有了显著的改进,因此平均上下文切换时间也有所改善。一篇旧的(1998年12月)《Linux Journal》文章确定,在x86类系统上,平均上下文切换时间为19微秒,最坏的情况为30微秒;这些数字现在已经过时。

接下来我们要问的问题是:如何知道代码当前是在进程上下文中还是中断上下文中运行?这很简单——使用 in_task() 宏来确定(正如我们在convenient.h头文件中的PRINT_CTX()宏所做的):

if (in_task())
    /* 我们在进程上下文中(通常可以安全地休眠/阻塞) */
else
    /* 我们在原子或中断上下文中(不能休眠/阻塞) */

现在你了解了在什么情况下使用互斥锁或自旋锁,接下来让我们进入实际使用部分。我们将从如何使用互斥锁开始!

使用互斥锁

互斥锁也被称为可休眠或阻塞的互斥锁(mutex)。正如你所学到的,它们在进程上下文中使用,如果临界区可能会休眠(阻塞)。它们不能在任何类型的原子上下文或中断上下文中使用(如上半部分、下半部分如tasklet或softirq等)、内核定时器,或在不允许阻塞的进程上下文中使用。

初始化互斥锁

在使用之前,每个锁必须初始化为“未锁定”状态。互斥锁“对象”在内核中表示为 struct mutex 数据结构。考虑以下代码:

#include <linux/mutex.h>
struct mutex mymtx;

为了使用这个互斥锁,必须显式地将它初始化为未锁定状态。初始化可以通过静态方式(声明并初始化对象)使用 DEFINE_MUTEX() 宏,或者通过动态方式使用 mutex_init() 函数(实际上这是对 __mutex_init() 函数的宏封装)。

例如,要声明并初始化互斥锁对象 mymtx,我们可以使用 DEFINE_MUTEX(mymtx);

我们也可以动态地执行此操作。为什么使用动态方式?通常,互斥锁是它保护的(全局)数据结构的成员。
将锁变量作为它所保护的数据结构的成员是一种常见(且巧妙)模式,Linux中广泛使用;这种方法的额外好处是避免了命名空间污染,并且明确标识哪个互斥锁保护哪个共享数据项(这是一个比看起来更大的问题,特别是在像Linux内核这样的大型项目中!)。

例如,假设我们在驱动程序代码中有以下全局上下文数据结构(注意,这段代码是虚构的):

struct mydrv_priv {
    <member 1>;
    <member 2>;
    [...]
    struct mutex mymtx; /* protects access to mydrv_priv */
    [...]
} *drvctx;

在你的驱动程序模块的初始化方法中,执行以下操作:

static int __init init_mydrv(struct mydrv_priv *drvctx)
{
    [...]
    mutex_init(drvctx->mymtx);
    [...]
}

正确使用互斥锁

在内核源代码中,你经常会看到非常有见地的注释。这里有一个很好的例子,它简洁地总结了正确使用互斥锁时必须遵循的规则(elixir.bootlin.com/linux/v6.1.…);请仔细阅读:

/*
 * 简单、直接的互斥锁,具有严格的语义:
 *
 * - 只能有一个任务持有互斥锁
 * - 只有所有者才能解锁互斥锁
 * - 不允许多次解锁
 * - 不允许递归锁定
 * - 必须通过API初始化互斥锁对象
 * - 不能通过memset或复制初始化互斥锁对象
 * - 任务在持有互斥锁时不能退出
 * - 持有锁的内存区域不能被释放
 * - 持有的互斥锁不能重新初始化
 * - 互斥锁不能在硬件或软件中断上下文中使用,如tasklet和定时器
 *
 * 当启用DEBUG_MUTEXES时,这些语义会被完全执行。除此之外,除了执行上述规则,互斥锁还...
 */

作为内核(或驱动程序)开发者,你必须理解以下几点(我们在《锁定指南与死锁》部分已经涵盖了大部分内容):

  1. 临界区使得代码路径被序列化,从而破坏并行性。因此,必须尽量保持临界区的简短。与此相关的一个重要结论是:锁定数据,而不是代码。
  2. 尝试重新获取已经获取(锁定)的互斥锁——实际上是递归锁定——是不支持的,且会导致自死锁。
  3. 锁定顺序:这是防止危险死锁情况的一个非常重要的经验法则。在多个线程和多个锁的情况下,锁定的顺序必须被文档化,并且所有开发人员必须严格遵循。实际的锁定顺序并不是神圣不可侵犯的,但一旦决定了锁定顺序,就必须遵循它。在浏览内核源代码时,你会看到许多地方,内核开发人员确保遵循这个规则,并且他们(通常)会为其他开发人员写下评论,确保他们看到并遵循这一点。以下是来自slab分配器代码(mm/slub.c)中的一个示例注释:
/*
* 锁定顺序:
 *   1. slab_mutex(全局互斥锁)
 *   2. node->list_lock(自旋锁)
 *   3. kmem_cache->cpu_slab->lock(局部锁)
 *   4. slab_lock(slab)(仅在某些架构上)
 *   5. object_map_lock(仅用于调试)
...

现在我们从概念上理解了互斥锁的工作原理(以及它们的初始化),让我们学习如何使用它们的实际API,锁定/解锁API。

互斥锁的锁定与解锁API及其使用

在Linux内核中,互斥锁的锁定和解锁API分别如下:

void __sched mutex_lock(struct mutex *lock);
void __sched mutex_unlock(struct mutex *lock);

(这里的 __sched 可以忽略;它只是一个编译器属性,旨在使该函数在WCHAN输出中消失,这会显示在procfs中,并且在使用某些ps选项(如 -l)时也会出现。)

再次强调,内核/锁定/mutex.c源代码中的注释非常详细且描述性强;我鼓励你仔细查看这个文件。我们这里只展示了其中的部分代码,取自Linux内核6.1.25的源代码树:elixir.bootlin.com/linux/v6.1.…

[ ... ]
/**
 * mutex_lock - 获取互斥锁
 * @lock: 要获取的互斥锁
 *
 * 将互斥锁独占地锁定给当前任务。如果互斥锁当前不可用,
 * 任务会休眠直到可以获取它。
 *
 * 互斥锁必须由获取它的相同任务释放。递归锁定是不允许的。
 * 任务在退出前不得持有互斥锁。持有锁的内存区域不能在
 * 互斥锁仍被锁定的情况下释放。互斥锁必须在锁定前被初始化
 * (或者静态定义)。不能通过memset()将互斥锁初始化为0。
 *
 * (配置选项 CONFIG_DEBUG_MUTEXES 开启调试检查,强制执行这些限制,
 * 并进行死锁调试)
 *
 * 此函数与 down() 类似,但不等价。
 */
void __sched mutex_lock(struct mutex *lock)
{
    might_sleep();
    if (!__mutex_trylock_fast(lock))
        __mutex_lock_slowpath(lock);
}
EXPORT_SYMBOL(mutex_lock);

might_sleep() 是一个具有有趣调试特性的宏:它的存在会捕获那些应该在原子上下文中执行但实际上没有这样做的代码!(有关 might_sleep() 的更多解释可以在《Linux内核编程 - 第二部分》中找到。)

想想看:might_sleep() 作为 mutex_lock() 中的第一行代码,字面意思是告诉你“接下来的代码可能会休眠”!这进一步暗示,这段代码路径不应由任何处于原子上下文中的代码执行,因为它可能会休眠。当然,这意味着你应该仅在进程上下文中使用互斥锁,且在该上下文中可以安全地休眠!

快速且重要的提醒

Linux内核可以配置许多调试选项;在此上下文中,CONFIG_DEBUG_MUTEXES=y 配置选项将帮助你捕获可能的与互斥锁相关的bug,包括死锁。同样,当你通过常见的 make menuconfig UI进行配置时,在Kernel Hacking菜单下,你会发现许多与调试相关的内核配置选项;我们在第5章《编写你的第一个内核模块 - 第2部分》中讨论了这些内容。关于锁调试,有几个非常有用的内核配置;我们将在下一章的“内核中的锁调试”部分中详细讨论这些内容。

互斥锁的锁定 - 通过[可中断/不可中断]休眠?

像往常一样,互斥锁有更多的细节没有展示出来。你已经知道,Linux进程(或线程)会经历一个状态机的各种状态。在Linux中,休眠有两个离散的状态——可中断休眠和不可中断休眠。处于可中断休眠状态的进程(或线程)对用户空间的信号敏感——这意味着它会响应信号,而处于不可中断休眠状态的任务则对用户信号不敏感。

在具有底层驱动程序的交互式应用程序中,作为一般经验法则,你通常应该让进程进入可中断休眠状态(当它在等待锁时),从而将是否终止应用程序(通过按Ctrl + C等机制发送信号)交给最终用户。

有一个在类似Unix的系统中经常遵循的设计规则:提供机制,而不是政策。

话虽如此,在非交互式代码路径中,通常必须让进程无限期等待锁,并且该语义是,已发送给任务的信号不应该中断阻塞等待(它应该保持挂起)。在Linux中,不可中断休眠的情况通常是最常见的。

所以,问题来了:mutex_lock() API,在“失败”线程的代码路径中,始终将这个失败任务置于不可中断的休眠状态。如果这不是你想要的效果,可以使用 mutex_lock_interruptible() API,将(失败的)调用任务置于可中断休眠状态。语法上有一个区别:后者在成功时返回整数值0,在信号中断时返回 -EINTR(记住0/-E返回约定;顺便说一下,EINTR 的英文错误信息是“中断的系统调用”)。

一般来说,使用 mutex_lock() 比使用 mutex_lock_interruptible() 更快;当临界区较短时(从而几乎可以保证锁持有的时间较短,这是一种非常理想的特性),使用它是非常合适的。

在6.1.25内核代码库中,分别调用 mutex_lock()mutex_lock_interruptible() 的实例超过22,000次和800次;你可以通过利用强大的 cscope 工具在内核源代码树中查看这些内容。

理论上,内核还提供了一个 mutex_destroy() API。它是 mutex_init() 的对立面;它的作用是将互斥锁标记为不可使用。它必须在互斥锁处于解锁状态时调用,并且一旦调用,这个互斥锁就不能再使用了。这有点理论化,因为在常规系统中,它最终会变成一个空函数;只有在启用了 CONFIG_DEBUG_MUTEXES 的内核中,它才会成为实际的(简单的)代码。

因此,我们在使用互斥锁时应使用以下模式,如下所示:

DEFINE_MUTEX(...);        /* 初始化:静态初始化互斥锁对象 */
/* 或动态地,通过 */
mutex_init();           /* 不能同时使用这两者,只使用其中一个... */
[ ... ]
 /* 临界区:执行(互斥锁)锁定和解锁 */
 mutex_lock[_interruptible]();
 << ... 临界区代码 ... >>
 mutex_unlock();
    [ ... ]
        mutex_destroy();      // 清理:销毁互斥锁对象

现在你已经学会了如何使用互斥锁的API,让我们将这些知识付诸实践。在下一部分,我们将以一个写得很差——没有保护!——的简单“misc”驱动为基础,通过使用互斥锁对象来锁定所需的临界区。

互斥锁锁定 – 示例驱动

在《Linux内核编程 - 第2部分》附带的书中,我们在第一章《编写简单的杂项字符设备驱动》中创建了一个简单的设备驱动代码示例。

如前所述,您可以免费下载《Linux内核编程 - 第2部分》附带的书籍。PDF版本可以在此获取:github.com/PacktPublis…。(您还可以从Amazon下载Kindle版。)

在书中,我们编写了一个名为 miscdrv_rdwr 的简单杂项(miscellaneous)类字符设备驱动(代码可以在此GitHub仓库中找到:github.com/PacktPublis…)。我们还编写了一个小的用户空间实用程序(github.com/PacktPublis…),用于从设备驱动的内存中读取和写入(所谓的)“秘密”。《Linux内核编程 - 第2部分》书籍的第一章将详细展示如何编写并尝试这个设备驱动。

然而,在该项目中,我们显著地(这里使用“严重”这个词更准确!)没有保护共享(全局)可写数据,以防止并发调用!换句话说,我们轻率地忽视了临界区。这在实际中会让我们付出代价。我强烈建议你花一些时间思考这个问题:难道不可能有两个(或更多)用户模式进程打开这个驱动的设备文件,然后同时发出各种I/O操作:读取和写入吗?在这里,全球共享的可写数据(在这个特殊案例中,是两个全局整数和驱动程序上下文数据结构的成员)很容易被破坏。

因此,让我们从错误中学习并加以改正,通过复制此驱动程序(我们现在将其称为 ch12/1_miscdrv_rdwr_mutexlock/1_miscdrv_rdwr_mutexlock.c)并重写其中的部分代码。

关键点是:我们使用互斥锁来保护所有临界区。为了使问题更有趣,我们不在这里展示所有代码(当然,它在本书的GitHub仓库中),而是做一些有意思的事情:我们来看看新旧驱动程序代码版本之间的“差异”(diff)输出(为了可读性,输出已被截断):

$ 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> // 互斥锁,锁定,解锁等
 #include "../../convenient.h"
[ ... ] 
+DEFINE_MUTEX(lock1);   /* 这个互斥锁用来保护整数 ga 和 gb */
[ ... ]
+     struct mutex lock; // 这个互斥锁保护这个数据结构
 };
[ ... ]

diff 输出中,前面有“+”符号的行是新版本添加的,而前面有“-”符号的行是在新版本中删除的(旧版本中有)。

在这里,我们可以看到,在新版的安全驱动中,我们已经保护了所有共享的可写数据(即共享状态)免受并发访问,这是重点。为此,我们首先声明并初始化了一个互斥锁变量,叫做 lock1;我们将使用它来保护(仅用于演示目的)驱动程序中的两个全局整数 gagb。接下来,重要的是,我们在全局“驱动上下文”数据结构中声明了一个名为 lock 的互斥锁;即 drv_ctx。它将用于保护对该数据结构成员的所有访问。它在模块的初始化代码路径中被初始化:

+     mutex_init(&ctx->lock);
+     /* 获取该设备的设备指针 */
+     ctx->dev = llkd_miscdev.this_device;
+
+     /* 初始化“秘密”值 :-) */
-       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) 进程),
+      * 因此这里不存在并发问题。清理代码路径也是如此。
+      */

接下来的详细注释清楚地解释了为什么在这个特定情况下我们不需要在 strscpy() 周围加锁/解锁(因为它是模块初始化代码,只能在一个上下文中运行)。同样,这应该是显而易见的,局部变量是隐式地私有于每个进程上下文的(因为它们位于该进程或线程的内核模式堆栈中),因此不需要保护(每个线程/进程都有变量的单独实例,所以不会发生互相覆盖!)。在我们忘记之前,清理代码路径(通过 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);
 }

现在,让我们看看驱动程序的 open 方法的相关补丁(diff);它揭示了一些有趣的事情,展示了我们如何使用驱动程序上下文结构中的互斥锁来保护代码路径中的临界区:

+
+     mutex_lock(&lock1);
+     ga++; gb--;
+     mutex_unlock(&lock1);
+
+     dev_info(dev, " filename: "%s"\n"
      [ ... ]

但是(总是有个“但是”,不是吗?),并非一切都很好!看看在 mutex_unlock() 之后的 printk 函数(通过 dev_info() 封装器):

+ dev_info(dev, " filename: "%s"\n"
+         " wrt open file: f_flags = 0x%x\n"
+         " ga = %d, gb = %d\n",
+         filp->f_path.dentry->d_iname, filp->f_flags, ga, gb);

你觉得这样看起来合适吗?不,仔细看:我们正在读取全局整数 gagb 的值。回想一下基本原则:在并发存在的情况下(在这里的驱动程序的 open 方法中肯定存在并发),即使是读取共享可写数据而没有将其作为独占访问(通常通过加锁)进行处理,也可能是不安全的。如果这对你来说不太明白,请思考一下:如果在一个线程读取这些整数时,另一个线程正在同时更新(写入)它们,那会怎么样?这种情况可能会导致所谓的脏读或撕裂读;我们可能会读取到过时/不正确/部分正确的数据,这些数据必须受到保护。

说实话,这实际上不是一个很好的脏读示例,因为在大多数现代处理器上,读取和写入单个整数项往往是原子操作。然而,我们不能假设这些事情——我们必须做好工作,保护共享可写数据的访问——无论是读取还是写入——免受并发访问。

事实上,还有另一个类似的待解决bug:在刚才看到的代码片段中,你是否注意到我们没有保护从打开文件结构(filp 指针)读取的数据?(实际上,打开文件结构是有锁的;我们应该使用它!我们稍后会做到这一点。)

关于脏读发生的精确语义,往往是依赖于具体架构(机器)的;不过,作为内核或驱动程序的作者,我们的工作是明确的:我们必须确保保护所有临界区。这包括读取共享可写数据。

现在,我们暂时将这些标记为潜在的缺陷(bug,待办事项)。我们将在下一章的“使用 atomic_trefcount_t 接口”部分,以更具性能友好的方式解决这些问题。

接下来,看看驱动程序的 read 方法的补丁(diff);它揭示了一个有趣的事情,我们现在如何使用驱动程序上下文结构中的互斥锁来保护代码路径中的临界区:

image.png

图12.8是diff的一个(部分)截图,重点展示了我们驱动程序的read方法。仔细研究它;你会注意到我们现在已经使用互斥锁来保护对共享可写数据的每次访问,换句话说,保护每个临界区。(有些部分可能显得有点过于细致,毫无疑问。还有,为什么不使用get_task_comm()辅助函数来获取线程名(正如我们在第6章《内核内部精要 - 进程和线程》中所学到的)?这一点将在接下来的章节中变得更加清晰,请耐心等待!)同样的通过互斥锁的保护也适用于设备驱动的openwriteclose(释放)方法(为什么不为自己生成补丁并查看一下呢?)。

请注意,用户模式应用程序保持不变,这意味着为了测试新版本的更安全版本,我们必须继续使用原始的用户模式应用程序,在ch12/miscdrv_rdwr/rdwr_drv_secret.c。为了方便起见,我已将其代码复制到本书的GitHub仓库中:github.com/PacktPublis…。请试试看,并与杂项驱动一起使用!

在调试内核上运行和测试这种驱动程序代码是至关重要的,调试内核包含各种锁定错误和死锁检测功能(我们将在下一章的《内核中的锁调试》部分中回到这些“调试”功能)。

在前面的代码diff(图12.8)中,我们在copy_to_user()例程之前获取了互斥锁;copy_to_user()辅助宏是一个阻塞的调用(并且可能会休眠!)。这是可以的;毕竟,使用互斥锁保护可能会阻塞的临界区是其关键用例之一。

然而,我们仅在dev_info()调用之后才释放互斥锁。为什么不在这个例程之前释放它,从而缩短临界区呢?仔细查看dev_info(),你会明白为什么它在临界区内。我们在这里打印了三个变量的值:读取的字节数——由secret_len表示——以及通过ctx->txctx->rx“传输”和“接收”的字节数。现在,secret_len是一个局部变量,不需要保护,但另外两个变量位于全局驱动程序上下文结构中,因此(严格来说)即使只是“读取”它们,也需要保护,从而防止可能的脏读或撕裂读!

互斥锁 – 一些剩余的要点

在本节中,我们将讨论一些关于互斥锁的额外要点。

互斥锁API变种

首先,让我们看看几个互斥锁API的变种;除了可中断变种(在《互斥锁 – 通过[可中断/不可中断]休眠?》部分中描述过),我们还有trylock、killable和io变种。

互斥锁trylock变种

假设你希望实现一个忙等待的语义;也就是说,测试(互斥)锁是否可用,如果可用(意味着它当前未锁定),则获取它并继续执行临界区代码路径。然而,如果它不可用(意味着它当前处于锁定状态),则不要等待锁定;相反,执行一些其他工作,然后重试。

实际上,这是一种非阻塞的互斥锁变种,因此称为trylock;以下流程图大致描述了它的工作原理:

image.png

图12.9中的虚线框表示内部实现——检查锁是否未锁定并然后锁定它——是原子的。此trylock变种的互斥锁API如下:

int __sched mutex_trylock(struct mutex *lock);

此API的返回值表示运行时发生了什么:

  • 返回值为1表示成功获取锁。
  • 返回值为0表示锁当前被争用(已锁定)。

尽管可能会很诱人,但请不要尝试使用mutex_trylock() API来判断一个互斥锁是否处于锁定或解锁状态;这本质上是“竞争条件”。接下来请注意,在高度争用的锁路径中使用这种trylock变种可能会减少你获取锁的机会。trylock变种传统上用于死锁预防代码中,这些代码可能需要回退到某个锁定顺序,并通过另一个顺序(排序)重新尝试。

另外,关于trylock变种,尽管文献中使用了“尝试并原子地获取互斥锁”这样的措辞,但它并不在原子或中断上下文中工作——它只在进程上下文中有效(与任何类型的互斥锁一样)。如往常一样,锁必须由互斥锁所有者上下文调用mutex_unlock()来释放。

我建议你现在尝试练习使用trylock互斥锁变种。请查看本章末尾的“问题”部分,获取练习任务!

互斥锁的可中断和可杀变种

如你所学,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;
}

注意,在失败时,API 返回 -EINTR。 (顺便说一下,SYSCALL_DEFINEn() 宏定义了系统调用的签名;n 表示此特定系统调用接受的参数数量。还要注意权限检查——除非你以 root 用户身份运行,或者具有 CAP_SYS_MODULE 权限(或者模块加载被完全禁用),否则系统调用将返回失败(-EPERM)。)

然而,如果你的驱动程序只愿意被致命信号中断(那些会杀死用户空间上下文的信号),那么可以使用 mutex_lock_killable() API(其签名与可中断变种相同)。

互斥锁I/O变种

mutex_lock_io() API 的语法与 mutex_lock() API 相同;唯一的区别是,内核认为失败线程的等待时间与等待 I/O 相同(这一点在 kernel/locking/mutex.c:mutex_lock_io() 中有明确的代码注释说明;可以查看:elixir.bootlin.com/linux/v6.1.…)。这在会计上可能很重要。

你可以在内核中找到一些比较复杂的API,比如 mutex_lock[_interruptible]_nested(),重点在于 nested 后缀。然而,注意,Linux 内核并不鼓励开发者使用嵌套(或递归)锁定(如在《锁定指南与死锁》和《正确使用互斥锁》部分中所提到的)。此外,这些API只有在启用了 CONFIG_DEBUG_LOCK_ALLOC 配置选项时才会被编译;实际上,嵌套的API是为支持内核锁验证机制而添加的。它们应该仅在特殊情况下使用(需要在相同锁类型的多个实例之间引入嵌套级别时)。

在下一部分,我们将解答一个典型的常见问题:互斥锁和信号量对象有什么区别?等等,Linux 甚至有信号量对象吗?继续阅读,答案即将揭晓!

信号量与互斥锁

Linux内核确实提供了信号量对象,并且你可以对(二进制)信号量执行常规操作:

  • 通过 down[_interruptible]() (及其变种)API 获取信号量锁
  • 通过 up() API 解锁信号量

一般来说,信号量是一种较早的实现,因此建议使用互斥锁代替信号量。

不过,有一个值得探索的常见问题是:互斥锁和信号量有什么区别?它们在概念上看起来相似,但实际上却有很大的不同;以下几点总结了一个答案(当然,这些答案主要是针对Linux内核的):

  • 信号量是互斥锁的更通用形式;互斥锁只能被获取一次(随后被释放或解锁),而信号量可以被获取(并随后释放)多次。
  • 互斥锁用于保护临界区,防止同时访问,而信号量应该作为一种机制,用来通知另一个等待的任务,某个特定的里程碑已经达到(通常情况下,生产者任务通过信号量对象发布信号,消费者任务在等待接收该信号,以便继续执行进一步的工作)。
  • 互斥锁有锁的所有权概念,只有所有者上下文才能执行解锁操作;而二进制信号量没有所有权的概念。

优先级反转与RT-互斥锁

在使用任何类型的锁时,有一点需要小心,那就是你应该仔细设计和编码,以防止可能出现的死锁场景(在下一章的《锁验证器 lockdep – 提前捕获锁定问题》部分中会详细介绍如何捕获此类问题)。

除了死锁之外,使用互斥锁时还会遇到另一个风险场景:优先级反转(我们不会在本书中深入探讨这个问题)。简单地说,未加界限的优先级反转是致命的;最终的结果是,产品的最高优先级线程会被迫长时间无法使用CPU。

正如我在早期的书籍《Linux系统编程实践》中详细介绍的那样,正是这个优先级反转问题曾在1997年7月影响了NASA的火星探路者机器人,甚至发生在火星表面!该系统使用硬件看门狗,在项目的高优先级线程等待互斥锁超过预定时间时重启;问题在于,这种情况经常发生!由于启用了调试遥测(太好了!),问题得以从地球诊断并修复(通过使用优先级继承(PI)互斥锁属性,防止低优先级线程在释放互斥锁之前被抢占)。然后,固件被上传到火星上的机器人上(!),一切都顺利运行!有关这方面的有趣资源,请查看本章的进一步阅读部分,这是每个软件开发人员都应该了解的内容!

用户空间的Pthreads互斥锁实现确实提供了优先级继承(PI)语义。但是,Linux内核中又如何呢?为此,Ingo Molnar 提供了基于PI-futex的RT-互斥锁(实时互斥锁;实际上是扩展了优先级继承能力的互斥锁)。当启用 CONFIG_RT_MUTEXES 配置选项时,它会变得可用。与“常规”互斥锁语义非常相似,RT-互斥锁API提供了初始化、(解)锁定和销毁RT-互斥锁对象的功能。(此代码已经从Ingo Molnar的-rt树合并到主线内核中。)至于实际使用,RT-互斥锁用于内部实现PI futex(你知道在Linux中,futex(2)系统调用本身就是在用户空间实现Pthreads互斥锁的吗?)。除此之外,内核锁自测代码和I2C子系统也直接使用了RT-互斥锁。

因此,对于典型的模块(或驱动)开发者来说,这些API的使用并不频繁。内核提供了一些关于RT-互斥锁内部设计的文档,可以在此查看:docs.kernel.org/locking/rt-…(涵盖优先级反转、优先级继承等)。

内部设计

关于互斥锁在内核深处的内部实现的实际情况:Linux尽量在可能的情况下实现快速路径。

快速路径是最优化的高性能代码路径,通常没有锁定和阻塞。其目的是让代码尽可能走这条快速路径。只有在确实无法走快速路径时,内核才会回退到(可能的)“中间路径”,然后是“慢路径”方法;虽然仍然有效,但速度较慢。

当没有锁争用时(即锁最初未被锁定),便会采用快速路径。这样,锁可以不费吹灰之力地几乎立即被锁定。然而,如果互斥锁已经被锁定,内核通常会使用“中间路径”乐观旋转实现,使其更像是一个混合型(互斥锁/自旋锁)锁类型。如果连这种方法都不可行,便会采取“慢路径”——进程上下文可能会进入休眠状态。如果你对互斥锁的内部实现感兴趣,可以在官方内核文档中找到更多细节:docs.kernel.org/locking/mut…

LDV(Linux驱动验证)项目

在《在线章节,内核工作区设置》中的《LDV - Linux驱动验证》部分,我们提到过这个项目提供了关于Linux模块(主要是驱动程序)以及核心内核的各种编程方面的有用“规则”。

针对我们当前的话题,这里有一个规则:不能重复锁定互斥锁或在没有先前锁定的情况下解锁(linuxtesting.org/ldv/online?…)。它提到了不能对互斥锁做的事情(我们已经在《正确使用互斥锁》部分中讨论过)。有趣的是,你可以看到一个实际的bug示例——尝试重复获取互斥锁,导致(自我)死锁——在内核驱动中(以及后续的修复)。

另一种互斥锁,我们仅提及一下,就是所谓的w/w——wait/wound(或简称WW-互斥锁)——其中wound与“doomed”(注定的)押韵。这个术语源自RDBMS文献,是一种处理死锁的方式(文档甚至使用“防死锁”这个词)。在Linux内核中,w/w的使用主要是在图形子系统和某些程度的DMA中。在这里,获取互斥锁的任务会获得一个独特的预定或票证标识符。现在,获取WW-互斥锁任务的“年龄”被考虑在内。如果检测到死锁,优先处理“最老”的任务——即持有预定时间最长的任务。如何做到这一点?通过让“较年轻”的任务后退,让它们释放它们持有的WW锁;因此,年轻的任务被“伤害”(可怜的家伙)。与常规互斥锁相比,内核中WW-互斥锁的使用非常少。有关它的更多信息,请查看《进一步阅读》部分。

现在你已经了解了如何使用互斥锁,接下来让我们看看内核中另一个非常常见的锁——自旋锁!

使用自旋锁

在《实际应用中如何选择锁》部分中,你学到了在实践中何时使用自旋锁而不是互斥锁,反之亦然。为了方便起见,我们将之前提供的关键陈述再次列出:

  • 如果临界区运行在原子(例如中断)上下文中,或者在无法休眠的进程上下文中,使用自旋锁。
  • 如果临界区运行在进程上下文中,并且临界区内可能会有休眠或阻塞I/O,使用互斥锁。

在本节中,我们假设你已经决定使用自旋锁。

自旋锁 – 简单使用

对于所有自旋锁API,你必须包含相关的头文件,即 #include <linux/spinlock.h>

与互斥锁类似,你必须在使用之前声明并初始化自旋锁为未锁定状态。自旋锁是一个“对象”,通过名为 spinlock_t 的数据类型声明(在内部,它是一个在 include/linux/spinlock_types.h 中定义的结构)。它可以通过 spin_lock_init() 宏动态初始化:

spinlock_t lock;
spin_lock_init(&lock);

或者,也可以通过 DEFINE_SPINLOCK(lock); 宏静态声明并初始化。

与互斥锁一样,在它需要保护的(全局/静态)数据结构中声明自旋锁通常是一个非常好的主意。正如我们之前提到的,这种做法在Linux内核中经常被使用;例如,表示内核中打开文件的数据结构称为 struct file

// include/linux/fs.h
struct file {
    [...]
    struct path f_path;
    struct inode *f_inode; /* 缓存值 */
    const struct file_operations *f_op;
    /*
     * 保护 f_ep_links,f_flags。
     * 不能在中断上下文中获取。
     */
    spinlock_t f_lock;
    [...]
    struct mutex f_pos_lock;
    loff_t f_pos;
    [...]
};

注意:在文件结构中,名为 f_lock 的自旋锁是保护(如注释所述) f_ep_linksf_flags 成员的自旋锁;此外,它还有一个互斥锁来保护另一个成员,即文件的当前寻址位置 f_pos

内核为我们模块/驱动程序开发者提供了多种自旋锁API的变体;最简单的自旋锁(解)锁API如下:

void spin_lock(spinlock_t *lock);
<< ... 临界区 ... >>
void spin_unlock(spinlock_t *lock);

请注意,自旋锁没有类似于 mutex_destroy() 的API。现在,让我们看看自旋锁API的实际应用!

自旋锁 – 示例驱动

类似于我们在《互斥锁 – 示例驱动》部分所做的(如果需要,请参考该部分),为了说明自旋锁的简单用法,我们将(再次)复制我们之前的简单“misc”字符设备驱动程序(该程序故意没有进行锁定),这段代码出自《Linux Kernel Programming – Part 2》配套书的第一章《编写一个简单的 misc 字符设备驱动程序》(ch1/1_miscdrv_rdwr),并将其放入本书的新模块驱动程序位置,即:ch12/2_miscdrv_rdwr_spinlock(当然,我们希望你能够克隆本书的GitHub仓库)。

同样,为了便于阅读,我们将在此仅显示原程序和新程序之间的少部分diff(差异,diff生成的delta):

+#include <linux/spinlock.h>
[ ... ]
-/*
- * The driver 'context' (or private) data structure;
- * all relevant 'state info' regarding the driver is here.
+static int ga, gb = 1;
+DEFINE_SPINLOCK(lock1); /* this spinlock protects the global integers ga and gb */
+
+/* The driver 'context' data structure;
+ * all relevant 'state info' reg the driver is here.
  */  
 struct drv_ctx {
        struct device *dev;
        int tx, rx, err, myword;
        u32 config1, config2;
        u64 config3;
    [ … ]
+#define MAXBYTES    128
        char oursecret[MAXBYTES];
+       struct mutex mutex;  /* this mutex protects these members of this data
+                             * structure: oursecret
+                             * in the (possibly) blocking critical section
+                             */
+       spinlock_t spinlock; /* this spinlock protects these members of this data
+                             * structure: oursecret, tx, rx
+                             * in the non-blocking / atomic critical sections
+                             */
 };
 static struct drv_ctx *ctx;

在这个版本中,为了保护我们 drv_ctx 全局数据结构的成员,我们既使用了互斥锁(来自之前的版本),也引入了新的自旋锁。数据结构中有多个锁是非常常见的;通常,互斥锁用于保护在可能发生阻塞的临界区内的成员,而自旋锁用于保护在无法发生阻塞(休眠)的临界区内的成员(你也可以在进程上下文中使用自旋锁来保护数据成员,只要临界区是非阻塞的)。

当然,我们必须确保初始化所有锁,以便它们处于未锁定状态。我们在驱动程序的初始化代码路径中完成这一操作(因此,继续显示补丁输出):

+       mutex_init(&ctx->mutex);
+       spin_lock_init(&ctx->spinlock);

在这个版本中,在驱动程序的 open 方法中,我们用自旋锁替换了互斥锁,以保护全局整数的增减操作;补丁的部分截图如下所示,以颜色高亮显示:

image.png

再次强调:每当我们使用自旋锁时,这意味着它所保护的临界区——自旋锁和解锁之间的代码路径——绝对是非阻塞代码。

接下来,在这个版本中,在驱动程序的 read 方法中,我们使用自旋锁代替互斥锁来保护一些临界区:

static ssize_t read_miscdrv_rdwr(struct file *filp, char __user *ubuf,
-                                size_t count, loff_t *off)
+                               size_t count, loff_t *off)
 {
-       int ret = count, secret_len = strnlen(ctx->oursecret, MAXBYTES);
+       int ret = count, secret_len, err_path = 0;
        struct device *dev = ctx->dev;
-       char tasknm[TASK_COMM_LEN];
+
+       spin_lock(&ctx->spinlock);
+       secret_len = strlen(ctx->oursecret);
+       spin_unlock(&ctx->spinlock);

然而,这还不是全部!继续阅读驱动程序的 read 方法,仔细查看以下代码片段,其中获取了互斥锁,以及后面的注释:

        ret = -EFAULT;
+       mutex_lock(&ctx->mutex);
+       /* 为什么我们不直接使用自旋锁?
+        * 因为 - 非常重要! - 记住,自旋锁只能在
+        * 临界区不会以任何方式休眠或阻塞时使用;在这里,
+        * 临界区调用了 copy_to_user();这很可能
+        * 导致发生“休眠”(即 schedule())。
+        */
        if (copy_to_user(ubuf, ctx->oursecret, secret_len)) { [ … ]

当保护的数据所在的临界区可能包含阻塞API——例如 copy_to_user()——时,我们必须仅使用互斥锁!(由于篇幅限制,我们没有在这里显示更多补丁内容;我们希望你能通读自旋锁示例驱动程序代码,并亲自尝试。)

测试 – 在原子上下文中休眠

你已经了解到,我们绝不应在任何原子或中断上下文中进行休眠(阻塞)。让我们来测试一下这个理论。和往常一样,经验主义方法——即自己测试,而不是依赖他人的经验——是关键!

我们到底如何测试这个问题呢?很简单:我们将使用一个简单的整数模块参数 buggy,当其值为 1 时(默认值为 0),我们将执行一个代码路径,该路径位于我们驱动程序的自旋锁保护的临界区内,违反了这个规则。在这个代码路径中,我们将调用 schedule_timeout() API,它会内部调用 schedule();这是将进程上下文(当前进程)置于内核空间休眠的一种方法。(供参考,使用这个 API 以及更多内容,在《Linux Kernel Programming – Part 2》配套书第5章《与内核定时器、线程和工作队列一起工作》中有详细介绍。)以下是相关代码:

// ch12/2_miscdrv_rdwr_spinlock/miscdrv_rdwr_spinlock.c
[ ... ]
static int buggy;
module_param(buggy, int, 0600);
MODULE_PARM_DESC(buggy,
"If 1, cause an error by issuing a blocking call within a spinlock critical section");
[ ... ]
static ssize_t write_miscdrv_rdwr(struct file *filp, const char __user *ubuf,
                size_t count, loff_t *off)
{
    int ret, err_path = 0;
    [ ... ]
    spin_lock(&ctx->spinlock);
    strscpy(ctx->oursecret, kbuf, (count > MAXBYTES ? MAXBYTES : count));
    [ ... ]
    if (1 == buggy) {
        /* We're still holding the spinlock! */
        set_current_state(TASK_INTERRUPTIBLE);
        schedule_timeout(1*HZ); /* ... and this is a blocking call!
                    * Congratulations! you've just engineered a bug */
    }
    spin_unlock(&ctx->spinlock);
    [ ... ]
}

现在进入有趣的部分:让我们在自定义的 6.1.25 “debug” 内核中测试这个(有 bug 的)代码路径(这个内核启用了多个内核调试配置选项(主要来自 make menuconfig UI 中的 Kernel Hacking 菜单))。

在 6.1 调试内核上测试有 bug 的模块

首先,确保你已经构建了自定义的 6.1 内核(我们使用的是 6.1.25 版本),并且所有所需的内核调试配置选项已启用(如果需要,参考第5章《编写第一个内核模块 – Part 2》中的“配置一个‘debug’内核”部分)。然后,启动你的调试内核(在这里,它被命名为 6.1.25-dbg)。现在,针对这个调试内核构建 misc 驱动程序(在 ch12/2_miscdrv_rdwr_spinlock/ 目录下),通过运行驱动目录中的 make 命令来完成这个操作;你可能会发现,在调试内核上,构建会稍慢一些,生成的二进制模块也会更大:

$ lsb_release -a 2>/dev/null | grep "^Description" ; uname -r
Description:    Ubuntu 23.10
6.1.25-dbg
# 在运行 make 之前,确保 Makefile 中的 MYDEBUG 变量设置为 y(我已经这样做了)。
$ cd <lkp2e_book_src>/ch12/2_miscdrv_rdwr_spinlock
$ make
--- Building : KDIR=/lib/modules/6.1.25-dbg/build ARCH= CROSS_COMPILE= ccflags-y="-DDEBUG -g -ggdb -gdwarf-4 -Wall -fno-omit-frame-pointer -fvar-tracking-assignments -DDYNAMIC_DEBUG_MODULE" MYDEBUG=y DBG_STRIP=n ---
gcc (Ubuntu 13.2.0-4ubuntu3) 13.2.0
[ … ]
$ modinfo ./miscdrv_rdwr_spinlock.ko 
filename:       /home/c2kp/lkp2e/ch12/2_miscdrv_rdwr_spinlock/./miscdrv_rdwr_spinlock.ko
[ … ]
vermagic:       6.1.25-dbg SMP preempt mod_unload modversions 
parm:           buggy:If 1, cause an error by issuing a blocking call within a spinlock critical section (int)
$ sudo virt-what 
virtualbox
kvm
$  

如你所见,我们在 x86_64 Ubuntu 23.10 客户机虚拟机上运行的是自定义的 6.1.25 “debug” 内核。

如何知道你是在虚拟机(VM)上运行还是在“裸机”(本地)系统上运行?virt-what 是一个非常有用的小脚本,它可以显示这一信息(你可以通过 sudo apt install virt-what 在 Ubuntu 上安装它)。

要运行我们的测试用例,将驱动程序插入内核并将 buggy 模块参数设置为 1。通过我们的用户空间应用程序(即 ch12/rdwr_test_secret)调用驱动程序的 read 方法没有问题,因为没有执行 bug 代码路径,如下所示:

$ sudo dmesg -C
$ sudo insmod ./miscdrv_rdwr_spinlock.ko buggy=1
$ lsmod |grep miscdrv
miscdrv_rdwr_spinlock    20480  0
$ ../rdwr_test_secret 
Usage: ../rdwr_test_secret opt=read/write device_file ["secret-msg"]
 opt = 'r' => we shall issue the read(2), retrieving the 'secret' form the driver
 opt = 'w' => we shall issue the write(2), writing the secret message <secret-msg>
  (max 128 bytes)
$ ../rdwr_test_secret r /dev/llkd_miscdrv_rdwr_spinlock 
Device file /dev/llkd_miscdrv_rdwr_spinlock opened (in read-only mode): fd=3
../rdwr_test_secret: read 7 bytes from /dev/llkd_miscdrv_rdwr_spinlock
The 'secret' is:
 "initmsg"
$ 

接下来,我们通过用户空间应用程序发出 write(2) 系统调用,这次驱动程序的 write 方法运行(当然是在我们的用户空间 rdwr_test_secret 进程上下文中),因此 bug 代码路径被执行!

$ ../rdwr_test_secret w /dev/llkd_miscdrv_rdwr_spinlock "When you have exhausted all possibilities, remember this: you haven't"
Device file /dev/llkd_miscdrv_rdwr_spinlock opened (in write-only mode): fd=3
../rdwr_test_secret: wrote 70 bytes to /dev/llkd_miscdrv_rdwr_spinlock
$

正如你在本节前面展示的代码片段中看到的,我们在自旋锁临界区内(即在锁和解锁之间)调用了 schedule_timeout()。调试内核将此检测为一个 bug,并将(非常大量的)调试诊断信息输出到内核日志中(请注意,像这样的 bug 很可能会挂起你的系统,因此总是建议在测试虚拟机上进行类似的测试)。我们通过 sudo dmesg 来查看这些信息:

image.png

前面的(部分)截图(图12.11)显示了发生的情况;让我们在查看位于 ch12/2_miscdrv_rdwr_spinlock/miscdrv_rdwr_spinlock.c 中的驱动程序代码时跟随其过程:

首先,查看第二行,我们有用的 PRINT_CTX() 宏的输出(我们在此重新呈现这一行):

miscdrv_rdwr_spinlock:write_miscdrv_rdwr(): 005) rdwr_test_secre :3066 | ...0   /* write_miscdrv_rdwr() */

显然,驱动程序的写入方法 write_miscdrv_rdwr() 正在我们的用户空间进程(rdwr_test_secre)上下文中运行;请注意,名称被截断为前16个字符,包括 NULL 字节。

它从用户空间写入进程复制新的“秘密”数据,并写入 70 字节。

然后,它“获取”了自旋锁,从而进入了临界区,并将数据复制到驱动程序上下文结构的 oursecret 成员中。

之后,if (1 == buggy) 条件评估为真。

因此,它调用了 schedule_timeout(),这是一个阻塞 API(因为它内部调用了 schedule()),触发了这个 bug,红色高亮显示:

BUG: scheduling while atomic: rdwr_test_secre/3066/0x00000002

内核现在输出了大量的诊断信息。首先输出的是调用栈。

该调用栈或内核模式栈的回溯(这里是我们的用户空间应用程序 rdwr_drv_secret,它在进程上下文中运行我们(有问题的)驱动程序的代码)可以在图12.11中清晰地看到。每个 Call Trace: 标头后的行本质上是内核栈上的一个调用帧。

几点提示:

  1. 总是从底部向上阅读调用栈(或调用回溯) :我们可以清楚地看到我们是通过系统调用进入的,首先是 write(2),接着变成 ksys_write(),然后是 vfs_write(),等等。
  2. 忽略所有以 ? 符号开头的栈帧:它们是可疑的调用帧,很可能是“剩余的”栈帧,来自于同一内存区域先前的栈使用。值得稍微做一点内存相关的插曲:栈内存实际上是如何分配的;栈内存不是按每个调用帧的基础上进行分配和释放的,因为那样会非常昂贵。只有当栈内存页被用尽时,新的内存页才会自动加载!(回顾我们在第9章《内核模块作者的内存分配》中的讨论,特别是《内存分配和需求分页》部分)。因此,实际上,随着代码调用并从函数返回,通常会重复使用相同的栈内存页。
  3. 对于性能原因,调用帧的内存不会每次都清空,这导致以前的帧残留物经常出现(它们可能会“破坏”图像)。然而,幸运的是,现代的栈调用帧跟踪算法通常能够出色地确定正确的堆栈跟踪。

vfs_write() 调用 __vfs_write(),最终调用我们的驱动程序写方法,即 write_miscdrv_rdwr()!(当然,这就是字符驱动框架的设计方式。此外,注意任何模块函数调用帧右侧的 [模块名称],这是另一个有用的线索。)我们知道,这段代码调用了有问题的代码路径,在那里我们调用了 schedule_timeout(),它反过来调用了 schedule()(后者调用实际的工作例程 __schedule()),导致整个 BUG: scheduling while atomic 内核 bug 被触发。

调度时原子操作代码路径的格式可以通过以下代码行获得,该代码位于 kernel/sched/core.c 中:

printk(KERN_ERR "BUG: scheduling while atomic: %s/%d/0x%08x\n", prev->comm, prev->pid, preempt_count());

有趣的是!在这里,您可以看到它打印了以下字符串:

BUG: scheduling while atomic: rdwr_test_secre/3066/0x00000002

atomic: 后面,它打印了(可能被截断的)进程名、PID,然后调用了 preempt_count() 内联函数,该函数打印了抢占深度;本质上,抢占深度是一个计数器,每次获取自旋锁时递增,每次解锁时递减。因此,如果抢占深度为正,这意味着代码处于临界区或原子区;在这里,显示的值是 2,证明我们处于一个原子且不可抢占的代码路径中!

我们不在这里解释其余有用的内核诊断信息;有关这些方面的深入分析,请查看《Linux内核调试》一书。

需要注意的是,在此测试运行中,这个 bug 被内核整洁地报告出来,因为启用了 CONFIG_DEBUG_ATOMIC_SLEEP 调试内核配置选项(它之所以开启,是因为我们正在运行一个自定义的“调试内核”!)。该配置选项的详细信息(您可以在 make menuconfig 中交互式地查找并设置此选项,位于 Kernel Hacking 菜单下)如下:

// lib/Kconfig.debug
[ ... ]
config DEBUG_ATOMIC_SLEEP
    bool "Sleep inside atomic section checking"
    select PREEMPT_COUNT
    depends on DEBUG_KERNEL
    depends on !ARCH_NO_PREEMPT
    help 
      If you say Y here, various routines which may sleep will become very 
      noisy if they are called inside atomic sections: when a spinlock is
      held, inside an rcu read side critical section, inside preempt disabled
      sections, inside an interrupt, etc...

如果我们在标准的“发行版”系统和内核上执行相同的测试(例如我们的 Ubuntu 22.04 LTS 虚拟机),重要的是,它通常没有配置为“调试”内核(因此 CONFIG_DEBUG_ATOMIC_SLEEP 配置选项没有被设置)?我们可能会认为此时内核不会捕获这个特定的 bug;好吧,作为经验测试,结果是这样的:

在本书的第一版中,在标准的 Ubuntu 20.04 虚拟机上运行该测试,并使用标准的发行版(5.4)内核,确实没有捕获到这个 bug。

然而,我发现,在较新的发行版和它们所运行的内核中,即使没有明确设置 CONFIG_DEBUG_ATOMIC_SLEEP 配置选项,它仍然捕获了“调度时原子” bug!多年来,内核的改进似乎确保了这一进展……(我在 Ubuntu 22.04 上使用 5.19.0-45-generic 内核以及在 Fedora 38 发行版上使用较新的 6.5.6-200.fc38.x86_64 内核进行了测试。)

即使在生产环境中,保持 CONFIG_DEBUG_ATOMIC_SLEEP 配置选项启用通常是有帮助的。

再次,LDV 项目有以下规则:“不允许两次获取 spin_lock。不允许释放未获取的 spin_lock。所有 spin_lock 应该在结束时释放。不允许通过 spin_unlock 或 spin_unlock_irqrestore 函数重复释放锁”(linuxtesting.org/ldv/online?…)。(我们在《锁定指南和死锁》部分中已经讨论了这些要点。)它提到了正确使用自旋锁的关键点;有趣的是,这里展示了一个实际的驱动程序 bug 实例,其中尝试两次释放自旋锁——这是对锁定规则的明显违反,导致系统不稳定。

太好了,完成自旋锁了吗?不,还远远不够!接下来的部分将带领我们深入了解自旋锁及其在内核和模块中的使用,其中我们还会在中断上下文中使用自旋锁。

锁和中断

到目前为止,我们已经学习了如何使用互斥锁(mutex)和自旋锁(spinlock)的基本 spin_[un]lock() API。自旋锁还有一些其他的变种 API,我们将在这里讨论一些常见的变体。

本节内容涉及一些更高级的内容,如果你至少了解如何编写(字符)设备驱动程序和在 Linux 上处理硬件中断(通常是在设备驱动程序上下文中),将对你有帮助。有关这些主题的深入讨论可以在本书的伴随卷《Linux Kernel Programming - Part 2》中找到,尤其是在第一章《编写简单的 misc 字符设备驱动程序》和第四章《处理硬件中断》中(LKP-2 电子书可免费下载)。此外,作为快速指南/复习,我们在本节后面提供了一个小节:Linux 中断处理概述关键点。(如果你不熟悉 Linux 中断处理的概念,建议先查看 LKP-2 书籍和/或本节内容,然后继续阅读。)

为了更好地理解为什么你可能需要其他自旋锁的 API,让我们来分析一个场景:作为驱动程序作者,你发现你正在使用的设备触发了硬件中断;因此,你编写了一个中断处理程序。

现在,在实现你的驱动程序的读取方法时,你发现其中有一个非阻塞的临界区。这很容易处理:正如你所学,你应该使用自旋锁来保护它。太好了!但是,想想看,如果在读取方法的临界区中,设备的硬件中断触发了怎么办?正如你所知道的,硬件中断会抢占所有内容;因此,控制权将转移到中断处理程序代码,抢占驱动程序的读取方法。

关键问题是:这是一个问题吗?这个答案取决于你的驱动程序的中断处理程序和读取方法的具体实现。让我们设想几种情况:

  1. 驱动程序的中断处理程序(理想情况下)只使用局部变量,因此即使读取方法在临界区内,这也无关紧要;中断处理将非常快速地完成(通常而言,在10到100微秒之间),控制将返回给被中断的程序(当然,这个过程并不止于此;任何现有的底半机制,如 softirq 或 tasklet,也可能需要执行,才会将处理器交回给驱动程序的读取方法)。因此,这种情况下没有数据竞争。
  2. 驱动程序的中断处理程序正在处理(全局)共享可写数据,但没有处理与驱动程序读取方法相关的共享数据项。因此,仍然没有冲突,也没有与读取方法代码的竞争。(你还应该意识到,当然,当中断处理程序正在处理共享状态时,它也有一个必须保护的临界区(通常通过另一个自旋锁保护)。)
  3. 驱动程序的中断处理程序正在处理与驱动程序读取方法正在处理的相同(或部分相同)的共享可写数据。在这种情况下,我们可以看到确实存在数据竞争的潜力,因此我们需要加锁!(回顾我们在《数据竞争 - 更正式的定义》部分中对数据竞争的定义。)

当然,让我们集中讨论第三种情况,即可能存在数据竞争的情况。显然,我们必须使用自旋锁来保护中断处理代码中的临界区(回顾一下,当我们处于任何类型的原子或中断上下文时,不允许使用互斥锁)。此外,除非我们在驱动程序的读取方法和中断处理程序代码路径中使用相同的自旋锁,否则它们将根本无法得到保护!

正如这个讨论所表明的,在使用锁时要格外小心;花时间仔细思考你的设计和代码。

让我们试着将这个讨论变得更加实用(使用伪代码):假设我们有一个(全局)共享数据结构 gctx;我们在驱动程序的读取方法和中断处理程序(上半部分和/或任务)中都在操作它。关于我们的读取方法,由于这段代码路径可以并发运行,并且它包含共享状态,它是一个临界区,因此需要保护。现在,由于我们在进程上下文中运行,似乎在这里使用互斥锁是没问题的。

不!想想看……为了有效地保护共享状态,我们显然需要在驱动程序的读取方法和中断处理程序中使用相同的锁。现在,中断处理程序的临界区也需要保护,正如我们所学,我们实际上没有太多选择:我们必须使用自旋锁。所以,我们发现,驱动程序的读取方法和中断处理程序都必须使用相同的自旋锁!(在这里,我们将自旋锁变量命名为 slock;可以将其理解为“s 锁”)。

以下伪代码显示了此情况的一些时间戳(t1、t2 等);但首先,让我们以错误的方式做:

/* 驱动程序读取方法;错误! */
driver_read(...)                  << 时间 t0 >>
{
    [ ... ]
    spin_lock(&slock);
    <<--- 时间 t1:读取方法临界区开始 >>
... << 临界区:操作全局数据对象 gctx >> ...
    spin_unlock(&slock);
    <<--- 时间 t2:读取方法临界区结束 >>
    [ ... ]
}                                << 时间 t3 >>

(为什么我们将这个驱动程序的读取方法标记为“错误”?你很快就会知道!)

现在,为了让这个讨论更有趣,我们考虑三种情况(从简单到复杂):

  • 情况 1 — 驱动程序的读取方法和中断按顺序运行,一个接一个(换句话说,按序列化的方式运行,没有竞争条件)
  • 情况 2 — (更有趣)它们以交错的方式运行(存在数据竞争的潜力)
  • 情况 3 — 它们交错运行,并且一些中断被屏蔽(有的没有被屏蔽;存在数据竞争的潜力)

我们从第一种情况开始。

场景 1 – 驱动程序方法和硬件中断处理程序顺序执行

以下伪代码是设备驱动程序的中断处理程序。由于我们“知道”它们在这里是顺序执行的,所以时间线从之前(在本节之前我们看到的虚拟驱动程序读取方法代码片段)的时间 t3 继续;因此,第一次时间戳显示为时间 t4,紧接着是读取方法末尾的时间 t3:

handle_interrupt(...)           << 时间 t4;硬件中断触发! >>
{
    [ ... ]
    spin_lock(&slock);
    <<--- 时间 t5: 中断临界区开始 >>
    ... << 临界区:操作全局数据对象 gctx >> ...
    spin_unlock(&slock);
    <<--- 时间 t6 : 中断临界区结束 >>
    [ ... ]
}                               << 时间 t7 >>

这可以通过以下图表总结;请仔细研究:

image.png

在图12.12中,sL和sU表示使用的spin_lock()和spin_unlock() APIs。幸运的是,一切顺利——之所以说幸运,是因为硬件中断是在读取函数的临界区完成后触发的(它也可能在之前触发),而不是在临界区中间触发(如我们将在下一个场景中考虑的那样!)。当然,我们不能指望运气作为产品安全的唯一保障!

场景 2 – 驱动程序方法和硬件中断处理程序交替执行

无论您的代码运行在UP(单处理器,仅一个CPU核心)还是SMP(多核)系统上都很重要。让我们首先考虑在UP系统中的这种情况。

场景 2:单核(UP)系统

硬件中断当然是异步的(它们可以在任何时候到达)。假设外设芯片(您的驱动程序专门用于“驱动”它)的中断触发了,并且恰好在与读取方法的临界区运行的同一个CPU核心上发生(对我们来说,时间不太合适)——例如,在读取方法的临界区运行时,也就是在时间t1和t2之间(见图12.12)?那么,spinlock(在t1时获取的)不应该就能保护我们的数据吗?

不,仔细想想:此时,中断处理程序将抢占读取方法的临界区,并很快进入它的临界区;这样,它显然会尝试获取相同的spinlock(&slock)。但等一下——它不能获取(锁定)它,因为它目前被读取方法运行的进程上下文锁定!因此,它会“自旋”,实际上在等待解锁。但是,它如何解锁呢?驱动程序的读取进程上下文将不得不解锁它(因为它拥有它),但由于被硬件中断抢占,它无法解锁!因此,它无法解锁,导致中断侧的spinlock永远“自旋”;所以,我们得到了一个(自我)死锁。

那么,解决方案是什么呢?我们马上就会得出答案,请继续阅读……

场景 2:多核(SMP)系统

有趣的是,在SMP(或多核)系统上,spinlock更直观,并且完全合理。那么,让我们考虑一下与之前不同的情景——在多核上运行。假设读取方法在CPU核心1上运行;而中断可以在另一个CPU核心(例如核心2)上触发。由于读取方法处于其临界区,意味着spinlock已被锁定(当然);因此,中断代码路径将尝试在CPU核心2上获取相同的spinlock,它将在锁上“自旋”。一旦读取方法在核心1上完成其临界区,它将解锁spinlock,从而解锁中断处理程序,它现在可以“获取”spinlock并继续执行;太好了!

但在UP上怎么办呢?毕竟,不能在一个(例如,进程)上下文中运行逻辑,同时在另一个(例如,中断)上下文中在同一个CPU核心上“自旋”。如果在一个多核(SMP)系统上,驱动程序的读取方法和硬件中断处理程序恰好在同一个核心上执行,又该如何处理呢?

别担心,确实有解决方案!

通过spin_[un]lock_irq() API变种解决UP和SMP上的问题

终于,这里有了这个难题的解决方案:当“与中断竞争”时,无论是在单处理器(UP)还是SMP系统上,只需使用spinlock API的_irq变种:

#include <linux/spinlock.h>
void spin_lock_irq(spinlock_t *lock);

spin_lock_irq() API内部会屏蔽硬件中断(除了不可屏蔽的中断,如处理器核心上的不可屏蔽中断(NMI))。因此,通过在驱动程序的读取方法中使用此API,临界区期间将有效禁用本地核心上的所有中断,从而使硬件中断导致的任何可能的“竞争”变得不可能。(如果中断在另一个CPU核心上触发,spinlock技术将按预期工作,正如之前讨论的那样!)

spin_lock_irq()的实现非常嵌套(像大多数spinlock功能一样),但它非常快速;最终,它会调用local_irq_disable()和preempt_disable()宏,从而禁用本地处理器核心上的中断和内核抢占。换句话说,使用这个spinlock API变种,禁用硬件中断有一个(期望的)副作用——也禁用了内核抢占!

spin_lock_irq() API与相应的spin_unlock_irq() API配对使用。所以,在这个场景下正确的spinlock使用方法(与之前我们看到的可能导致自我死锁的朴素方法不同)如下:

/* 驱动程序读取方法;正确! */
driver_read(...)                  << 时间 t0 >>
{
    [ ... ]
    spin_lock_irq(&slock);        // 注意:我们使用的是_irq版本的spinlock!
    <<--- 时间 t1 : 临界区开始 >>
    [现在本地CPU核心上的所有中断和内核抢占都被屏蔽(禁用)]
    ... << 临界区:操作全局数据对象gctx >> ...
    spin_unlock_irq(&slock);
    <<--- 时间 t2 : 临界区结束 >>
    [现在本地CPU核心上的所有中断和内核抢占被恢复(启用)]
    [ ... ]
}                                << 时间 t3 >>

现在,应该能正常工作,因为使用_irq版本的spinlock API后,硬件中断无法抢占驱动程序读取方法的临界区!

不过,仍然有一个隐蔽的问题存在(翻个白眼);以下场景描述了这个问题并提供了解决方案。

场景 3 – 一些中断被屏蔽,驱动程序方法和硬件中断处理程序交替执行

在我们为自己庆祝并准备休息之前,让我们再考虑一个场景。在这个场景中,在一个更复杂的产品(或项目)中,很可能在多个开发人员共同工作时,有一个开发人员故意将硬件中断(位)掩码设置为某个值,从而屏蔽了某些硬件中断,同时允许其他中断(disable_irq() / enable_irq() API 允许选择性地禁用/启用单个 IRQ 行)。

为了举例说明,假设这一操作发生在某个时间点 t0。现在,正如我们之前所描述的,另一个开发人员(假设是你)过来,并为了保护驱动程序读取方法中的临界区,使用了 spin_lock_irq() API(正如我们在《解决UP和SMP上的问题与spin_[un]lock_irq() API变种》部分中所学到的)。这看起来是正确的,是吗?嗯,是的,但这个API有能力关闭(屏蔽)本地CPU核心上的所有硬件中断(以及内核抢占,我们暂时忽略内核抢占)。它通过低级操作处理器(非常依赖架构的)硬件CPU状态寄存器来实现。

为了说明,x86[_64]架构上,spin_lock_irq() API 保存 EFLAGS(32 位)/ RFLAGS(64 位)寄存器内容(更准确地说,它保存 LSB 16 位内容,也就是叫做“FLAGS”的部分)。在 AArch32 上,它保存 EFLAGS 寄存器内容,在 AArch64 上,它保存 DAIF(调试、异步错误、中断和快速中断异常)寄存器内容。spin_unlock_irqrestore() API 当然会恢复这些内容。

那么,问题是什么呢?考虑这个场景的发生:

  • 时间 t0: 中断掩码被设置为某个值,比如 0x8e(10001110b),启用了某些中断并禁用了其他中断(通过比如精心使用 disable_irq() API)。这个设置对项目至关重要(在这里为了简化,我们假设它是一个 8 位的掩码寄存器): [... 时间流逝 ...]
  • 时间 t1: 在进入驱动程序读取方法的临界区之前,调用 spin_lock_irq(&slock);。正如我们现在理解的,这个API会在内部保存一些CPU状态寄存器,并对中断掩码寄存器的所有位进行清零,从而有效地屏蔽所有中断。
  • 时间 t2: 现在,硬件中断不能在这个CPU核心上处理,因此我们继续完成临界区的操作。这是可以的。一旦临界区完成,我们调用 spin_unlock_irq(&slock);。这个API会有内部效果,恢复某些(依赖架构的)CPU状态寄存器,这也会导致中断掩码寄存器的所有位被设置为1,从而重新启用所有中断。

然而,现在中断掩码寄存器被错误地“恢复”到0xff(11111111b),而不是原开发人员所期望的0x8e!这可能(并且很可能)会破坏项目中的某些功能,听起来很复杂。

然而,解决方案非常简单:在保存CPU状态时,不要假设任何事情;只需保存和恢复现有的CPU状态,因此,保存和恢复中断掩码状态。这样,如果中断掩码最初是0x8e(10001110b),那么这就是保存的值,并且稍后会恢复它;完美。这个保存和恢复CPU状态(与spinlock一起)可以通过以下spinlock API对来实现:

#include <linux/spinlock.h>
unsigned long spin_lock_irqsave(spinlock_t *lock, unsigned long flags);
void spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags);

这两个锁定和解锁函数的第一个参数是要使用的spinlock变量的指针。第二个参数 flags 必须是一个 unsigned long 数据类型的局部变量。这个变量将用于保存和恢复架构特定的CPU寄存器(因此也包括中断掩码状态)。因此,最终,对于这个场景,正确的(伪)代码如下:

spinlock_t slock;
spin_lock_init(&slock);
[ ... ]
driver_read(...)                  
{
    [ ... ]
    spin_lock_irqsave(&slock, flags);
        <<--- 时间 t1 : 临界区开始 >>
    [现在CPU状态被保存;副作用是,所有中断和内核抢占在本地CPU核心上都被屏蔽(禁用)]
    << ... 临界区 ... >>
    spin_unlock_irqrestore(&slock, flags);
<<--- 时间 t2 : 临界区结束 >>
[现在CPU状态被恢复;副作用是,只有先前被屏蔽的中断和内核抢占在本地CPU核心上被解除屏蔽(启用)]
    [ ... ]
}

严谨地说,spin_lock_irqsave() 不是一个API,而是一个宏;为了可读性,我们把它作为API展示。另外,尽管这个宏的返回值不是 void,它是一个内部细节(flags 参数变量在这里会被更新)。

很好,现在让我们通过使用spinlocks处理中断底半部分的情况来结束这个讨论。

中断处理、底半部和锁

在讨论锁和底半部的要点之前,快速回顾一下与Linux中断处理的一些关键点会有所帮助。

Linux中断处理 - 关键点总结

  1. 中断处理程序必须是非阻塞的,并且要尽快完成工作。多快?一个经验法则是,应该在100微秒内完成。然而,如果处理程序需要执行大量的工作,可能会花费更长时间,这会导致延迟。解决方案(大多数现代操作系统采用的方式)是将中断处理分为两部分:顶部底部顶部半部(或硬中断处理程序)几乎在硬件中断触发时立即执行;在这里,作为驱动程序作者,你应尽量保持工作量最小。如果需要执行更多工作,则应该注册并调用底部半部,它是一个延迟的函数,会在“稍后”运行。
  2. 底部半部实际上是通过任务(tasklets)实现的,而任务又是基于内核的低级软中断(softirq)技术构建的。任务或软中断通常在顶部半部完成后立即执行。
  3. “分割”处理有什么好处? 这非常重要,尤其与锁相关的副作用:顶部半部(硬中断处理程序)始终在当前CPU上禁用所有中断,并且它所处理的IRQ在所有CPU上都会被禁用。底部半部处理程序则是在启用所有中断时运行。
  4. 需要注意的是,即使底部半部机制——软中断和任务——是在中断上下文中运行的,而不是进程上下文。因此,就像在顶部半部或硬中断处理程序中一样,底部半部中不能进行任何可能阻塞的操作。
  5. 硬中断处理程序永远不会与自己并行运行(因此它是不可重入的)。而任务(它是基于软中断机制的)也不会与自己并行运行;这个属性使得使用任务更加容易。然而,软中断可以与自己并行运行(在其他核心上)。
  6. 使用线程化的中断处理程序:如今许多类型的驱动程序(特别是对于一些较慢的设备)都非常流行使用线程化的中断处理程序。在这种情况下,因为中断处理程序实际上是内核线程(在SCHED_FIFO调度策略和实时优先级50下),所以在其中发出阻塞调用是允许的。此外,这也消除了“顶部-底部半部”二分法。

底部半部和锁

现在我们理解了这些要点,让我们继续讨论锁。

  1. 如果你的驱动程序的软中断或任务有一个临界区,而这个临界区与顶部半部(硬中断)中断处理程序竞争(在不同的核心上执行)怎么办? 解决办法是:在两者中都使用常规的spinlock来保护临界区。
  2. 如果你的驱动程序的软中断或任务有一个临界区,而这个临界区与进程上下文代码路径竞争怎么办? 在这种情况下,通常需要在驱动程序的方法中使用 spin_lock_bh() 例程,因为它首先会禁用本地处理器上的底部半部,然后再获取spinlock,从而保护临界区(类似于 spin_lock_irq[save]() 在进程上下文中通过禁用本地核心的硬件中断来保护临界区):
void spin_lock_bh(spinlock_t *lock);

与之对应的解锁API是 spin_unlock_bh()

  1. 当然,在高度性能敏感的代码路径中,开销确实很重要(例如实现网络堆栈的内核代码就是一个很好的例子)。因此,使用最简单的spinlock形式有助于提高性能,而比起使用更复杂的变体会更有效。
  2. 然而,确实有时候会有情况需要使用更强的spinlock API
  3. 举个例子,关于6.1.25 LTS Linux内核代码库,以下是我们看到的不同形式的spinlock API使用情况的近似统计数字
  • spin_lock():超过10,200次使用
  • spin_lock_irq():超过3,800次使用
  • spin_lock_irqsave():超过15,500次使用
  • spin_lock_bh():超过4,400次使用

(为了对比,书籍第一版中的数字为9,400,超过3,600,超过15,000,超过3,700次使用。)

  1. 最终,关于spinlock的内部实现: 在底层实现中,通常是非常架构特定的代码,通常包括执行非常快速的原子机器语言指令。在流行的x86[_64]架构上,spinlock通常会最终通过原子测试和设置机器指令(通常通过 cmpxchg 指令实现)来完成。在许多ARM机器上,正如我们之前提到的,它通常是wfe(等待事件)机器指令在实现中的核心。

总之,作为内核或驱动程序作者,你应仅使用公开的API(和宏)来使用spinlocks。

使用自旋锁 – 快速总结

我们快速总结一下自旋锁的API使用:

  1. 最简单、最低开销: 在进程上下文中保护临界区时,使用非IRQ版本的自旋锁原语:spin_lock()/spin_unlock()。这种形式可以在没有硬件中断干扰的情况下使用,或者即使有中断,当前的临界区与中断无竞争(实际上,当中断不起作用或不相关时使用此方法)。
    此外,这种形式也可以用来保护顶半部和底半部处理程序之间的临界区。
  2. 中等开销: 当硬件中断存在并且需要考虑时,使用禁用IRQ(以及禁用内核抢占)的版本:spin_lock_irq()/spin_unlock_irq()。在这种情况下,进程上下文和中断上下文可以产生“竞争”,即它们共享全局可写数据。
    此外,当保护进程上下文和底半部之间的临界区时,也可以使用 spin_[un]lock_bh() API对,它内部禁用/启用本地核心上的底半部。
  3. 最强形式,(相对)高开销: 这是使用自旋锁的最安全方式。它与中等开销形式相同,只是通过 spin_lock_irqsave()/spin_unlock_irqrestore() API对进行保存和恢复CPU状态,以保证不会无意中覆盖之前的中断屏蔽设置,这在上面的中等开销形式中是可能发生的。

如我们之前所见,自旋锁——在等待锁时“自旋”在处理器上——在单核(UP)系统上是不可能的(因为如何在唯一可用的CPU上自旋,而另一个线程可能正在同一个CPU上同时运行呢?)。事实上,在UP系统中,自旋锁的API变成了无操作(除非开启了自旋锁调试配置);这里自旋锁API的唯一实际效果是禁用(屏蔽)硬件中断和内核抢占!然而,在SMP(多核)系统中,自旋逻辑实际上开始发挥作用,因此锁的语义按预期工作。

别担心——这些细节不应该让你感到困扰,作为一个正在成长的内核/驱动开发者,重点是你应该按照描述的方式使用自旋锁API,这样你就不必担心UP和SMP、内核抢占等问题;内部执行的细节会被实现封装。

一个新的特性在5.8内核中加入了,来自实时Linux(RTL,之前称为PREEMPT_RT)项目,值得在这里简要提一下:“本地锁”。本地锁的主要用例是硬实时内核,但它们也有助于非实时内核,主要用于通过静态分析进行锁调试,以及通过lockdep进行运行时调试(我们将在下一章讨论lockdep)。这是LWN的相关文章:lwn.net/Articles/82…

接下来,当线程持有一个关闭IRQ/关闭抢占的自旋锁时,按照定义,它不能被抢占。这对Linux实时内核(RTL)来说是个难题;显然,它需要能够保证当一个更高优先级的实时线程变得可运行时,低优先级的线程可以被抢占,不管怎样。使用传统的自旋锁会破坏这一设计;因此,当启用RTL时,自旋锁实际上被重新实现为“睡眠自旋锁”!这是通过将自旋锁替换为rt-mutex锁来实现的,实际上使得临界区可以睡眠,从而变得可抢占。这一点需要注意。

至此,我们完成了自旋锁部分的内容,作为Linux内核中几乎所有子系统(包括驱动程序)中非常常见和关键的锁。

锁定 – 常见错误与指导原则

总结一下,以下是关于锁定时常见错误的快速参考或总结,并附上(部分重复的)锁定指导原则。(请注意,以下章节中将涵盖一些这里提到的技术,比如无锁编程。)

常见错误

  1. 未识别临界区:

    • “简单的递增/递减(如 i++ 或 i--)”:正如我们在《经典案例 – 全局 i++ 部分》中学习到的,这些也可能是临界区。在下一章中,我们将展示优化和原子操作来处理这些情况。
    • “嘿,我只是读取共享数据”:如果满足临界区的两个条件,它仍然是一个临界区;不保护它可能导致脏读或撕裂读,进而导致数据不一致或损坏。
  2. 死锁:

    • 死锁是指无法继续前进的情况;需要精心设计锁定方案并遵循明确的锁定规则或指导原则来避免死锁。主要要点包括:

      • 记录并始终遵循锁顺序规则。
      • 不要尝试重新获取已经持有的锁。
      • 只释放当前持有的锁。
      • 防止饥饿(锁被长期占用)。

锁定指导原则

以下是锁定指导原则的总体总结:

  1. 首先,尽量避免使用锁:

    • 这并不意味着“不要使用全局变量”。相反,你需要设计一个架构,在该架构下,尽可能避免任何写线程(写共享可写数据)与其他读/写操作并发执行。
  2. 如果你使用共享可写数据,举例来说,在全局结构中,尽量将尽可能多的成员(尤其是整数成员)设置为 refcount_tatomic_t(稍后介绍)。

  3. 考虑内存屏障(在需要时)。

  4. 使用无锁技术!

  5. 如果必须使用锁,请按以下顺序操作:

    • 首先尝试使用无锁技术:

      • 每CPU变量
      • RCU(Read-Copy-Update)
    • 如果不能,使用常规锁:

      • 互斥锁(mutex): 在进程上下文中,临界区较长,或者临界区中需要或可能发生阻塞I/O(睡眠)。
      • 自旋锁(spinlock): 当在任何原子上下文中工作时(如中断处理),且临界区较短;必须是非阻塞的(不能睡眠)。或者,可以在进程上下文中使用,只要临界区没有阻塞。
  6. 如果互斥锁或自旋锁都可以使用,优先选择自旋锁(它不仅通常能提供更好的性能,还能强制执行临界区内必须遵循的严格规则)。

  7. 对于整数操作使用 refcount_tatomic_t 是旧的接口)。

  8. 在操作位时,使用内核的 RMW(读取修改写入)位操作器(将在下一章中介绍)。

  9. 使用读写锁(自旋锁),尽管它们正在被 RCPU 替代。

  10. 始终记住锁的顺序:

    • 始终按照相同的顺序获取锁;记录顺序并严格遵循它;这有助于避免死锁(释放锁的顺序实际上不重要)。
  11. 锁数据,而不是代码:

    • 尽量实现更细粒度的锁定。
    • 进一步讲,这意味着通过仔细查看你要保护的数据结构(甚至是其中的成员),来设计你的锁定方案,明确它如何防止并发访问——实际上,采用以数据为中心的方法,而不是代码中心的方法(后者通常是在代码中随便撒上几个互斥锁或自旋锁,直到看起来“有效”)。
  12. 保持临界区尽可能短:

    • 性能受临界区长度的影响(锁定和解锁之间的代码路径);保持它简短!(请记住之前提到的 criticalstat eBPF 工具,可以用于检查和报告遇到的长原子临界区。)
  13. 防止饥饿。

  14. 注意缓存效应和内存屏障(伪共享和缓存行碰撞;将在下一章中介绍)。

  15. 使用调试内核运行所有测试用例;关于锁定,调试内核必须启用“lockdep”;此外,启用锁统计信息也有助于定位热点(将在下一章中介绍)。

  16. 尽量保持锁定方案简单。

解决方案

这些小练习在《什么是临界区?》一节中提到过。

练习 2 的解决方案

再次问自己,究竟什么构成了临界区?当代码路径可能并行执行,并且操作共享可写数据时,它就是临界区。那么,代码中的这段(t1 到 t2 之间的代码)是否满足这两个前提条件?答案是肯定的:它可以并行执行(正如明确说明的那样),并且它确实操作共享可写数据(mydrv 变量是全局的,因此在这段代码路径中,每个线程都会并行地操作同一内存项)。

因此,答案显然是:是的,它确实是一个临界区。换句话说,它不应当在没有任何明确保护的情况下执行。

练习 3 的解决方案

这个问题很有趣;第二个条件——是否操作共享可写数据——是正确的,但第一个条件——是否可能并行执行——是错误的。这是因为内核模块的初始化和清理“方法”(函数)只会执行一次(因此不会有更多可能与之并行执行的实例)。因此,这并不构成临界区,因此不需要特殊保护。

总结

祝贺你完成了本章内容!

理解并发及其相关问题对任何软件专业人士来说都是至关重要的。在本章中,你学习了关于临界区的关键概念、在其中需要独占执行的原因以及原子性和数据竞争的真正含义。然后,你了解了在为 Linux 操作系统编写代码时,为什么需要关注并发问题。之后,我们深入讨论了内核中常用的两种锁定技术——互斥锁和自旋锁——并详细介绍了它们。你还学会了如何根据具体情况选择使用哪种锁。最后,重点讲解了在硬件中断(及其可能的下半部分)参与时,如何处理并发问题。

但我们还没有完成内核并发的学习!还有许多概念和技术等待我们学习,这正是我们在本书的最后一章中将要做的。我建议你在进入最后一章之前,先通过浏览本章内容以及《进一步阅读》部分的资源,消化这些内容,并完成提供的练习!

问题

在结束之前,下面是一些问题,帮助你测试自己对本章内容的掌握:问题清单。你可以在本书的 GitHub 仓库中找到部分问题的答案:解答链接

进一步阅读

为了帮助你深入学习该主题,我们在本书的 GitHub 仓库中提供了一个相当详细的在线参考和链接(有时还包括书籍)的列表,作为《进一步阅读》文档。你可以在这里找到这个文档:进一步阅读