第 11 章 CPU 调度器(第二部分)
引言:控制的幻觉
如果你在同一个 Linux 服务器上跟另外九个人一起工作,你大概会觉得 CPU 时间是公平的——每个人分一份,井水不犯河水。但这其实是一种幻觉。内核的 CFS 调度器确实尽力了,但“完全公平”只在真空中成立。
现实往往更混乱:如果其中一个人写了个程序,疯狂地fork出一堆子线程,每个都死命吃 CPU,那一刻,这台服务器对他来说就是独享的,对你来说就是死机。内核需要一种更强力、更暴力的手段来打破这种僵局——不能只是“尽力调度”,而是要能“硬性限制”。
这就是我们这一章要聊的故事:从如何把一个线程死死钉在某个 CPU 核心上(亲和性),到如何把一群线程关进资源管理的笼子里,再到如何把普通的 Linux 变身成一个硬实时系统。我们要从“让内核决定”走向“我们要内核听命”。
这是我们在 Linux 内核 CPU(或者说任务)调度器话题上的第二趟旅程。在上一章(第 10 章),我们已经打好了地基:搞清楚了内核里到底谁才是被调度的基本单位(是线程,不是进程),看透了 POSIX 的调度策略,甚至用 perf 这样的工具亲眼见证了调度器的起舞。我们也知道如何查询一个线程的调度策略和优先级,并深入挖掘了调度器内部的模块化设计。
既然手里有了这些底牌,现在我们可以把牌局搞得更大一点。在这一章,我们将深入探讨以下主题:
- 理解、查询并设置 CPU 亲和性掩码
- 查询并设置线程的调度策略与优先级
- cgroups(控制组)初探
- 将 Linux 作为 RTOS 运行——入门指南
- 其他与调度相关的杂项话题
这一章的内容紧承上一章,所以强烈建议你在搞定第 10 章之前别急着翻这一页。
技术准备
我假设你已经搞定了内核工作空间的搭建(具体步骤见本书的在线章节),并且准备好了一台跑着 Ubuntu 22.04 LTS(或更新版本,或者最新的 Fedora)的虚拟机,该装的包也都装好了。如果你还没做,现在是个好时机。
此外,为了获得最佳体验,建议你把本书配套的 GitHub 仓库克隆下来,跟着代码动手敲。
仓库地址在此:
https://github.com/PacktPublishing/Linux-Kernel-Programming_2E
理解、查询并设置 CPU 亲和性掩码
task_struct——这个线程(或任务)的根数据结构,里面塞了几十个成员变量——有几个属性是直接跟调度挂钩的:优先级(既包括 nice 值,也包括实时优先级)、调度类结构体指针、线程所在的运行队列(如果有的话)等等。(顺便说一句,我们在第 6 章聊进程和线程时细过 task_struct 的细节)。
在这些属性中,有一个成员特别重要,那就是 CPU 亲和性位掩码(实际的结构体成员是 cpumask_t *cpus_ptr。值得一提的是,在 5.3 内核之前,它叫 cpus_allowed;后来在这个 commit 里改名了:https://github.com/torvalds/linux/commit/3bd3706251ee8ab67e69d9340ac2abdca217e733)。
这个位掩码顾名思义:它是一串比特位,用来标示这个线程(即 task_struct 代表的那个实体)被允许在哪些 CPU 核心上跑。画个图最直观了:假设在一个有 8 个核心的系统上,典型的 CPU 亲和性掩码大概长这样:
7 6 5 4 3 2 1 0 <- CPU 核心编号
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
0 0 1 1 1 1 1 1 <- 亲和性位
在上面的例子里,每个格子代表一个 CPU 核心。第一行是核心编号,下面那行是对应的位值:如果是 0,表示线程不能在这个核上跑;如果是 1,表示可以。
所以,如果掩码值是 0x3f(二进制 0011 1111),就意味着这个线程可以被调度到 CPU 0 到 5 上,但永远别想出现在核心 6 和 7 上。
默认情况下,所有的掩码位都是置 1 的。也就是说,默认状态下,线程想在哪跑就在哪跑。这很合理——比如在一个 OS 看起来有 8 个核心的盒子里,每个存活的线程默认的 CPU 亲和性掩码都是二进制 1111 1111(即十六进制的 0xff)。
既然这个掩码藏在 task_struct 里,这就告诉我们:CPU 亲和性是针对线程的。这也很好理解——毕竟 Linux 内核里可调度的实体(KSE)本来就是线程嘛。
运行时的时候,调度器决定线程最终落在哪个核心上。其实你仔细想想,这是隐式的:设计上,每个 CPU 核心都关联着一个运行队列。每一个可运行的线程都会在某个 CPU 的运行队列里排队;于是它就有资格跑,并且默认情况下,它就在那个队列代表的 CPU 上跑。
当然,调度器里有个“负载均衡”组件,必要时会把线程搬到别的 CPU 核心上去(也就是别的运行队列,干这活的内核线程叫 migration/n,这里的 n 就是核心号)。
内核向用户空间暴露了一系列 API(也就是系统调用,具体来说是 sched_{s,g}etaffinity(2),以及它们的 pthread 封装库函数),让应用程序可以按需把线程(或多个线程)“亲和”到特定的 CPU 核心上。
同样的逻辑,我们在内核空间里也可以这么干,针对任何给定的内核线程进行设置。举个例子,如果你把 CPU 亲和性掩码设置为二进制 1000 0001(也就是十六进制的 0x81),那就意味着这个线程只能在核心 7 和核心 0 上跑(记得核心编号是从 0 开始的)。
(此处插入 Figure 11.1 图片:图示 CPU 亲和性位掩码)
虽然技术上你可以随意篡改线程的 CPU 亲和性掩码,但强烈建议你别乱来。内核调度器子系统对 CPU 的拓扑结构(或者叫域)门儿清,它能实现最好的系统负载均衡。
不过话又说回来,在某些特定场景下,显式地设置 CPU 亲和性确实有好处:
- 减少缓存失效:确保一个线程始终在同一个核心上跑,可以大大减少缓存数据的 bouncing(抖动/失效),这对性能至关重要。(第 13 章讲内核同步时会深入探讨 CPU 缓存)。
- 消除迁移开销:直接消灭了线程在不同核心之间来回迁移的成本。
- 实现 CPU 保留:这是一种策略,通过显式禁止其他线程运行在特定的核心上,从而把那个核心独占给某个线程用。这招在时间敏感的实时系统里常用。
前两点通常只适用于某些极端的 corner case。第三点,也就是 CPU 保留,通常是在对时间要求极高的实时系统里才会用的技术,虽然代价不小,但在那个场景下是值得的。(顺便提一句,以前这玩意儿是通过 isolcpus= 内核参数实现的;现在已经不推荐了,现在大家都改用 cpusets cgroup 控制器)。
既然理论都懂了,咱们来写个用户空间的 C 程序,实际查查、改改线程的 CPU 亲和性掩码吧。
查询并设置线程的 CPU 亲和性掩码
光说不练假把式。我们提供了一个小的用户空间 C 程序,用来查询和设置用户空间进程(其实是线程)的 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) 的 man page)。
上面那两个系统调用的第二个参数都是 CPU 掩码的大小(我们就老老实实 sizeof 一下就行)。第一个参数则是目标进程(或线程)的 PID,也就是你想查询或设置谁的家底。
光看代码可能没感觉,我们跑一下示例代码(GitHub 上有:ch11/cpu_affinity)。
下面是在一台有 12 个核心的 Linux 原生机上跑的效果:
(此处插入 Figure 11.2 图片:演示程序显示调用进程的 CPU 亲和性掩码)
在这个例子里,我们在没带参数的情况下运行了程序。在这种模式下,它会查询自己的 CPU 亲和性掩码。我们把掩码的位打印出来:正如你在上图中(Figure 11.2)能清楚看到的,输出是二进制的 1111 1111 1111(等于十六进制的 0xfff)。这说明默认情况下,该进程有资格在系统上所有 12 个核心上跑!
程序内部通过 popen() 这个库 API 调用了 nproc 工具来检测核心数量。但要注意,nproc 返回的是当前进程可用的核心数;这可能会比实际(在线和离线)的核心数少,虽然通常情况下是一样的。可用核心数的改变方式有好几种,最“正统”的做法就是通过 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 位掩码(十六进制格式)——它就会尝试把这个进程的亲和性掩码改成你传的值。
当然,想改别人的掩码,你得拥有那个进程,或者有 root 权限(更准确地说,得有 CAP_SYS_NICE 这个 capability)。
来个快速演示:在 Figure 11.3 里,nproc 告诉我们有 12 个核心。接着,我们运行我们的程序去查询并设置 shell(bash)进程的 CPU 亲和性掩码。假设这台 12 核的笔记本上,bash 的亲和性掩码一开始是 0xfff(二进制 1111 1111 1111),这很正常;然后,我们把它改成 0xdae(二进制 1101 1010 1110),再查一次验证一下:
(此处插入 Figure 11.3 图片:演示程序查询并设置 bash 的 CPU 亲和性掩码为 0xdae)
这就有点意思了。首先,程序正确检测出了可用的核心数是 12。然后它查询了 bash 进程的(默认)掩码(我们传了它的 PID 作为第一个参数),显示是 0xfff,毫无破绽。
紧接着,因为我们传了第二个参数——也就是我们要设置的掩码值(0xdae)——程序就照做了,把 bash 的掩码设成了 0xdae。
现在问题来了:我们当前所在的终端窗口,正是这个 bash 进程。如果你现在再跑一次 nproc,你会发现它显示的是 8,而不是 12!这完全没毛病:bash 进程现在只能看到 8 个 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 亲和性掩码,但如果你不拥有那个任务,没有 root 权限,或者没有 CAP_SYS_NICE capability,你就没法设置它。
使用 taskset 工具搞定 CPU 亲和性
就像我们在上一章用方便的用户空间工具 chrt 来查(或设)进程(或线程)的调度策略和优先级一样,你也可以用 taskset 这个用户空间工具来查或改某个进程(或线程)的 CPU 亲和性掩码。
举两个简单的例子;注意这些例子是在一台有 6 个核心的 x86_64 Linux 虚拟机上跑的:
- 查询 systemd(PID 1)的 CPU 亲和性掩码:
$ taskset -p 1
pid 1's current affinity mask: 3f
$
动动脑子:0x3f 换成二进制就是 0011 1111,这代表那个进程/线程(这里是 systemd)可以在所有 6 个核心上跑。
- 在 taskset 的庇护下跑编译器,确保 GCC——以及它的子进程(汇编器和链接器)——只在头两个核心上跑。
taskset 的第一个参数就是 CPU 亲和性掩码(03 就是二进制 0011):
$ taskset 03 gcc userspc_cpuaffinity.c -o userspc_cpuaffinity -Wall
Done.
搞定。想看完整的用法说明,去翻 taskset(1) 的 man page。(顺便提一句,就像上一章说的,schedtool(8) 这个工具也能搞定时设置给定线程/进程的 CPU 亲和性位掩码)。
在内核线程上设置 CPU 亲和性掩码
作为个挺有意思的例子,假设我们要演示一种叫“Per-CPU 变量”的同步技术(我们确实会在第 13 章学这个,并在“Per-CPU——一个内核模块示例”那一节动手实践),我们需要创建两个内核线程(kthreads),并且保证它们跑在不同的 CPU 核心上。
为此,我们必须显式地把这两个内核线程的 CPU 亲和性掩码设成不一样的,而且不能重叠(为了简单起见,我们把第一个 kthread 的掩码设为 0(只用核心 0),第二个设为 1(只用核心 1),这样就能保证它们分别在核心 0 和 1 上跑了)。
但这有个坑……下一节咱们细说。
破解未导出符号的可用性
问题是,现在想在模块里设置 CPU 亲和性,说实话活儿挺糙的——简直就是个 Hack。我们这里展示一下,但在生产环境中绝对不推荐。
原因在于,内核里我们需要的那个设置 CPU 亲和性掩码的 API——sched_setaffinity()——虽然在,但它是未导出的。我们在前面写模块的章节里学过,一个树外模块(就像我们这个)只能调用导出的函数(和数据)。那咋办?
以前很多年里(我在本书第一版里也是这么干的!),模块开发者们常用的“标准”路子是调用一个现成的便利例程 kallsyms_lookup_name(),去查内核里任何给定的符号,拿到它的(内核虚拟)地址。
拿到了地址,任何一个像样的 C 程序员都能把它当函数指针用,想怎么调就怎么调。这就算得上是“破解”了限制——只能调用导出函数的限制!(这招挺溜!但内核老手们看到这估计都要皱眉头了。)
确实如此,但从 5.7 版本的内核开始,社区觉得是时候停止这种(愚蠢的)滥用了,干脆把 kallsyms_lookup_name()(以及类似的 kallsyms_on_each_symbol())给取消导出了!(commit ID 简写是 0bd476e6c671,你可以去瞅一眼)。
这下好了,现在怎么办?别慌,只要你有 root 权限,我们总能通过 /proc/kallsyms 这个伪文件查到任何内核符号的地址(这也是出于安全考虑)。而且,现在的内核通常都开启了内核地址空间布局随机化(KASLR),这意味着这个值每次启动都不一样,没法硬编码(这对安全也是好事)。
所以,我们写了个小的封装脚本来干这事(代码在这里:ch13/3_lockfree/percpu/run;没错,这代码其实是第 13 章的),然后把查到的地址(通过 /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 *);
[ … ]
// 设置函数指针
schedsa_ptr = (unsigned long (*)(pid_t pid, const struct cpumask *))func_ptr;
[ … ]
/*
* !HACK! sched_setaffinity() 没导出,我们不能直接调它。
* 所以我们通过函数指针来Invoke它
*/
ret = (*schedsa_ptr)(0, &mask); // 0 => 针对自己
[ … ]
说实话,这种“破解”内核地址的方法非传统甚至有点争议,在工程上也挺粗糙,但它确实管用——尤其是在演示和实验性质的场景下。不过,有一点必须心里有数:这种 Hack 是建立在未导出接口之上的,属于“野路子”。在正经的生产环境里,或者在你的代码需要长期维护的情况下,请尽量避免依赖这种脆弱的技巧。内核没导出某个函数通常是有原因的,绕过这些限制就像是在走钢丝,虽然能到终点,但风险自负。
好了,折腾完这套“黑客”手段,咱们回到正轨。现在你知道怎么查/改(内核)线程的 CPU 亲和性掩码了,咱们进入下一个逻辑步骤:怎么通过编程的方式查/改线程的调度策略和优先级!下一节咱们深扒细节。
查询并设置线程的调度策略和优先级
在第 10 章(CPU 调度器——第一部分)的“线程优先级”那一节,你学到了怎么通过 chrt 工具查询任意给定线程的调度策略和优先级(我们还演示了一个简单的 Bash 脚本来干这事)。那里我们提到过,chrt 底层其实是调用了 sched_getattr() 系统调用。
非常类似,设置调度策略和优先级可以通过 chrt 工具来完成(比如在脚本里用很方便),或者在(用户空间)C 应用程序里通过 sched_setattr() 系统调用搞定。此外,内核还暴露了其他 API:sched_{g,s}etscheduler() 以及它们的 pthread 库封装 API pthread_{g,s}etschedparam()(既然这些都是用户空间 API,具体细节和用法就留给你们自己去翻 man page 了)。
在内核内部设置策略和优先级——针对内核线程
你也知道,内核既不是进程也不是线程。虽说如此,Linux 内核确实是支持多线程的,而且确实有线程,也就是所谓的内核线程(kthreads)。跟它们在用户空间的兄弟们一样,内核线程也可以按需创建(核心内核、设备驱动或者内核模块都可以干这事,内核为此暴露了 API)。
它们是可调度实体(KSE 嘛!),当然,每个内核线程都有自己的 task_struct 和内核态栈;因此,跟普通线程一样,它们也要争抢 CPU 资源,它们的调度策略和优先级也可以按需编程查询或设置。
(此处插入图片:Linux Kernel Programming Part 2 免费电子书广告)
(此处插入图片:关于 kthread 命名的链接)
说到点子上了:在用户空间,现代推荐的查询和设置线程调度属性的系统调用分别是 sched_getattr() 和 sched_setattr()。在前些年,用的是 sched_{g|s}et_scheduler() 这一对。
现在的 sched_{g|s}etattr() 系统调用接收一个指向 struct sched_attr 的指针,这结构里包含了所有可能需要的细节;具体可以去看 man page (https://man7.org/linux/man-pages/man2/sched_setattr.2.html)。
所以,按现代的路子走,大家会以为我们在内核里也用这些系统调用的内核实现来干类似的活。没那么快;内核社区认为旧的设计——允许用户(应用)和模块开发者开心地调用这些 API,随便填个 SCHED_FIFO 策略,再顺手填个自认为合理的(实时)优先级——从根本上就是有缺陷的。
为啥?因为这很容易导致翻车:比如两个或多个 SCHED_FIFO 线程优先级一样,或者用了“随机”选定的优先级值——完全没过脑子就选了。这会直接搞乱 CPU 调度,进而搞乱资源管理。
因此,从 5.9 内核开始,社区做了如下改动(请允许我直接引用 commit 的内容,这是传达信息的最佳方式);这是 commit https://github.com/torvalds/linux/commit/7318d4cc14c8c8a5dde2b0b72ea50fd2545f0b7a 的部分内容:
……
因此,暴露优先级字段毫无意义;内核根本 incapable 设置一个合理的值,这需要它所不具备的系统知识。
从模块中拿走
sched_setschedule() / sched_setattr()并替换为:
sched_set_fifo(p);创建一个 FIFO 任务(优先级 50)sched_set_fifo_low(p);创建一个高于 NORMAL 的任务,结果就是优先级为 1 的 FIFO 任务。sched_set_normal(p, nice);(重)置任务为普通。这就阻止了随意选择的、无关的优先级泛滥成灾,反正这些优先级也没啥实际意义。
系统管理员/集成者,也就是那些对实际系统设计和需求有洞察力(用户空间)的人,可以在需要时设置适当的优先级……
……
啊哈;也就是说,亲爱的模块作者们,现在我们在内核里设置 FIFO 线程时,得用这三个 API 了——sched_set_fifo(),sched_set_fifo_low(),和 sched_set_normal()。
正如上面的 commit 所说,我们信任管理员和/或用户空间开发者去编写用户程序并提供正确且有意义的实时优先级值;内核(或模块)不应该去质疑这些决定——它只是负责执行(再次强调,这就是“提供机制而非策略”的设计准则在发挥作用)。
前两个 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 的注释)
这里,*_nocheck() 的后缀意味着内核甚至懒得检查运行这些 API 的进程上下文是否有足够的权限;直接一路放行。(看这里的注释:https://elixir.bootlin.com/linux/v6.1.25/source/kernel/sched/core.c#L7742)。
此外,这三个 API 都是 GPL 导出的,意味着只有同样以 GNU GPL 许可证发布的模块才能用它们。
现实世界的例子——线程化中断处理程序
内核使用内核线程的一个典型案例是——其实非常常见——线程化中断(work queues 是另一个例子)。在这种情况下,内核必须创建一个专用的内核线程,并给它设置 SCHED_FIFO(软)实时调度策略,实时优先级设为 50(折中位置),以便正确处理所谓的线程化中断。
我们来看看相关的代码路径:https://elixir.bootlin.com/linux/v6.1.25/source/kernel/irq/manage.c#L1448
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() 这个宏负责创建内核线程。现在,irq_thread() 这个内核专用的 API(通过 kthread_create() 宏作为线程函数调用)会在执行过程中,把调度策略和优先级设置正确。代码路径在这里:https://elixir.bootlin.com/linux/v6.1.25/source/kernel/irq/manage.c#L1286
/* 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 线程用 FIFO 50 的提示)
理解了调度还不够——如果这十个人里有人 fork 炸弹怎么办?或者你只是想优雅地限制一下某个编译任务的资源占用?这时我们就需要动用更强大的工具——cgroups。我们下一节接着聊。
cgroups(控制组)简介
在那遥远的过去,内核社区曾经为一个相当棘手的问题绞尽脑汁:虽然调度算法及其实现——早期的 2.6.0 O(1) 调度器,以及稍晚一点(2.6.23)的完全公平调度器(CFS)——承诺了所谓的“完全公平”调度,但这在实际上并没有任何真正意义上的“完全公平”!
你稍微想一下:假设你跟另外 9 个人同时登录了一台 Linux 服务器。在其他条件都平等的情况下,CPU 时间大概会在你们十个人之间(或多或少)公平分配;当然,你也知道,真正跑在处理器上、吃内存的不是“人”,而是代表你们跑的进程和线程。
目前至少我们假设它还是(大致)公平的。但是,假如这十个人里的你——写了一个用户空间程序,在循环里肆无忌惮地 fork 出一堆新线程,每个线程都玩命吃 CPU(也许还附赠大把大把地分配内存)!那 CPU 带宽的分配(哪怕是通过 CFS)就不再真正公平了;你的账号实际上会霸占 CPU(也许还有其他系统资源,比如内存和 I/O)!
这时候就急需一个通用的解决方案,能精确有效地管理 CPU(以及其他资源)的带宽,一旦达到指定限制就进行节流(Throttling,也就是不让你继续用)。
当时提了很多补丁方案,讨论了一圈又都被扔进了垃圾桶。最终,来自 Google、IBM 等公司的工程师们挺身而出,提交了一套补丁,把现代版的 control groups(cgroups)解决方案放进了 Linux 内核(这得追溯到 2.6.24 版本,2007 年 10 月。最初的构思和实现是 Google 的 Paul Menage 和 Rohit Seth 在 2006 年搞出来的)。
简而言之,cgroups 是一个内核特性,允许系统管理员(或者任何有 root 权限的人)能够优雅地对系统的各种资源或控制器(在 cgroups 术语里这么叫)进行带宽分配和细粒度的资源管理。
注意:用 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 章和第 3 章详细讲过内核配置和构建)。
(此处插入图片:提示 cgroups 在 systemd 下默认开启)
好消息是:任何跑着 systemd init 框架的现代 Linux 系统,cgroups 默认都是开着的。正如刚才提到的,你可以通过 grep 内核配置文件来查询启用了哪些 cgroup 控制器,并按需修改;在桌面和服务器级系统上,通常不需要你动这个。
从 2.6.24 问世以来,cgroups 就跟其他所有内核特性一样,不断进化。最近,改进后的 cgroup 特性已经跟旧版不兼容了,从而导致了一个新的 cgroup 设计和发布,名叫 cgroups v2(或者干脆叫 cgroups2——维护者是 Tejun Heo);它在 4.5 内核系列中被宣布为生产可用(旧的那个现在被称为 cgroups v1,或者叫传统 cgroups 实现)。
注意,截止到我写这会儿,这两个版本是共存的,虽然有些限制;很多应用和框架还在用旧的 cgroups v1,还没迁移到 v2。但这正在改变;即便现在还没全面铺开,cgroups2 也将成为事实上的标准,所以你得打算用它。
在这一章的覆盖范围里,我们将几乎完全专注于使用现代版本,即 cgroups v2。最好的文档是官方内核文档,这里是 6.1 版本的:https://www.kernel.org/doc/html/v6.1/admin-guide/cgroup-v2.html。(顺便说一句,最新内核版本的文档也一直在这里:https://docs.kernel.org/admin-guide/cgroup-v2.html)。
(此处插入图片:关于 v1 vs v2 的文档链接)
cgroup 控制器
cgroup 控制器 是负责在 cgroup 层级结构内(一个 cgroup 及其后代)分发特定资源(比如 CPU 周期、内存和 I/O 带宽等)的底层内核组件。你可以把它想象成针对给定 cgroup 层级的某种“资源限制器”。
cgroups(7) 的 man page 详细描述了各种可用(资源)控制器(有时也叫子系统)的接口。cgroups v2 下通常可用的控制器如下(表 11.1 展示了 v2 的东西;许多控制器的原始 v1 实现可以追溯到 2.6.24):
| Cgroups v2 控制器名称 | 控制(或限制或调节)什么 | 启用时的内核版本 |
|---|---|---|
| cpu | CPU 带宽(周期) | 4.15 |
| cpuset | CPU 亲和性和内存节点放置(对大型 NUMA 系统特别有用) | 5.0 |
| memory | 内存(RAM)使用量 | 4.5 |
| io | I/O 资源分配 | 4.5 |
| pids | cgroup 中进程数量的硬限制 | 4.5 |
| devices | 设备文件的创建和访问(仅通过 cgroup BPF 程序) | 4.15 |
| rdma | 远程直接内存访问(RDMA)资源的分配和统计 | 4.11 |
| hugetlb | 限制每个 cgroup 的 HugeTLB(大页)使用量 | 5.6 |
| misc | 各种杂项;参见文档 | 5.13 |
表 11.1:现代 Linux 系统上可用的 cgroups v2 控制器总结
我们推荐感兴趣的读者去阅读上述官方内核文档和 man pages 了解细节;举个例子,PIDS 控制器在防止 fork 炸弹方面非常有用,它允许你限制该 cgroup 或其后代可以 fork 出多少个进程。(fork 炸弹是一种愚蠢但致命的 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 兼容的;别指望它对 v2 有效)。你在这里看到的确切控制器取决于内核是怎么配置的。
在 cgroups v2 中,所有控制器都挂载在**单个层级结构(或树)**中。这跟 cgroups v1 不一样,v1 允许多个控制器挂在多个层级或组下。
现代 init 框架 systemd 同时使用 v1 和 v2 的 cgroups。实际上,是 systemd 在启动时自动挂载 cgroups v2 文件系统的(就在 /sys/fs/cgroup/)。
探索 cgroups v2 层级结构
往 cgroups (v2) 伪文件系统的挂载点底下看——默认总是 /sys/fs/cgroup——看着那一堆伪文件(和文件夹),你可能会发愣(去吧,瞄一眼 Figure 11.4);这一节我们就来探索里面那些更有趣、更有用的旮旯拐角!
首先确认一下 cgroups v2 层级结构挂载在哪:
$ mount | grep cgroup2
cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot)
很明显,正如预期的那样,在 /sys/fs/cgroup 下。(好奇后面括号里那一堆挂载选项是什么意思?文档在这:https://www.kernel.org/doc/html/v6.1/admin-guide/cgroup-v2.html#mounting)。
(此处插入图片:关于老版本发行版可能没有控制器的提示)
好了,现在开始探索!
在这个环节里,我工作在一台 x86_64 Fedora 38 虚拟机上,我构建并启动了一个定制的 6.1.25 内核。让我们先看看总体情况:
(此处插入 Figure 11.4 图片:cgroups v2 层级结构的根目录)
在 root cgroup 位置——/sys/fs/cgroup——下,你可以看到好几个文件和文件夹(不用说,这些都是易失性的伪文件对象;通过 sysfs 挂在 RAM 里)。
首先:
- 看到的“常规”文件——比如
cgroup.controllers,cpu.pressure之类的——是 cgroup2 接口文件。 - 这些文件进一步细分为核心接口和控制器接口;所有
cgroup.*文件都是核心接口文件,cpu.*是 CPU 控制器的接口文件,memory.*是内存控制器的,以此类推。 - 看到的文件夹代表——终于露脸了!——控制组(cgroups)! 在这许多文件夹里,你会发现并不是所有的都被限制(或者说不都是“有料”的)。你可能会好奇是谁创建了它们;简短的回答(至少对那些默认就存在的来说)是 systemd;后面马上细说。
启用或禁用控制器
让我们先看一个关键的核心接口文件:cgroup.controllers。上一节简单提过这个。它的内容是该 cgroup 可用的控制器列表;对于 root cgroup,这意味着内核配置了哪些控制器。正如我们刚才看到的,对于现代发行版,通常是这样的:cpuset cpu io memory hugetlb pids rdma misc。
这里要小心:一个控制器出现在这个列表里,并不意味着它在这个 cgroup 层级里已经启用了;事实上,默认情况下是一个都没启用!
启用一个控制器,意味着对它的目标资源分配的约束将在直接子节点上生效。要启用一个控制器,你需要把字符串 +<controller-name> 写入 cgroup.subtree_control 伪文件(反之,写入 -<controller-name> 来禁用它)。
所以,举个例子,想启用 CPU 和 I/O 控制器,但禁用内存控制器(在当前 cgroup 上,从而让它在其后代,也就是下面的层级里生效),可以(用 root 权限)这样做:
echo "+cpu +io -memory" > cgroup.subtree_control
所以现在我们知道,cgroup.subtree_control 文件产生的是一个以空格分隔的控制器列表,这些控制器被启用用于从当前 cgroup 到其子节点的资源分发。
内核文档是这么说的(https://docs.kernel.org/admin-guide/cgroup-v2.html):
自上而下的约束 资源是自上而下分发的,且只有当资源已经从父节点分发给了子 cgroup 时,该子 cgroup 才能进一步分发这个资源。这意味着所有非 root 的 "cgroup.subtree_control" 文件只能包含其父节点的 "cgroup.subtree_control" 文件中已经启用的控制器。只有当父节点启用了某个控制器时,子节点才能启用它;如果有一个或多个子节点已经启用了某个控制器,那么父节点就不能禁用它。
(此处插入图片:两张文档截图)
花点时间消化一下这句话。还有几个有用的 cgroup 核心接口文件——只存在于 cgroup 文件夹(层级结构)里——值得你了解一下:
- cgroup.events:只读;可以有这些值:
populated:0 或 1。如果是 1,说明该 cgroup 或其后代包含存活的进程,否则是 0。因此,只有当populated的值是 1 时,这个 cgroup 才值得深挖!否则,它就是个空的、未填充的 cgroup(我们的 cgroups v2 “探索器”脚本马上就会看到这个)。frozen:0 或 1。如果是 1,说明该 cgroup 被冻结了,否则是 0(冻结一个 cgroup 就好比把它的所有进程——以及所有后代 cgroup 和它们的进程!——统统扔进“冰箱”,意味着它们保持停止状态直到解冻)。
在使用 systemd 作为 init 管理器的系统上(现在这很典型,我们也差不多默认假设是这种情况),总会有一个叫 init.scope 的 cgroup(scope?别急,我们后面会覆盖所有概念)。它只包含 init 进程 PID 1(systemd)。让我们查一下这个 init cgroup 的 cgroup.events 文件:
$ cat /sys/fs/cgroup/init.scope/cgroup.events
populated 1
frozen 0
既然它是 populated(值为 1),那就可以继续深挖。接下来,在 cgroup 层级结构里,你还会找到这个 cgroup 接口文件:
- cgroup.kill:只写;往这写 1 会让该 cgroup 树及其所有后代去死——目标 cgroup 树里的进程会被发送
SIGKILL信号。
接下来的几节还有几个 cgroup 文件。再说一遍,别担心,所有核心接口都记在文档里:https://www.kernel.org/doc/html/v6.1/admin-guide/cgroup-v2.html#core-interface-files。
内核文档关于 cgroups v2 的说明
目前我们覆盖的只是皮毛;正如你会欣赏的那样,在这一章和书里彻底解释清楚 cgroups 实在是太多了;更重要的是,书的作用是解释概念并通过示例帮助你学习。单纯重复内核文档里的细节完全没意义;关于 cgroups v2,资源模型、接口文件(包括核心的和各个控制器的)、使用规则、命名空间管理等等,在官方 6.1 系列内核文档里都有极其详尽的说明(就在 cgroups2 那一节):
https://www.kernel.org/doc/html/v6.1/admin-guide/cgroup-v2.html#control-group-v2
而且,最新内核版本的文档在这里:https://docs.kernel.org/admin-guide/cgroup-v2.html#control-group-v2。
所以,学会如何高效地查阅内核文档是很重要的(我们在在线章节《内核工作空间搭建》里简短地聊过这个,在“定位和使用 Linux 内核文档”那一节)。
给个截图(希望能帮你记住这些细节都在内核文档里,等着你在需要的时候去翻阅):
(此处插入 Figure 11.5 图片:内核文档关于 Cgroups v2 控制器/CPU 的部分截图)
我又要啰嗦了,强烈建议你在操作 cgroups v2 时,去浏览相关的文档。
好了,咱们接着往下看!
层级结构里的 cgroups
回到 cgroups 树的根部(或者叫层级结构,见 Figure 11.3)。正如提到的,那里看到的文件夹就代表 cgroups。现在咱们先不管是谁(以及怎么)创建它们的;只要知道它们是 systemd 在启动时设置的就行了。
让我们先从一个简单的 cgroup 开始,也就是 init.scope 这个文件夹代表的。往里看一眼,你会发现内核已经预填充了一大堆(伪)文件和接口;这是一个截断后的视图:
$ cd /sys/fs/cgroup/
$ ls init.scope/
cgroup.controllers cgroup.threads io.latency memory.high memory.oom.group
cgroup.events cgroup.type io.max memory.low memory.stat
cgroup.freeze cpu.idle io.pressure memory.max memory.swap.current
cgroup.kill cpu.max io.prio.class memory.min memory.swap.events
cgroup.subtree_control io.bfq.weight memory.events.local ...
$
这也不全是什么天书;我们刚才在上一节聊过几个关键的核心接口文件——cgroup.controllers 和 cgroup.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 里移除。不过,变更可能需要一点时间才能生效。)
如果你忘了这个接口文件代表什么,或者到底怎么操作它?简单,查内核文档。相关条目显然在这里:https://www.kernel.org/doc/html/v6.1/admin-guide/cgroup-v2.html#cpu(上一节其实就是在说这个)。
让我们在 init.scope cgroup 里看一些关于 CPU 控制器的关键接口(回想一下,我们现在在 /sys/fs/cgroup 文件夹里):
$ cat init.scope/cpu.max init.scope/cpu.weight init.scope/cpu.stat
max 100000
100
[...]
我们可以看到它们的值(啥意思?别急,马上就到:这将在“动手实验——通过 cgroups v2 约束 CPU 资源”那一节讲到!)。
现在,用 cat 也没问题,但你会发现,如果文件内容只有一行(左右),用简单的 grep hack 会更方便、更好看:grep . <file-spec>;所以这里,我们用这个技巧,把所有 CPU 控制器接口及其值一次性查出来:
(此处插入 Figure 11.6 图片:截图展示 init.scope cgroup 下所有 CPU 控制器接口文件及其当前值)
所以,我现在希望你能意识到,能看到这么多接口(和其他)伪文件,手头又有内核文档能帮你理解它们,这给了你真正开始理解 cgroups 的能力(跟学其他东西一样)。
你还应该意识到,cgroups 是可以嵌套的;cgroup 里面可以包含 cgroups,它们里面还可以包含 cgroups。要看到这一点,只需在一个 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.timedate1.scope
/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(文件夹)!这种设计故意是递归性质的。
(此处插入图片:提示只有 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 26
它揭示了在该 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 分页器):
(此处插入 Figure 11.7 图片:系统上 cgroup 层级结构的部分截图)
这当然太大了,没法全展示出来;你自己试着在系统上跑一下,好好研究研究。(跑一下 systemd-cgls -h 可以看帮助屏幕;细节见 man page)。
systemd 与 cgroups
手动管理 cgroups 是项艰巨的任务;强大的 systemd init 框架——大多数工作站发行版、企业级和数据中心服务器,甚至嵌入式 Linux 都默认在用——挺身而出解决了这个问题。正如我们已经注意到的那样,systemd 有趣的一面是它接管了在启动时创建和管理 cgroups 的角色,从而自动为用户和他们的应用利用这种强大能力。(当然,你越了解它是怎么做到的,你就越能根据项目需求去调整它)。
此外,你要知道有一些工具能帮助你可视化系统上定义的 cgroups(以及 slices/scopes);它们包括 ps,以及 systemd 项目自己的一些工具——systemd-cgls,systemctl,和 systemd-cgtop(我们马上就会看到我们自己的 cgroups 可视化脚本!)。
Slice 和 Scope
正如 Figure 11.6 清楚展示的那样,systemd 有智能自动构建 cgroups,逻辑上把进程分组在一起。为此,它定义并使用了两个概念——Slice(切片)和 Scope(作用域)。
- Slice:用于表示属于特定用户的所有进程,或者,它可以代表一堆其资源通过该单元进行管理的应用程序(进程)。(在 Figure 11.6 里,对于我的 UID 为 1000 的用户账户,显然我的 slice 叫
user-1000.slice)。 - Scope:代表 slice 的进一步逻辑细分或切分(这有点拗口,不是吗?);举个好例子,终端窗口里运行的所有进程通常会被 systemd 归到一个
session-<number>.scopecgroup 里(术语前缀是“session”,因为 session 代表在一个终端窗口里产生和管理的进程!)。
同样在 Figure 11.6 里,你可以清楚地看到一个由 sshd 建立的终端窗口, represented by the scope named session-9.scope,它是作为一个后代被组织在 user slice 下的。
此外,systemd 用(启动时的)scope 和 service 单元组织层级结构,把它们分配给一个合适的 slice(内部获得它们自己的 cgroup)。正如提到的,每个登录系统的用户也会被视为树中通用 user.slice 节点下的一个“slice”,当然他们跑的应用也会显示在那个 cgroup 下面(实际上,你在 Figure 11.6 里真的能看到我的 user slice,它显示为 user-1000.slice,在这个层级下就是那些“scope”单元)。
引用 systemd 文档的话:
(此处插入两张图片:systemd 文档关于 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
[ … ]
(此处插入图片:提示查看 Further reading 的 systemd 部分)
用 systemctl 和 systemd-cgtop 可视化 cgroups(以及 slices 和 scopes)
我们已经看过怎么用 systemd-cgls 扫描 cgroup 层级结构了。在 systemd 的庇护下查看它的另一种方式是通过 systemctl 应用和 systemd 单元类型。systemd 定义了几种单元类型:service, mount, swap, socket, target, device, automount, timer, path, slice, 和 scope。
这当中,只有最后两个跟我们这里相关,所以让我们通过 systemctl 命令来看看它们:
(此处插入 Figure 11.8 图片:展示如何通过 systemctl 查看 systemd 生成的 cgroup 层级结构)
现在你对 cgroups2 的基础有了把握,这里是 cgroups(7) man page 的部分截图,高亮了一些关键点;好好研读一下:
(此处插入 Figure 11.9 图片:cgroups.7 man page 的部分截图)
有没有办法看一个给定的进程属于哪个 cgroup(如果有)?当然:一种方式是把它们全列出来然后过滤出感兴趣的进程。通过 ps 带 cgroup 信息看进程列表很容易;只要加上 -o cgroup 选项就行;这里有个例子(f 选项的好处是显示父子关系):
$ ps fw -eo pid,user,cgroup,args
PID USER CGROUP COMMAND
2 root - [kthreadd]
3 root - \_ [ksoftirqd/0]
4 root - \_ [migration/0]
5 root - \_ [rcu_tasks_kthread]
[ … ]
注意看内核线程不属于任何 cgroup(这很合理,因为它们是内核的一部分)。好了,下面是上面 ps 输出的更多内容,展示了一些(被 systemd)挂载到 init.scope 和 system.slice cgroups 的进程(当然,init.scope cgroup 只有一个进程——systemd 它自己):
1 root 0::/init.scope /usr/lib/systemd/systemd [...]
1096 root 0::/system.slice/systemd-journal /usr/lib/systemd/systemd-journald
1109 root 0::/system.slice/systemd-udevd /usr/lib/systemd/systemd-udevd
1228 systemd+ 0::/system.slice/systemd-oomd /usr/lib/systemd/systemd-oomd
1229 systemd+ 0::/system.slice/systemd-resolve /usr/lib/systemd/systemd-resolved
[ … ]
(cgroup 的名字这里被截断了)。这里还有一个有趣的——就是 ps 进程自己:
[ … ]
1392 root 0::/system.slice/sshd.service /usr/sbin/sshd -D
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/6
1283749 kaiwan 0::/user.slice/user-1000.sl | \_ -bash
1377837 kaiwan 0::/user.slice/user-1000.sl | \_ ps f [...]
[ … ]
注意到它是属于 user.slice/user-1000.slice cgroup 的(另外,我是 ssh 到我的 Fedora 虚拟机的;所以我的 ps 进程是在 sshd … / bash 层级里的)。
回看一下 Figure 11.7 看看所有的 slices。有个问题:如果你稍微挖一下,你会发现好几个 slice 里似乎都没进程(比如 machine.slice,system-dbus…,system-getty,system-modprobe 等等);这是为啥?简单:cgroups 确实存在(systemd 在启动时创建的),但目前是空的——里面没有进程,这也没啥。
那么,当用户生成一个新进程(比如一个应用),它默认会出现在一个 cgroup 里吗?如果是,哪个?啊,这些都是重要的问题。答案大概是这样的:在 systemd 管理系统的情况下(通常都是这样),它会确保把每个新进程都放进一个合适的 slice 和 scope,也就是 cgroup 里。
所以,当我运行,比如说,vim 去编辑文件时,事情是这样的:
运行 vim 之前:
$ ps fw -eo pid,user,cgroup,args | grep "[v]im"
$
(此处插入图片:快速提示 grep 技巧)
运行 vim 之后:
$ ps fw -eo pid,user,cgroup,args | grep "[v]im"
1378003 kaiwan 0::/user.slice/user-1000.sl \_ vim
$
啊哈!正如预期的那样,在 systemd 的管理下,我们亲爱的 vim 进程已经成了我的 user slice 的一部分!它因此被追踪和管理了,任何应用到 user-1000.slice(及其祖先)slice 上的资源分配约束也会应用到它身上。
回到刚才的问题:如果不是跑 systemd 的盒子呢?那就没有 cgroups 管理了……这事儿就留给你了。你可以设置成把进程放进 cgroups(我们确实会在接下来的“手动方式——一个 cgroups v2 CPU 控制器”一节里讲讲怎么手动创建和管理带 CPU 控制器的 cgroup)。
要知道除了 systemd 这个大头,还有工具能帮你管理 cgroup(见 systemd-run(1));一个很好的例子是 cg* 工具套件(通过 cgroup-tools/libcgroup 包,安装后会有 cgcreate,cgexec,cgclassify 等工具)。
内核命名空间简介
稍微但有用地偏题一下:很有意思的是,关于容器(Containers)——一种强大的、行业标准且事实上的应用部署管理方式——的整个折腾,本质上基于 Linux 内核里的两个关键技术:cgroups 和 namespaces(命名空间)。
你可以把容器看作是某种程度的轻量级虚拟机(VM);如今使用的大多数容器技术——Docker,LXC,Kubernetes 等等——核心上都是这两个 Linux 内核 baked-in 技术的结合:cgroups 和 namespaces。
内核命名空间(Kernel namespace)是整个容器概念实现的一个关键 notions 和结构(内核里的结构是 struct nsproxy)。有了命名空间,内核可以以一种方式划分资源,使得一个命名空间里的一组进程看到的值是某些特定的值,而另一个命名空间里的进程看到的是另一些值。为啥要这玩意儿?
拿两个容器做例子;要有干净的隔离,每个都必须看到 PID 为 1, 2... 的进程。同样,每个可能都有自己的域名和主机名,自己的一套挂载点且内容对该容器唯一(比如一个容器里的 /proc),专属的网络接口等等。
内核可以维护很多命名空间;默认情况下,它们都是可选的,所以内核总是为每一个维护一个 <FOO> 全局命名空间的概念(其中 FOO 是命名空间的名字,比如 mount, PID 等等):
| 命名空间 | 提供了什么 |
|---|---|
| Mount | 每个 mount namespace 都有自己的文件系统布局(所以 mount ns1 里的 /proc 内容跟 mount ns2 里的不一样!) |
| PID | 进程隔离(因此每个 namespace 都可以有一个 PID 为 1 的进程) |
| Network | 网络隔离 |
| UTS | 域名和主机名隔离 |
| IPC | IPC 资源(共享内存、消息队列和信号量)可以被隔离 |
| User | User ID 隔离(允许不同 namespace 里的进程拥有相同的 UID/GID) |
表 11.2:内核命名空间
顺便提一句:正如你可能知道的,clone() 系统调用在 Linux 底层用来创建线程(pthread_create() 就调用它)。在它众多的标志位中——用来告诉内核如何创建一个定制的进程,或者说,线程——有一组标志位叫 CLONE_NEW*(比如 CLONE_NEWPID,CLONE_NEWNS,CLONE_NEWNET 等等)。
正是这些标志位让内核在新的命名空间里创建进程。其他命名空间相关的系统调用包括 setns(),unshare(),和 ioctl_ns();去翻翻它们的 man page 了解更多吧。(再次,本章的 Further reading 部分有更多关于内核命名空间和容器技术的链接)。
好了,回到我们的 cgroups 讨论上来!
使用 systemd-cgtop
另一种既能可视化 cgroups 层级结构,又能同时运行时观察哪些 cgroups——以及其中的 slices/services——在资源使用上是大头的方法,是通过非常有用的 systemd-cgtop 工具(实际上,它就是 systemd cgroups 版的、 venerable 的 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
-k Count all processes instead of tasks
--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.
你甚至可以交互式地切换排序字段;man page 里有解释:
... (type) p, t, c, m, i Sort the control groups by path, number of tasks, CPU load, memory load or IO load, respectively. ...
我建议你在你的 Linux 系统上运行这个工具,试试各种开关。
不过要小心:光运行 systemd-cgtop 工具还不够;(正如之前提过的)如果一个 cgroup 的资源统计没启用,它也没法告诉你资源使用情况。systemd-cgtop 的 man page 是这么说的:
... unless "CPUAccounting=1", "MemoryAccounting=1" and "BlockIOAccounting=" are enabled [...] some (or even all) data will not be available for system services and the data shown by systemd-cgtop will be incomplete and/or incorrect.
我们的 cgroups v2 探索脚本
现有的 cgroups 可视化工具有个问题:我们不能立即看出一个 cgroup 是有进程(populated)还是空的;而且,就算是有进程,我们也看不出到底哪些控制器在里面是启用(或禁用)的。知道这些是理解系统上 cgroup 树的关键。
我们的 Bash 脚本 cgroupsv2_explore (https://github.com/PacktPublishing/Linux-Kernel-Programming_2E/blob/main/ch11/cgroups/cgroupsv2_explore) 试图解决这个问题,它展示以下东西(以及更多):
- 给定一个起始 cgroup 作为参数,它会递归遍历所有嵌套的 cgroup;如果没指定,它就简单地从 cgroup 树的根部(
/sys/fs/cgroup)开始,从而扫描整棵树。 - 对于它解析的每个 cgroup,它首先检查:如果它不是 populated(如果里面没有存活进程),跳过下一个 cgroup,否则显示一些关于它的内容,比如:
- 子控制器(实际上是该 cgroup 的
cgroup.subtree_control伪文件的内容!回想一下我们关于子控制器的简要介绍...) - cgroup 类型(domain/domain threaded/threaded/...)
- 冻结状态(0/1,否/是)
- 属于该 cgroup 的进程:默认情况下,它显示进程数量(在括号里)然后是 PID 列表;如果给脚本传了
-p选项,它会显示里面的进程(通过 ps) - 属于该 cgroup 的线程:默认情况下,它显示线程数量(在括号里)然后是 PID 列表;如果给脚本传了
-t选项,它会显示里面的线程(通过 ps) - 关于该 cgroup 里几个控制器的数据;目前(这东西还在演进!):
- CPU
- Memory
- 子控制器(实际上是该 cgroup 的
此外,我们的脚本还接受几个选项开关:
-d:控制扫描树的深度-v:以详细模式显示-p / -t:显示属于每个 cgroup 的进程和/或线程(如上所述)
cgroupv2_explore 脚本的帮助屏幕一下子就把这些都展示出来了:
(此处插入 Figure 11.10 图片:我们的 cgroupsv2_explore bash 脚本的帮助屏幕截图)
好了。让我们跑一下它,把深度设为 1(这样就让它只报告 cgroup 根目录下的第一层文件夹)来保持输出最小化(截图被截断了,为了简洁):
(此处插入 Figure 11.11 图片:我们的 cgroupsv2_explore bash 脚本在深度设为 1 时的输出截图)
输出格式对应的就是刚才描述的那些。注意这里(Figure 11.10),/sys/fs/cgroup/init.scope 这个 cgroup 没有子控制器,意味着实际上并没有施加任何资源约束!但 /sys/fs/cgroup/system.slice 这个 cgroup 确实启用了 memory 和 pids 子控制器,因此系统会保持在里面指定的那些资源的约束。
你也可以看到这里(Figure 11.10),好几个 cgroup——从 machine.slice 开始到 sys-kernel-tracing.mount 为止——是空的,未填充(里面没有存活进程)。
你可以给脚本传一个特定的 cgroup;它会递归地显示该 cgroup 的元数据以及所有的后代 cgroup(如果有的话)。我们在下一节创建我们自己的 cgroup 时会试试这个功能;现在,作为一个练习,你自己试着在不同的 cgroup 上跑一下这个脚本。
动手实验——通过 cgroups v2 约束 CPU 资源
既然我们聚焦于 CPU(任务)调度,我们现在花点时间看看两种方法,用来指定 cgroup 内进程对 CPU 使用的资源约束:
- 简单方法:利用 systemd(通过一个在启动时运行的服务)。
- 手动方法:通过 Bash 脚本手动创建和管理一个 cgroup (v2),给一个演示应用施加 CPU 使用资源约束。
让我们开始第一种方法。
(此处插入图片:提示需要 systemd 基础知识)
利用 systemd 为服务设置 CPU 资源约束
Systemd 看起来像是 cgroups 上的一层抽象,允许系统管理员相当容易地对 cgroup 设置资源约束。Systemd 允许管理员(或 root 用户)定义服务,让它在启动时自动跑起来,并服从你在服务单元文件里指定的许多属性和/或约束——单元文件是定义跑什么以及怎么跑的元数据。
systemd 服务单元文件通常命名为 <foo>.service(通常能在 /etc/systemd/system 下找到好几个系统定义的)。这是一个纯 ASCII 文本文件,分为几个部分:[Unit],