前两章深入探讨了 Linux 内存管理的各个方面,重点是作为内核/驱动开发人员,你如何高效地动态分配和释放内核内存(除了 API,我们还讨论了 MGLRU、DAMON 和 OOM 杀手等有趣的内容!)。
在本章和下一章中,你将深入了解一个关键的操作系统主题——即 Linux 操作系统中的 CPU(或任务)调度。我会通过提问(并回答)典型问题和执行与调度相关的常见任务,尽量让学习既具有概念性也具有实践性。理解操作系统级别的 CPU 调度机制,不仅对内核(和驱动)开发人员来说至关重要,它还会自动使你成为更好的系统架构师,即便是对用户空间应用程序也是如此。
我们将从介绍必要的背景材料开始;这将包括 Linux 上的内核可调度实体(KSE)概念,以及 Linux 实现的 POSIX 调度策略。接着,我们将使用工具——如 perf 等——来可视化操作系统在 CPU 上运行任务并在任务之间切换的流程。当你需要对应用程序进行性能分析时,这也非常有用!
然后,我们将深入了解 Linux 中 CPU 调度是如何工作的(在内核空间内),涵盖模块化调度类,包括完全公平调度(CFS)、核心调度函数的运行、调度的“谁”和“何时”等等。途中,我们还将讨论如何以编程方式动态查询系统中任何线程的调度策略和优先级。
首先需要指出的是:正如你所知道的,系统中的 CPU 是一个资源,用户、进程和线程在其上共享。CPU(或任务)调度程序当然是用来调解对这一宝贵资源的访问。然而,CPU 只是一个资源;内存、IO 设备、网络等也是共享资源。Linux 内核有一个先进的、深度嵌入的框架,用于更高抽象层次、实际重要的资源共享特性:cgroups(控制组)!(事实上,我们的第一个图示,图 10.1,也暗示了这一点!)对 CPU 调度的深入理解必须也包括 cgroups 在其中发挥的关键作用;不用担心——我们将在下一章中覆盖 cgroups 的一些关键细节。
在本章中,我们将涵盖以下内容:
- 学习 CPU 调度内部原理 – 第一部分 – 基础背景
- 可视化调度流程
- 学习 CPU 调度内部原理 – 第二部分
- 查询给定线程的调度策略和优先级
- 学习 CPU 调度内部原理 – 第三部分
现在,让我们开始这个有趣的主题吧!
技术要求
我假设你已经完成了在线章节、内核工作区设置,并适当地准备了一个运行 Ubuntu 22.04 LTS(或更高稳定版本)的虚拟机(VM),并安装了所有所需的包。如果没有,强烈建议你先做这些准备工作。
为了最大限度地发挥这本书的作用,我强烈建议你首先设置工作区环境,包括克隆本书的 GitHub 仓库来获取代码,并以实践的方式进行操作。该仓库可以在以下链接找到:GitHub 仓库。
学习 CPU 调度内部原理 – 第一部分 – 基础背景
让我们快速回顾一下理解 Linux 上 CPU 调度所需的基本背景信息。
请注意,在本书中,我们并不打算涵盖那些 Linux 上的熟练(用户空间)系统程序员应该已经熟知的材料;这些包括进程(或线程)状态、实时的基本概念、POSIX 调度策略等内容。这些内容(及更多)已经在我之前出版的书籍《Hands-On System Programming with Linux》中详细介绍,该书由 Packt 于 2018 年 10 月出版。然而,我们在这里还是会涉及一些基础内容。
什么是 Linux 上的 KSE(内核可调度实体)?
正如你在第六章《内核内部要点 – 进程和线程》中所学到的,在“组织进程、线程及其堆栈 – 用户空间和内核空间”一节中,系统上每一个(用户模式)线程都会拥有一个任务结构(struct task_struct)以及用户模式和内核模式的堆栈。正如我们所学到的,内核线程确实有一个任务结构,但只有一个内核模式的堆栈。
每个操作系统当然都需要执行任务调度。现代操作系统——Linux/Unix/Windows/Mac——都有进程和线程的概念。那么,关键的问题是:当执行任务调度时,它作用于哪个“对象”?换句话说,什么是内核可调度实体(KSE)?在 Linux 中,KSE 是线程,而不是进程(当然,每个进程至少包含一个线程)。因此,调度是按线程的粒度级别进行的。
一个例子——以及图示(图 10.1)——将帮助解释这一点:假设我们有一个假设场景,其中只有一个 CPU 核心,且有三个用户空间进程(P1、P2 和 P3),每个进程分别包含一个、两个和五个线程,加上一些内核线程(假设有三个),那么我们总共有(1+2+5+3)个线程,合计 11 个线程。
好的,我现在请你忽略一个显而易见的事实。图 10.1 的最上方展示了 cgroups 内核框架的抽象、概念性视图,这是一个现在已经深深嵌入到内核结构中的框架。在这里,我请求你想象——不必担心“为什么/如何/谁/何时……”这些问题——这个 cgroups 层几乎在现代 Linux 中无处不在,并且它确实可能对 CPU 调度产生影响。现在,只需知道它的存在;关于细节——以及如何尝试它!——将在下一章中详细讨论。
关于图 10.1,每个线程——除了内核线程——都有一个用户堆栈和一个内核堆栈,并且有一个任务结构(内核线程只有内核堆栈和任务结构;所有这些内容在第六章《内核内部要点 – 进程和线程》中已经详细解释过,在“组织进程、线程及其堆栈 – 用户空间和内核空间”一节中)。现在,如果这 11 个线程都处于可运行状态,也就是说,它们准备好运行,那么它们都会争夺单个处理器核心。(虽然不太可能所有线程都能同时处于可运行状态,但为了讨论的方便,暂且这样假设。)关键是要理解,我们现在有 11 个线程在竞争 CPU 资源,而不是三个进程和三个内核线程。一个更现实的场景是,在这 11 个存活的线程中,可能有四个是可运行的,也就是说,它们在运行队列中,准备运行,而其余的七个线程处于其他各种状态(比如正在睡眠(在阻塞调用中)或者已停止(被冻结));这些剩余的七个线程甚至都不是调度器的候选者。
现在我们理解了 KSE 是线程,我们将(几乎)总是从调度的角度来看待线程。接下来,让我们继续讨论所谓的 Linux 进程状态机。
Linux 进程状态机
在 Linux 操作系统中,每个进程或线程都会经历一系列明确的状态,通过对这些状态的编码,我们可以形成一个进程(或线程)的状态机(在阅读时请参见图 10.2)。
既然我们已经理解了在 Linux 操作系统中,KSE 是线程而不是进程,我们将忽略常规的术语“进程”,而在描述这个在状态机中循环的实体时使用“线程”一词。(如果觉得更舒服,你可以在心里将以下文本中的“线程”替换为“进程”)。
Linux 线程可以循环的状态如下(ps 工具通过以下字母编码这些状态):
-
R: 就绪或运行(或“可运行”)
-
Sleeping:
- S: 可中断睡眠
- D: 不可中断睡眠
-
T: 停止(或挂起/冻结)
-
Z: 僵尸(或已终止)
-
X: 死亡
当一个线程通过 fork() 或 clone() 系统调用,或者通过 pthread_create() API 新创建时,一旦操作系统确定该线程已经完全创建,它会通过将线程置于可运行(R)状态来通知调度器该线程的存在。一个处于 R 状态的线程,要么实际上在 CPU 核心上运行,要么处于就绪状态。我们需要理解的是,在这两种情况下,线程都被加入到操作系统中的一个数据结构——运行队列(runqueue)中。Linux 系统为每个 CPU 核心维护一个运行队列(实际上,情况更为复杂;我们稍后会详细讲解)。在运行队列中的线程是有效的候选线程,能够在特定的 CPU 核心上运行;没有被加入运行队列的线程不可能运行。Linux 不会明确区分就绪状态和实际运行状态;它只是将线程标记为 R 状态。当然,正如我们在第六章《内核内部要点 – 进程和线程》中所学到的,这些状态是通过任务结构中的一个成员(unsigned int __state;)设置为适当的值,从而标记线程处于特定的状态。
以下图示展示了 Linux 中任何进程或线程的状态机:
前面的图示通过(红色)箭头展示了各个状态之间的转换。请注意,为了简洁起见,某些转换(例如线程可以在休眠或停止状态时被终止)在前面的图中没有显式展示。(仅供参考,一个死掉但没有被其父进程等待的进程最终会进入一个半死半活的状态,称为僵尸进程(或已终止进程);它是一个过渡状态。为了防止僵尸进程永远存在于系统中,必须遵循的规则是每次调用 fork() 都需要有一个对应的 wait*() 系统调用。(Linux 通常有一个巧妙的做法:杀死僵尸进程的父进程,僵尸进程也会“被收割”并死亡。)
我们知道每个存活的线程(无论是用户线程还是内核线程)都有自己的任务结构。因此,在代码层面上,任务结构中的成员 __state(以前称为 state)保存了任务——即线程——在所谓的“状态机”中的“状态”。当线程处于可运行状态时,__state 会被设置为 TASK_RUNNING(在图 10.2 中由 "R" 表示,内部则由 Rr 和 Rcpu 状态表示)。
等待队列 是一个数据结构,用于将处于休眠状态的任务入队——也就是说,它们在等待某个事件(实际上,它们会在阻塞调用中停留在这里)。Linux 中有两种可能的线程休眠状态;线程可以处于:
- 可中断睡眠:
__state = TASK_INTERRUPTIBLE:它“休眠”,等待某个事件;然而,任何发送到进程/线程的信号都会唤醒它并执行信号处理程序(在图 10.2 中用字母 “S” 表示)。 - 不可中断睡眠:
__state = TASK_UNINTERRUPTIBLE:它“休眠”,等待某个事件;任何发送到进程/线程的信号都不会对它产生影响(在图 10.2 中用字母 “D” 表示)。
当它等待的事件发生时,操作系统会发出唤醒信号,使其重新变为可运行状态(从等待队列中移除并重新加入运行队列)。注意,线程不会立即运行;它会变为可运行状态(图 10.2 中的 Rr),并成为调度器的候选者;很快它会得到机会,实际在 CPU 上运行(Rcpu)。
一个常见的误解是认为操作系统只维护一个运行队列和一个等待队列。实际上,Linux 内核为每个 CPU 维护一个运行队列。等待队列通常由设备驱动程序(以及内核)创建和使用,因此它们的数量没有限制,可以有多个。
了解了这些基础内容后,让我们继续深入探讨吧!
POSIX 调度策略
将“调度策略”视为调度算法的同义词是有道理的。重要的是要理解,Linux 内核并不仅仅有一种策略来实现 CPU 调度;事实上,POSIX 标准指定了至少三种调度策略(实际上是算法),POSIX 合规的操作系统必须遵守这些策略。Linux 不仅实现了这三种策略,还实现了更多策略,使用了一种强大的设计——调度类(将在本章后续的“理解模块化调度类”一节中讲解)。
现在,让我们简要总结一下 POSIX 调度策略及其影响,见下表;在此之前,首先需要理解的是,任何线程(无论是用户空间还是内核空间)在任何给定时间都会与这些调度策略中的一种关联(它可以在运行时改变)。以下是表格内容:
| 调度策略 | 关键点 | 优先级范围 |
|---|---|---|
| SCHED_OTHER 或 SCHED_NORMAL | 始终是默认策略;使用此策略的线程为非实时线程;它们内部实现为完全公平调度(CFS)类(将在接下来的“完全公平调度(CFS)类简述”一节中讲解)。该策略的动机是公平性(即,“公平对待所有可运行线程,避免让任何线程饿死”)和整体吞吐量。 | 实时优先级为 0,非实时优先级为 nice 值,范围从 -20 到 +19(较低的值意味着更高的优先级),初始值为 0 |
| SCHED_RR | 此策略的动机是提供一种(软)实时策略,较为激进。线程属于此调度类时,有一个有限的时间片(通常默认为 100 毫秒)。SCHED_RR 线程会在以下情况下主动让出 CPU: 1. 阻塞 I/O(进入睡眠); 2. 停止或终止; 3. 更高优先级的实时线程变为可运行(会抢占该线程); 4. 时间片到期。 | (软)实时:1 到 99(较高的值意味着更高的优先级) |
| SCHED_FIFO | 此策略的动机是提供一种(软)实时策略,与 SCHED_RR 相比更为激进。SCHED_FIFO 线程会在以下情况下主动让出 CPU: 1. 阻塞 I/O(进入睡眠); 2. 停止或终止; 3. 更高优先级的实时线程变为可运行(会抢占该线程)。 | (软)实时:1 到 99(与 SCHED_RR 相同) |
| SCHED_BATCH | 此策略适用于低优先级非交互式批处理作业,具有较少的抢占。 | nice 值范围为 -20 到 +19 |
| SCHED_IDLE | 特殊情况:通常,PID 为 0 的内核线程(传统上称为 swapper;实际上,它是每个 CPU 的空闲线程)使用此策略。它始终被保证是系统中最低优先级的线程,并且只有在没有其他线程需要 CPU 时才会运行。 | 所有策略中优先级最低(比 nice 值 +19 还低) |
表 10.1:Linux(POSIX 合规)调度策略及其简要说明
需要特别注意的是,当我们说到“实时”时,指的是软实时(或最好的情况下是坚实实时),而不是硬实时,像实时操作系统(RTOS)那样的硬实时。Linux 是一个通用操作系统(GPOS),而不是 RTOS。话虽如此,你可以通过应用外部补丁系列(称为 RTL,由 Linux 基金会支持)将标准的 Linux 转变为真正的硬实时 RTOS;你将在下一章的“将主线 Linux 转换为 RTOS”部分学到如何实现这一点。
仔细研究表格,你会注意到,SCHED_FIFO 线程实际上具有无限的时间片,因此可以在 CPU 核心上运行,直到它自己决定停止;它只有在满足上述条件之一时才会被抢占(从 CPU 中取下)。此外,SCHED_FIFO 和 SCHED_RR 之间的其他关键区别包括:
- 虽然 SCHED_FIFO 线程有效地拥有无限的时间片,但 SCHED_RR 线程具有有限的时间片(可以通过
/proc/sys/kernel/sched_rr_timeslice_mssysctl 调节;默认 100 毫秒)。 - 同优先级的 SCHED_RR 线程按轮转方式调度(允许其他 SCHED_RR 线程共享处理器)。而在 SCHED_FIFO 中不是这样——被抢占的线程会再次成为下一个运行的任务(因此剥夺了同优先级其他线程的 CPU 时间)。因此,在使用 SCHED_FIFO 时,应避免将线程保持在同一优先级(我们将在下一章的“在内核中设置策略和优先级 – 对内核线程”部分进一步讨论)。
此时,必须理解的是,在像 Linux 这样的操作系统中,实际上硬件(和软件)中断始终优先,并且总是会抢占甚至 SCHED_FIFO 线程!(请参考第六章《内核内部要点 – 进程和线程》中的图 6.1 来查看这一点。如果你希望深入了解硬件中断的主题,请参见《Linux 内核编程 第二部分》一书,第 4 章《处理硬件中断》)。在此讨论中,我们暂时忽略中断。现在,让我们更详细地了解每个线程的优先级值。
线程优先级
线程的优先级设置非常简单(以下按从低到高的优先级顺序排列;请参见图 10.3):
- 非实时线程(SCHED_OTHER)具有实时优先级 0;这确保了它们甚至无法与实时线程竞争;它们不在同一竞争领域!那么,如何区分所有非实时线程的优先级呢?
很简单——它们使用一个(旧版 UNIX 风格的)优先级值,称为 nice 值,其范围从 -20 到 +19,其中 -20 为最高优先级,0 为基准或默认值,+19 为最低优先级。(在命令行和 API 中都提供了适当的接口来查询和设置它们;我们将在接下来的“查询给定线程的调度策略和优先级”部分以及第 11 章《CPU 调度器 – 第 2 部分》中讨论这些内容。)
需要注意的一点:许多 Linux 发行版启用了一个名为 autogroups 的内核功能(CONFIG_SCHED_AUTOGROUP);它有助于加快前台终端进程及其线程的交互响应时间。当启用时,传统的 nice 值概念实际上不会被使用(背后利用了 cgroups)。在“进一步阅读”部分可以查看更多相关内容。 - 实时线程(具有 SCHED_FIFO 或 SCHED_RR 策略的线程)具有一个实时优先级范围,从 1 到 99,其中 1 为最低优先级,99 为最高优先级。可以这样理解:在一个不可抢占的内核和单核 CPU 上,一个优先级为 99 的 SCHED_FIFO 线程,如果在一个不可中断的无限循环中运行,将会让机器“挂死”!(当然,即使是这样,硬件和软件中断仍然可以抢占它;详见第六章《内核内部要点 – 进程和线程》,图 6.1。)
图 10.2 展示了 Linux 中的线程优先级范围:
调度策略(大致来说)通过任务结构中的一个成员——调度类来指定。此外,线程的策略和优先级(包括静态的 nice 值和实时优先级)是任务结构的成员(如图 10.1 所示)。需要注意的是,线程所属的调度类是排他的;线程在任何给定时间只能属于一个调度类(不用担心——我们将在接下来的“学习 CPU 调度内部原理 – 第二部分”中详细讨论调度类)。
此外,你应该意识到,在现代 Linux 内核中,还有一些调度类(如 stop-sched 和 deadline),它们的优先级高于我们之前看到的 FIFO/RR 类(更多内容将在接下来的章节中讲解)。现在,既然你对基础有了了解,我们接下来将讨论一个有趣的话题:我们如何可视化线程的执行流。继续阅读吧!
可视化线程流
多核系统使得进程——实际上是线程(包括用户空间和内核空间的线程)——可以在不同的处理器上并发执行。这有助于提高吞吐量,从而提高性能,但当它们操作共享可写数据时,也会带来同步问题(我们将在本书的最后两章深入讨论内核同步这一重要主题)。
例如,在一个硬件平台上,假设有六个处理器核心,我们可以预期进程(线程)会在这些核心上并行执行;这并不是什么新鲜事。然而,是否有方法能够实际看到哪些进程或线程在执行于哪个 CPU 核心——也就是说,有没有方法可视化处理器的时间轴呢?事实证明,确实有几种方法可以做到这一点。在接下来的章节中,我们将探讨几种有趣的方法:使用 GNOME 系统监视器 GUI 程序、perf 以及其他可能性。
使用 GNOME 系统监视器 GUI 可视化线程流
GNOME 项目提供了一个出色的 GUI,用于查看和监控系统活动,适用于笔记本、台式机和服务器类系统(实际上,适用于任何足够强大的系统来运行 GNOME GUI 环境):gnome-system-monitor 应用程序。
为了快速测试如何使用它查看多个 CPU 核心的工作流,我们首先运行一些进程并发执行。对于我们的简单测试案例,需要一个持续在 CPU 上执行的进程——即一个 CPU 密集型进程。一个很好的候选进程是名为 yes 的简单工具,它只是不断地将字符 "y" 打印到标准输出(试试看!)。所以,我们假设我们运行它三次,放在后台。为了使这个实验有意义,我们希望将每个进程固定(绑定)到系统上的特定 CPU 核心上。可以使用 nproc 命令查看核心数量;它们从 0 开始编号。在我的 x86_64 Fedora 38 虚拟机上:
$ nproc
6
对,我这里有六个 CPU 核心(编号从 0 到 5)。我们使用有用的 taskset 工具(它属于 util-linux 包,我们在第一章中安装时就指定了)。运行 taskset 并使用 -c 选项可以指定命令要在哪些 CPU 核心上运行;因此,如果我们这样做:
taskset -c 2 yes >/dev/null
它将使 yes 仅在 CPU 核心 #2 上运行(我们将标准输出重定向到 /dev/null,这样就不会看到不断打印的 "y")。
接下来,让我们快速设置一个测试案例:我们将三次运行 yes 工具(当然是在后台),每个进程实例运行在不同的 CPU 核心上——例如,如下所示:
taskset -c 1 yes >/dev/null &
taskset -c 2 yes >/dev/null &
taskset -c 3 yes >/dev/null &
(当然,你必须有至少四个核心的系统才能使上述命令正常工作,因为核心编号是从 0 开始的。)当它们运行时,启动 gnome-system-monitor 应用程序(假设你在一个已经安装该程序的系统上,以 GUI 模式运行)。以下截图显示了典型的输出:
注意,gnome-system-monitor GUI 应用程序将 CPU 核心编号从 1 开始(而不是从 0 开始)。现在,执行以下命令:
pkill yes
将会终止所有三个 yes 进程实例。因此,在图 10.4 中(在上方的 CPU 面板中),你可以直观地看到并发和并行性,它们在不同的 CPU 核心上执行。(此外,正如你所看到的,除了“资源”标签,它还提供了“进程”和“文件系统”标签视图。)
好了,接下来让我们看看如何使用强大的 perf 工具来可视化 CPU 核心上进程/线程的执行流!
使用 perf 可视化流
Linux 拥有众多的开发者和质量保证(QA)工具,而其中非常强大的一个工具就是 perf。简而言之,perf 工具集是对 Linux 系统进行 CPU 分析的一种方式。(除了提供一些技巧外,我们本书并没有详细介绍 perf。)
类似于老牌的 top(以及更新版的 htop)工具,perf 工具集能够以比 top 更详细的方式,让我们获得一个“千里眼”的视角,了解哪些进程正在占用 CPU。不过,需要注意的是,perf 与它所运行的内核紧密耦合,作为一个应用程序它非常独特。重要的是,在 Ubuntu 上,想要安装 perf,你需要安装 linux-tools-$(uname -r) 包。此外,针对我们构建的自定义 6.1 内核,perf 相关的发行版包将不可用;因此,在使用 perf 时,建议你使用标准(或发行版)内核启动虚拟机,安装 linux-tools-$(uname -r) 包,然后尝试使用 perf。(当然,你也可以从内核源代码树中的 tools/perf/ 文件夹手动构建 perf。)
安装并运行 perf 后,你可以尝试以下与 perf top 相关的命令(详细信息请参考手册页或教程):
sudo perf top
sudo perf top --sort comm,dso
sudo perf top -r 90 --sort pid,comm,dso,symbol
上述 perf top 的变体不仅可以帮助我们从宏观角度了解哪些进程正在占用 CPU,还能看到它们执行的代码路径,甚至允许我们深入到每个任务,查看更详细的信息(这是传统工具通常做不到的)。顺便说一下,comm 表示命令/进程的名称,dso 是动态共享对象(Dynamic Shared Object)的缩写。有关 perf 的详细信息可以参考 perf(1) 手册页;使用 man perf-<foo> 命令——例如,man perf-top——可以获取 perf top 的帮助。
回到我们的重点:使用 perf 的一种方式是获取一个清晰的图景,了解到底是哪些任务在什么 CPU 核心上运行;这可以通过 perf 的 sched map 子命令来完成。首先,你需要使用 perf 记录事件,这可以在系统范围内或特定进程中进行。要记录事件,可以运行以下命令:
sudo perf sched record [command]
如果在 record 关键字后没有跟参数,它会记录系统范围内的事件;如果传递一个参数,它将仅记录该命令进程及其后代的事件。使用 SIGINT 信号(^C)终止记录会话。这将默认生成一个名为 perf.data 的二进制数据文件;我们很快就会看到如何直观地解读它。
尝试一下——命令行方法
首先,当然,我们要并发地运行我们的 CPU 练习进程——yes,每个实例绑定到不同的 CPU 核心。为了简化任务,我在本文件夹中设置了几个简单的包装脚本:ch10/concurrent_exercise/。让我们进行一次示例运行并查看,在你本书的 GitHub 仓库副本中运行(可以在以下网址访问:GitHub 仓库):
cd ch10/concurrent_exercise
由于我们运行的虚拟机(x86_64 Fedora 38)配置了六个 CPU 核心,我们将指示脚本生成六个 yes 进程,每个进程绑定到一个 CPU 核心:
$ ./concurrency 6
concurrency: spawning a 'yes' process on ...
... CPU core #0 now ...
... CPU core #1 now ...
... CPU core #2 now ...
... CPU core #3 now ...
... CPU core #4 now ...
... CPU core #5 now ...
现在(在另一个终端窗口中),使用 perf 开始记录(采样,CPU 分析)(请注意,我们执行的是系统范围的记录;这没问题):
sudo perf sched record
(顺便提一下,在这个时候查看 gnome-system-monitor 应用程序的资源标签是很有趣的,尽管它可能会影响“基准测试”的准确性。)
接下来,在让进程运行一段时间(半分钟应该足够了)后,我们关闭并发进程,并让 perf 为我们提供记录期间的 CPU 使用详细图:
$ pkill yes
Terminated
Terminated
Terminated
Terminated
Terminated
Terminated
在运行 perf record 命令的终端中按 ^C 停止记录。然后,运行以下命令生成报告:
$ sudo perf sched map > mymap.txt
Done. Let’s look up the results!
报告文件 mymap.txt 的格式如下(由于列没有明确标注,我在图 10.5 的顶部插入了注释):
从左侧开始,每一列代表一个 CPU 核心,从 0 开始(请忽略最左侧的行号)。
在 CPU 核心列之后(这里会有六列,因为我们有六个 CPU),接下来是一个时间戳列(以秒.微秒格式表示的系统启动时间)。
接下来是“图例”列。在这里,每个执行的线程都会被分配一个两字符的名称;在我们的运行中,一个好的例子(见图 10.5)是,A0 是名为 migration/0:18 的内核线程,B0 是我们的一个 yes 线程(yes:6428),C0 是另一个内核线程 migration/1:23,以此类推。
因此,我们可以看到我们的六个 yes 进程(它们当然是单线程的)分别标记为 B0、D0、F0、H0、J0 和 K0。为了更容易地可视化,我在 vim 中打开了映射文件,并使用正则表达式进行搜索,像这样:
/B0..D0..F0..H0..J0..K0
它成功了!你可以在图 10.5 和图 10.6 中看到它们被高亮显示。现在,所有的 yes 进程——在这里是 B0、D0、F0、H0、J0 和 K0——都在同一行中,这意味着它们六个在 CPU 核心 0 到 5 上并发地、平行地运行(如图 10.5 中第 11、13 和 15 行所示)!很好。
(仅供参考,星号表示该核心发生了上下文切换,通常是该核心的上下文切换。)观察其他线程在不同时间点的执行是很有启发性的;显然,不仅仅是我们的 yes 进程想要执行!
同样,你不需要完全按照我们这里的步骤进行测试;任何足够 CPU 密集型的应用程序都可以执行(例如像 stress-ng 这样的基准测试程序、内核构建本身等,都是不错的候选者),然后运行我们展示的 perf 命令来捕获和报告细节。
我们刚刚使用 perf 获取了一个基于命令行(或控制台)的线程执行视图,现在,让我们利用 perf 来图形化展示相同的信息!
尝试一下——图形化方法
这里的步骤与上一节完全相同,唯一不同的是最后一个命令(sudo perf sched map > mymap.txt 命令);相反,我们将使用以下 perf 命令生成 CPU 调度的图形表示:
sudo perf timechart -i ./perf.data
此命令默认生成一个名为 output.svg 的可缩放矢量图形(SVG)文件!它可以通过矢量绘图工具(如 Inkscape,或者通过 ImageMagick 中的 display 命令)查看,也可以直接在 web 浏览器中打开。研究时间图表是很有用的;试试看。然而,需要注意的是,矢量图像可能非常大,因此打开时可能需要一些时间。
仅供参考,perf 可以配置为非 root 用户访问。SVG 文件是使用我们在上一节中运行时生成的相同 perf.data 文件生成的,并且可以在浏览器中查看:
你可以对这个 SVG 文件进行放大和缩小,研究 perf 默认记录的调度和 CPU 事件。显然,我们的六个并发 yes 进程正在六个 CPU 核心上运行(蓝色表示运行状态,正如图例中的第一行所示)。其他进程(大多数处于睡眠状态)显示在它们下面。
以下图示是当放大 400% 后,从前一个截图的左上角区域截取的部分屏幕截图;再次可以清楚地看到,yes 进程就是正在 CPU 上运行的进程:
还有什么?通过使用 -I 选项开关来执行 perf timechart record,你可以请求仅记录系统范围的磁盘 I/O(以及网络)事件。这在很多情况下特别有用,因为性能瓶颈往往是由 I/O 活动引起的(而不是 CPU)。有关更多有用选项的详细信息,请参考 perf-timechart(1) 的手册页。
你可以使用 perf data convert --all --to-ctf <dir> 将 perf 的二进制记录文件 perf.data 转换为流行的 Common Trace Format (CTF) 文件格式,其中最后一个参数是存储 CTF 文件的目录。为什么这有用?CTF 是强大的 GUI 可视化工具和分析工具(如 Trace Compass)使用的原生数据格式。(我发现这在 Fedora 上有效,而在 Ubuntu 上似乎存在一些小问题。)
通过其他方法可视化线程流
当然,还有其他方法可以可视化每个处理器上正在运行的内容。以下是简要概述。
事实上,许多这些可视化工具在《Linux 内核调试》一书的第 9 章《跟踪内核流》中有更详细的介绍。因此,我们在此不会重复相同的信息,而是提供一个简要概述。
首先,我们提到一些基于控制台的可视化工具:
-
perf-tools:Brendan Gregg 提供了一系列非常有用的脚本,用于在使用 perf 监控生产环境的 Linux 系统时执行大量必要的工作;可以在这里查看它们:perf-tools GitHub(一些发行版将其作为名为 perf-tools[-unstable] 的软件包包含)。
-
Ftrace:这是一个非常强大的内核跟踪系统,内置于 Linux 内核中(当然,它必须被启用;大多数发行版默认启用 Ftrace):
- 原始 Ftrace 可用于跟踪(几乎)每个执行的函数,深度执行在内核内部;所获取的信息包括时间戳、执行所在的 CPU 核心、执行上下文、中断状态和函数名。
- 原始 Ftrace 的前端是非常出色的 trace-cmd 工具;它提供了与原始 Ftrace 相同的信息,同时使用起来更加简单。它还可以配置为显示传递给内核函数的参数。(我为它构建了一个简单的前端,命名为 trccmd:trccmd GitHub;不妨试试看。)
-
一些简单的 Bash 脚本可以显示给定核心上正在执行的内容(通过对
ps的简单包装)。在以下示例中,我们展示了一些 Bash 函数;例如,以下的c0()函数显示当前在 CPU 核心 #0 上执行的内容,而c1()函数则显示 CPU 核心 #1 上的内容(这意味着你使用的是常规的 GNUps,而不是像 busyboxps这样的简化版本):
# 显示在 CPU 核心 'n' 上运行的线程 - 函数 c'n'()
function c0()
{
ps -eLF | awk '{ if($5==0) print $0}'
}
function c1()
{
ps -eLF | awk '{ if($5==1) print $0}'
}
- LTTng:Linux Trace Toolkit – Next Generation(LTTng)是一个功能强大且流行的开源跟踪系统,用于跟踪 Linux 内核以及用户空间应用程序和库。它的原始版本(LTT)始于 2005 年,LTTng 目前仍在积极维护。它在帮助追踪性能问题和调试多核并行及实时系统问题方面有着很高的声誉。
以下是一些 GUI 可视化工具:
- KernelShark:一个非常好的 GUI 前端,用于显示由原始 Ftrace 和 trace-cmd 生成的数据。(对于原始 Ftrace,你需要先执行 trace-cmd extract 来将数据转储到它能够识别的文件格式中。)
- TraceCompass:一个出色的 GUI 前端,用于可视化 LTTng 生成的跟踪数据;实际上,它支持流行的 CTF 格式,因此任何采用该格式的跟踪数据都可以被导入并通过它进行可视化。
不妨试试这些替代方法!
好了,既然你已经了解了如何通过各种方式可视化进程/线程在 CPU 核心上的执行流,让我们暂时将重点转回到内核内部的内容。
学习 CPU 调度内部原理 – 第二部分
本节将深入探讨内核 CPU 调度的内部机制,重点讨论现代设计的核心方面——模块化调度类。
理解模块化调度类
Ingo Molnar(一个关键的内核开发者)和其他人重新设计了内核调度器的内部结构,引入了一种名为调度类的新方法(这是在 2007 年 10 月,随着 2.6.23 内核的发布)。
顺便提一下,调度类中的“类”一词并非巧合;许多 Linux 内核特性本质上是以面向对象的方式设计的。虽然 C 语言本身不允许我们直接在代码中表达这一点(因此使用了大量既包含数据又包含函数指针成员的结构,模拟类的行为),但这种设计方式往往是面向对象的(就像你将在《Linux 内核编程 – 第 2 部分》一书中看到的驱动模型)。有关此方面的更多详细信息,请参阅本章的“进一步阅读”部分。
在核心调度代码中引入了一层抽象,位于 kernel/sched/core.c:schedule() 函数中。这个 schedule() 函数中的层次被通用地称为调度类,并且是模块化设计的。
需要注意的是,这里提到的“模块化”意味着调度类可以从内核源代码中添加或删除;这与可加载内核模块(LKM)框架无关。
基本思想是这样的:从 6.1 版本开始(截至目前为止,最新的 6.7 内核版本),Linux 内核包含五个调度类,每个调度类都与一个优先级级别相关。当核心调度代码——即 schedule() 函数(它本身是 __schedule() 的一个薄包装)——被调用时,它会按照预定义的优先级顺序迭代每个调度类,询问每个调度类是否有准备好运行的线程(具体如何,我们将很快看到)。在这五个调度类中,总有一个会给出肯定的答案,并选择一个候选线程来执行;一旦发生这种情况,核心调度代码会进行上下文切换,切换到该线程(跳过任何剩余的调度类),任务完成。以下流程图概括了这一设计:
你,保持警觉,已经注意到图 10.9 顶部标有 S 的连接器;它意味着什么?它在这里是为了回答一个关键问题:“到底是谁调用了核心调度函数 schedule(),并且在什么时刻调用?”(这个问题将在接下来的章节“学习 CPU 调度内部原理 - 第三部分”中详细回答;放松——我们很快会讨论到!)
截至 6.1 版本的 Linux 内核(截至目前,最新的是 6.7 版本内核),内核中有以下五个调度类,按优先级从高到低排列:
| 调度策略 | 调度类 | sched_class 数据结构名称 | 定义位置(通过 DEFINE_SCHED_CLASS() 宏) |
|---|---|---|---|
| - | Stop-task / Stop-sched (__sched_class_highest) | stop_sched_class | kernel/sched/stop_task.c |
| SCHED_DEADLINE | (早期) Deadline First | dl_sched_class | kernel/sched/deadline.c |
| SCHED_FIFO / SCHED_RR | RT(实时) | rt_sched_class | kernel/sched/rt.c |
| SCHED_OTHER (或 SCHED_NORMAL):默认 | CFS | fair_sched_class | kernel/sched/fair.c |
| SCHED_IDLE | Idle (__sched_class_lowest) | idle_sched_class | kernel/sched/idle.c |
表 10.2:五个模块化调度类
因此,我们有了五个模块化的调度类——stop-task、deadline、(软)实时(RT)、公平(CFS)和空闲——按优先级从高到低排列。抽象这些调度类的数据结构 struct sched_class 被串联在一个单向链表中,核心调度代码会遍历这个链表。(我们稍后会讨论 sched_class 结构,暂时忽略它。)
每个线程都与其唯一的任务结构(struct task_struct)相关联;在任务结构中(如我们在第六章中看到的,你可以在这里查找:链接),以下内容适用:
- 成员
struct sched_class *保存指向线程所属调度类的指针;它是独占的——一个线程在任何给定时刻只能属于一个调度类(它会是表 10.2 中提到的那些)。默认情况下,它指向 CFS(fair_sched_class)。 policy成员指定线程遵循的调度策略(它会是表 10.2 中第一列提到的那些)。它也是独占的——一个线程在任何给定时刻只能遵循一个调度策略(不过可以改变)。- 线程优先级值;包括这些优先级的成员有
prio、static_prio、normal_prio和rt_priority。 - 调度策略和优先级是动态的,可以通过编程查询和设置(或者通过工具;你很快就会看到这一点)。
仅供参考,属于 stop-sched 类的线程非常少;这是因为它是一个极端的优先级级别。当 stop-sched 线程获得处理器时,内核会关闭所有其他核心的执行(以及所有与锁相关的操作、中断等),因此该线程在一个核心上执行——该核心的所有中断和内核抢占都被屏蔽——它字面上是孤立运行的,完全没有被抢占的机会。谁需要这种优先级和不可抢占性?举个例子,Ftrace 内核跟踪子系统就是如此;另一个例子是实时内核补丁。接下来优先级较低的调度类是 Deadline,它适用于必须满足特定截止时间的实时任务(类似于经典 RTOS 的方式)。由于 stop-sched 和 deadline 类线程比较少,我们将重点关注 RT 和(大部分)CFS(公平调度)线程;这些“公平类”线程通常是活跃并在运行的。
所以,了解了这些,你现在会明白,所有遵循 SCHED_FIFO 或 SCHED_RR 调度策略的线程都会映射到 rt_sched_class(通过它们任务结构中的 sched_class 成员),所有 SCHED_OTHER(或 SCHED_NORMAL)的线程都会映射到 fair_sched_class,而 CPU 空闲线程(swapper/n,其中 n 是从 0 开始的 CPU 编号)总是映射到 idle_sched_class 调度类。
当内核需要进行调度时,基本的调用序列是:
schedule() --> __schedule() --> pick_next_task()
实际的调度类迭代过程发生在这里;请参阅 pick_next_task() 函数的(部分)代码,如下所示:链接:
static inline struct task_struct *
__pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
const struct sched_class *class;
struct task_struct *p;
/*
* Optimization: [ ... ] */
[ ... ]
put_prev_task_balance(rq, prev, rf);
for_each_class(class) {
p = class->pick_next_task(rq);
if (p)
return p;
}
BUG(); /* The idle class should always have a runnable task. */
}
(实际上,任务选择代码逻辑经过了大量优化;为了简洁,我们将跳过这些细节,专注于标准情况。)上述的 for_each_class() 宏设置了一个循环,遍历所有调度类。它的实现如下:
kernel/sched/sched.h
#define for_class_range(class, _from, _to) \
for (class = (_from); class < (_to); class++)
#define for_each_class(class) \
for_class_range(class, __sched_class_highest, __sched_class_lowest)
从上述代码片段可以看到,它使得从 __sched_class_highest 到 __sched_class_lowest 的每个类通过 class->pick_next_task() 方法“询问”谁是下一个要调度的任务(如图 10.9 概念性展示的那样)。现在,由调度类代码来决定是否有候选线程准备执行。如何判断?其实很简单;它只是查找它的 runqueue 数据结构。
现在,这是一个关键点:内核为每个处理器核心和每个调度类维护一个运行队列!所以,如果我们有一个系统,假设有六个 CPU 核心,那么我们将有 6 个核心 * 5 个调度类 = 30 个运行队列!(有一个例外:在单处理器(UP)系统中,stop-sched 类不存在)。运行队列是按调度类实现的;例如,CFS 类的运行队列是 struct cfs_rq(链接);同样,RT 类的运行队列是 struct rt_rq,以此类推。以下图示尝试呈现这一信息:
请注意,在前面的图示中,我展示的运行队列可能让它们看起来像数组。实际上,这并不是我的意图;它仅仅是一个概念性图示。实际使用的运行队列数据结构取决于调度类(毕竟是类代码实现了运行队列)。它可能是一个链表数组(例如实时类),也可能是一个树结构——实际上是红黑树(rb 树)——比如公平类,等等。
理解调度类的概念示例
为了帮助更好地理解调度类模型,我们设计了一个示例:假设在一个对称多处理器(SMP)或多核系统上,我们有 100 个线程在运行(包括用户空间和内核空间的线程)。其中一些线程在竞争 CPU;也就是说,它们处于准备就绪(Rr)状态,意味着它们是可运行的,并因此被加入到运行队列数据结构中(参见图 10.2,状态机)。假设这些可运行的线程分布在各个调度类中,如下所示:
- 一个 stop-sched(SS)类线程,S1
- 两个 Deadline(DL)类线程,D1 和 D2
- 两个 Real Time(RT)类线程,RT1 和 RT2
- 三个 Completely Fair Scheduling(CFS)(或公平)类线程,F1、F2 和 F3
- 一个 idle 类线程,I1
现在假设,开始时,线程 F2 正在一个处理器核心上愉快地执行代码。此时,内核希望将上下文切换到该 CPU 上的其他任务。(是什么触发了这个?你很快会看到。)在调度代码路径中,内核代码最终会到达核心调度代码 kernel/sched/core.c:void schedule(void) 内核例程(代码细节将在后面讨论)。目前需要理解的是,pick_next_task() 例程由 schedule() 调用,继而变成 __schedule(),它会遍历调度类的链表,询问每个调度类是否有准备好运行的线程(再次参见图 10.9)。它的代码路径(当然是概念性的)大致如下:
核心调度代码(__schedule()):“嘿,SS(stop-sched 类),你有任何准备好运行的线程吗?”
SS 类代码:遍历其运行队列并找到了一个可运行的线程(S1);它回应:“是的,我有;是线程 S1。”
核心调度代码(__schedule()):“好,太棒了;让我们将上下文切换到 S1。”
任务完成(至少对于这次调度轮次或时段)。但是让我们稍微改变一下场景:如果 SS 运行队列中没有可运行的线程 S1(或者它已经进入睡眠、停止,或者它在另一个 CPU 的运行队列中)呢?那么,SS 会回答“没有”,接下来会询问下一个最重要的调度类 Deadline(DL)。如果它有准备好运行的候选线程(例如我们的示例中的 D1 和 D2),它的类代码将运行其算法来确定应该运行 D1 还是 D2,并将该线程的任务结构指针返回给 __schedule(),然后内核调度器会将上下文切换到该线程。这个过程会继续进行,直到 Real-time(RT)和 Fair(CFS)调度类完成。 (一句话胜千言,对吧?请参见图 10.11。)
很可能,在你典型的负载适中的 Linux 系统上,SS、DL 或 RT 类的线程都不会在相关 CPU 上准备好运行,并且通常至少会有一个 fair(CFS)线程准备运行。
因此,竞争通常发生在公平(CFS)可运行线程之间;由公平类实现(CFS)选中的线程将是上下文切换到的线程。
如果真的没有线程准备运行(没有 SS/DL/RT/CFS 类线程想要运行),这意味着系统当前处于空闲状态(懒惰的家伙)。现在,Idle 类会被询问是否有线程想要运行;它总是回答“是的”!这很有道理;毕竟,CPU 空闲线程的工作就是在没有其他线程需要/想要运行时占用 CPU。因此,在这种情况下,内核会将上下文切换到空闲线程,通常标记为 swapper/n,其中 n 是它执行的 CPU 编号(从 0 开始;是的,我知道你可能会想:为什么叫“swapper”?……这只是旧 Unix 历史的遗留问题——没别的了)。
另外,注意到 swapper/n(CPU 空闲)内核线程不会出现在 ps 列表中,尽管它总是存在(回想一下我们在第六章《内核内部要点——进程和线程》展示的代码:链接)。在那里,我们编写了一个 disp_idle_thread() 例程来显示一些 CPU 空闲线程的细节,因为即使是内核的 do_each_thread() {...} 和 while_each_thread() 循环也不会显示空闲线程。
以下图示简洁地总结了核心调度代码按优先级顺序调用调度类,并将上下文切换到最终选中的“下一个”线程的过程:
请求调度类
核心调度代码是如何询问调度类是否有任何准备运行的线程的?我们之前已经看过这个内容,但我觉得值得重复以下代码片段以便清晰说明(它主要由 __schedule() 和线程迁移代码路径调用):
kernel/sched/core.c
static inline struct task_struct *
__pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
const struct sched_class *class;
struct task_struct *p;
/*
* Optimization: [ ... ] */
[ ... ]
put_prev_task_balance(rq, prev, rf);
for_each_class(class) {
p = class->pick_next_task(rq);
if (p)
return p;
}
[ ... ]
}
注意到面向对象的应用:class->pick_next_task(rq) 代码,实际上是在调用调度类 class 的 pick_next_task() 方法!返回值方便且故意地是所选任务的任务结构指针,内核可以通过这个指针进行上下文切换。如你所见,pick_next_task() 返回 NULL 表示当前调度类没有任何可调度的候选线程;因此,程序将移动到下一个调度类并继续询问它。这个循环会一直进行,直到空闲类返回一个非 NULL 的候选线程(该线程即为该核心的空闲线程)。
上述代码和段落意味着,当然,每个调度类都有一个预填充的类结构,体现了我们所说的调度类。的确如此:它包含了所有可能的操作,以及你可能在调度类中需要的有用钩子。它被称为 sched_class 结构:
https://elixir.bootlin.com/linux/v6.1.25/source/kernel/sched/sched.h#L2147
struct sched_class {
[ ... ]
void (*enqueue_task) (struct rq *rq, struct task_struct *p, int flags);
void (*dequeue_task) (struct rq *rq, struct task_struct *p, int flags);
[ ... ]
struct task_struct *(*pick_next_task)(struct rq *rq);
void (*task_tick)(struct rq *rq, struct task_struct *p, int queued);
void (*task_fork)(struct task_struct *p);
[ ... ]
};
(这个结构体的成员比我们展示的更多;请在内核代码中查找。)如你现在应该能够明显看到的,每个调度类实例化这个结构体,并通过函数指针适当地填充它。核心调度代码在遍历调度类链表(以及内核中的其他部分)时,按需调用这些方法和钩子函数(再次,图 10.9 和图 10.11 提醒我们这一点)。
作为示例,考虑公平调度类(CFS)如何实现它的调度类:
https://elixir.bootlin.com/linux/v6.1.25/source/kernel/sched/fair.c#L12365
DEFINE_SCHED_CLASS(fair) = {
.enqueue_task = enqueue_task_fair,
.dequeue_task = dequeue_task_fair,
[ ... ]
.pick_next_task = __pick_next_task_fair,
[ ... ]
.task_tick = task_tick_fair,
.task_fork = task_fork_fair,
.prio_changed = prio_changed_fair,
[ ... ]
};
(早期直接定义了结构体;从 5.11 版本开始,使用了 DEFINE_SCHED_CLASS() 宏来定义。想了解原因,请阅读提交:提交链接)
现在你可以看到:公平调度类用来选择下一个要运行的任务的代码是函数 __pick_next_task_fair()(它是 pick_next_task_fair() 的薄包装)。仅供参考,task_tick 和 task_fork 成员是调度类钩子的很好的例子;这些函数会在每个定时器滴答时被调度器核心调用(也就是说,每个定时器中断,理论上每秒会触发 CONFIG_HZ 次),以及当一个属于该调度类的线程进行 fork 操作时。
一个有趣的深入的 Linux 内核项目,或许是:创建你自己的调度类,带有特定的方法和钩子,实现其内部调度算法。根据需要将所有部分链接起来(插入调度类链表,按所需优先级插入,等等),然后测试!现在,你就会明白为什么它们被称为模块化调度类。
太棒了——现在你已经了解了现代模块化 CPU 调度器背后的架构,让我们简要看一下 CFS 背后的调度算法,或许它是最常用的调度类,广泛应用于通用 Linux 系统。
完全公平调度(CFS)类的简要工作原理
自 2.6.23 版本以来(即 2007 年),CFS 一直是常规线程的事实内核 CPU 调度代码;大多数 Linux 线程默认属于 SCHED_OTHER 策略,而该策略由 CFS 驱动。CFS 算法背后的驱动力是提供公平性和整体吞吐量。
它的实现简而言之如下:内核跟踪每个可运行的 CFS(SCHED_OTHER / SCHED_NORMAL)线程的实际 CPU 运行时间(以纳秒为单位);运行时间最短的线程最应该运行,并将在下一个调度周期或时间段(“epoch”表示“某一事物历史时期的开始”)中获得处理器。相反,不断占用处理器的线程将累积大量的运行时间,从而受到惩罚(这真的有点因果报应的味道!)。
我们将讨论分为两个部分:第一部分简要介绍 CFS 的 vruntime 和它的内部 rb-tree 运行队列,第二部分则介绍 CFS 动态时间片的工作原理。当然,我们首先从第一部分开始。
关于 CFS vruntime 值和其运行队列的说明
不深入探讨 CFS 实现的过多细节,我们可以看到,在任务结构中嵌入了另一个数据结构,struct sched_entity,其中包含一个无符号 64 位的值,称为 vruntime(或虚拟运行时间)(链接)。从简单的层面来看,vruntime 是一个单调计数器,跟踪线程在处理器上累积的时间(以纳秒为单位)。
在实际的实现中,很多代码级的调整、检查和权衡是必需的。例如,内核通常会将 vruntime 值重置为 0,从而触发另一个调度周期。同时,还有各种可调参数(或 sysctl),位于 /proc/sys/kernel/sched_* 目录下,有助于更好地微调 CPU 调度器的行为(其中一些已在此处记录:链接)。
CFS 如何选择下一个运行的任务
CFS 选择下一个运行的任务的过程封装在 kernel/sched/fair.c:pick_next_task_fair() 函数中。理论上,CFS 的工作原理非常简单:将所有可运行的任务(对于该 CPU)入队到 CFS 运行队列,该队列是一个 rb-tree(一种自平衡的二叉搜索树),这样,消耗最少时间的任务将是树中的最左叶节点,接下来的叶节点代表下一个要运行的任务,再接下来是下一个。
实际上,从左到右扫描树可以获得未来任务执行的时间轴。如何保证这一点?通过使用前面提到的 vruntime 值作为键,将任务入队到 rb-tree 中!
为什么它叫 vruntime 而不仅仅是 runtime?因为 vruntime 成员的值不仅仅是线程在处理器上花费的时间;它更为复杂:它在计算这个重要值时考虑了线程的优先级——即 nice 值(毕竟,线程在 CFS rb-tree 运行队列中的“位置”是基于 vruntime 量的)。所以,这里做的是:nice 值 越低(优先级越高),vruntime 值就越小,从而被更靠左地入队;nice 值 越高(优先级越低),vruntime 值就越大,从而被更靠右地入队。(这种任务调度方法通常称为加权公平排队调度器)。
当核心调度器需要调度,并且询问 CFS “你有任何线程想要运行吗?”,CFS 类代码——我们之前提到过(pick_next_task_fair() 函数)——会运行并简单地选择树中最左的叶节点,返回任务结构指针。想一想——它按定义是具有最低 vruntime 值的任务,也就是说,实际上是运行时间最短的任务!(遍历一棵树是 O(log n) 时间复杂度的算法,但由于一些代码优化和对最左叶节点的巧妙缓存,实际实现具有非常理想的 O(1) 时间复杂度。)当然,实际代码比这里展示的要复杂得多;它需要多个检查和权衡。我们这里不会深入讨论所有的细节。
对于有兴趣进一步了解 CFS 的读者,我们推荐查看内核文档:CFS 设计。
此外,内核中包含了几个可调参数(sysctls),位于 /proc/sys/kernel/sched_* 目录下,它们直接影响调度。有关这些参数的说明以及如何使用它们,请参阅:sysctl 调度器可调参数分析,IBM LTC,以及在 Scylla JMX 处理的实际应用案例。
此外,最近这些内核级调度可调参数已经迁移到了 debugfs 中,路径为:/sys/kernel/debug/sched/*。因此,你需要 root 权限才能查看或修改这些参数。
关于 CFS 调度周期和时间片的说明
你注意到了吗?与传统的操作系统调度器不同,CFS 的时间片是动态的!那些占用 CPU 较少的任务(即所谓的 IO 绑定任务)会自动积累较少的 vruntime,因此会迁移到 CFS rb-tree 的左侧。对于 CPU 绑定任务,情况则相反;它们由于 vruntime 值较大,向右移动,从而减少了快速获得 CPU 的机会。
在调度器上下文中,调度周期(有时被误称为“调度延迟”)是指一个完整的调度周期(或时间段,epoch)运行的时间;在这个时间段内,操作系统保证每个线程都有机会在 CPU 上运行。那么,调度周期是什么呢?默认值可以在 sysctl 中找到,路径为 /sys/kernel/debug/sched/latency_ns(在 Ubuntu 22.04 上默认值通常为 24 毫秒,在 Fedora 38/39 上为 18 毫秒)。此外,在运行时,它可以动态变化(更多内容将在下文讨论),其计算公式如下:
调度周期长度 = min_granularity_ns * nr_running;
其中,nr_running 是当前可运行任务的数量(这是针对每个运行队列的)。在最近的内核版本中,这些调度器可调参数位于 debugfs 中;让我们在 x86_64 Ubuntu 22.04 LTS 上查看一些值。(这里使用的 grep . <files-spec> 语法是列出(单行)可调参数并查看其当前值的绝妙方法!当然,使用 grep -v 语法来排除某些匹配项。)
# grep -E . /sys/kernel/debug/sched/* 2>/dev/null | grep -v -E "^/sys/kernel/debug/sched/debug:|features"
/sys/kernel/debug/sched/idle_min_granularity_ns:750000
/sys/kernel/debug/sched/latency_ns:24000000
/sys/kernel/debug/sched/latency_warn_ms:100
/sys/kernel/debug/sched/latency_warn_once:1
/sys/kernel/debug/sched/migration_cost_ns:500000
/sys/kernel/debug/sched/min_granularity_ns:3000000
/sys/kernel/debug/sched/nr_migrate:32
/sys/kernel/debug/sched/preempt:none (voluntary) full
/sys/kernel/debug/sched/tunable_scaling:1
/sys/kernel/debug/sched/verbose:N
/sys/kernel/debug/sched/wakeup_granularity_ns:4000000
上述 grep 命令还过滤掉了名为 debug 和 features 的可调参数。下面是一些相关可调参数的含义:
- latency_ns: 这是当前计算的“周期”值(以纳秒为单位);因此,值为 24,000,000 ns,默认调度周期为 24 毫秒,即每个线程保证每 24 毫秒至少运行一次!
- min_granularity_ns: 这是 CFS rb-tree 中节点之间的最小“距离”;实际上,这就是每个 CFS 线程在运行时保证的最小时间片(默认:3 毫秒)。
在继续之前,有几点需要注意:
- 默认值可能会在不同系统(内核)之间有所不同!在我的 x86_64 Fedora 38 虚拟机上,周期和最小粒度(最小时间片)分别为 18 毫秒和 2.25 毫秒。你可以检查你系统上的值。
- 这些调度器可调参数是在函数
sched_init_debug()中创建的(该函数在系统初始化时调用,参考这里:代码链接)。
考虑一下——当前可运行的任务数(nr_running)直接影响周期(因此也影响时间片)。因此,给定以下事实:
- 调度周期长度 =
min_granularity_ns*nr_running(如我们所见) - min_granularity_ns 实际上就是 CFS(动态)时间片的值
考虑到默认的 min_granularity_ns 值为 3 毫秒,周期(latency_ns)为 24 毫秒(如我在 Ubuntu 22.04(和 23.10)上的情况),为了更好地理解这一点,我们计算并展示了以下表格中的有效时间片和调度周期。
CFS 运行队列中的可运行线程数 (nr_running) | 有效时间片 (ms) = 调度延迟 (ms) / nr_running | 是否小于最小粒度或时间片 (min_granularity_ns)? | 调度周期 (时间段) = min_granularity_ns * nr_running (ms) |
|---|---|---|---|
| 3 | 24 / 3 = 8 ms // 可以 | 3 * 3 = 9 ms | |
| 6 | 24 / 6 = 4 ms // 可以 | 6 * 3 = 18 ms | |
| 8 | 24 / 8 = 3 ms // 最小允许 | 8 * 3 = 24 ms | |
| 12 | 24 / 12 = 2 ms // 小于 min_granularity_ns;=> 不可行!重新计算周期 | 12 * 3 = 36 ms |
表 10.3:基于可运行线程数的有效 CFS 任务时间片
有效时间片计算结果见第二列;只要它的值在 min_granularity_ns 范围内(3 毫秒),就没有问题。然而,如所见,当可运行线程的数量足够大时,显然情况变得不可行(见表 10.3 最后一行);现在,调度器并不会放弃,而是巧妙地动态调整调度周期,重新计算它!(好吧,这是其中的一种说法;狡猾的内核会说:“我是否保证每个线程至少每 24 毫秒获得一次 CPU?不不,您误解了;我是说每 36 毫秒一次...”)。
从技术上讲,进行的检查是这样的(伪代码):
effective_timeslice (ms) = latency_ns (ms) / nr_running
if effective_timeslice < min_granularity_ns
recalculate the scheduling period
换句话说:
if nr_running > (latency_ns / min_granularity_ns)
recalculate the scheduling period
(这些术语可能会令人困惑;请记住,latency_ns 就是调度周期。)有关 CFS 及这些调度器相关可调参数的更多内容,请参见进一步阅读部分。接下来,我们将简要讨论如何查找由内核维护的任务调度统计信息(如果配置了这些统计信息)。
调度统计信息
你能看到调度的状态吗?无论是系统级别还是进程级别的粒度?实际上,当配置为 CONFIG_SCHEDSTATS=y 时,内核会提供以下伪文件:
-
/proc/schedstat:系统级调度统计信息。这些信息已经存在很久了;它们展示了每个 CPU 的调度统计信息(以及在 SMP 系统上有关域、CPU 和微架构层次的信息)。其中包括调度器(以及
try_to_wake_up()函数)被调用的次数(在该 CPU 核心上),任务在核心上运行/等待的累计时间,以及该核心上运行的时间片数。最好参考官方内核文档了解更多:官方文档。 -
/proc/PID/schedstat 和 /proc/PID/sched:进程/线程级粒度:
-
/proc/PID/schedstat内容包括三个以空格分隔的数字,分别对应进程 ID 为 PID 的线程:- 在 CPU 上花费的时间(纳秒)
- 在运行队列上等待的时间(纳秒)
- 在该 CPU 上运行的时间片数
-
/proc/PID/sched内容非常详细;它包括任务的sched_entity结构中的许多字段(以se.foo表示;这包括任务的vruntime(以纳秒为单位),即se.vruntime!),上下文切换统计信息,调度策略和优先级,以及一些 NUMA 统计信息。
-
快速提示:
尝试以下操作以观察字段变化。作为一个有趣的实验,后台运行 yes(yes >/dev/null &),记录其 PID,然后执行以下命令:
watch -d -n1 'cat /proc/<PID_of_yes>/sched'
按 ^C 结束。
新调度器:EEVDF
Linux 内核一直在不断演进(这真的是它的秘密武器!);任务(CPU)调度也不例外。从 6.6 内核(2023 年 10 月)开始,一个新的调度器替代了久负盛名的 CFS,用于公平类调度器;它被命名为 EEVDF(Earliest Eligible Virtual Deadline First,最早符合条件的虚拟截止时间优先调度器)(Peter Zijlstra 是其主要开发者之一)。有趣的是,EEVDF 的整体方法与 CFS 类似——它们都使用基于虚拟时间的加权公平排队方法。EEVDF 解决了 CFS 的一些局限性并带来了一些优势;其中,它解决了一些线程对延迟的严格要求(使它们能够以较短的时间段运行,但更频繁地获得 CPU 资源,且具有更低的延迟);它还被认为是一种更简洁的实现(去除了 CFS 依赖的若干启发式算法)。由于我们关注的是 6.1(S)LTS 内核,本书不再深入讨论;如需了解更多,见进一步阅读部分。
现在,让我们继续学习如何查询任何给定线程的调度策略和优先级。
查询给定线程的调度策略和优先级
在本节中,你将学习如何通过命令行查询系统上任何给定线程的调度策略和优先级。(但是,如何编程查询和设置这些内容呢?我们将在第 11 章《CPU 调度器 – 第 2 部分》的 查询和设置线程的调度策略和优先级 部分中讨论这个问题。)
我们已经知道,在 Linux 中,线程就是 KSE(内核可调度实体);它是被调度并在处理器上运行的实体。此外,Linux 提供了几种调度策略(或算法)。调度策略和优先级是按线程设置的,默认的调度策略始终是 SCHED_OTHER,默认的实时优先级为 0(换句话说,它是一个非实时线程;请参见表 10.1)。
在给定的 Linux 系统上,我们可以始终看到所有活跃的进程(通过简单的 ps -A 命令),或者,使用 GNU ps,我们甚至可以看到所有活跃的线程(其中一种方式是使用 ps -LA)。然而,这并没有显示一个关键事实:这些任务使用的调度策略和优先级是什么。我们如何查询它们?
这实际上非常简单:在 shell 中,chrt 工具非常适合查询和设置给定进程的调度策略和/或优先级。使用 chrt 命令加上 -p 选项并提供 PID 参数,就可以显示该任务的调度策略和实时优先级。例如,让我们查询 init 进程(或 systemd)的 PID 1:
$ chrt -p 1
pid 1's current scheduling policy: SCHED_OTHER
pid 1's current scheduling priority: 0
很清楚:PID 1 进程(通常是 systemd)使用的是 SCHED_OTHER 调度策略(由 CFS 驱动),并且它的实时优先级为 0(因为它不是实时任务;请参见图 10.3 提醒你优先级的等级)。如常,chrt(1) 的 man 页面提供了所有选项开关及其使用方式;不妨看一下。
在下面的(部分)截图中,我们展示了一个简单的 Bash 脚本(ch10/query_task_sched.sh,本质上是 chrt 的封装)运行的结果,它遍历并查询所有当前活跃线程的调度策略和优先级(包括 nice 和(软)实时优先级)。这是在我运行的 x86_64 Fedora 38 虚拟机上的输出:
一些注意事项:
在我们的脚本中,通过使用 GNU ps,调用 ps -LA 命令,我们能够捕获系统上所有活跃的线程;它们的 PID 和 TID 会被显示。如我们在第 6 章《内核内部要点——进程与线程》中所学,PID 是用户空间等价于内核的 TGID,而 TID 是用户空间等价于内核的 PID。因此,我们可以得出以下结论:
- 如果 PID 和 TID 匹配,那么该线程(在该行中,第 3 列有它的名字)就是进程的
main()线程。 - 如果 PID 和 TID 匹配,并且 PID 只出现一次,那么它是一个单线程进程。
- 如果 PID 重复(最左列)且 TID 不同(第二列),则说明该进程是多线程的,而这些 TID 是该进程的子线程(更准确地说,是工作线程)。我们的脚本通过将 TID 的数字稍微向右缩进来显示这一点(在图 10.12 的截断截图中,你看不见这个缩进,因为它发生在稍后的部分)。
注意,典型的 Linux 系统(无论是桌面、服务器,还是嵌入式系统)上的绝大多数线程通常是非实时的(属于 SCHED_OTHER 策略)。少数(软)实时线程(SCHED_FIFO/SCHED_RR)可能会出现。而 Deadline (DL) 和 Stop-Sched (SS) 线程则非常罕见(当然,这类任务通常是项目特定的)。
接下来,请注意以下关于实时线程的观察:
- 我们的脚本通过在第 6 列标记为 *RT 的方式,突出显示了所有实时线程(那些调度策略为 SCHED_FIFO 或 SCHED_RR 的线程)。
- 任何实时优先级为 99(最大可能值)的软实时线程,在同一列中会显示三个星号(这些通常是专用的内核线程)。
- SCHED_RESET_ON_FORK 标志与调度策略进行按位 OR 运算时,会使得任何通过 fork() 创建的子进程无法继承特权调度策略(这是一个安全措施)。
- 倒数第二列是线程的 “nice” 值(回顾一下,范围是 -20 到 +19,其中 -20 是最佳优先级,+19 是最差,默认值是 0)。注意,nice 值仅对具有 SCHED_OTHER 调度策略的线程有效(以及批处理和空闲线程)。
- 最右列显示了 CPU 亲和力掩码(以十六进制表示),这也是每个线程的属性!这意味着你可以设置线程可能被调度到的 CPU 核心(我们将在下章详细讨论这一点)。例如,掩码值
0x3f的二进制表示为0011 1111,意味着该线程可以在任何设置为 1 的核心上运行,在这里,也就是在任何核心上(因为系统总共有六个核心)。
快速小测:CPU 亲和力掩码值 0x8 表示什么?
改变线程的调度策略和/或优先级可以通过 chrt 工具进行;但是,你应该意识到,这是一项需要 root 权限的敏感操作(或者现在,首选的机制是通过能力模型,所需的能力位是 CAP_SYS_NICE)。
我们将留给你去检查脚本代码(ch10/query_task_sched.sh)。此外,请注意,性能和 shell 脚本并不总是搭配得很好(所以不要期望这里的性能会很高)。考虑一下——每个在 shell 脚本中发出的外部命令(我们在这里有几个,比如 awk、grep 和 cut)都涉及一个 fork-exec-wait 语义和上下文切换。而且,这些命令都在一个循环中执行。
tuna(8) 程序,一个强大的基于 GUI(或控制台模式)的系统监控、调优和配置文件管理工具,可以用来查询和设置各种属性;这包括进程/线程级的调度策略/优先级、CPU 亲和力掩码以及 IRQ 亲和力。它是一个设计精良的 GUI,值得一试!(安装 tuna:安装说明)。
此外,schedtool 工具与 chrt 稍有相似,可以用来查询和设置给定线程的任何或所有任务调度参数;schedtool(8) 的 man 页面覆盖了它的用法。
你可能会想,具有 SCHED_FIFO 策略且实时优先级为 99 的线程是否总是占用系统的处理器?不,实际上并非如此;实际情况是,这些线程通常大部分时间都处于休眠状态。当内核需要它们执行一些工作时,它会唤醒它们。由于它们具有实时策略和非常高的优先级,因此几乎可以保证它们会立即获得一个 CPU 核心,并在需要时执行,完成工作后又回到休眠状态。关键点是,当它们需要处理器时,它们就会得到处理器(这有点类似于实时操作系统,但不像实时操作系统那样提供严格的保证、低调度延迟和确定性)。
chrt 工具如何查询(和设置)实时调度策略/优先级?
这应该显而易见:由于这些信息存在于内核虚拟地址空间(VAS)中的任务结构内,chrt 进程必须发出系统调用。执行这些任务的系统调用有几种变体:chrt 用来查询调度策略和优先级的系统调用是 sched_getattr(),而 sched_setattr() 系统调用则用来设置调度策略和优先级。(一定要查阅 sched(7) 的 man 页面,了解更多关于调度器相关的系统调用。)快速执行 strace 命令就能验证这一点!
$ strace chrt -p 1
[ … ]
sched_getattr(1, {size=56, sched_policy=SCHED_OTHER, sched_flags=0, sched_nice=0, sched_priority=0, sched_runtime=0, sched_deadline=0, sched_period=0, sched_util_min=0, sched_util_max=0}, 56, 0) = 0
newfstatat(1, "", {st_mode=S_IFCHR|0620, st_rdev=makedev(0x88, 0), ...}, AT_EMPTY_PATH) = 0
write(1, "pid 1's current scheduling polic"..., 47pid 1's current scheduling policy: SCHED_OTHER
) = 47
write(1, "pid 1's current scheduling prior"..., 39pid 1's current scheduling priority: 0
) = 39
[ … ]
现在,你已经掌握了如何查询线程的调度策略/优先级和 CPU 亲和力掩码的实用知识,是时候更深入地了解了。在接下来的章节中,我们将进一步探讨 Linux 的 CPU 调度器的内部工作原理。
我们将理解用户空间和内核空间的抢占基本概念,弄清楚到底是谁执行内核任务调度器的代码以及它何时执行。感兴趣吗?我希望你会!继续阅读!
学习CPU调度内部 – 第三部分
让我们从抢占的主题开始探讨。
可抢占的内核
请设想以下假设情况:你正在一个只有一个CPU核心的系统上运行。一个模拟时钟应用程序正在GUI界面上运行,同时还有一个C程序 a.out,它的代码只有一行 while (1);。那么你认为,CPU占用过高的 while(1) 程序会一直占用CPU,导致GUI时钟程序停止运行(秒针会停下来吗)?
经过一番思考(和实验),你会发现,尽管有这个“坏蛋”CPU占用程序,GUI时钟应用仍然继续走动!这正是操作系统调度程序的关键:它可以,并且确实会抢占(把它踢出!)占用CPU的用户空间进程。(我们之前简要讨论过CFS算法;CFS会让那些占用CPU的计算密集型进程累积巨大的vruntime值,从而使它们在其红黑树队列上向右移动,惩罚它们自己从而失去处理器的使用机会!)所有现代操作系统都支持这种类型的抢占,这被称为用户模式抢占。
但现在考虑一下:如果你写一个内核模块,它在单核系统上执行同样的 while(1) 无限循环呢?这就会引发一个问题:系统现在会挂起。为什么?因为大多数操作系统内核默认是不可抢占的;也就是说,它们不能自己抢占自己!
除了这个简单的例子之外,实际情况中确实存在内核不可抢占时,可能对(实时)线程调度产生负面影响的情况。想一想:如果你有一个高优先级的线程,已经在运行队列中(因此是可运行的),并且需要紧急运行,但内核却卡在了一个不可抢占的内核代码段中,或者正在处理一个长时间运行的循环?或者一个硬件中断发生,但由于中断被屏蔽在另一个长时间运行的不可抢占代码段中无法被处理……
在这里,锁的使用起着作用(你将在接下来的两章中详细了解);事实上,多年来,内核有一个臭名昭著的粗粒度(和递归)锁,名为大内核锁(BKL);当它被持有时,会让内核处于不可抢占状态,持续较长时间(并且会屏蔽中断),从而导致性能和延迟响应的问题。它终于在2.6.39版本的内核中完全被移除了。
所以,问题来了:当需要时,内核能否自我抢占?猜猜看:事实上,Linux已经为内核提供了一个构建时的配置选项,使得内核可以进行抢占;它被称为 CONFIG_PREEMPT。 (实际上,这只是为了减少延迟并提高内核和调度器响应的长期目标的一部分工作。大部分工作来自早期的低延迟(LowLat)补丁、(旧版)RTLinux的工作,尤其是RTL(实时Linux)项目(使Linux成为真正的RTOS!在下一章中,我们将介绍如何设置RTL)。)
启用 CONFIG_PREEMPT 内核配置选项并构建和启动内核后,我们就能在一个可抢占的内核上运行——在大多数时候,操作系统有能力进行自我抢占!
要检查这个选项,可以在 make menuconfig UI中,导航到 General Setup | Preemption Model。
实际上,关于内核抢占,现代Linux内核默认有三种配置选项:
| 抢占类型 | 特性 |
|---|---|
CONFIG_PREEMPT_NONE | 传统模型,面向高整体吞吐量(适用于服务器)。虽然偶尔会发生较长的延迟,但这个选项是为了“最大化内核的处理能力,而不关心调度延迟”。(在此,内核抢占仅限于显式调用 cond_resched() 的地方。) |
CONFIG_PREEMPT_VOLUNTARY | 可抢占内核(适用于桌面/笔记本);操作系统内部更多明确的抢占机会点(这些点通常是在调用 cond_resched() 和 might_sleep() 的地方)。导致较低的调度延迟和更好的应用响应,但吞吐量稍低。通常是发行版的默认设置。 |
CONFIG_PREEMPT | 也称为低延迟内核;(几乎)整个内核都是可抢占的(适用于桌面/笔记本/强大嵌入式系统)。选择此选项意味着甚至内核代码路径也可以被抢占(任何未明确禁用抢占的内核代码段现在都可以抢占)。提供更低的调度延迟(通常在几十微秒到低几百微秒之间),以牺牲吞吐量和轻微的运行时开销为代价。如果你正在为桌面或嵌入式系统构建内核,并且有毫秒级延迟要求,请选择此选项。 |
表10.4:Linux操作系统内核抢占——可用配置选项
kernel/Kconfig.preempt 配置文件包含了与内核抢占相关的配置项。
会不会很棒,如果我们能在启动时动态选择内核抢占行为?接下来的章节将向你展示如何做!
动态可抢占内核特性
从5.12版本的内核开始,引入了一个新的动态可抢占内核特性(CONFIG_PREEMPT_DYNAMIC)。启用此特性后,可以通过传递内核参数(通常通过引导加载程序)在启动时调节内核的抢占行为(或模式);该参数名为 preempt=。
可以设置的值如下所示(参考表10.4):
preempt=none:内核的抢占行为与CONFIG_PREEMPT_NONE相同。preempt=voluntary:内核的抢占行为接近CONFIG_PREEMPT_VOLUNTARY。preempt=full[默认]:内核的抢占行为与CONFIG_PREEMPT相同。
一个典型的应用场景是允许发行版发布一个内核二进制镜像——使用 CONFIG_PREEMPT 构建,内核几乎完全可抢占——但可以允许最终用户在启动时修改内核的抢占模式,从而让用户选择将其作为典型的服务器系统(preempt=none)、桌面系统(preempt=voluntary)或启用完全抢占的系统来运行。通过这种方式,发行版(或产品)不必发布和维护不同的内核镜像来服务不同的使用场景。
内核配置项为 CONFIG_PREEMPT_DYNAMIC;将其设置为 y 以启用此特性。(在 make menuconfig UI 中,导航到 General Setup | Preemption behaviour defined on boot。这是一个布尔选项;开启它,重新构建并重启,传递 preempt=<value> 参数。)
如需查看此特性是否启用,可以通过 uname -a 或 /proc/version 来确认(例如,我在定制的6.1内核上启用了此特性,运行在我的 Fedora VM 上):
$ uname -a
Linux fedora 6.1.25-onfc38 #4 SMP PREEMPT_DYNAMIC Wed Jul 26 21:49:07 IST 2023 x86_64 GNU/Linux
建议练习:在你的6.1 LTS定制内核上启用 CONFIG_PREEMPT_DYNAMIC;构建它,然后使用不同的 preempt= 内核参数值启动(并通过查看 /proc/cmdline 来验证它是否已传递)。
完成后,我们将回到一些更详细的内部内容。让我们简要总结一下前面章节中学到的几个关键点。你已经学到,内核调度的核心代码位于 void schedule(void) 函数中,这是一个对底层函数 __schedule() 的简易封装,调度程序会按优先级顺序遍历调度类,最终由某个调度类的代码选择一个线程进行上下文切换。到此为止,一切都很清楚;现在有几个关键问题:究竟是谁调用了这个“任务调度”核心代码路径?它到底在什么时候运行?(这正是图10.9顶部标记为“S”的连接器所暗示的!)接下来的章节将尝试回答这些问题;继续读下去吧!
谁运行调度器代码?
关于调度如何工作的一个微妙但关键的误解是,许多人认为有一个名为“调度器”的内核线程(或类似实体)存在,它会定期运行并调度任务。这是完全错误的;在像Linux这样的单内核操作系统中,调度是由进程上下文本身执行的,即在内核模式下运行的常规线程!
实际上,调度代码始终由当前执行内核代码的进程上下文运行——换句话说,由 current 运行(我们在第6章《内核内部要点——进程与线程》的“通过 current 访问任务结构”一节中详细讲解了 current 是什么)。
此时也适合提醒你我们所称的Linux内核黄金法则之一:调度程序绝不能在任何原子上下文中运行(包括中断上下文)。
换句话说,原子(字面意义上的不可分割)和/或中断上下文中的代码必须确保是非阻塞且原子的——它必须在不被中断的情况下运行完成。这就是为什么你不能调用 schedule(),因为它没有意义。(当我们让调用者进入睡眠时,代码路径怎么可能是原子的呢?不可能,所以不要调用它。)另外,作为另一个例子,在任何原子上下文中调用 kmalloc() 并使用 GFP_KERNEL 标志是错误的——因为 GFP_KERNEL 标志表示它可能会阻塞!但如果使用 GFP_ATOMIC 标志就没问题了,因为它告诉内核内存管理代码永远不会阻塞。
还需要意识到的是,在可抢占内核中,内核抢占在运行 schedule() 代码路径时是禁用的;这也很有道理。
这里的关键点总结如下:Linux上的核心任务调度代码——[__]schedule() 及其调用的所有内容——是由即将通过最终的上下文切换将自己从CPU上踢出去的进程(线程)在进程上下文中运行的!而这个线程是什么?当然是 current!(重新看看图10.9和图10.11;那里展示的代码路径——除了最后一个框架底部的“下一个”线程在核心上运行——都是由 current 执行的。)另外,有一个“规则”:在任何原子上下文中(包括中断上下文)都不能调用 schedule()。
那么,究竟何时发生这种情况?换句话说,schedule() 是何时被调用的?继续阅读你就知道了...
我能理解如果你第一次阅读时觉得以下几节内容有些密集... 没关系。如果你愿意的话,现在可以先跳过细节(也许看看CPU调度程序的入口点——总结部分来消化一下总结)。等你准备好了再回来看细节。
schedule() 何时运行?
操作系统调度器的任务是仲裁处理器(CPU)资源的访问,将其在竞争使用它的实体(线程)之间共享。但是,如果系统繁忙,许多线程不断竞争、获取然后释放处理器呢?更准确地说,我们真正想要了解的是:为了确保任务之间公平地共享 CPU 资源,必须确保调度器(即“警察”)周期性地在处理器上运行。听起来不错,但到底该怎么确保呢?
这里有一种(看似)合乎逻辑的方式:让操作系统在启动时挂钩到定时器芯片的中断,并且在定时器中断触发时,在中断处理程序中作为“清理工作”调用调度器!现在,(有些简化)与我们之前学到的相对比,定时器中断每秒触发 CONFIG_HZ 次。
所以,如果我们在这里调用 schedule(),它每秒有 CONFIG_HZ 次机会运行(通常在 x86_64 的 Ubuntu 系统上设置为 250 次,在 x86_64 的 Fedora 系统上为 1000 次)!不过,等一下,我们在第8章《内核内存分配与模块作者》第1部分的“永远不要在中断或原子上下文中睡眠”部分学到了一条黄金法则:你不能在任何原子(包括中断)上下文中调用调度器(正如前面再次提到的)。所以,根据这条规则,我们无法在定时器中断中调用调度器代码路径(这样做会立即导致内核错误)。
那么操作系统怎么做呢?我们马上讲,但首先,我们需要了解一个名为 thread_info 的结构体。
最基础的 thread_info 结构理解
为了清楚理解以下内容,你需要了解一个与架构相关的每个线程数据结构,叫做 thread_info。这个结构体很小,包含几个关键的“热点”成员(这些成员最初都在任务结构体中)。之所以使用 thread_info 结构,是出于性能考虑;查找 thread_info 内容比查找较大任务结构体的内容要快,因为它更小,通常设计为能够适应一个 CPU 的缓存行。(至少在 x86_64 和 AArch64 架构上是这样的:它的大小只有 24 字节。此外,这个结构体也用于计算 AArch32 中的 current。)
thread_info 结构有着丰富的历史。在早期的 Linux 中,它是一个完全独立的实体;从 2.6 版本开始,在 32 位平台上(至少是 x86-32 和 AArch32),它就存在于每个线程的内核模式栈中。在更现代的 Linux 版本(从 4.0 或 4.4 开始)以及某些架构上,当 CONFIG_THREAD_INFO_IN_TASK=y 时,它变成了任务结构体的一部分(主要是出于安全问题)。
你可以查看 x86[_64] 架构的 thread_info 定义:elixir.bootlin.com/linux/v6.1.…架构的定义:elixir.bootlin.com/linux/v6.1.…。
为了我们的立即目的,thread_info 中有一个成员是有意义的——一个位掩码:unsigned long flags。正如你猜到的,它是各种标志位的位掩码(所有的标志都采用 TIF_<FOO> 的格式,其中 TIF 是 “thread_info flag”的缩写)。你可以在这里找到所有的 TIF_* 标志宏:elixir.bootlin.com/linux/v6.1.…。我们讨论任务调度时,最关键的标志是TIF_NEED_RESCHED。如稍后所解释,若该标志被设置,则意味着内核“需要尽快重新调度”;若未设置,则表示不需要重新调度。
在这些讨论中,另一个偶尔出现的话题是硬件中断及其处理(硬中断和软中断)在 Linux 中的情况。同样,《Linux内核编程第二部分》一书的第4章《处理硬件中断》对这些内容进行了深入讲解;如果你需要,务必查看。
好了,现在你已经了解了与 thread_info 结构相关的基础知识,接下来我们回到我们的讨论!调度代码路径(schedule())以及它调用的所有内容可以分为两个部分:第一,TIF_NEED_RESCHED 位何时被设置?第二,TIF_NEED_RESCHED 位何时被检查?让我们来看一下:
“需要重新调度”(TIF_NEED_RESCHED)位何时被设置?
当 thread_info.flags:TIF_NEED_RESCHED 位被设置时(这不是 C 代码;这里只是为了概念性地展示“需要重新调度”的位),它实际上类似于一个红旗,通知内核必须尽快执行(重新)调度(实际上,current 必须现在就被抢占!)。该标志可以在以下上下文中设置:
- (定时器)中断清理:在每次定时器中断时(技术上来说,是在定时器软中断代码路径中),会执行调度器相关的“清理工作”;在这其中,一个关键问题是:是否需要抢占
current?如果需要,就设置TIF_NEED_RESCHED位。(小心:这里只是设置位;不会在这里调用schedule(),因为那是不允许的。) - 任务唤醒:当一个任务被唤醒时,它会被加入到适当的运行队列中;如果需要抢占
current,就设置TIF_NEED_RESCHED位。
“需要重新调度”(TIF_NEED_RESCHED)位何时被检查?
- 调度机会——进程上下文识别:让
current在某些设计良好的进程上下文“机会点”检查TIF_NEED_RESCHED位是否被设置。如果该位被设置,就调用调度代码路径;否则,继续正常执行。
你注意到了吗?在(定时器)中断清理和任务唤醒的情况下,即使我们在中断上下文中运行,也不会出错。这是因为我们在这里不会立即调用 schedule();我们只是设置了 TIF_NEED_RESCHED 位,通知内核我们需要尽快重新调度,并且必须在下一个可用的“机会点”进行调度!接下来的几节将进一步扩展这个讨论。
定时器中断清理 – 设置 TIF_NEED_RESCHED
在定时器中断中(在 kernel/sched/core.c:scheduler_tick() 的代码中,其中本地核心禁用了中断),内核执行了保持调度顺利进行所需的元工作(即清理工作);这包括适当更新每个 CPU 的运行队列、任务负载均衡工作等。请注意,这里从未调用实际的 schedule() 函数。最多,调度类钩子函数(对于被中断的进程上下文 current)sched_class:task_tick()(如果不为 NULL)会被调用(参考图 10.13)。例如,对于属于公平(CFS)类的任何线程,vruntime 成员(虚拟运行时间)的更新以及任务在处理器上花费的时间(按优先级偏向)都在此钩子函数 task_tick_fair() 中完成。
更技术性地说,前述段落中描述的所有工作,scheduler_tick() 的代码,都在定时器中断软中断代码路径 TIMER_SOFTIRQ 中运行。
现在(除了其他与调度相关的清理工作),在此定时器中断(软中断)上下文中,我们必须决定:current 是否需要被抢占?这个关键决策是通过检查以下条件来做的,如果其中任何一个条件为真,就会抢占 current:
current是否超过了它的时间片?此外,是否超过了一个足够大的阈值来与它的邻居比较?这正是可调参数(或 sysctl)/sys/kernel/debug/sched/min_granularity_ns所持有的内容;它是有效的 CFS 时间片。默认值不同(在 x86_64 的 Ubuntu 22.04 上是 3 ms,在 Fedora 38/39 上是 2.25 ms)。作为快速提醒,我们在《关于 CFS 调度周期和时间片的说明》部分中覆盖了有效的 CFS 时间片的信息。- 一个新生并开始运行的任务,或者最近唤醒的任务(在此 CFS 运行队列中),是否比
current具有更高的优先级? - CFS 红黑树中是否有一个任务,其
vruntime小于current(换句话说,current是否不再是此树的最左叶节点)?
想一想,第一点和第三点其实是相似的;如果时间片被超出,很可能 vruntime 值已经增加到不再是运行队列中最左的叶节点。
假设内核代码路径刚才已决定,一个新的任务更应该获得 CPU,那么接下来发生什么?它会调用 schedule() 吗?不会!如前所述,我们不能在中断上下文中调用 schedule()。内核现在仅仅是“标记”我们需要尽快重新调度,设置一个“全局”标志——即 thread_info->flags 中的 TIF_NEED_RESCHED 位。(之所以将“全局”这个词加引号,是因为它并不是真正的内核级全局;它实际上只是当前实例的 thread_info->flags 位掩码中的一个位,叫做 TIF_NEED_RESCHED。为什么?这样访问这个位比通过全局变量更快!)此外,需要记住的是,如果一个新生或最近唤醒的任务需要调度,它会被放入适当的运行队列中,但不会立即运行;它将在下一个调度机会到来时运行。
图 10.13 以概念化的方式展示了这一过程。当定时器中断(或 IRQ)触发时,所谓的快速且小型的上半部分或硬中断处理程序部分会运行,迅速执行其工作;完成后,内核调用定时器的下半部分或软中断处理程序(图 10.13 的左侧部分)。这里的部分工作是定时器清理任务,如前所述。我们以概念化的方式展示 CFS 红黑树,节点 x 代表最左侧的叶节点,c 是代表 current 的节点(图 10.13 的中间部分)。在此图中,current 不再是最左侧的叶节点(意味着它的 vruntime 值高于 x 的 vruntime 值)。定时器(与调度相关的)清理代码路径中的代码会检测到这种情况并设置 TIF_NEED_RESCHED 位(ti 是 thread_info 的缩写,在现代 Linux 中,它位于任务结构中,如图 10.13 的右侧部分所示)。
另一个类似检查发生的地方是任务被唤醒时。当任务被唤醒时,它会从其所在的等待队列中移除,并加入 CPU 运行队列。如果发现最近唤醒的任务(在该核心的运行队列中)必须抢占 current(因为它具有更高的优先级、较低的 vruntime 或其他原因),则会设置 TIF_NEED_RESCHED 位。(我们将在接下来的“CPU 调度器入口点 - 概要部分”中详细讨论这种情况。)
还需要强调的是,在典型的(可能的)情况下,当运行定时器软中断代码路径时,通常不需要抢占 current,因此也不需要设置 thread_info.flags:TIF_NEED_RESCHED 位;它将保持为清除状态。如果设置了该位,调度器激活将很快发生,但具体什么时候呢?请继续阅读...
进程上下文部分 - 检查 TIF_NEED_RESCHED
在定时器中断(软中断)部分调度清理工作中,可能会设置 thread_info:TIF_NEED_RESCHED 位,以“通知”内核应尽快调用调度代码,这部分工作随着系统的运行不断进行。
另一方面,另一部分是检查或识别该标志是否已设置,并在设置时调用 schedule();这部分 - 调用 schedule() - 需要特别注意:
- 仅在进程上下文中执行,
- 仅在内核代码路径中散布的特定“机会点”处执行。
以下是典型的所谓“机会点”,其中会检查 thread_info->flags.TIF_NEED_RESCHED(通常通过 need_resched() 辅助函数):
- 从系统调用代码路径返回时。
- 从中断代码路径返回时。
- 一般来说,内核中的任何从非抢占模式到抢占模式的切换都是一个机会点(当调用
preempt_enable()时)。一个典型的机会点是当一个自旋锁被解锁时。
因此,考虑一下:每次用户空间的任何线程发出系统调用时,该线程会(上下文)切换到内核模式并在内核中运行代码,拥有内核权限(这是单体内核设计)。当然,系统调用是有限长度的;完成后,它会遵循一个已知的返回路径,切换回用户模式并继续执行。
在这个返回路径中,内核引入了一个调度机会点:检查 TIF_NEED_RESCHED 位(位于 thread_info 结构的 flags 成员)是否被设置。如果是,则通过使进程上下文调用 schedule() 激活调度器。
调用 schedule() 的其他地方有:
- 任何显式(或隐式)调用
schedule()(例如,当发出阻塞调用时)。 - 任何对
cond_resched*()函数的调用可能导致调用schedule()。
显然,在进程上下文中显式或隐式地调用 schedule() 会触发它的运行。此外,内核提供了一些“条件调度”API(如 cond_resched()),允许例如一个驱动程序检查:我是否占用了太多 CPU?如果是,放弃... 它们导致在需要时调用 schedule()(实际上,只有当 current->ti->flags.TIF_NEED_RESCHED 位被设置时)。
关于从内核返回到用户模式的代码路径,以下是激活调度代码路径的流程:
// include/linux/entry-common.h
#define EXIT_TO_USER_MODE_WORK \
(_TIF_SIGPENDING | _TIF_NOTIFY_RESUME | _TIF_UPROBE | \
_TIF_NEED_RESCHED | _TIF_PATCH_PENDING | _TIF_NOTIFY_SIGNAL | \
ARCH_EXIT_TO_USER_MODE_WORK)
请注意,不仅仅是调度,内核在准备切换回用户模式时还会处理其他几件事(实际上,这也是这些事情的一个机会点 - 比如处理挂起的信号、Uprobe、内核实时补丁等)。如果以上任意一个标志被设置,EXIT_TO_USER_MODE_WORK 宏返回真;然后内核设置好准备退出到用户模式并调用 schedule();以下是代码视图:
kernel/entry/common.c
static void exit_to_user_mode_prepare(struct pt_regs *regs)
{
…
ti_work = read_thread_flags();
if (unlikely(ti_work & EXIT_TO_USER_MODE_WORK))
ti_work = exit_to_user_mode_loop(regs, ti_work);
[ … ]
static unsigned long exit_to_user_mode_loop(struct pt_regs *regs,
unsigned long ti_work)
{
/*
* Before returning to user space ensure that all pending work
* items have been completed.
*/
while (ti_work & EXIT_TO_USER_MODE_WORK) {
local_irq_enable_exit_to_user(ti_work);
if (ti_work & _TIF_NEED_RESCHED)
schedule();
[ … ]
if (ti_work & (_TIF_SIGPENDING | _TIF_NOTIFY_SIGNAL))
arch_do_signal_or_restart(regs);
[ … ]
类似地,在处理任何硬件中断(以及需要运行的任何相关软中断处理程序)之后,也会发生相同的情况。虽然在处理中断时内核设置为不可抢占,但一旦处理完成,它会恢复为可抢占。这里是一个机会点:内核检查 TIF_NEED_RESCHED 位,如果它被设置,则调用 schedule()。(顺便说一下,当解锁自旋锁时也会发生同样的机会!)
以下图(图 10.14)试图以概念化的方式展示这个工作过程。顶部部分(1A 到 5A;按顺序阅读它们以跟随发生的过程)显示了“从系统调用返回”代码路径的机会点检查 TIF_NEED_RESCHED 位;底部部分(1B 到 3B)显示了“硬件中断(硬中断)返回路径的机会点”:
CPU 调度器入口点 – 总结
在这里,我们总结了前面关于设置和识别 TIF_NEED_RESCHED 位的讨论;实际上,我们准确地回答了何时进入 schedule() 或何时激活调度(重新查看图 10.9、图 10.13 和图 10.14 在阅读时可能会有所帮助);因此,schedule() 被调用(或核心调度器被激活):
-
当进程/线程发出任何显式的阻塞调用时:这很有意义。阻塞的 API 最终会调用
schedule(),因为调用者需要等待某些事件的发生(当事件发生时,内核(或将其置于睡眠状态的驱动程序)会唤醒它)。 -
来自定时器清理的用户模式抢占:如果在《定时器中断清理 – 设置 TIF_NEED_RESCHED》部分描述的三种条件中的任何一种为真;让我们快速重申它们(见图 10.13):
current是否超出了它的时间片(有效时间片)并超出了足够的阈值(值为min_granularity_ns)?- 新出生的或最近被唤醒的任务是否具有比当前任务更高的优先级(即当前正在执行内核代码路径的任务)?
- CFS rb-tree 中是否存在 vruntime 小于当前任务的任务(换句话说,当前任务是否不再是这个树中的最左叶节点)?
-
定时器(软中断)清理:检查是否需要设置
TIF_NEED_RESCHED位(此检查在定时器软中断上下文函数scheduler_tick()中执行;见图 10.13)。请注意,这里可以设置TIF_NEED_RESCHED位,但在这里永远不会调用schedule()。 -
所有所谓“机会点”总结:在这里,我们检查
TIF_NEED_RESCHED位是否已设置。以下是执行此检查的进程上下文中的“机会点”(见图 10.14):-
在系统调用返回路径上
-
在中断返回路径上
-
调度被激活的其他点(有些重复):
- 任何对
schedule()的调用 - 任何可能导致调用
schedule()的cond_resched*()调用 - (一般而言,任何从非抢占模式切换到抢占模式的内核操作)
- 任何对
-
-
任务唤醒:这种情况发生时,任务被添加到运行队列中。该任务是否必须抢占当前任务?如果是,设置
TIF_NEED_RESCHED,并在下一个机会点调用schedule():-
在可抢占内核中:在任何内核抢占点(实际上,任何调用
preempt_enable()的时候——例如,解锁自旋锁时):- 在任何系统调用返回时
- 在任何硬件中断返回时
-
在不可抢占内核中,在下一个:
- 从系统调用返回到用户空间
- 从中断返回到用户空间
- 任何
cond_resched*()调用 - 任何显式调用
schedule()时
-
在核心内核调度函数 kernel/sched/core.c:__schedule() 前的详细注释非常值得阅读;它们列出了所有可能的内核 CPU 调度器入口点(正如我们刚刚学到的那样)。对于 6.1 内核代码库,你可以在这里找到它们;请查看:elixir.bootlin.com/linux/v6.1.…
核心调度器代码简述
如前所述,请始终记住,调度器代码是由当前正在执行内核代码的进程(线程)运行的,实际上,它会通过上下文切换到另一个线程来将自己从 CPU 上移除!而这个线程就是 current。
__schedule() 函数包含(其中之一)两个局部变量,它们是指向 struct task_struct 的指针,分别命名为 prev 和 next。指针 prev 被设置为 rq->curr,它就是 current!而指针 next 将被设置为下一个要进行上下文切换并运行的任务!因此,你可以看到,current 运行了调度器代码,它将自己视为前一个任务,执行该模块调度类(即调度算法)中的 pick-next-task 操作,然后通过上下文切换到 next,将自己从处理器上移除!再次提醒,重新查看图 10.9,理解那里提到的代码路径——除了最后的框框中,“next” 线程运行在核心上——其实都是由 current 运行的!
以下是核心调度代码的一些片段(带有注释 << 如此 >> 重申我们讨论的要点):链接到代码片段。
static void __sched notrace __schedule(unsigned int sched_mode)
{
struct task_struct *prev, *next;
unsigned long *switch_count;
unsigned long prev_state;
struct rq_flags rf;
struct rq *rq;
int cpu;
cpu = smp_processor_id();
rq = cpu_rq(cpu);
prev = rq->curr; << this is current ! >>
[ ... ]
next = pick_next_task(rq, prev, &rf);
<< Here we 'pick' the task to run next in an 'object-oriented' manner, as discussed earlier in detail... >>
clear_tsk_need_resched(prev);
clear_preempt_need_resched();
[ ... ]
if (likely(prev != next)) { << switching to another thread's likely… >>
[ ... ]
/* Also unlocks the rq: */
rq = context_switch(rq, prev, next, &rf);
[ ... ]
}
上下文切换
为了结束本章,让我们快速了解一下(CPU 调度器的)上下文切换。上下文切换的任务(在 CPU/任务调度器的上下文中)是显而易见的:在简单地切换到下一个任务之前,操作系统必须保存当前执行任务(即 current)的状态。任务结构包含另一个嵌套的结构 struct thread_struct thread,用于存储/恢复线程的硬件上下文;在 x86 架构中,它通常是任务结构的最后一个成员。
在 Linux 中,一个内联函数 kernel/sched/core.c:context_switch() 执行上下文切换的任务;你现在应该明白了,它的代码由 current 执行,它被视为“前一个”任务。它执行从 prev 任务(来自 current)到下一个任务的切换,这个任务是这个调度轮次或抢占战斗的赢家。这个上下文切换基本上分为两个非常特定于架构的阶段:
- 内存(MM)切换:切换一个特定于架构的 CPU 寄存器,指向下一个任务的内存描述符结构(
struct mm_struct)。在 x86[_64] 中,这个寄存器叫做 CR3(控制寄存器 3);在 ARM(AArch32)中,它叫做 TTBR0(转换表基址寄存器 0)。为什么?因为在mm_struct中,内核可以“看到”进程的整个内存图,包括重要的指向分页表基址的指针;正确地设置这个指针会使 MMU 在进行地址转换时引用下一个任务的分页表。 - 实际的 CPU 切换:通过保存
prev的堆栈和 CPU 寄存器状态并恢复next的堆栈和 CPU 寄存器状态来实现从prev到next的切换;这在switch_to()宏中完成。这样,next就会从它上次停止的地方恢复执行...
上下文切换的更详细实现不在本章讨论范围内;请查看“进一步阅读”部分以获取更多资源。
最后,非常有趣的是:内核提供了一种将给定的处理器集合从干扰中隔离开来的方法——实际上,是将其从调度类和 SMP 负载平衡的影响中隔离!这可以通过指定内核参数 isolcpus=[flag-list,]<cpu-list> 来实现。
flag-list 默认为 domain;当指定时——并且默认——cpu-list 中指定的所有 CPU 核心会被隔离,不受“常规的 SMP 平衡和调度算法”的影响。(这对于一些实时应用程序非常有用。)
不过,值得注意的是:isolcpus= 内核参数现在被认为是已弃用的;取而代之的是,建议使用更强大、更灵活的 cpusets cgroups(v2)控制器。别担心——我们将在下一章详细讨论 cgroups。
总结
在本章中,您学习了多方面的内容,涉及 Linux 内核 CPU(或任务)调度器的多个领域。首先,您了解了实际的 KSE(内核空间执行实体)是线程而不是进程。我们还了解到,在像 Linux 这样的单体操作系统中,并不存在一个专门的“调度器”线程:调度是由一个进程上下文线程——即当前线程——执行的,它运行调度代码路径,并在完成后上下文切换到下一个任务(从而将自己移出处理器——当然,定时器中断软中断的家务处理代码路径在调度器相关的家务工作中也发挥了关键作用!)。
接着,我们了解了操作系统实现的可用调度策略。随后,您理解了为了支持多个 CPU,内核采用了一种非常强大的设计,使得每个 CPU 核心每个模块调度类都有一个对应的运行队列(再次提醒,cgroups 框架暗示了这一设计的更多内容,我们将在后续章节中学习)。我们还介绍了如何查询任何给定线程的调度策略和优先级,并深入了解了 CPU 调度器的内部实现。我们着重讨论了现代调度器如何利用模块化调度类设计,究竟是谁在运行实际的调度器代码(是 current!),以及何时运行,并简要介绍了核心调度代码和上下文切换的内部代码实现。
下一章将继续这段有趣的旅程,进一步深入探讨内核级 CPU 调度器的工作原理。我建议您先充分消化本章内容,完成相关问题和练习,然后再继续下一章的学习。非常棒!我们很快再安排下一次学习!
问题
在本章结束时,以下是一些问题,帮助您测试对本章内容的理解: 问题链接。您可以在本书的 GitHub 仓库中找到部分问题的答案:解答链接。
进一步阅读
为了帮助您深入了解该主题,我们在本书的 GitHub 仓库中提供了一份详细的在线参考资料和链接列表(有时甚至包括书籍),可以通过以下链接访问:进一步阅读。