Linux内核编程——CPU调度(下)

359 阅读35分钟

在本章中,我们将继续探讨Linux内核的CPU(或任务)调度器,这是我们关于这一主题的第二章。在上一章中,我们介绍了Linux操作系统中CPU调度器的几个关键方面(以及可视化)。包括Linux中什么是内核可调度实体(KSE)(它就是线程!)、Linux实现的POSIX调度策略、使用perf(和其他工具)查看线程/调度器流动,以及现代调度器的设计是如何基于模块化调度类的。我们还介绍了如何查询任何线程的调度策略和优先级(使用几个命令行工具),并深入探讨了操作系统任务调度器的内部工作原理。

在掌握了这些背景知识后,我们现在准备进一步探讨Linux上的CPU调度器;在本章中,我们将涵盖以下几个方面:

  • 理解、查询和设置CPU亲和性掩码
  • 查询和设置线程的调度策略和优先级
  • cgroups简介
  • 将Linux作为实时操作系统(RTOS)运行——简介
  • 其他与调度相关的主题

我们预计在阅读本章之前,你已经阅读过(或具备等同的知识)第10章《CPU调度器 - 第1部分》。

技术要求

我假设你已经阅读了完整的《内核工作区设置》章节,并且已经适当地准备了一台运行Ubuntu 22.04 LTS(或更新的稳定版本,或较新的Fedora发行版)的虚拟机(VM),并安装了所有必需的包。如果没有,我强烈建议你首先完成这一部分。

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

理解、查询和设置CPU亲和性掩码

任务结构(任务或线程的根数据结构,包含了几十个线程属性)有几个与调度直接相关的属性:优先级(包括nice值和实时(RT)优先级值)、调度类结构指针、线程所在的运行队列(如果有的话)等。(顺便提一下,我们在第6章《内核内部基础——进程与线程》中已经介绍了任务结构的一些通用细节。)

其中有一个重要的成员,CPU亲和性位掩码(实际的结构成员是 cpumask_t *cpus_ptr。顺便提一下,在5.3内核之前,这个成员名为 cpus_allowed;这个名字在以下提交中进行了更改:github.com/torvalds/li…)。这个位掩码就是一个线程可以在其上运行的CPU核心的位掩码。一个简单的可视化帮助理解;在一个有8个CPU核心的系统上,典型的CPU亲和性位掩码(概念上)可能如下所示:

7  6  5  4  3  2  1  0
← CPU 核心 #
0  0  1  1  1  1  1  1
← 亲和性位

在上述示例中,每个单元格代表一个CPU核心;顶部行表示CPU核心号,下面的行显示了一个示例值:底行的单元格可以设置为0或1,表示线程是否可以在对应的CPU核心上运行。因此,这里当CPU位掩码值为0x3f(二进制数字 0011 1111 转换为十六进制值)时,表示该线程可以在CPU核心0到5上调度,但不能在核心6和7上调度。

默认情况下,所有CPU亲和性掩码位都被设置;因此,默认情况下,线程可以在任何核心上运行;这是合乎逻辑的。例如,在一台有8个CPU核心的机器上(操作系统识别到的),每个活动线程的默认CPU亲和性位掩码为二进制1111 1111(十六进制0xff)。

由于CPU亲和性位掩码存储在任务结构中,这表明CPU亲和性位掩码是每个线程的一个属性;这也是合乎逻辑的——毕竟,Linux上的内核可调度实体(KSE)是线程。运行时,调度器决定线程实际在哪个核心上运行。实际上,考虑到这一点,它是隐含的:根据设计,每个CPU核心都有一个与之关联的运行队列。每个可运行的线程都将在某个CPU的运行队列中;因此,它有资格运行,并且默认情况下,会在其运行队列所代表的CPU上运行。当然,调度器有一个负载均衡组件,可以根据需要将线程迁移到其他CPU核心(实际上是迁移到其他运行队列)上(内核线程叫做迁移/辅助线程,在需要时会帮助完成这项工作,其中n是核心编号)。

内核确实向用户空间暴露了API(当然是通过系统调用,如 sched_{s,g}etaffinity(2) 以及它们的pthread包装库API),允许应用程序根据需要将线程(或多个线程)与特定的CPU核心关联。按照同样的逻辑,我们也可以在内核中为任何给定的内核线程执行相同的操作。例如,将CPU亲和性掩码设置为二进制1000 0001(十六进制值0x81)意味着线程只能在CPU核心7和0上执行(记住,核心编号从0开始)。

一个关键点是:尽管你可以操作给定线程的CPU亲和性掩码,但建议避免这样做;因为内核调度器子系统详细理解CPU拓扑(或域),并能够最好地进行负载均衡。

尽管如此,显式地设置线程的CPU亲和性掩码在以下情况下是有益的:

  • 通过确保线程始终在同一个CPU核心上运行,可以大大减少频繁的缓存失效(因此减少不愉快的缓存“跳跃”)。更多关于CPU缓存的内容,可以参见第13章《内核同步 - 第2部分》。
  • 可以有效消除线程在核心之间迁移的成本。
  • 实现CPU预留——通过保证其他线程无法在该核心上执行,专门将核心分配给某个线程。

前两个情况在一些特殊场景中有用;第三种情况,即CPU预留,通常是实时系统中使用的一种技术,特别是那些时间关键的系统,其中执行此操作的成本是可以接受的(顺便提一下,这是通过 isolcpus= 内核参数实现的;如今,它被认为是已弃用的,建议使用 cpusets cgroup控制器来代替)。

现在你了解了背后的理论,我们来编写一个用户空间的C程序,用于查询和/或设置任何给定线程的CPU亲和性掩码。

查询和设置线程的CPU亲和性掩码

作为示范,我们提供了一个小的用户空间C程序,用于查询和设置用户空间进程(实际上是线程)的CPU亲和性掩码。查询CPU亲和性掩码通过调用 sched_getaffinity() 系统调用实现,设置它则使用其对应的 sched_setaffinity() 系统调用:

#define _GNU_SOURCE
#include <sched.h>

int sched_getaffinity(pid_t pid, size_t cpusetsize, cpu_set_t *mask);
int sched_setaffinity(pid_t pid, size_t cpusetsize, const cpu_set_t *mask);

cpu_set_t 是一个专用的数据类型,用于表示CPU亲和性位掩码(第三个参数)。它非常复杂:其大小是根据系统上看到的CPU核心数动态分配的。这个CPU掩码(类型为 cpu_set_t)必须首先被初始化为零;CPU_ZERO() 宏可以实现这一点(还有几个类似的辅助宏;可以参考 CPU_SET(3) 的手册页)。前面提到的两个系统调用中的第二个参数是CPU集合的大小(我们可以使用 sizeof 运算符来获取它)。第一个参数是你想要查询或设置CPU亲和性掩码的进程或线程的进程ID(PID)。

为了更好地理解这一点,下面展示了我们代码的示例运行(可以在GitHub上找到,链接如下:github.com/PacktPublis…)。在这里,我们将在一个具有12个CPU核心的本地Linux系统上运行该代码:

image.png

在这里,我们以不带参数的方式运行了该应用程序。在这种模式下,它查询自己的CPU亲和性掩码(即查询调用 userspc_cpuaffinity 进程的CPU亲和性掩码)。我们打印出位掩码的各个位:如您在上面的截图(图11.1)中清楚地看到的,它是二进制 1111 1111 1111(相当于十六进制的0xfff),意味着默认情况下,该进程可以在系统上任何可用的12个CPU核心上运行!

该应用程序通过运行 nproc 工具,利用 popen() 库API内部检测可用的CPU核心数。不过,请注意,nproc 返回的是调用进程可用的CPU核心数;它可能少于实际的(在线和离线)CPU核心数,尽管通常是相同的。可用核心的数量可以通过几种方式进行更改,正确的方法是通过cgroup cpuset资源控制器(我们将在本章后面介绍控制组(cgroups)的相关信息)。

查询代码如下(源文件是 ch11/cpu_affinity/userspc_cpuaffinity.c):

static int query_cpu_affinity(pid_t pid)
{
    cpu_set_t cpumask;
    CPU_ZERO(&cpumask);
    if (sched_getaffinity(pid, sizeof(cpu_set_t), &cpumask) < 0) {
        perror("sched_getaffinity() failed");
        return -1;
    }
    disp_cpumask(pid, &cpumask, numcores);
    return 0;
}

我们的 disp_cpumask() 函数用于显示位掩码(我们留给你自己去查看它的实现)。

如果给该程序传递了额外的参数——第一个参数是进程(或线程)的PID,第二个参数是CPU位掩码(以十六进制表示)——我们将尝试将该进程(或线程)的CPU亲和性掩码设置为传递的值。当然,修改CPU亲和性位掩码要求你拥有该进程或具备root权限(更准确地说,需要具备 CAP_SYS_NICE 能力)。

以下是一个简单的演示:在图11.2中,nproc 显示了CPU核心数(12);然后,我们运行应用程序来查询并设置我们(bash)shell进程的CPU亲和性掩码。在一台具有12个核心的笔记本上,假设bash的亲和性掩码开始时是0xfff(即二进制 1111 1111 1111),正如预期的那样;这里,我们将其更改为0xdae(二进制 1101 1010 1110),并再次查询以验证更改:

image.png

好的,这很有趣。首先,应用程序正确地检测到它可以使用的CPU核心数是12。然后,它查询了bash进程的(默认)CPU亲和性掩码(我们将其PID作为第一个参数传递);它显示为0xfff,正如预期的那样。接着,由于我们还传递了第二个参数——要设置的位掩码(0xdae)——它成功地将bash的CPU亲和性掩码设置为0xdae。现在,由于我们所在的终端窗口实际上就是这个bash进程,再次运行 nproc 显示值为8,而不是12!这确实是正确的:bash进程现在只可以使用8个CPU核心。(这是因为我们没有在退出时将CPU亲和性掩码恢复为原始值。)

以下是设置CPU亲和性掩码的相关代码:

// ch11/cpu_affinity/userspc_cpuaffinity.c
static int set_cpu_affinity(pid_t pid, unsigned long bitmask)
{
    cpu_set_t cpumask;
    int i;
    printf("\nSetting CPU affinity mask for PID %d now...\n", pid);
    CPU_ZERO(&cpumask);
    /* 遍历给定的位掩码,根据需要设置CPU位 */
    for (i=0; i<sizeof(unsigned long)*8; i++) {
        /* printf("bit %d: %d\n", i, (bitmask >> i) & 1); */
        if ((bitmask >> i) & 1)
            CPU_SET(i, &cpumask);
    }
    if (sched_setaffinity(pid, sizeof(cpu_set_t), &cpumask) < 0) {
        perror("sched_setaffinity() failed");
        return -1;
    }
    disp_cpumask(pid, &cpumask, numcores);
    return 0;
}

在上述代码片段中,你可以看到我们首先适当地设置了 cpu_set_t 位掩码(通过遍历每个位;如你所知,表达式 (bitmask >> i) & 1 用来测试第i位是否为1),然后使用 sched_setaffinity() 系统调用来为给定的PID设置新的CPU亲和性掩码。

需要注意的是,虽然任何人都可以查询任何任务的CPU亲和性掩码,但除非你拥有该任务、具有root权限或具有 CAP_SYS_NICE 能力,否则不能修改它。

使用taskset执行CPU亲和性

类似于我们在前一章中使用便捷的用户空间工具程序 chrt 来获取(或设置)进程(或线程)的调度策略和/或优先级,您可以使用用户空间的 taskset 工具来获取和/或设置给定进程(或线程)的CPU亲和性掩码。以下是几个快速示例;请注意,这些示例是在具有6个CPU核心的x86_64 Linux虚拟机上运行的:

使用 taskset 查询systemd的CPU亲和性掩码(PID 1):

$ taskset -p 1
pid 1's current affinity mask: 3f 
$

仔细想想:0x3f 是二进制 0011 1111,它表示进程/线程(这里是systemd)启用了所有6个CPU核心。

现在,作为一个示例,我们使用 taskset 来运行编译器,确保GCC及其后继进程(汇编器和链接器进程)仅在前两个CPU核心上运行;taskset 的第一个参数是CPU亲和性位掩码(03是二进制 0011):

$ taskset 03 gcc userspc_cpuaffinity.c -o userspc_cpuaffinity -Wall -O3
Done. 

请查阅 taskset(1) 手册页以获取完整的使用详情。(顺便提一下,正如上一章所提到的,schedtool(8) 工具也可以用来获取/设置给定线程/进程的CPU亲和性掩码。)

设置内核线程的CPU亲和性掩码

作为一个有趣的示例,如果我们想演示一种名为“每CPU变量”的同步技术(我们将在第13章《内核同步 - 第2部分》中学习并实现,在“每CPU – 一个示例内核模块”部分中),我们需要创建两个内核线程(kthreads),并确保它们分别在不同的CPU核心上运行。为此,我们当然需要显式地将每个内核线程的CPU亲和性掩码设置为不同且不重叠的值(为了简单起见,我们将第一个kthread的亲和性掩码设置为0,第二个kthread的掩码设置为1,以保证它们分别仅在CPU核心0和1上执行)。但有一个问题……接下来的部分将解释这个问题。

破解非导出符号的可用性

问题是,当前在模块中设置CPU亲和性掩码的操作不再是一个干净的任务——说实话,这有点像黑客行为;我们在这里展示它,但绝对不推荐在生产环境中使用。

原因是,我们需要的内核API来设置CPU亲和性位掩码——sched_setaffinity()——虽然存在,但并没有导出。正如我们从前几章关于编写模块的内容中学到的那样,外部内核模块(如我们这类模块)只能使用导出的函数(和数据)。那么,我们该怎么办呢?

模块开发人员多年来使用的“常规”做法(实际上,我在本书第一版中也使用过!)是使用便利例程 kallsyms_lookup_name() 查找内核中的任何给定符号,并获取它的(内核虚拟)地址。

有了这个,任何合格的C程序员都可以将地址当作函数指针,随意调用它,从而有效地克服了只有导出函数才能从外部模块调用的限制!(一个巧妙的黑客手段!不过,经验丰富的内核开发者肯定会对此感到不满。)

确实如此,但从5.7版本开始,社区决定停止这种(愚蠢的)滥用,并直接将 kallsyms_lookup_name()(以及类似的 kallsyms_on_each_symbol())函数取消导出!(简短的提交ID是 0bd476e6c671,可以看看)。那么,现在怎么办呢?好吧,我们总是可以通过 /proc/kallsyms 虚拟文件来查找任何内核符号,只要我们有root访问权限(这是安全措施)。此外,启用了内核地址空间布局随机化(KASLR)(现代内核通常会启用此功能)后,该值在每次启动时都会改变,因此无法硬编码(这对安全也有好处)。因此,我们编写了一个小的包装脚本来实现这一操作(它在这里:ch13/3_lockfree/percpu/run;是的,这段代码来自第13章《内核同步 - 第2部分》),并将通过 /proc/kallsyms 查找到的 sched_setaffinity() 例程的地址传递给模块作为参数(ch13/3_lockfree/percpu/percpu_var.c),然后模块将其视为函数指针并成功调用它。呼!

下面是 sched_setaffinity() 函数的签名:

long sched_setaffinity(pid_t pid, const struct cpumask *new_mask);

相关代码的简短片段(在其中我们通过模块参数 func_ptr 传递的 sched_setaffinity() 函数指针来设置CPU亲和性掩码)如下:

// ch13/3_lockfree/percpu/percpu_var.c
[ … ]
static unsigned long func_ptr;
module_param(func_ptr, ulong, 0);
unsigned long (*schedsa_ptr)(pid_t, const struct cpumask *) = NULL;
[ … ]
// 设置函数指针
schedsa_ptr = (unsigned long (*)(pid_t pid, const struct cpumask *in_mask))func_ptr;
[ … ]
/* pr_info("setting cpu mask to cpu #%u now...\n", cpu); */
cpumask_clear(&mask);
cpumask_set_cpu(cpu, &mask); // 1st param is the CPU number, not bitmask
/* !HACK! sched_setaffinity() is NOT exported, we can't call it
 * sched_setaffinity(0, &mask);  // 0 => on self 
 * 所以我们通过它的函数指针调用它
 */
ret = (*schedsa_ptr)(0, &mask);  // 0 => on self
[ … ]

虽然这种方法不常规且具有争议,但它确实有效,但请避免在生产环境中使用此类黑客行为。

现在你知道如何获取/设置(内核)线程的CPU亲和性掩码了,让我们进入下一个逻辑步骤:如何编程地获取/设置线程的调度策略和优先级!下一节将深入探讨细节。

查询和设置线程的调度策略与优先级

在第10章《CPU调度器 - 第1部分》的“线程优先级”一节中,您学习了如何通过 chrt 工具查询任何给定线程的调度策略和优先级(我们还演示了一个简单的Bash脚本来完成这项操作)。我们提到,chrt 工具内部调用了 sched_getattr() 系统调用来查询这些属性。

同样,设置调度策略和优先级也可以通过使用 chrt 工具来完成(例如,在脚本中方便地执行),或者通过在用户空间的C应用程序中使用 sched_setattr() 系统调用来编程地实现。此外,内核还暴露了其他API:sched_{g,s}etscheduler() 及其 pthread 库的包装API pthread_{g,s}etschedparam()(由于这些都是用户空间API,建议您查看它们的手册页以了解详细信息并亲自尝试)。

在内核中设置调度策略与优先级——针对内核线程

正如您现在所知道的,内核绝对不是一个进程或线程。尽管如此,Linux内核确实具备多线程能力,并且包含线程,所谓的内核线程(或kthreads)。像它们的用户空间对应物一样,内核线程可以根据需要创建(通过内核核心、设备驱动程序或内核模块;内核为此暴露了API)。

它们是可调度实体(KSE!),当然,每个内核线程都有一个任务结构和一个内核模式堆栈;因此,与普通线程一样,它们也会竞争CPU资源,并且它们的调度策略和优先级可以根据需要编程查询或设置。

如果您想了解更多关于内核线程的信息并使用它们,建议您参考《Linux内核编程 - 第2部分》的(免费电子书!)伴随指南,特别是第5章《使用内核定时器、线程和工作队列》。

关于内核线程,如何解读它们(相当奇怪的)名称(例如,[kworker/u12:2])呢?这个链接提供了答案:unix.stackexchange.com/a/152865

那么,回到正题:在用户空间,查询和设置线程调度属性的现代首选系统调用分别是 sched_getattr()sched_setattr()。在早期,这通常是通过 sched_{g|s}et_scheduler() 系列系统调用实现的。现在,sched_{g|s}etattr() 系统调用接收一个指向 struct sched_attr 的指针,该结构包含所有可能需要的详细信息;可以参考手册页(man7.org/linux/man-p…)。

因此,按照现代的方式,我们本应使用这些系统调用的内核实现,在内核中执行类似的操作。但事情并非如此;内核社区认为,旧的设计——允许用户(应用程序)和模块开发人员愉快地调用这些API并使用如 SCHED_FIFO 的策略,且随意选择任何(实时)优先级——本质上是有缺陷的。

为什么呢?因为我们很容易遇到这种情况:两个或多个 SCHED_FIFO 线程具有相同的优先级,或者使用“随机”优先级值——这些值没有经过仔细考虑——被选择。这些情况可能导致CPU调度的混乱,从而影响资源管理。因此,从5.9内核开始,做出了如下的更改(请允许我直接引用提交信息,因为这确实是传达信息的最佳方式);以下是提交的部分内容,来自:github.com/torvalds/li…

因此,暴露优先级字段没有意义;
内核根本无法设置合理的值,它需要系统知识,而这些知识它并不具备。
从模块中移除 sched_setscheduler() / sched_setattr(),
并将其替换为:
  - sched_set_fifo(p); 创建一个FIFO任务(优先级为50)
  - sched_set_fifo_low(p); 创建一个高于NORMAL的任务,
        最终成为一个优先级为1的FIFO任务。
  - sched_set_normal(p, nice); (重新)设置任务为正常任务。
这避免了随机选择的、不相关的FIFO优先级,它们实际上并没有任何意义。
系统管理员/集成者,或者任何对实际系统设计和需求有见解的人(用户空间),可以在需要时设置适当的优先级……

所以,现在亲爱的模块开发者们,我们应该在内核中设置(SCHED_)FIFO任务(线程)时,使用这些API——sched_set_fifo()sched_set_fifo_low()sched_set_normal()。正如上述提交所提到的,我们信任管理员和/或用户空间开发人员来编程用户应用程序,并根据需要为它们提供正确和有意义的实时优先级值;内核(或模块)不应该知道或质疑这些决定——它只是执行这些决定(这再次是提供机制,而不是政策设计指导的一个例子)。

前两个API:

  • 是内核中 sched_setscheduler_nocheck() 函数的封装
  • 将线程的调度策略设置为 SCHED_FIFO
  • 将线程的(实时)优先级分别设置为 MAX_RT_PRIO/2(即50)和1

_sched_set_normal()

  • sched_setattr_nocheck() 的封装
  • 将线程的调度策略设置为 SCHED_NORMAL(与 SCHED_OTHER 相同,意味着非实时,完全公平调度器(CFS)驱动)
  • 将线程的nice值设置为第二个参数

在这里,*_nocheck() 表示内核根本不检查执行这些API的进程上下文是否具有足够的权限;它会直接执行。(请查看此评论:elixir.bootlin.com/linux/v6.1.…

此外,这三个API是GNU公共许可证(GPL)导出的,意味着它们只能由符合GNU GPL许可证的模块使用。

真实世界示例 —— 线程化的中断处理程序

内核使用内核线程的一个例子是,当内核(非常常见地)使用线程化中断时(工作队列是另一个例子)。在这种情况下,内核必须创建一个专用的内核线程,使用 SCHED_FIFO(软)实时调度策略和优先级值为50(位于中间),以正确处理所谓的线程化中断。让我们看看相关的代码路径:elixir.bootlin.com/linux/v6.1.…:

static int
setup_irq_thread(struct irqaction *new, unsigned int irq, bool secondary)
{
        struct task_struct *t;
        if (!secondary) {
                t = kthread_create(irq_thread, new, "irq/%d-%s", irq,
                                   new->name);
        } else {
                t = kthread_create(irq_thread, new, "irq/%d-s-%s", irq,
                                   new->name);
        }
        [ … ]
}

kthread_create() 宏负责创建内核线程(kthread)。现在,irq_thread() 内核专用API(通过 kthread_create() 宏作为线程函数调用)将在其代码路径的一部分中,适当地设置调度策略和优先级:elixir.bootlin.com/linux/v6.1.…:

/* Interrupt handler thread */
static int irq_thread(void *data)
{
        struct callback_head on_exit_work;
        struct irqaction *action = data;
        [ ... ]
        sched_set_fifo(current);
        [ ... ]
}

看到了吗?注意 sched_set_fifo() 的调用;正如我们所看到的,它将调用线程(由 current 引用)设置为使用 SCHED_FIFO 策略和(实时)优先级50。完成。

那么,为什么中断(IRQ)线程使用 SCHED_FIFO 和优先级50呢?事实上,为什么要使用线程化的IRQ处理程序呢?(顺便提一下,现在,绝大多数驱动程序使用线程化的处理程序已经成为常态。)您可以在本书的伴随卷《Linux内核编程 - 第2部分》(免费电子书!)的第4章《处理中断》部分了解所有相关内容。

好吧,现在您已经充分理解了操作系统级别的CPU(或任务)调度如何工作,我们将进入另一个非常引人入胜的讨论——cgroups;继续阅读!

cgroups简介

在过去的某个模糊时期,内核社区在处理一个令人头痛的问题时曾费尽心思:尽管调度算法及其实现——早期的2.6.0 O(1)调度器,以及稍后(在2.6.23版本中)引入的完全公平调度器(CFS)——承诺提供“完全公平”的调度,但在任何有意义的层面上,它实际上并不是“完全公平”!

想一想这个问题:假设你和其他九个人一起登录到一台Linux服务器上。在其他条件相同的情况下,处理器时间很可能会(或多或少)在你们十个人之间公平地分配;当然,你会理解,实际上并不是人类在处理器上运行并占用内存,而是进程和线程代表人类在运行。

现在,至少可以假设这些资源是(大致上)公平共享的。但是,假设你是登录的十个用户之一,写了一个用户空间程序,这个程序在一个循环中无差别地生成几个新的线程,每个线程都执行大量的CPU密集型工作(并可能作为附加功能,分配大量内存)!此时,CPU带宽分配(即使通过CFS)就不再是公平的;你的账户实际上会霸占CPU资源(甚至可能占用其他系统资源,如内存和I/O)!

因此,需要一种通用的解决方案,能够精确有效地管理CPU(以及其他资源)带宽,限制(检查,不允许)在达到指定限制时更多资源的消耗。许多提议的补丁进行了讨论,但最终,来自Google、IBM等公司的工程师们提供了一个补丁集,将现代的控制组(cgroups)解决方案引入Linux内核(在2.6.24版本中,2007年10月。最初的构思和实现由Google的Paul Menage和Rohit Seth于2006年提出)。简而言之,cgroups是一个内核特性,允许系统管理员(或任何具有root访问权限的人)能够优雅地对系统上的各种资源或控制器(在cgroup术语中称为“控制器”)执行带宽分配和精细粒度的资源管理。需要注意的是:通过cgroups,不仅仅是处理器(CPU带宽),还包括内存、块I/O带宽(以及更多资源)可以根据项目或产品的要求被仔细分配、监控和调度。

以我们开始时提到的例子——Linux系统上的十个用户——为例,如果所有进程都放置在同一个cgroup中,并且启用了cgroup的CPU控制器,那么当面临CPU争用时,它将确保每个进程公平地共享CPU!或者,作为系统管理员,你可以做更复杂的操作:你可以将系统分割成多个cgroup——一个用于构建项目(例如,Yocto构建),一个用于Web浏览器,一个用于虚拟机,等等——然后根据需要为每个cgroup精细调整和分配资源(CPU、内存和I/O)!事实上,几乎所有现代发行版都会自动这样做,得益于强大的systemd框架(稍后会介绍);嵌入式Linux(包括Android)通常也是这样做的。

现在,你肯定开始感兴趣了!如何启用这个cgroups特性?很简单——这是一个内核特性,你可以像往常一样通过配置内核来启用(或禁用),并且可以非常精细地设置。相关的菜单(通过方便的 make menuconfig 用户界面)是“General setup | Control Group support”。试试这个命令:在你的内核配置文件中使用 grep CGROUP;然后根据需要调整你的内核配置,重新构建,重启并测试。(我们在第2章《从源代码构建6.x版本Linux内核 - 第1部分》和第3章《从源代码构建6.x版本Linux内核 - 第2部分》中详细讲解了内核配置和构建安装。)

好消息是:在任何运行systemd初始化框架的Linux系统上,cgroups默认是启用的。如前所述,你可以通过在内核配置文件中使用 grep 查询启用的cgroup控制器,并根据需要修改配置;在桌面和服务器级别的系统上,通常不需要进行额外的操作。

从2.6.24版本开始,cgroups与其他内核特性一样,持续发展。最近,cgroup特性得到显著改进,并且不再与旧版本兼容,最终导致了一个新的cgroup设计和发布,称为cgroups v2(或简称cgroups2,由Tejun Heo负责维护);在4.5版本的内核中,这一版本被宣布为生产就绪版本(而旧版本现在被称为cgroups v1或传统的cgroups实现)。请注意,截至本文撰写时,cgroups v1和v2仍然可以共存,但存在一些限制;许多应用程序和框架仍在使用旧的cgroups v1,尚未迁移到v2。然而,这种情况正在改变;不久后,cgroups2将成为默认使用的版本,因此计划使用它。

在本部分内容中,我们将几乎完全聚焦于使用现代版本的cgroups,即cgroups v2。最佳的文档是官方的内核文档,链接如下(适用于6.1内核):www.kernel.org/doc/html/v6…。 (顺便提一下,最新内核版本的文档也可以在这里找到:docs.kernel.org/admin-guide…

关于为何使用cgroups v2而非cgroups v1的详细理由,可以在内核文档中找到,链接如下:www.kernel.org/doc/html/la…

Cgroup 控制器

cgroup 控制器是负责在 cgroup 层次结构中分配给定资源(如 CPU 周期、内存和 I/O 带宽等)的内核组件:即一个 cgroup 及其后代。你可以将其视为给定 cgroup 层次结构的“资源限制器”。

cgroups(7) 手册页中,详细描述了接口和各种可用的(资源)控制器(有时也称为子系统)。通常可用的 cgroups v2 控制器如下所示(表 11.1 显示了 cgroups v2 的内容;许多控制器的原始 cgroups v1 实现可以追溯到 2.6.24 版本):

Cgroups v2 控制器名称启用时控制(或约束、调节)的内容起始内核版本
cpuCPU 带宽(周期)4.15
cpusetCPU 亲和性和内存节点分配(特别适用于大规模 NUMA 系统)5.0
memory内存(RAM)使用4.5
ioI/O 资源分配4.5
pidscgroup 中进程的硬限制数量4.5
devices设备文件的创建和访问(仅通过 cgroup BPF 程序)4.15
rdma远程直接内存访问(RDMA)资源的分配和计费4.11
hugetlb限制每个 cgroup 的 HugeTLB(大页面)使用5.6
misc各种资源;详见 内核文档5.13

表 11.1:现代 Linux 系统上可用的 cgroups v2 控制器总结

我们建议感兴趣的读者查阅官方内核文档和手册页了解详细信息;例如,PIDS 控制器在防止 Fork Bomb 攻击时非常有用,它允许你限制从该 cgroup 或其后代创建的进程数目。(Fork Bomb 是一种愚蠢但仍然致命的 DoS 攻击,通常是在无限循环中调用 fork() 系统调用!)

接下来,非常重要的是,内核中的 cgroups 如何被暴露给(与)用户空间?啊,按照 Linux 上的常规方式:控制组通过一个专用的合成或伪文件系统暴露!它就是 cgroup 文件系统,通常挂载在 /sys/fs/cgroup。在 cgroups v2 中,文件系统类型现在称为 cgroup2(你可以通过运行 mount | grep cgroup 来查看)。其中有很多有趣的内容可以探索;这也是我们在接下来的工作中会进行的……

让我们从这个问题开始:如何查看哪些控制器已启用在我的系统上(实际上是内核)?很简单:

$ cat /sys/fs/cgroup/cgroup.controllers
cpuset cpu io memory hugetlb pids rdma misc

显然,它显示了一个以空格分隔的可用控制器列表(我在我的 x86_64 Fedora 38 虚拟机上运行了这个命令)。另外,请注意,使用 /proc/cgroups 来查看控制器只兼容 cgroups v1;不要依赖它来查看 cgroups v2。你看到的具体控制器取决于内核的配置。

在 cgroups v2 中,所有控制器都挂载在一个单一的层次结构(或树)中。这与 cgroups v1 不同,后者可以在多个层次结构或组下挂载多个控制器。现代的初始化框架 systemd 是 v1 和 v2 cgroups 的用户。事实上,正是 systemd 在启动时自动挂载了 cgroups v2 文件系统(位于 /sys/fs/cgroup/)。

探索 cgroups v2 层次结构

查看 cgroups (v2) 伪文件系统挂载点——默认情况下是 /sys/fs/cgroup——你可能会惊讶于其中所有的伪文件(和文件夹)(请看图 11.3)。本节将探索它的许多有趣和实用的角落和细节!

首先,我们确认一下 cgroups v2 层次结构挂载的位置:

$ mount | grep cgroup2
cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot)

显然,它正如我们预期的那样挂载在 /sys/fs/cgroup。 (对括号中列出的各种挂载选项感到好奇吗?它们在这里有文档说明:www.kernel.org/doc/html/v6…

如果你正在运行较旧的发行版(例如 Ubuntu 18.04 或类似版本,正如本书第一版中所做的),可能会发现 cgroup2 中没有控制器。这是由于混合使用了 cgroups v1 和 v2 的情况。为了仅使用后者版本(如我们预期的那样),并确保所有已配置的控制器都可见,你必须首先通过在启动时传递这个内核命令行参数来禁用 cgroups v1:cgroup_no_v1=all(回想一下,所有可用的内核参数都可以方便地在这里查看:www.kernel.org/doc/html/la…)。然后重启并重新检查。如果你使用的是较新的发行版(如 Ubuntu 22.04 或 Fedora 38),则不需要这样做。

现在,让我们开始探索它!

在这个例子中,我正在一台 x86_64 Fedora 38 虚拟机上工作,在其中构建并启动了一个定制的 6.1.25 内核。让我们先看看整体情况:

image.png

在根 cgroup 位置 /sys/fs/cgroup 下,你可以看到几个文件和文件夹(不用说,这些都是易失性的伪文件对象;它们通过 sysfs 挂载在内存中)。首先:

  • 看到的“常规”文件——如 cgroup.controllerscpu.pressure 等——是 cgroup2 接口文件。
  • 这些文件进一步细分为核心接口和控制器接口;所有以 cgroup.* 开头的文件是核心接口文件,以 cpu.* 开头的文件是 CPU 控制器的接口文件,以 memory.* 开头的文件是内存控制器的接口文件,依此类推。
  • 看到的文件夹代表——终于!——控制组或 cgroups!在许多文件夹中,你会发现并非所有的都受到约束。你可能会想知道是谁创建了它们;简短的回答是(至少对于默认存在的那些)是 systemd;稍后会详细讲解这一点。

启用或禁用控制器

让我们查看一个关键的核心接口文件,cgroup.controllers。上一节简要提到了这一点。其内容是可用于 cgroup 的控制器列表;对于根 cgroup,它列出了内核配置时所启用的控制器。如前所见,对于现代发行版,默认通常包括以下控制器:cpusetcpuiomemoryhugetlbpidsrdmamisc

这里需要注意的是:一个控制器出现在这个列表中,并不意味着它在 cgroup 层次结构中被启用;事实上,默认情况下没有控制器被启用!启用一个控制器意味着它对目标资源分配的约束将会在其直接子节点上生效。要启用控制器,你需要将字符串 +<controller-name> 写入 cgroup.subtree_control 伪文件中(相反,写 -<controller-name> 来禁用它)。例如,要在当前 cgroup 中启用 CPU 和 I/O 控制器,但禁用内存控制器(并使其对其后代生效,应用于其下的层次结构),可以执行以下命令(以 root 权限):

echo "+cpu +io -memory" > cgroup.subtree_control

现在,我们知道 cgroup.subtree_control 文件返回一个以空格分隔的控制器列表,表示哪些控制器被启用以控制从该 cgroup 到其子节点的资源分配。

内核文档中这样描述:docs.kernel.org/admin-guide…:

自上而下的约束 资源是自上而下分配的,只有当父节点分配给它资源时,cgroup 才能进一步分配资源。这意味着所有非根节点的“cgroup.subtree_control”文件只能包含在父节点的“cgroup.subtree_control”文件中已启用的控制器。只有父节点启用了控制器,子节点才能启用该控制器,而且如果一个或多个子节点启用了控制器,父节点就不能禁用它。

花点时间消化这段话。接下来,介绍几个有用的核心 cgroup 接口文件——这些文件仅存在于 cgroup 文件夹(层次结构)中——值得注意的如下:

  • cgroup.events:只读;可能包含以下值:

    • populated:0 或 1。如果是 1,表示该 cgroup 或其后代包含活动进程,否则是 0。因此,只有当 populated 的值为 1 时,cgroup 才值得进一步研究!否则,它是一个空的或未填充的 cgroup(我们稍后会看到的 cgroups v2 “explorer” 脚本会显示这一点)。
    • frozen:0 或 1。如果是 1,表示该 cgroup 被冻结,否则是 0(冻结一个 cgroup 类似于将它的所有进程——以及所有子 cgroup 和它们的进程——都放入“冰柜”,意味着它们将保持停止状态,直到解冻)。

在使用 systemd 作为初始化管理器的系统上(如今这是典型的,我们也假设如此),将始终有一个名为 init.scope 的 cgroup(scope?不用担心,我们稍后会介绍这一点)。它仅包含 init 进程 PID 1(systemd)。让我们查看这个 init cgroup 的 cgroup.events 文件:

$ cat /sys/fs/cgroup/init.scope/cgroup.events
populated 1
frozen 0

由于它是已填充的,因此可以进一步深入研究它。接下来,在 cgroup 层次结构中,你还会发现这个 cgroup 接口文件:

  • cgroup.kill:写入专用;在这里写入 1 会导致该 cgroup 树及其所有后代进程死亡——目标 cgroup 树中的进程将接收到 SIGKILL 信号。

在接下来的章节中,还会介绍一些其他的 cgroup 文件。再次提醒,所有核心接口都可以在这里找到文档:www.kernel.org/doc/html/v6…

内核文档关于 cgroups v2

到目前为止,我们讨论的是 cgroups 的基础知识;正如你所理解的,详细讲解 cgroups 的所有内容实在太多,无法在本章和本书中完全涵盖;更重要的是,本书的目的是解释概念并展示示例,帮助你学习。简单重复内核文档中的细节是毫无意义的;至于 cgroups v2、资源模型、接口文件(包括核心部分以及每个控制器的部分)、如何使用它们的规则、命名空间管理等内容,都在官方的 6.1 系列内核文档中进行了详细讲解:www.kernel.org/doc/html/v6…。对于最新的内核版本,文档也可以在这里找到:docs.kernel.org/admin-guide…

因此,学习如何高效地查阅内核文档是非常重要的(我们在《在线章节,内核工作区设置》中简要讨论过这一点,具体在“定位和使用 Linux 内核文档”一节)。一张截图(希望能帮助你记住这些细节)提醒你,这些内容已经详细记录在内核文档中,等待你在需要时参考:

image.png

尽管有些重复,但我还是强烈建议你在使用 cgroups v2 时,浏览相关的文档。

现在,让我们继续前进,深入探索!

层次结构中的 cgroups

回到 cgroups 树(或层次结构)的根部(见图 11.3)中的 cgroups。如前所述,那里看到的文件夹代表 cgroups。现在,我们先不讨论它们是谁创建的(以及如何创建);可以说,它们是在启动时通过 systemd 设置的。

我们从一个简单的 cgroup 开始,代表文件夹 init.scope。查看其中,我们发现内核预先填充了许多(伪)文件和接口;这是一个简化的视图:

$ cd /sys/fs/cgroup/
$ ls init.scope/
cgroup.controllers cgroup.threads io.latency    memory.high memory.swap.current
cgroup.events      cgroup.type    io.max        memory.low  memory.swap.events
cgroup.freeze      cpu.idle       io.pressure   memory.max  memory.swap.high
cgroup.kill        cpu.max        io.prio.class memory.min  memory.swap.max
[ … ] 
cgroup.subtree_control           io.bfq.weight memory.events.local  memory.stat       pids.peak
$ 

并非所有内容都很陌生;我们刚刚讨论过几个关键的核心接口文件(在前一节中)——cgroup.controllerscgroup.subtree_control 文件——以及它们的含义。

现在,让我们调查这里一些更有趣的文件:

$ cat init.scope/cgroup.procs 
1

在这里,cgroup.procs 显示了属于该 cgroup 的进程的 PID 列表。这是有道理的——systemd 设置了一个 init.scope cgroup,其中只包含 init 进程,PID 为 1——实际上,它就是 systemd。(类似地,<cgroup-name>/cgroup.threads 伪文件保存了所有属于该 cgroup 的线程的 PID。)

要将进程迁移到给定的 cgroup,只需将其 PID 写入目标 cgroup 的 cgroup.procs 文件即可。写入者需要具有适当的权限;当然,root 权限可以使用,但即使是非 root 用户,只要权限匹配,也可以进行写入。(将进程迁移到另一个 cgroup 类似于剪切粘贴操作;它会从源 cgroup 中隐式移除。不过,这些变化可能需要一些时间才能传播。)

如果你忘记了这个接口文件表示什么,或者如何准确操作它?很简单:查阅内核文档。相关条目当然可以在这里找到:www.kernel.org/doc/html/v6…。(上一节正是讲解这个内容。)

接下来,让我们查看一些与 init.scope cgroup 中的 CPU 控制器相关的关键接口(回忆一下,我们当前位于 /sys/fs/cgroup 文件夹中):

$ cat init.scope/cpu.max  init.scope/cpu.weight  init.scope/cpu.weight.nice 
max 100000
100
0

我们可以看到它们的值(它们的含义是什么?耐心点,我们很快就会讲解:它的解释将在“尝试一下 – 通过 cgroups v2 限制 CPU 资源”一节中覆盖!)。

现在,使用 cat 是没问题的,但当文件内容仅有一行(或左右)时,使用快速的 grep 方法会更简单——而且更好!——方法是:grep . <file-spec>;所以在这里,使用这种技术,我们可以查找所有 CPU 控制器接口及其值,如下所示:

image.png

现在,我希望你意识到,能够看到大量的接口(和其他)伪文件,并且手头有内核文档来理解它们,能够让你真正开始理解 cgroups(实际上任何东西都如此)。

你还应该意识到,cgroups 是可以嵌套的;一个 cgroup 可以包含其他 cgroup,而这些 cgroup 还可以包含更多的 cgroup。要查看这一点,只需在一个 cgroup 下——即根 cgroup 下的一个文件夹——查找更多的文件夹。

让我们再看一个 cgroup(同样,systemd 在启动时创建)——system.slice——并查看它下面的子文件夹,亦即它的 cgroups。快速的查看方法如下:

$ find /sys/fs/cgroup/system.slice/ -maxdepth 1 -type d 
/sys/fs/cgroup/system.slice/
/sys/fs/cgroup/system.slice/system-dbus\x2d:1.15\x2dorg.freedesktop.problems.slice
/sys/fs/cgroup/system.slice/abrt-journal-core.service
/sys/fs/cgroup/system.slice/system-systemd\x2dfsck.slice
/sys/fs/cgroup/system.slice/sysroot.mount
/sys/fs/cgroup/system.slice/low-memory-monitor.service
[ … ]
/sys/fs/cgroup/system.slice/abrt-oops.service
/sys/fs/cgroup/system.slice/var-lib-nfs-rpc_pipefs.mount
$ 

(尽管我将深度参数设置为最大 1,但这个命令在我的系统上显示了 55 个 cgroups!)因此,在每一个这些 cgroups(由文件夹表示)下,我们看到相似(镜像的)内容——它们的伪文件代表核心和控制器接口——实际上是在限制系统资源,并可能还包含更多的 cgroups(文件夹)!这种设计本质上是递归的。

不过,别忘了:cgroup 的资源限制功能只有在该 cgroup 内部,或父 cgroup 中的 cgroup.subtree_control 文件启用了相关控制器时,才会生效!

现在,让我们从一个小步骤开始:在一个基于 systemd 的 Linux 系统(几乎所有现代发行版都是)上,我们将使用 systemd-cgls 工具(cgls = cgroup list)来“查看” init.scope 下的 cgroups;运行带有参数的工具——要查看的 cgroup 层次结构——它将精确显示:

$ systemd-cgls /sys/fs/cgroup/init.scope
Directory /sys/fs/cgroup/init.scope:
└─1 /usr/lib/systemd/systemd --switched-root --system –deserialize=32

它显示了运行在该 cgroup 下的每个进程以及它的命令行!这个输出是在 Fedora 38 虚拟机上生成的;在 Ubuntu 上,输出稍微更易读:

$ systemd-cgls /sys/fs/cgroup/init.scope
Directory /sys/fs/cgroup/init.scope:
└─1 /sbin/init

很好;现在让我们看一下 system.slice cgroup(在 Fedora 38 上):

$ systemd-cgls /sys/fs/cgroup/system.slice
Directory /sys/fs/cgroup/system.slice:
├─abrt-journal-core.service
│ └─1386 /usr/bin/abrt-dump-journal-core -D -T -f -e
├─bolt.service
│ └─1425 /usr/libexec/boltd
├─low-memory-monitor.service
│ └─1296 /usr/libexec/low-memory-monitor
├─systemd-udevd.service …
│ └─udev
│   ├─   688 /usr/lib/systemd/systemd-udevd
│   ├─610731 (udev-worker)
│   ├─610732 (udev-worker)
[ … ]

它显示了几个“服务”(进程——其中大多数是在 systemd 的支持下启动的)及其子进程(后代进程);非常好。现在,让我们查看整体视图,查看所有 cgroups,通过运行 systemd-cgls 工具而不带任何参数,这样它将显示整个 cgroups 层次结构(默认情况下在 /sys/fs/cgroup 下;--no-pager 参数是为了不使用 less 分页显示输出):

image.png

这太大了,当然不能在这里展示整个内容;请在你的系统上尝试并进行学习。(运行 systemd-cgls -h 会显示帮助屏幕;详细信息请参阅手册页。)

systemd 和 cgroups

手动管理 cgroups 可能是一个令人生畏的任务;默认情况下,最常用的工作站发行版、企业和数据中心服务器,甚至嵌入式 Linux 都运行强大的 systemd 初始化框架,它为我们提供了极大的帮助。正如我们已经注意到的,systemd 的一个有趣特点是,它在启动时接管了创建和管理 cgroups 的角色,从而自动利用它们的功能来为用户和他们的应用程序提供支持。(当然,了解更多关于它如何执行这些操作的知识,你可以根据自己的项目需求调整它。)

此外,要意识到,存在几种工具可以帮助你可视化系统中定义的 cgroups(以及 slices/scopes);这些工具包括 ps,以及一些来自 systemd 项目的工具——systemd-cglssystemctlsystemd-cgtop。(我们很快也会看到我们自己的 cgroups 可视化脚本!)

Slice 和 Scope

正如图 11.6 清楚显示的那样,systemd 具备自动构建 cgroups 的智能,能够将进程按逻辑分组。为此,它定义并使用了切片(slice)和作用域(scope)这两个概念。slice 用于表示属于特定用户的所有进程,或者它可以表示一组应用程序(进程),这些应用程序的资源通过该单元进行管理。(在图 11.6 中,对于我的用户账户 UID 值为 1000,很明显,我的 slice 被称为 user-1000.slice。)

scope 表示对 slice 的进一步逻辑拆分或分割(这可真是个绕口令,对吧?);举个例子,所有在终端窗口中运行的进程通常都会被 systemd 分组到一个 session-<number>.scope 的 cgroup 中(术语以“session”开头,因为会话代表在终端窗口中启动并由 systemd 管理的进程)。同样,在图 11.6 中,你可以清楚看到,一个终端窗口,代表为名为 session-9.scope 的作用域,通过 sshd 设置,并拥有一个 Bash shell(其 PID 为 1283555,teletypewriter 设备为 pts/4),作为 user slice 的后代,在其中表示或组织为一个“会话”类型的作用域。

此外,systemd 会组织层次结构,通过为作用域和服务单元分配到适当的 slice(并为它们分配自己的 cgroups)来进行管理。如前所述,每个登录系统的用户也会被视为“slice”,并出现在树中的通用 user.slice 节点下,他们运行的应用程序也会出现在该 cgroup 下(同样,你可以在图 11.6 中看到我的用户 slice,它显示为 user-1000.slice,在该层次结构下是“scope”单元)。引用 systemd 文档:

"(slice)名称由一系列通过破折号分隔的名称组成,描述了从根 slice 到该 slice 的路径。根 slice 的名称是 -.slice。例如:foo-bar.slice 是位于 foo.slice 内的一个 slice,而 foo.slice 又位于根 slice -.slice 中..."

(你可以在图 11.7 中看到根 slice -.slice 作为第一个 slice。)

如果你想更改默认设置,修改 systemd 设置 cgroups 的方式呢?有三种主要方式:首先,通过手动编辑服务单元文件;其次,使用 systemctl set-property 子命令进行编辑;第三,通过在 systemd 目录结构中使用所谓的 drop-in 文件。举个简单的例子:

$ cat /usr/lib/systemd/system/user@.service
[ … ]
[Unit]
Description=User Manager for UID %i
Documentation=man:user@.service(5)
After=user-runtime-dir@%i.service dbus.service systemd-oomd.service
Requires=user-runtime-dir@%i.service
IgnoreOnIsolate=yes
[Service]
User=%i
PAMName=systemd-user
Type=notify-reload
ExecStart=/usr/lib/systemd/systemd --user
Slice=user-%i.slice
KillMode=mixed
Delegate=pids memory cpu
TasksMax=infinity
[ … ]

请查看本章的“进一步阅读”部分了解有关 systemd 的内容;这里有很多链接和示例,帮助你详细了解如何操作。

使用 systemctl 和 systemd-cgtop 可视化 cgroups(以及 slices 和 scopes)

我们已经看到如何使用 systemd-cgls 来扫描 cgroup 层次结构。另一种查看 cgroup 层次结构的方法(在 systemd 的管理下)是通过 systemctl 应用程序和 systemd 单元类型。systemd 定义了几种单元类型:servicemountswapsockettargetdeviceautomounttimerpathslicescope。在这些类型中,只有最后两种类型与我们在这里讨论的内容相关,因此我们通过 systemctl 命令来查看它们:

image.png

现在你已经掌握了 cgroups2 的基础知识,下面是 cgroups(7) 手册页的一个部分截图,突出了更多的关键点;请务必学习它:

image.png

当然,你可能会想知道,如何查看一个给定进程所属的 cgroup(如果有的话)?答案是:当然可以!一种方法是查看所有 cgroup 并筛选出感兴趣的进程。通过 ps 命令查看进程列表并显示 cgroup 信息很简单;只需加上 -o cgroup 选项;以下是一个示例(-f 选项可以显示父子关系):

$ ps fw -eo pid,user,cgroup,args
   PID USER     CGROUP                      COMMAND
      2 root     -                                           [kthreadd]
      3 root     -                                            _ [rcu_gp]
      4 root     -                                            _ [rcu_par_gp]
      5 root     -                                            _ [slub_flushwq]
      [ … ]

注意,内核线程不属于任何 cgroup(这是有道理的,因为它们是内核的一部分)。好吧,下面是更多的 ps 输出,显示了一些由 systemd 附加到 init.scopesystem.slice cgroups 的进程(当然,init.scope cgroup 里只有一个进程——systemd 本身):

1377834 root     -                                       _ [kworker/1:1-ata_sff]
         1 root      0::/init.scope                    /usr/lib/systemd/systemd --switched-root --system [...]
   1096 root     0::/system.slice/systemd-jo /usr/lib/systemd/systemd-journald
   1109 root     0::/system.slice/systemd-ud /usr/lib/systemd/systemd-udevd
   1228 systemd+ 0::/system.slice/systemd-oo /usr/lib/systemd/systemd-oomd
   1229 systemd+ 0::/system.slice/systemd-re /usr/lib/systemd/systemd-resolved
    [ … ]

(这里的 cgroup 名称被截断了。)有趣的是,下面是这个 ps 进程本身的输出:

[ … ]
      1392 root     0::/system.slice/sshd.servi sshd: /usr/sbin/sshd -D [listener] 0 of 10-100 startups
1283542 root     0::/user.slice/user-1000.sl  _ sshd: kaiwan [priv]
1283547 kaiwan   0::/user.slice/user-1000.sl  |   _ sshd: kaiwan@pts/4
1283555 kaiwan   0::/user.slice/user-1000.sl  |       _ -bash
1283732 root     0::/user.slice/user-1000.sl  _ sshd: kaiwan [priv]
1283739 kaiwan   0::/user.slice/user-1000.sl  |   _ sshd: kaiwan@pts/3
1283749 kaiwan   0::/user.slice/user-1000.sl  |       _ -bash
1377837 kaiwan   0::/user.slice/user-1000.sl  |           _ ps fw -eo pid,user,cgroup,args
[ … ]

注意它属于 user.slice/user-1000.slice cgroup(此外,我通过 SSH 登录到我的 Fedora 虚拟机,因此我的 ps 进程属于 sshd … / bash 层级结构)。

回顾图 11.7,查看所有的 slices。值得问一下:如果你稍微深入挖掘,你会发现许多 slices(例如 machine.slicesystem-dbus...system-gettysystem-modprobe 等)似乎没有任何进程属于它们,为什么?很简单:这些 cgroups 确实存在(由 systemd 在启动时创建),但是当前没有进程存在于其中,这也是可以接受的。

用户进程是否默认出现在 cgroup 中?

那么,当用户启动一个新的进程(比如一个应用程序)时,它是否会默认出现在某个 cgroup 中?如果是,哪个 cgroup 呢?这些是重要的问题。大致来说,答案是:在 systemd 管理的系统中(通常是这种情况),systemd 会确保将每个新进程放入一个合适的 slice 和 scope,从而将其放入对应的 cgroup 中。所以,当我运行 vim 编辑文件时,发生的事情如下:

在运行 vim 之前:

$ ps fw -eo pid,user,cgroup,args | grep "[v]im"
$ 

小提示:使用正则表达式 "[v]im" 而不是 "vim" 很有帮助;它能让 grep 避免在输出中显示自己!

运行 vim 后:

$ ps fw -eo pid,user,cgroup,args | grep "[v]im"
1378003 kaiwan   0::/user.slice/user-1000.sl              _ vim cgroupsv2_explore
$ 

啊哈!正如预期的那样,vim 进程已经成为我用户 slice 的一部分!因此,它被跟踪和管理,任何适用于 user-1000.slice(以及它的祖先)slice 的资源分配限制也将适用于它。

如果不是 systemd 呢?

回到问题:如果系统没有运行 systemd?那么就没有 cgroups 管理……这就交给你自己了。你可以设置进程使其被放置到 cgroups 中(我们将在接下来的《手动方式 – 一个 cgroups v2 CPU 控制器》一节中介绍如何手动创建和管理一个 CPU 控制器)。值得注意的是,除了强大的 systemd,确实还有一些工具可以帮助进行 cgroup 管理(参见 systemd-run(1));一个很好的例子是 cg* 工具集(通过 cgroup-tools/libcgroup 包,安装了像 cgcreatecgexeccgclassify 等工具)。

快速了解内核命名空间

一个简短但有用的偏离话题:值得注意的是,关于容器的整个技术体系——一个强大、行业标准的、事实上的应用程序部署管理方式——本质上是基于 Linux 内核中的两项关键技术:cgroups 和命名空间。你可以将容器看作是本质上轻量级的虚拟机(在某种程度上);如今大多数容器技术(如 Docker、LXC、Kubernetes 等)本质上是这两项内建的 Linux 内核技术——cgroups 和命名空间——的结合。

内核命名空间是整个容器实现思想中的一个关键概念和结构(内核中的结构是 struct nsproxy)。通过命名空间,内核可以以某种方式划分资源,使得一个命名空间中的一组进程看到某些值,而另一个命名空间中的一组进程看到某些其他值。为什么需要这样做?举个例子,假设有两个容器;为了确保干净的隔离,每个容器都必须看到 PID 1、2 等进程。同样,每个容器可能需要有自己的域名和主机名,独立的挂载点(如 /proc),每个容器独有的网络接口等等。内核可以维护多个命名空间;默认情况下,它们都是可选的,因此内核始终维护每个命名空间的全局概念(例如挂载、PID 等):

命名空间提供的功能
Mount每个挂载命名空间有自己的文件系统布局(因此,mount ns1 中的 /procmount ns2 中的内容不同!)
PID进程隔离(因此,每个命名空间可以有一个 PID 1 进程)
Network网络隔离
UTS域名和主机名隔离
IPCIPC 资源(共享内存、消息队列和信号量)可以被隔离
User用户 ID 隔离(允许不同命名空间中的进程具有相同的 UID/GID)

表 11.2:内核命名空间

顺便提一下:如你所知,clone() 系统调用用于 Linux 中创建线程(pthread_create() 调用它)。它有许多标志——用于告诉内核如何创建自定义进程,或者换句话说,创建一个线程——其中有一些标志是 CLONE_NEW*(例如,CLONE_NEWPIDCLONE_NEWNSCLONE_NEWNET 等)。这些标志使内核在新命名空间中创建进程。其他与命名空间相关的系统调用包括 setns()unshare()ioctl_ns();请查阅它们的手册页以了解更多。

(再说一次,本章的“进一步阅读”部分包含了更多关于内核命名空间和容器技术的链接。)好吧,我们回到 cgroups 的讨论!

使用 systemd-cgtop

另一种可视化 cgroups 层次结构并同时观察在运行时哪些 cgroups——以及它们内部的 slices/services——正在使用大量资源的方法是通过非常有用的 systemd-cgtop 工具(实际上,它是 systemd cgroups 版的传统 top 工具的等效工具)!

默认情况下,在 systemd-cgtop 的输出中,cgroups 是按 CPU 使用率排序的。帮助屏幕非常有用:

$ systemd-cgtop -h
systemd-cgtop [OPTIONS...] [CGROUP]
Show top control groups by their resource usage.
  -h --help                       Show this help
     --version                    Show package version
  -p --order=path                 Order by path
  -t --order=tasks                Order by number of tasks/processes
  -c --order=cpu                  Order by CPU load (default)
  -m --order=memory               Order by memory load
  -i --order=io                   Order by IO load
  -r --raw                        Provide raw (not human-readable) numbers
     --cpu=percentage             Show CPU usage as percentage (default)
     --cpu=time                   Show CPU usage as time
  -P                              Count userspace processes instead of tasks (excl. kernel)
  -k                              Count all processes instead of tasks (incl. kernel)
     --recursive=BOOL       Sum up process count recursively
  -d --delay=DELAY          Delay between updates
  -n --iterations=N         Run for N iterations before exiting
  -1                        Shortcut for --iterations=1
  -b --batch                Run in batch mode, accepting no input
     --depth=DEPTH          Maximum traversal depth (default: 3)
  -M --machine=             Show container
See the systemd-cgtop(1) man page for details.

你还可以交互式地切换排序字段;手册页解释了这个功能:

...
(type) p, t, c, m, i
           Sort the control groups by path, number of tasks, CPU load, memory usage, or I/O load, respectively. This setting may also be controlled using the --order= command line switch.

我建议你在你的 Linux 系统上运行该工具并试用选项开关。

不过要小心:仅仅运行 systemd-cgtop 工具是不够的;(如前所述)如果没有在 cgroup 上启用资源计量,它无法显示任何关于资源使用的信息。systemd-cgtop 手册页明确指出了这一关键点:

…除非在相关服务中启用了 "CPUAccounting=1"、"MemoryAccounting=1" 和 "BlockIOAccounting=1",否则系统服务将无法进行资源计量,systemd-cgtop 显示的数据将是不完整的。

我们的 cgroups v2 探索脚本

使用现有的 cgroups 可视化工具,有一个问题是:我们不能立即看到一个 cgroup 是否已被填充或为空;此外,即使它已被填充,我们也不能直接看到其中哪些控制器已启用(或禁用)。了解这些信息对于理解系统中 cgroup 树的结构至关重要。我们的 Bash 脚本 cgroupsv2_explore链接到脚本)尝试通过显示以下内容来解决这一问题(以及更多功能):

  • 给定一个起始的 cgroup 作为参数,它会递归地遍历所有嵌套的 cgroup;如果没有指定参数,它会从 cgroup 树的根部(/sys/fs/cgroup)开始,扫描整个树。

  • 对于它解析的每个 cgroup,它首先检查:如果该 cgroup 未被填充(即没有活动进程),则跳过该 cgroup,否则显示关于它的一些内容,例如:

    • 子控制器(实际上是该 cgroup 的 cgroup.subtree_control 伪文件的内容!参见我们之前对子控制器的简要介绍...)

    • cgroup 类型(如:domain、domain threaded、threaded 等)

    • 冻结状态(0/1,否/是)

    • 属于该 cgroup 的进程:默认情况下,显示进程的数量(括号内),然后是 PID 列表;如果传递 -p 选项给脚本,它会显示其中的进程(通过 ps

    • 属于该 cgroup 的线程:默认情况下,显示线程的数量(括号内),然后是 PID 列表;如果传递 -t 选项给脚本,它会显示其中的线程(通过 ps

    • 与 cgroup 中几个控制器相关的数据;目前(仍在发展中!):

      • CPU
      • 内存

此外,脚本接受以下选项开关:

  • -d:控制扫描树的深度
  • -v:以详细模式显示
  • -p / -t:显示每个 cgroup 所属的进程和/或线程(如前所述)

cgroupsv2_explore 脚本的帮助屏幕一次性展示了所有这些内容:

image.png

好的。我们只需使用深度为 1 来运行它(这样它只会报告 cgroup 根目录下的第一层文件夹),以保持输出简洁(截图为了简洁而被截断):

image.png

输出格式与刚才描述的相符。注意这里(图 11.10),/sys/fs/cgroup/init.scope cgroup 没有子控制器,这意味着实际上没有施加任何资源约束!但 /sys/fs/cgroup/system.slice cgroup 确实启用了 memorypids 子控制器,因此系统将按照其中指定的约束来限制这些资源。你还可以看到(图 11.10)这里有几个 cgroups——从 machine.slice 开始,到 sys-kernel-tracing.mount(以及其他一些)——是空的,未填充的(其中没有活动进程)。

你可以运行脚本并传递给它一个特定的 cgroup;它会递归地显示该 cgroup 的元数据以及所有子孙 cgroup(如果有的话)。我们将在下一节中尝试这个功能,当我们创建自己的 cgroup 时!现在,作为练习,请尝试在不同的 cgroup 上运行该脚本。

尝试一下 – 通过 cgroups v2 限制 CPU 资源

由于我们在这里关注 CPU(任务)调度,现在我们花些时间来了解两种方式,通过它们我们可以对 cgroup 内进程的 CPU 使用进行资源限制:

  1. 简便方式,通过利用 systemd(使用在启动时启动的服务)。
  2. 手动方式,通过创建和管理一个 cgroup(v2),并通过 Bash 脚本对演示应用程序的 CPU 使用施加资源限制。

现在,让我们从第一种方式开始。

本节假设你对 systemd 和设置服务单元有一些基本的了解;如果没有,请参考进一步阅读部分中的资源(你可以在这里找到一些 systemd 资源:github.com/PacktPublis…)并熟悉这些基本概念。

利用 systemd 设置服务的 CPU 资源限制

systemd 可以看作是 cgroups 上的抽象层,它使系统管理员能够非常容易地对 cgroups 设置资源限制。systemd 让管理员(或 root 用户)定义它在启动时自动启动的服务,并对这些进程施加许多你在服务单元文件中指定的属性和/或约束——这些元数据定义了什么程序需要运行,以及如何精确运行。一个 systemd 服务单元文件通常命名为 <foo>.service(你通常可以在 /etc/systemd/system 下找到几个系统定义的服务单元文件)。它是一个普通的 ASCII 文本文件,分为几个部分:[Unit]、[Service]、[Install] 等等(请通过前述的进一步阅读部分的资源学习详细内容)。例如,通过 [Service] 部分中的 ExecStart= 指令指定要运行的应用程序(服务)的路径。

在 [Service] 部分的各种指令中,你可以指定 CPUQuota= 指令;它可以设置为分配给服务的最大 CPU 周期(CPU 时间)百分比!类似地,AllowedCPUs=n 设置服务在执行时可以使用的最大 CPU 核心数量。同样,内存、进程、I/O,甚至网络计量限制也可以应用于该服务!要了解这些及更多内容,请查阅 systemd.resource-control(5) 手册页。

另外,回想一下我们在第 10 章《CPU 调度器 – 第 1 部分》中关于线程调度策略和优先级的内容(参考《POSIX 调度策略》一节)。通过 systemd,你可以通过适当的指令轻松地设置服务单元的 nice 值、CPU 调度策略、优先级和 CPU 亲和性掩码。

systemd.exec(5) 手册页(man7.org/linux/man-p…)清楚地显示了这一点;以下是它的一部分截图,简要引用:

image.png

作为演示,我们编写了一个 C 程序,用于生成素数(从 2 到指定的最大值)。该程序设计为接受两个参数:

  1. 生成素数的最大值
  2. 执行此操作所能花费的最大时间(以秒为单位;使用报警器来使程序在时间到期后自行终止)

其理念如下:当程序在没有(CPU)资源限制的情况下运行时,它将在分配的时间内生成大量的素数。然而,当程序在严格的 CPU 限制下运行——通过 systemd 使用 cgroups v2 内核技术设置——你会发现它能生成的素数相对较少!

在这里,我们不会深入探讨如何设置 systemd 服务单元(也不会深入探讨素数生成器程序);请通过《进一步阅读》部分提供的链接浏览有关 systemd 的资源。我们将留给你详细了解并尝试素数生成器代码;它位于:ch11/cgroups/cpu_constrain/primegen/;此外,systemd 服务单元和几个支持脚本位于 ch11/cgroups/cpu_constrain/systemd_svcunit/(链接:github.com/PacktPublis…)。

一个设置脚本——setup_service——设置好后会立即安装并运行服务(当然,我们也可以设置它在每次系统启动时运行,但这是测试的更简单方式,当然,我们不想让你的系统每次重启时都运行一个无用的素数生成程序!)。

不再赘述,下面是没有任何限制的运行素数生成程序的服务单元;事实上,我们通过轻松地将 CPU 调度策略设置为 SCHED_FIFO,并将优先级设置为(一个相当任意的)83(回忆一下,范围是 1 到 99),给它加了一个“boost”:

$ cd <book_src>/ch11/cgroups/cpu_constrain/systemd_svcunit
$ ls
run_primegen   svc1_primes_normal.service  svc3_primes_lowram.service
setup_service    svc2_primes_lowcpu.service
$ cat svc1_primes_normal.service 
# svc1_primes_normal.service
# NORMAL version, no artificial CPU (or other) constraints applied
[Unit]
Description=My test prime numbers generator app to launch at boot (normal version)
[ … ]
After=mount.target
[Service]
# run_primegen: the script that launches the app.
# Our setup_service script ensures it copies all required files to this location.
# UPDATE the /path/to/executable if required.
ExecStart=/usr/local/bin/systemd_svcunit_demo/run_primegen
# Optional: Apply 'better' cpu sched settings for this process
CPUSchedulingPolicy=fifo
CPUSchedulingPriority=83
# (Below) So that the child process - primegen - runs with these sched settings!
# (well it's anyway the default)
CPUSchedulingResetOnFork=false
# Nice value applies for only the default 'other/normal' cpu sched policy
#Nice=-20
[ … ]
# UPDATE to your preference
[Install]
WantedBy=graphical.target
#WantedBy=multi-user.target
$ 

一个小的 Bash 脚本 run_primegen 被执行,它进一步调用素数生成器程序,基本上是这样的:

/usr/local/bin/systemd_svcunit_demo/primegen 100000 3

因此,它会让我们的简单素数生成进程尝试生成最大为 100,000 的素数,最大运行时间为 3 秒。

尝试 1.1 – 在没有资源限制的情况下,通过 systemd 执行素数生成器,使用 SCHED_FIFO 策略和 rtprio 83

好吧,让我们简单地让 systemd 执行这个脚本。我们执行我们的设置脚本,传递服务单元文件作为参数,这样 systemd 就会执行 run_primegen 程序(有关详细信息,请查看源代码;下面的输出来自我运行自定义 6.1.25 内核的 x86_64 Fedora 38 虚拟机):

image.png

好的! systemctl status <service.unit> 命令显示了服务的状态以及它生成的任何输出(见图 11.12;值得注意的是,systemd 会自动将所有标准输出、标准错误和内核的 printk 输出保存到日志中)。

在这次运行中,它成功地在 3 秒钟内生成了从 2 到 99,991 的素数,且没有资源(CPU)限制,并以 SCHED_FIFO 策略和高优先级运行。(当然,你会意识到,生成的素数数量可能会根据硬件系统的不同而有所不同。小提示:要查看完整输出,只需运行 journalctl -b。)

顺便提一下,我们的脚本在运行一次后故意禁用了服务;你可以通过在 setup_service 脚本中将变量 KEEP_PROGRAM_ENABLED_ON_BOOT 改为 1 来更改此行为。

如图 11.12 所示,运行:

systemctl status svc1_primes_normal.service --no-pager -l

查看服务的状态。

此外,通常非常有用的 systemctl show <service-unit-name> 命令显示该服务单元的所有设置;我们可以这样做,使用 grep 查找与 CPU 相关的设置:

image.png

我们已经在服务单元文件 svc1_primes_normal.service 中明确指定了几个 CPU 设置。其他设置则保持为默认值(顺便说一下,名为 LimitCPU* 的设置是指定服务单元内进程的(旧式)资源限制)。因此,请记住,这次运行——没有任何 CPU 限制,使用 SCHED_FIFO 策略,实时优先级为 83(并在大约 3 秒内)——在我的系统上生成了大约 99,991 个素数。

尝试 1.2 – 在 systemd 下执行素数生成器,限制 CPU 资源,使用 SCHED_OTHER 和 rtprio 0

现在,我们在同一个系统上运行相同的素数生成程序,但这次通过 systemd 指定了明确的 CPU 限制。现在使用的服务单元文件是:ch11/cgroups/cpu_constrain/systemd_svcunit/svc2_primes_lowcpu.service。与我们刚刚在上一节中看到的第一个服务单元几乎相同,唯一的区别是:

#--- Apply CPU constraints ---
CPUQuota=10%
AllowedCPUs=1

我们还移除了 CPUSchedulingPolicy=fifoCPUSchedulingPriority=83 这两行,因此保留了默认设置:调度策略为 SCHED_OTHER,实时优先级为 0,当然,我们将它限制为只使用 10% 的 CPU 带宽并只使用 1 个核心!让我们运行它,然后检查状态:

$ ./setup_service svc2_primes_lowcpu.service
[ … ]
$ systemctl status svc2_primes_lowcpu.service --no-pager -l
○ svc2_primes_lowcpu.service - My test prime numbers generator app to launch at boot (CPU constrained version)
     Loaded: loaded (/usr/lib/systemd/system/svc2_primes_lowcpu.service; disabled; preset: disabled)
    Drop-In: /usr/lib/systemd/system/service.d
             └─10-timeout-abort.conf
     Active: inactive (dead)
Aug 27 17:26:27 fedora run_primegen[52673]:  30347,  30367,  30389,  30391,  30403,  30427,  30431,  30449,  30467,  30469,  30491,  30493,  30497,  30509,  30517,  30529,
[ … ]
Aug 27 17:26:28 fedora run_primegen[52673]:  31531,  31541, primegen.c:buzz()
Aug 27 17:26:28 fedora run_primegen[52672]: Terminated
Aug 27 17:26:28 fedora systemd[1]: svc2_primes_lowcpu.service: Deactivated successfully.
$ 

啊哈!这次,程序在仅限 10% CPU 带宽(配额),允许仅在 1 个核心上运行,SCHED_OTHER 策略和 rtprio 0 的约束下,在 3 秒钟的时间内,只生成了到 31,541 的素数,而在第一次“正常”情况下生成了超过 99,000 个素数,这表明第一次运行生成的素数数量比第二次多了近 70%,证明了 systemd cgroups 控制的有效性。所以就是这样。

作为一个演示,说明如何在 cgroup 上设置内存限制,我们提供了另一个示例服务单元:svc3_primes_lowram.service。在其中,cgroup 的内存限制通过 MemoryHighMemoryMax systemd 设置进行指定(请查阅 systemd.resource-control 手册页,特别是名为 Memory Accounting and Control 的部分了解详细信息)。我们的服务故意让 stress-ng 程序分配大量内存,从而突破指定的限制,并导致 OOM 杀手(或如果配置了,systemd-oomd 进程)终止 cgroup 任务。我们在第 9 章《模块作者的内核内存分配 - 第 2 部分》中详细介绍了 OOM 杀手的工作原理——Stayin’ alive – the OOM killer 部分。运行时请小心;我们强烈建议在测试虚拟机上进行此操作。

手动方式 – cgroups v2 CPU 控制器

让我们做一些有趣的事情(不,应该说,去做,而不是尝试。——尤达)。我们现在将手动在系统的 cgroups v2 层级中创建一个新的 cgroup。然后,我们为它设置一个 CPU 控制器,并设定一个用户指定的上限,限制该 cgroup 内进程的 CPU 带宽使用!接着,我们将在其中运行我们的素数生成程序,看看它如何受到我们设定的限制的影响。

在这里,我们概述了通常需要执行的步骤(所有这些步骤都要求你以 root 权限运行):

  1. 确保你的内核支持 cgroups v2; 我们假设你运行的是 4.5 或更高版本的内核,并且已启用 cgroups v2 支持。

    如何检查是否正在运行 cgroups v2?

    很简单:运行 mount | grep cgroup;输出中必须包含子字符串 type cgroup2

  2. 在层级中创建一个 cgroup(通常是在 /sys/fs/cgroup/ 下)。这通过在 cgroup v2 层级中简单地创建一个具有所需 cgroup 名称的目录来实现。例如,要创建一个名为 test_group 的子 cgroup,可以这样做:

    mkdir /sys/fs/cgroup/test_group
    
  3. 向新 cgroup 添加一个 CPU 控制器;通过执行以下命令实现(以 root 身份):

    echo "+cpu" > /sys/fs/cgroup/test_group/cgroup.subtree_control
    

    回想一下,如果没有控制器,则不会对 cgroup 施加任何资源限制(以及它的后代;如果你想深入了解,可以回读“启用或禁用控制器”部分,尤其是其中提到的“自上而下的约束”段落)。

  4. 这里的关键部分是: 设置该 cgroup 中进程可使用的最大 CPU 带宽。这是通过将两个整数写入 <cgroups-v2-mount-point>/<our-cgroup>/cpu.max(伪)文件来实现的。为清晰起见,根据内核文档(链接)的解释,以下是该文件的说明:

    cpu.max
    一个读写的双值文件,存在于非根 cgroup 中。默认值是 "max 100000"。
    最大带宽限制。其格式如下:

    $MAX $PERIOD
    

    这表示该组可以在每个 $PERIOD 时间内消耗最多 $MAXmax 表示没有限制。如果只写一个数字,则更新 $MAX

    实际上,cgroup 中的所有进程将被允许在 $PERIOD 微秒的时间内总共运行 $MAX。例如,设置 MAX = 300,000PERIOD = 1,000,000 时,实际上允许所有进程在 1 秒钟内运行 0.3 秒!换句话说,CPU 带宽或利用率为 30%。如内核文档所述,默认情况下,MAXPERIOD 相同,意味着默认情况下是 100% 的 CPU 利用率。

  5. 将进程(或多个进程)插入新的 cgroup; 这是通过将它们的 PID 写入 <cgroups-v2-mount-point>/<our-cgroup>/cgroup.procs 伪文件来实现的。

就是这样;现在,属于该新 cgroup 的进程将在施加的 CPU 带宽限制下执行其工作(如果有的话);执行完毕后,它们将像往常一样终止...你可以通过简单的 rmdir <cgroups-v2-mount-point>/<our-cgroup> 删除(或删除)该 cgroup。

为了方便起见,我们提供了一个执行前述步骤的 Bash 脚本:cgv2_cpu_ctrl.sh。请查看它!

为了让它更有趣,这个脚本允许你将最大允许的 CPU 带宽作为参数传递——即步骤 4 中讨论的 $MAX 值;实际上,这是一种指定 cgroup 内所有进程的最大 CPU 带宽的方式(我们马上会看到这个)。现在,在脚本中,在新 cgroup 创建并设置好最大允许的 CPU 带宽后,我们像这样运行我们的素数生成程序:

primegen 1000000 5

一切与之前通过 systemd 的情况类似,唯一的区别是我们要求它生成更多的素数,并给它更多的时间,5 秒,这样它就有机会执行一些工作,因为脚本接下来必须查询并将其 PID 写入 <cgroups-v2-mount-point>/<our-cgroup>/cgroup.procs 伪文件。

这里进行几次测试运行有助于你理解它的工作原理:

$ sudo ./cgv2_cpu_ctrl.sh 
[+] Checking for cgroup v2 kernel support
cgv2_cpu_ctrl.sh: detected cgroup2 fs here: /sys/fs/cgroup
Usage: cgv2_cpu_ctrl.sh max-to-utilize(us) [run-cgroupsv2_explore-script]
max-to-utilize : REQUIRED: This value (microseconds) is the max amount of  
  time the processes in the control group we create will be allowed to utilize the
  CPU; it's relative to the period, which is set to the value 1000000.
  So, f.e., passing the value 300,000 (out of 1,000,000) implies a max CPU utiltization
  of 0.3 seconds out of 1 second (i.e., 30% utilization).
  The valid range for the $MAX value is [1000-1000000].
run-cgroupsv2_explore-script : OPTIONAL: OFF by default.
  Passing 1 here has the script invoke our cgroupsv2_explore bash script, passing
  the new cgroup as the one to show details of.
$ 

你应该以 root 身份运行它,并将 $MAX 值(在使用说明中已明确解释,并显示有效范围——以微秒为单位)作为第一个参数传递。

现在,我们用参数 800000 运行 Bash 脚本,这意味着 CPU 带宽为 800,000(每 1,000,000 的周期中);实际上,相当高的 CPU 利用率为 0.8 秒/1 秒(即 80% CPU 利用率):

$ sudo ./cgv2_cpu_ctrl.sh 800000
[+] Checking for cgroup v2 kernel support
cgv2_cpu_ctrl.sh: detected cgroup2 fs here: /sys/fs/cgroup
[+] Creating a cgroup here: /sys/fs/cgroup/test_group
[+] Adding a 'cpu' controller to it's cgroups v2 subtree_control file
***
Now allowing 800000 out of a period of 1000000 to all processes in this cgroup, i.e., 80.000% !
***
[+] Launch the prime number generator process now ...
../primegen/primegen 1000000 5 &
  2,  3,      5,      7,     11,     13,     17,     19,     23,     29,     31,     37,     41,     43,     47,     53, 
[  ]
   3071 pts/1    00:00:00 primegen
[+] Insert the 3071 process into our new CPU ctrl cgroup
   227,    229,    233,    239,    241,    251,    257,    263,    269,    271,    277,    281,    283,    293,    307,    311, 
cat /sys/fs/cgroup/test_group/cgroup.procs
3071
[  ] 7541,   7547,   7549,   7559,   7561, 
............... sleep for 6 s, allowing the program to execute ................
  7573,   7577,   7583,   7589,   7591,   7603,   7607,   7621,   7639,   7643,   7649,   7669,   7673,   7681,   7687,   7691, 
[  ]
 41143,  41149,  41161,  41177,  41179,  41183,  41189,  41201,  41203,  41213,  41221,  41227,  41231,  41233,  41243,  41257, 
primegen.c:buzz()
	[+] Removing our (cpu) cgroup
$ 

检查我们的脚本输出;你可以看到它完成了工作。它在验证 cgroup v2 支持后,在 /sys/fs/cgroup/ 下创建了一个名为 test_group 的 cgroup,并向其中添加了一个 CPU 控制器。然后它设置最大允许的 CPU 带宽为 800,000(每 1,000,000 周期中的 800,000,即 80% CPU 利用率)。然后,它启动我们的素数生成程序(使用适当的参数),并——关键是——将其 PID 添加到该 cgroup 中。在我们的测试运行中,提供 80% CPU 带宽,它恰好生成了素数,直到 41,257(在 5 秒钟内;当然,这个数字在不同的系统上可能会有所不同)。脚本随后清理并删除了 cgroup。

然而,要真正理解 cgroups 的影响,我们再运行一次脚本(查看下面的输出,见图 11.14),但这次将最大 CPU 带宽设置为仅 1,000(即 $MAX 值)——实际上是最大 CPU 利用率仅为 0.1%!

image.png

这差别太大了!这一次——仅分配了 0.1% 的 CPU 带宽——我们的可怜素数生成器进程只能生成从 2 到 659 的素数(而不是在 80% CPU 带宽下生成到 41,257)。这显然证明了 cgroups v2 CPU 控制器的有效性。

还有一件事:我们现在按相同方式重新运行脚本,只是这次我们将第二个参数设置为 1;在后台,这将启用我们的 cgroupsv2_explore Bash 脚本——第一个参数设置为 -p 来显示其中的进程,第二个参数设置为新的 cgroup!这样就会执行,从而揭示一些有关它的详细信息。在截断的截图中(见图 11.15),我们仅展示并突出显示其输出:

image.png

太棒了,正如预期的那样,所有信息都显示出来了。

顺便提一下,可以通过调整 cpu.weight 伪文件来影响分配给 cgroup 内进程的 CPU 权重。默认情况下,所有任务的值为 100。

这意味着所有进程将平等地分享 CPU 资源;分配的实际值并不重要。只有当你在进程(任务)之间调整权重时,这个值才会起作用。

需要理解的一个关键点是:CPU 资源控制器(以及其他控制器)的约束只在资源饱和时才有意义并被应用;如果资源(这里是 CPU 核心)没有饱和,进程可以自由地(或许是开心地?)使用它们想要的 CPU。很有道理!

所以,现在,我希望你能明白,为什么在上一章的图 10.1 中,最顶部展示了“Cgroups v2”层。现在我们可以清楚地看到,cgroups 是如何深深嵌入 Linux 内核的结构中;因此,它们可以被编程来影响各种资源的分配(或带宽/利用率),包括 CPU,从而实现对 CPU 调度的影响。

简单练习: 在你的系统上运行 ch11/cgroups/cpu_constrain/cpu_manual/cgv2_cpu_ctrl.sh Bash 脚本,分配不同的 CPU 带宽利用率,并使其显示我们之前 cgroupsv2_explore 脚本的输出。研究结果。

至此,我们完成了对一个非常强大且有用的内核功能的介绍:cgroups(v2)。接下来,让我们进入本章的最后一部分:介绍什么是实时操作系统(RTOS),以及如何通过应用适当的补丁和配置将常规的 Linux 转变为实时操作系统。

将 Linux 作为 RTOS 运行——简介

主线或标准 Linux(即从 kernel.org 下载的内核,或者是典型的 Linux Git 内核树)显然不是一个实时操作系统(RTOS);它是一个通用操作系统(GPOS;与 Windows、macOS 和 Unix 类似)。在 RTOS 中,硬实时(RT)特性至关重要,不仅软件必须得到正确的结果,而且还需要遵守与结果相关的截止时间;它必须保证每次都能够按时完成任务。

可以按照操作系统的实时特性将操作系统大致分为以下几类(见图 11.16);左侧是非实时操作系统,右侧是实时操作系统(RTOS):

image.png

主线或“原生” Linux 操作系统,尽管不是一个 RTOS,但在性能上表现出色,几乎毫不费力。它完全可以算作一个软实时操作系统(Soft RTOS):即大多数时间都能满足截止时间,且是基于“尽力而为”的方式(有时称其为符合“99.999%”的标准,因为它能在 99.999% 的时间内满足截止时间!)。然而,真正的硬实时领域(例如,许多类型的军事操作、运输、机器人技术、电信、工厂自动化、证券交易所、医疗电子等)要求硬实时保证,因此需要一个 RTOS。对于这些领域来说,普通的原生 Linux,作为一个 GPOS,显然是不足以胜任的。

在这个背景下,一个关键点是确定性:关于实时操作系统的一个常见误解是,软件对外部事件的响应时间不必总是非常快(例如,在几微秒内响应)。它可能要慢得多(比如在几毫秒的范围内);单单这一点,在 RTOS 中并不是最重要的。真正重要的是,系统必须可靠且可预测,以一致的方式工作,并始终保证能够按时完成任务;这样的系统被称为具有确定性响应,这是实时系统的一个关键特性。

例如,响应调度请求所需的时间应该是稳定、可预测的,而不是时高时低。与所需时间(或基准)的偏差通常被称为“抖动”(jitter);RTOS 的目标是将抖动保持在非常小,甚至可以忽略不计的水平。而在 GPOS 中,这往往是不可能的,且也从来不是设计目标!因此,在这些非实时系统中,抖动的变化可能非常大,有时低,有时又非常高。

总的来说,能够在极端工作负载压力下,保持稳定、均匀、可预测的响应,并且抖动最小——这就是确定性,它是 RTOS 的标志。为了提供这样的确定性响应,系统的算法必须尽可能设计为 O(1)(大O 1)时间复杂度的算法。

另一个 RT 系统的目标是减少延迟和时延。实际上,这一表述并不完全准确:目标是将最大延迟或最坏情况的延迟减少到一个可接受的水平;(具有讽刺意味的是)最小和平均延迟往往会——并且常常是——比非实时系统更糟糕。

Thomas Gleixner 和社区的支持者长期致力于将常规的(或原生的)非实时 Linux 内核转变为硬实时操作系统(RTOS)。他和他的合作伙伴早在很久之前就取得了成功:从 2.6.18 内核(2006 年 9 月发布)开始,就有了将 Linux 内核转变为 RTOS 的树外补丁!这些补丁可以在多个内核版本中找到,地址为:mirrors.edge.kernel.org/pub/linux/k…。这个项目的旧名称为“可抢占实时”(preemptible real-time),简称 PREEMPT_RT。后来(从 2015 年 10 月开始,4.1 版本之后),Linux 基金会(LF)接管了该项目的管理——这是一个非常积极的步骤!——并将其更名为“实时 Linux”(Real-Time Linux,RTL)协作项目(wiki.linuxfoundation.org/realtime/rt…),简称 RTL(不要与 Xenomai 或 RTAI 等共内核方法,或现已废弃的 RTLinux 混淆)。

一个常见的 FAQ 是:“为什么这些将 Linux 转换为硬实时操作系统的补丁不直接进入主线内核?”实际上,情况是这样的:

  • 很多 RTL 工作确实已经合并到主线内核中;这包括调度子系统、互斥锁和自旋锁、锁依赖、线程中断、PI(优先级继承)、追踪等重要领域。事实上,RTL 的一个主要目标是将其合并到主线内核中;截至目前,它已经非常接近成功!
  • 传统上,Linus Torvalds 认为,Linux 作为一个主要设计和架构为 GPOS 的操作系统,不应包含只有 RTOS 需要的高度侵入性特性;因此,尽管补丁会被合并进来,但这是一个缓慢且经过深思熟虑的过程。

我们在本章的“进一步阅读”部分包括了一些关于 RTL(以及硬实时)的有趣文章和参考资料,请务必查看。

接下来,你将进行一个非常有趣的操作:我们将简要介绍如何将主线的 6.1 LTS 内核补丁上(仍然是树外 RTL 补丁),配置它,构建它,并启动它;最终,你将运行一个 RTOS——实时 Linux 或 RTL!我们将会在我们的 x86_64 Linux 虚拟机(或本地系统)上进行此操作。

构建主线 6.x 内核的 RTL(在 x86_64 上)

在这里,我们不再详细讲解如何为原生 Linux 内核打补丁、配置它,并将其构建为实时内核(即 RTL),而是简单地指出一个出色的 RTL Wiki 网站教程:如何正确设置 Linux 使用 PREEMPT_RT

实际上,本书的第一版详细解释了如何打补丁、配置并构建 RTL;如果需要这些细节,可以参考该版内容。

这个简短的教程——RTL Wiki 网站中的一部分——解释了以下内容:

  1. 下载适用于特定稳定内核的 RTL 补丁集(从 mirrors.edge.kernel.org/pub/linux/k… 获取;截至本文写作时,教程中提到使用的是 4.4 LTS 内核的 RTL 补丁):

    • 补丁集通常有两种形式。第一种是单个补丁文件,命名为 patch-<kver>-rt<xy>.tar.xz(还有 gzip 格式的和签名文件);这个文件更容易下载和解压——整个 RTL 补丁都包含在这个文件中。另一种形式是补丁系列(多个补丁文件),命名为 patches-<kver>-rt<xy>.tar.xz;这种方式解压后,补丁文件位于名为 patches/ 的文件夹中。这里可能有多个补丁(必须按顺序应用——请查看系列文件;例如,对于 patches-6.1.75-rt23.tar.xz 文件,解压后包含 63 个补丁文件)。
  2. 提取补丁并应用到内核源代码树。

  3. 配置内核,设置为完全预抢占模式,并启用 RTL(CONFIG_PREEMPT_RT=y);请参见接下来的配置提示。

  4. 以通常的方式构建内核(我们在第二章《从源代码构建 6.x Linux 内核 - 第 1 部分》和第三章《从源代码构建 6.x Linux 内核 - 第 2 部分》中已有详细讲解)。

一些快速的内核配置技巧:

  • 要配置将 Linux 构建为 RTL,在 make menuconfig 界面中,导航至 General Setup | Preemption Model。假设你已经下载并应用了(树外的)RTL 补丁,你应该看到四个选项(而不是通常的三个)。第四个选项是我们需要的:Fully Preemptible Kernel (Real-Time) 。选择它(CONFIG_PREEMPT_RT=y)。如果你发现只有前三个选项,这通常是因为 CONFIG_EXPERT 选项没有开启:

    Depends on: <choice> && EXPERT [=y] && ARCH_SUPPORTS_RT [=y]
    

    所以,如果在应用 RTL 补丁集后没有看到第四个选项,首先开启 General Setup | Configure standard kernel features (expert users) ,然后第四个 RTL 选项应该会出现,你就可以启用它。

  • 另外,值得注意的是,当启用 PREEMPT_RT(即 RTL)时,动态抢占模型会被关闭。我们在前一章的 动态可抢占内核特性 部分对此进行了讲解。

截至本文写作时,支持的 6.1 LTS 内核补丁有 6.1.77[-rt24]、6.1.75[-rt23]、6.1.67[-rt20]、6.1.46[-rt14] 和 6.1.38[-rt13] 内核(请访问 mirrors.edge.kernel.org/pub/linux/k…);在撰写本文时,我选择了 6.1.46 内核(请注意,针对特定基础内核的 RTL 补丁会随着时间推移有所变化)。

一旦构建完成,执行常规的剩余步骤(在 x86_64 上:sudo make modules_install && sudo make install),然后重启。在引导菜单中,确保选择你新建的 RTL 内核!进入 shell 后,验证它确实是实时内核。以下是我得到的输出:

$ uname -a
Linux fedora 6.1.46-rt14-rc1 #1 SMP PREEMPT_RT [ ... ] x86_64 GNU/Linux

有趣的是,/sys/kernel/realtime(伪)文件的存在,以及它的值为 1,表明它是一个实时内核,RTL:

$ cat /sys/kernel/realtime 
1

太棒了,我们现在正如承诺的那样——运行 Linux 的 RTL 版本,一个硬实时操作系统(RTOS)!

请注意:仅仅将操作系统作为实时系统运行,在部署实时项目时远远不够;应用程序本身也需要精心设计和编写——特别是它们的时间关键区域——并考虑实时设计准则。再次强调,RTL Wiki 网站提供了许多关于这一点的提示;一定要查看它们。

杂项调度相关话题

本章最后,我们提到几个杂项但有用的话题。

一些小的(内核空间)例程

以下是一些你可能会发现有用的内核例程(具体的使用细节和示例请自行查阅内核文档):

  • rt_prio() :给定优先级参数,返回一个布尔值,指示该任务是否为实时任务。
  • rt_task() :根据任务的优先级值,给定任务结构体指针作为参数,返回一个布尔值,指示该任务是否为实时任务(这是对 rt_prio() 的封装)。
  • task_is_realtime() :类似于 rt_task(),但基于任务的调度策略。给定任务结构体指针作为参数,返回一个布尔值,指示该任务是否为实时任务。

这些例程都位于头文件 include/linux/sched/rt.h 中,只需包含该头文件,即可直接在模块中使用它们。

ghOSt 操作系统

近年来,硬件和软件领域涌现出了许多新兴技术领域;例如,NUMA 与芯片组、处理器加速器(如 GPU、TPU、DPU)、AMD CCX、AWS Nitro 等。不仅如此,基于云的工作负载也有特殊需求;例如,某些高速网络/电信项目要求微秒级的调度延迟。这些要求大大增加了现有操作系统调度器的压力,甚至可能压垮它们。

当然,我们可以构建或使用数据平面操作系统,或编写多个自定义调度类来解决这些问题,但在大规模(如数据中心工作负载)上进行测试、优化和部署是一个非常复杂、艰难且耗时的任务。

这些以及类似的场景催生了一种操作系统和用户空间的抽象,专门针对调度问题;谷歌提出了一个相对较新的系统,命名为 ghOSt——它提供了灵活且快速的用户空间调度委托,旨在解决此类问题和场景。ghOSt 既面向研究社区,也面向生产中的数据中心。它包括内核和用户空间代码;其目的是使内核代码保持大致稳定(本质上,它是一个自定义调度类)。而用户空间部分则负责做出策略决策(通过常规进程,即代理与 ghOSt 内核代码进行通信);因此,用户空间部分可以迅速变化,并且保持开源(github.com/google/ghos…)。

整体思想似乎是提供“涵盖从微秒级延迟、吞吐量到能效等多种调度目标的策略……”。内核实现使用 eBPF 来加速代码路径。截至本文写作时,该项目似乎还处于实验开发阶段,并未被视为正式的谷歌产品。有关此主题的更多信息,可以参考本章的进一步阅读部分。

总结

在本章中,我们深入讨论了 Linux 操作系统上的 CPU(或任务)调度,你已经学习到几个关键内容。其中,你了解了如何以编程方式查询和设置任何(用户或内核)线程的 CPU 亲和力掩码;这自然引出了如何编程查询和设置任何线程的调度策略和优先级。

我们对“完全公平”这一概念(通过 CFS 实现)进行了探讨,且揭示了一个优雅的解决方案——cgroups v2,它现在已经深深嵌入到 Linux 内核中。我们讨论了 systemd 如何帮助自动将 cgroups 集成到现代发行版、服务器,甚至嵌入式系统中,自动并动态地创建和维护各种 cgroups(通过其 slice 和 scope 机制)。你还学习了如何通过 systemd 单元文件和手动方式,利用 cgroups v2 的 CPU 控制器来分配 CPU 带宽(或利用率),以便按需分配给子组中的进程。

然后,我们简要介绍了什么是实时操作系统(RTOS),并发现尽管 Linux 本身是一个 GPOS,但确实存在一个 RTL(外部补丁集),一旦应用并配置构建内核,你就可以将 Linux 运行成一个真正的硬实时系统——RTOS。(我们也注意到,精心设计和编写你的实时应用程序对于真正的实时系统来说同样至关重要。)最后,我们提到了几个内核调度器相关的例程,以及谷歌的研究项目 ghOSt OS

这可算是很多内容了!花时间好好理解这些材料,并通过实践加以应用。完成后,和我一起进入本书最后两章,让我们一起探索内核同步的复杂性!

问题

在本章结束时,以下是一些问题,帮助你测试对本章材料的理解:github.com/PacktPublis…。你可以在本书的 GitHub 仓库中找到一些问题的答案:github.com/PacktPublis…

进一步阅读

为了帮助你更深入地学习这一主题,我们提供了一份详细的在线参考资料和链接列表(有时甚至是书籍),这些都可以在本书的 GitHub 仓库中的 Further Reading 文档中找到。你可以在这里访问进一步阅读文档:github.com/PacktPublis…