故事的背景是:这周我在用 Rust 写今年 Advent of Code 的题目。全部完成之后,我想写个程序统一统计一下我写的程序的运行时间和最大占用内存。

运行时间的计算很简单,但是最大占用内存怎么处理呢?最简单的办法是轮询 /proc/<PID>/statm,就能拿到程序占用的 RSS(Resident set size),或者如果要把被换出到 swap 的页也算进去的话,就用慢一点但是更精确的 /proc/<PID>/smaps(Linux 2.6.14+)或者 /proc/<PID>/smaps_rollupLinux 4.14+):像 htop 这样的工具在 Linux 下获取每个进程的信息就是用读取 procfs 内容的方式实现的。(参考 proc(5)

虽然对于我手头要测试的简单算法程序来讲,这么做够用了,但是这么做有两个问题:如果每次轮询的间隔太短,那么有一个 CPU 就会被吃满;如果每次轮询的间隔太长,那么记录得到的最大值就不准确。并且读取文件(特别是 smaps)似乎还会在 kernel 里有额外的的开销。

所以我的第一反应是:把要跑的进程放进 cgroups 里面应该就行?这样让内核去帮我统计最大内存使用量就行了。为了减少麻烦,以下不考虑 swap 的问题。

Cgroups

如果简单看过 cgroups(7) 就能知道 cgroups 有两个版本:v1 和 v2。目前来讲,绝大多数的 Linux 发行版都在使用 systemd,cgroups 文件系统的挂载处理也由 systemd 完成。Systemd 提供了三种模式

  • Unified: 只有 v2;
  • Legacy: 只有 v1;
  • Hybrid: 同时挂载 v1 和 v2,v2 挂载到 /sys/fs/cgroup/unified

考虑到现在大多数 Linux 发行版已经在使用 unified 模式,所以接下来默认只考虑 cgroups v2,并且假设其挂载在 /sys/fs/cgroup

基本结构

Cgroups 的结构是一棵树。我们可以看一下这棵树长什么样子:

> tree -d
.
├── dev-hugepages.mount
├── dev-mqueue.mount
├── init.scope
├── machine.slice
├── sys-fs-fuse-connections.mount
├── sys-kernel-config.mount
├── sys-kernel-debug.mount
├── sys-kernel-tracing.mount
├── system.slice
│   ├── accounts-daemon.service
│   ├── bluetooth.service
│   ├── bolt.service
│   ├── colord.service
│   ├── com.system76.Scheduler.service
│   ├── dbus.service
│   ├── docker.service
│   ├── docker.socket
│   ├── flatpak-system-helper.service
│   ├── fwupd.service
│   ├── gdm.service
│   ├── home.mount
│   ├── libvirtd.service
│   ├── NetworkManager.service
│   ├── nix-daemon.service
│   ├── opt.mount
│   ├── polkit.service
│   ├── rtkit-daemon.service
│   ├── systemd-journald.service
│   ├── systemd-logind.service
│   ├── systemd-machined.service
│   ├── systemd-timesyncd.service
│   ├── systemd-udevd.service
│   │   └── udev
│   ├── system-getty.slice
│   ├── system-modprobe.slice
│   ├── system-systemd\x2dbacklight.slice
│   ├── system-systemd\x2dcoredump.slice
│   ├── tmp.mount
│   ├── udisks2.service
│   ├── upower.service
│   ├── var.mount
│   ├── virtlogd.service
│   └── wpa_supplicant.service
└── user.slice
    └── user-1000.slice
        ├── session-10.scope
        ├── session-13.scope
        ├── session-7.scope
        └── [email protected]
            ├── app.slice
            │   ├── app-flatpak-sh.cider.Cider-1174161.scope
            │   ├── app-flatpak-sh.cider.Cider-1174198.scope
            │   ├── app-gnome-firefox-1167395.scope
            │   ├── app-gnome-org.fcitx.Fcitx5-1165435.scope
            │   ├── app-gnome-org.gnome.eog-1016712.scope
            │   ├── app-gnome-org.gnome.Lollypop-1200452.scope
            │   ├── app-gnome-org.gnome.SettingsDaemon.DiskUtilityNotify-1165402.scope
            │   ├── app-gnome-org.gnome.Software-1165439.scope
            │   ├── app-gnome-org.kde.konsole-1167995.scope
            │   ├── app-gnome-org.kde.konsole-135967.scope
            │   ├── app-gnome-org.kde.konsole-939402.scope
            │   ├── app-gnome-thunderbird-1166260.scope
            │   ├── app-gnome\x2dsession\x2dmanager.slice
            │   │   └── [email protected]
            │   ├── app-org.gnome.Terminal.slice
            │   ├── dbus.socket
            │   ├── dconf.service
            │   ├── evolution-addressbook-factory.service
            │   ├── evolution-calendar-factory.service
            │   ├── evolution-source-registry.service
            │   ├── flatpak-portal.service
            │   ├── flatpak-session-helper.service
            │   ├── gnome-keyring-daemon.service
            │   ├── gnome-session-monitor.service
            │   ├── obex.service
            │   ├── xdg-desktop-portal-gnome.service
            │   └── xdg-desktop-portal-gtk.service
            ├── background.slice
            │   └── tracker-miner-fs-3.service
            ├── init.scope
            └── session.slice
                ├── at-spi-dbus-bus.service
                ├── dbus.service
                ├── gvfs-afc-volume-monitor.service
                ├── gvfs-daemon.service
                ├── gvfs-goa-volume-monitor.service
                ├── gvfs-gphoto2-volume-monitor.service
                ├── gvfs-metadata.service
                ├── gvfs-mtp-volume-monitor.service
                ├── gvfs-udisks2-volume-monitor.service
                ├── org.gnome.SettingsDaemon.A11ySettings.service
                ├── org.gnome.SettingsDaemon.Color.service
                ├── org.gnome.SettingsDaemon.Datetime.service
                ├── org.gnome.SettingsDaemon.Housekeeping.service
                ├── org.gnome.SettingsDaemon.Keyboard.service
                ├── org.gnome.SettingsDaemon.MediaKeys.service
                ├── org.gnome.SettingsDaemon.Power.service
                ├── org.gnome.SettingsDaemon.PrintNotifications.service
                ├── org.gnome.SettingsDaemon.Rfkill.service
                ├── org.gnome.SettingsDaemon.ScreensaverProxy.service
                ├── org.gnome.SettingsDaemon.Sharing.service
                ├── org.gnome.SettingsDaemon.Smartcard.service
                ├── org.gnome.SettingsDaemon.Sound.service
                ├── org.gnome.SettingsDaemon.UsbProtection.service
                ├── org.gnome.SettingsDaemon.Wacom.service
                ├── org.gnome.SettingsDaemon.XSettings.service
                ├── [email protected]
                ├── pipewire-pulse.service
                ├── pipewire.service
                ├── wireplumber.service
                ├── xdg-desktop-portal.service
                ├── xdg-document-portal.service
                └── xdg-permission-store.service

/sys/fs/cgroup 下面的每个目录(包括它本身)都是 cgroups 树的一个节点,每个节点都可以进行相关的设置(但是不仔细读文档的话会遇到坑)。节点内部的文件则用来控制这个节点的行为,例如,cgroup.procs 就包含这个节点控制的进程的 PID。

默认情况下,cgroups 树只有根节点本身,其 cgroup.procs 包含了系统的全部进程,但是由于 systemd 的存在,它会创建一些例如 user.slice 的子节点,并且将进程都移动到自己管理的 cgroup 节点中,所以如果 cat /sys/fs/cgroup/cgroup.procs,可以发现里面只有一部分进程(比如说内核进程)。

我们也可以创建自己的节点:

> cd /sys/fs/cgroup
> sleep 1d &
> jobs
Job     Group   CPU     State   Command
1       1202475 0%      running sleep 1d &
> grep 1202475 **/cgroup.procs  # bash 可能需要先 shopt -s globstar
user.slice/user-1000.slice/[email protected]/app.slice/app-gnome-org.kde.konsole-1167995.scope/cgroup.procs:1202475
> sudo -i
# cd /sys/fs/cgroup
# mkdir test
# cd test
# ls
cgroup.controllers      cpuset.mems.effective     io.bfq.weight        memory.reclaim
cgroup.events           cpu.stat                  io.latency           memory.stat
cgroup.freeze           cpu.uclamp.max            io.low               memory.swap.current
cgroup.kill             cpu.uclamp.min            io.max               memory.swap.events
cgroup.max.depth        cpu.weight                io.pressure          memory.swap.high
cgroup.max.descendants  cpu.weight.nice           io.prio.class        memory.swap.max
cgroup.pressure         hugetlb.1GB.current       io.stat              memory.zswap.current
cgroup.procs            hugetlb.1GB.events        io.weight            memory.zswap.max
cgroup.stat             hugetlb.1GB.events.local  irq.pressure         misc.current
cgroup.subtree_control  hugetlb.1GB.max           memory.current       misc.events
cgroup.threads          hugetlb.1GB.numa_stat     memory.events        misc.max
cgroup.type             hugetlb.1GB.rsvd.current  memory.events.local  pids.current
cpu.idle                hugetlb.1GB.rsvd.max      memory.high          pids.events
cpu.max                 hugetlb.2MB.current       memory.low           pids.max
cpu.max.burst           hugetlb.2MB.events        memory.max           pids.peak
cpu.pressure            hugetlb.2MB.events.local  memory.min           rdma.current
cpuset.cpus             hugetlb.2MB.max           memory.numa_stat     rdma.max
cpuset.cpus.effective   hugetlb.2MB.numa_stat     memory.oom.group
cpuset.cpus.partition   hugetlb.2MB.rsvd.current  memory.peak
cpuset.mems             hugetlb.2MB.rsvd.max      memory.pressure
# echo 1202475 > cgroup.procs
# cd ..
# cat user.slice/user-1000.slice/[email protected]/app.slice/app-gnome-org.kde.konsole-1167995.scope/cgroup.procs | grep 1202475
# cat test/cgroup.procs | grep 1202475
1202475

可以看到,我们创建了一个 test 节点,然后将 sleep 1d 这个进程从 user.slice/user-1000.slice/[email protected]/app.slice/app-gnome-org.kde.konsole-1167995.scope 移动到了 test 中,之后就可以做操作了。

看起来 memory.peak 是我们要的东西……?(备注:memory.peak 其实读取的是 cgroup 组内 memory 的 “watermark”

> cat /sys/fs/cgroup/test/memory.peak
0

诶,可能是因为 sleep 一直在睡觉吧,加个 python 进去看看:

> python
>>> import os
>>> os.getpid()
1203372
> # 开个新终端
> sudo bash -c 'echo 1203372 > /sys/fs/cgroup/test/cgroup.procs'
> cat /sys/fs/cgroup/test/memory.peak
0
> # ehh?
> # 回去看看 python
>>> x = [0] * 100000
> # 再跳回去
> cat /sys/fs/cgroup/test/memory.peak
1048576

首先可以看出来,cgroup 的 memory subsystem 只会在内存占用情况变化的时候记录信息。然后尽管 sys.getsize([0] * 100000) == 800056,但是 memory.peak 的值 1048576 恰好是 2 ** 20。观察 /sys/fs/cgroup/test/memory.stat,发现匿名内存占用(anon)为 802816 bytes,而缺页次数(pgfault)为 196,页为 4K,802816 / 4096 = 196,似乎和缺页次数也能对上。但是 memory.peak 实际对应了 1048576 / 4096 = 256 页。

小插曲:Cgroup 到底是怎么统计内存占用最大值的?

(Kernel version = 6.1.1)

我希望能够解释为什么它的值恰好是 2 ** 20,所以我看了一下 python 使用的系统调用:

> strace -e mmap,brk python
// 省略
>>> x = [0] * 100000
mmap(NULL, 802816, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f76c9871000
>>>

以及简单的 C 程序测试也出现了相同的 peak 值:

#include <stdio.h>
#include <unistd.h>
#include <sys/mman.h>

int main() {
    int x;
    printf("%d\n", getpid());
    scanf("%d", &x);  // add to cgroup node now, and then input & enter
    char *y = mmap(NULL, 802816, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
    printf("%p\n", y);
    for (int i = 0; i < 802816; i++) {
        y[i] = 114;
    }
    for (;;) {
    }
}

把这个程序做了一些小修改后:

#include <stdio.h>
#include <unistd.h>
#include <sys/mman.h>

int main() {
#define PAGESIZE 4096
    int x;
    printf("cd .. && rmdir test3 && mkdir test3 && cd test3 && echo %d > cgroup.procs\n", getpid());
    scanf("%d", &x);  // add to cgroup node now, and then input & enter
    char *y = mmap(NULL, 196 * PAGESIZE, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
    printf("%p\n", y);
    for (int i = 0; i < PAGESIZE * x; i++) {
        y[i] = 114;
    }
    for (;;) {
    }
    return 0;
}

我把 mm 的关于 readahead 的部分简单读了一下,在 2022 年最后一天晚上跑了测试之后,本来打算写这么一段话的:

不正确的结论我在自己机器上跑了一下,发现在顺序写入的模式下,mmap() 产生的内存区域在首次访问缺页时会向后预取 64 页(包含它本身,大小是 256 KiB),而在写完被预取的最后一页之后,又会再向后预取 64 页,这样的话,ceil(196 / 64) * 64 = 256,就解释得通了。这和网络上流传的「mmap() 默认预读取 32 页(128 KiB),并且前后各读取 15/16 页(read-around)」的说法是不相符的。我翻了一遍 kernel mm 的源代码,没有找到 readahead pages 大小具体的设置。并且这个行为似乎也和读取/写入的 pattern 相关联,且 mmap() 得到的内存区域如果足够大,会被 kernel 的透明巨页处理,在访问时可能会分配 2M 的巨页。

但是感觉还是怪怪的,关键是,这是匿名页,而且只做了写入操作,真的会用到 readahead 的逻辑吗?而且这个 readahead 测试出来的结果也很奇怪。简单搜索了一下,发现 kernel 的 ftrace 机制可以 trace 进程访问的内核函数,然后我试了一下,果然我的猜测是完全错误的:

> sudo trace-cmd record -p function_graph -P 1280748  # 换成对应的 PID
(好了之后 Ctrl + C 结束 tracing)
> sudo trace-cmd report
(以上略)
          python-1280748 [010] 840907.640742: funcgraph_entry:                   |  handle_mm_fault() {
          python-1280748 [010] 840907.640742: funcgraph_entry:        0.382 us   |    __rcu_read_lock();
          python-1280748 [010] 840907.640742: funcgraph_entry:        0.355 us   |    mem_cgroup_from_task();
          python-1280748 [010] 840907.640743: funcgraph_entry:                   |    __count_memcg_events() {
          python-1280748 [010] 840907.640744: funcgraph_entry:        1.030 us   |      cgroup_rstat_updated();
          python-1280748 [010] 840907.640745: funcgraph_exit:         1.570 us   |    }
          python-1280748 [010] 840907.640745: funcgraph_entry:        0.266 us   |    __rcu_read_unlock();
          python-1280748 [010] 840907.640746: funcgraph_entry:                   |    __handle_mm_fault() {
          python-1280748 [010] 840907.640747: funcgraph_entry:                   |      vma_alloc_folio() {
          python-1280748 [010] 840907.640747: funcgraph_entry:        0.300 us   |        __get_vma_policy();
          python-1280748 [010] 840907.640748: funcgraph_entry:        0.263 us   |        policy_nodemask();
          python-1280748 [010] 840907.640748: funcgraph_entry:        0.286 us   |        policy_node();
          python-1280748 [010] 840907.640749: funcgraph_entry:                   |        __folio_alloc() {
          python-1280748 [010] 840907.640749: funcgraph_entry:                   |          __alloc_pages() {
          python-1280748 [010] 840907.640750: funcgraph_entry:        0.263 us   |            should_fail_alloc_page();
          python-1280748 [010] 840907.640750: funcgraph_entry:        0.344 us   |            __next_zones_zonelist();
          python-1280748 [010] 840907.640751: funcgraph_entry:                   |            get_page_from_freelist() {
          python-1280748 [010] 840907.640752: funcgraph_entry:        0.496 us   |              _raw_spin_trylock();
          python-1280748 [010] 840907.640753: funcgraph_entry:        0.280 us   |              _raw_spin_unlock_irqrestore();
          python-1280748 [010] 840907.640754: funcgraph_exit:         3.391 us   |            }
          python-1280748 [010] 840907.640755: funcgraph_exit:         5.442 us   |          }
          python-1280748 [010] 840907.640755: funcgraph_exit:         6.003 us   |        }
          python-1280748 [010] 840907.640755: funcgraph_exit:         8.319 us   |      }
          python-1280748 [010] 840907.640755: funcgraph_entry:                   |      __mem_cgroup_charge() {
          python-1280748 [010] 840907.640756: funcgraph_entry:                   |        get_mem_cgroup_from_mm() {
          python-1280748 [010] 840907.640756: funcgraph_entry:        0.273 us   |          __rcu_read_lock();
          python-1280748 [010] 840907.640757: funcgraph_entry:        0.268 us   |          __rcu_read_lock();
          python-1280748 [010] 840907.640757: funcgraph_entry:        0.427 us   |          __rcu_read_unlock();
          python-1280748 [010] 840907.640758: funcgraph_entry:        0.264 us   |          __rcu_read_unlock();
          python-1280748 [010] 840907.640758: funcgraph_exit:         2.475 us   |        }
          python-1280748 [010] 840907.640759: funcgraph_entry:                   |        charge_memcg() {
          python-1280748 [010] 840907.640759: funcgraph_entry:                   |          try_charge_memcg() {
          python-1280748 [010] 840907.640759: funcgraph_entry:                   |            page_counter_try_charge() {
          python-1280748 [010] 840907.640760: funcgraph_entry:        0.291 us   |              propagate_protected_usage();
          python-1280748 [010] 840907.640760: funcgraph_entry:        0.266 us   |              propagate_protected_usage();
          python-1280748 [010] 840907.640761: funcgraph_entry:        0.274 us   |              propagate_protected_usage();
          python-1280748 [010] 840907.640762: funcgraph_entry:        0.293 us   |              propagate_protected_usage();
          python-1280748 [010] 840907.640762: funcgraph_entry:        0.276 us   |              propagate_protected_usage();
          python-1280748 [010] 840907.640763: funcgraph_entry:        0.287 us   |              propagate_protected_usage();
          python-1280748 [010] 840907.640764: funcgraph_exit:         4.314 us   |            }
          python-1280748 [010] 840907.640764: funcgraph_entry:                   |            refill_stock() {
          python-1280748 [010] 840907.640764: funcgraph_entry:                   |              __refill_stock() {
          python-1280748 [010] 840907.640765: funcgraph_entry:                   |                drain_stock() {
          python-1280748 [010] 840907.640765: funcgraph_entry:                   |                  page_counter_uncharge() {
(以下略)

对着 elixir 翻一遍(或者其实要很多很多遍,我其实看了几乎两天多)代码可以知道:

  • vma_alloc_folio() 分配出了一个内存页结构。在 Linux 5.16+ 引入了 folio 的概念之后,struct pagestruct folio 就是两个指代相同东西的结构体:一个页或者一组页(compound pages)。
  • __handle_mm_fault() 似乎没有直接调用下面的函数,但是可以找到逻辑是 -> handle_pte_fault() -> do_anonymous_page()vma_alloc_folio() 则是再被 alloc_zeroed_user_highpage_movable() -> alloc_page_vma() 调用。
  • Linux 的内存分配用的是伙伴算法,而 vma_alloc_folio()order 参数在 alloc_page_vma() 调用的时候是 0。2 ** 0 = 1,代表内存分配算法会分配一页出来(尽管存在分配器里分不出 4K 的页,只能分配更大的物理内存空间导致内部碎片的可能)。
  • cgroup memory 的 peak (watermark) 在 page_counter_try_charge() 里更新。而对应的,charge_memcg() 的参数就是我们刚刚在 vma_alloc_folio() 里面分出来的 folio。照理来说……不应该有问题?

之后没有想到什么合理的解释,这时候搜索好像看到 eBPF 可以用来 trace 内核函数,于是我装了 bpftrace 跑了一下:

> sudo bpftrace -e 'kprobe:try_charge_memcg /pid == 1293555/ { printf("%u\n", arg2); }'  # 输出 try_charge_memcg() 被对应进程使用时的第三个参数(`nr_pages`)
Attaching 1 probe...
1
1
1
1
(略)
> sudo bpftrace -e 'kprobe:page_counter_try_charge /pid == 1293726/ { printf("%u\n", arg1); }'  # 重新开进程
Attaching 1 probe...
64
64
64
64
> # hmmm?

最后发现 try_charge_memcg() 有个 batch = max(MEMCG_CHARGE_BATCH, nr_pages),而 MEMCG_CHARGE_BATCH 值为 64。首次缺页的情况下,之后由于我们没有开 cgroupv1,没有 memsw,所以直接执行 page_counter_try_charge(&memcg->memory, batch, &counter) 给 page counter 加了 64。而第二次到后面的缺页会被 consume_stock() 处理,所以如果还在多记录的值以内,就不会再更新 page counter。

于是结案。

subtree_control 与 “No internal processes” rule

对于我想实现的场景,我希望我的程序能够依次测试需要测试的程序,但是 cgroup 节点的内存统计数据不能清除,所以每个被测试程序都需要开一个新节点装进去。初看似乎这么做是可以的:被测程序的启动器在某个节点上,每次要开的时候就建立一个新的子节点,然后把被测程序放进去,跑完之后销毁子节点就行。

真的吗?

# cd /sys/fs/cgroup
# mkdir test
# cd test
# ls
cgroup.controllers      cpuset.mems.effective     io.bfq.weight        memory.reclaim
cgroup.events           cpu.stat                  io.latency           memory.stat
cgroup.freeze           cpu.uclamp.max            io.low               memory.swap.current
cgroup.kill             cpu.uclamp.min            io.max               memory.swap.events
cgroup.max.depth        cpu.weight                io.pressure          memory.swap.high
cgroup.max.descendants  cpu.weight.nice           io.prio.class        memory.swap.max
cgroup.pressure         hugetlb.1GB.current       io.stat              memory.zswap.current
cgroup.procs            hugetlb.1GB.events        io.weight            memory.zswap.max
cgroup.stat             hugetlb.1GB.events.local  irq.pressure         misc.current
cgroup.subtree_control  hugetlb.1GB.max           memory.current       misc.events
cgroup.threads          hugetlb.1GB.numa_stat     memory.events        misc.max
cgroup.type             hugetlb.1GB.rsvd.current  memory.events.local  pids.current
cpu.idle                hugetlb.1GB.rsvd.max      memory.high          pids.events
cpu.max                 hugetlb.2MB.current       memory.low           pids.max
cpu.max.burst           hugetlb.2MB.events        memory.max           pids.peak
cpu.pressure            hugetlb.2MB.events.local  memory.min           rdma.current
cpuset.cpus             hugetlb.2MB.max           memory.numa_stat     rdma.max
cpuset.cpus.effective   hugetlb.2MB.numa_stat     memory.oom.group
cpuset.cpus.partition   hugetlb.2MB.rsvd.current  memory.peak
cpuset.mems             hugetlb.2MB.rsvd.max      memory.pressure
# mkdir test2
# cd test2
# pwd
/sys/fs/cgroup/test/test2
# ls
cgroup.controllers  cgroup.max.depth        cgroup.stat             cpu.pressure  memory.pressure
cgroup.events       cgroup.max.descendants  cgroup.subtree_control  cpu.stat
cgroup.freeze       cgroup.pressure         cgroup.threads          io.pressure
cgroup.kill         cgroup.procs            cgroup.type             irq.pressure
# # 诶,怎么只剩这么点了?

搜索就能知道要往 cgroup.subtree_control 里面加上 memory 才行。

# cd ..
# pwd
/sys/fs/cgroup/test
# echo +memory > cgroup.subtree_control
# ls test2/
cgroup.controllers      cgroup.subtree_control  memory.events.local  memory.reclaim
cgroup.events           cgroup.threads          memory.high          memory.stat
cgroup.freeze           cgroup.type             memory.low           memory.swap.current
cgroup.kill             cpu.pressure            memory.max           memory.swap.events
cgroup.max.depth        cpu.stat                memory.min           memory.swap.high
cgroup.max.descendants  io.pressure             memory.numa_stat     memory.swap.max
cgroup.pressure         irq.pressure            memory.oom.group     memory.zswap.current
cgroup.procs            memory.current          memory.peak          memory.zswap.max
cgroup.stat             memory.events           memory.pressure

看起来很好,让我加个进程进来……

# pwd
/sys/fs/cgroup/test
# echo 1297349 > cgroup.procs
-bash: echo: write error: Device or resource busy
# # ??????????????
# # 删掉 test2 试试
# rmdir test2
# echo 1297349 > cgroup.procs
-bash: echo: write error: Device or resource busy
# # ??????????????
# # 回滚一下刚刚的操作
# echo -memory > cgroup.subtree_control
# echo 1297349 > cgroup.procs
# # 又可以了,试试再加一下 subtree_control
# echo +memory > cgroup.subtree_control
-bash: echo: write error: Device or resource busy

仔细读过 cgroups(7) 可以发现有解释(这个文档很长,所以恐怕很多人都没有耐心看完):

Cgroups v2 "no internal processes" rule
    Cgroups  v2 enforces a so-called "no internal processes" rule.  Roughly
    speaking, this rule means that, with the exception of the root  cgroup,
    processes may reside only in leaf nodes (cgroups that do not themselves
    contain child cgroups).  This avoids the need to decide how  to  parti‐
    tion resources between processes which are members of cgroup A and pro‐
    cesses in child cgroups of A.

虽然:

# echo cgroup.procs
1297349
# mkdir test2
# ls test2
cgroup.controllers  cgroup.max.depth        cgroup.stat             cpu.pressure  memory.pressure
cgroup.events       cgroup.max.descendants  cgroup.subtree_control  cpu.stat
cgroup.freeze       cgroup.pressure         cgroup.threads          io.pressure
cgroup.kill         cgroup.procs            cgroup.type             irq.pressure
# # 好像不是不行?

这在之后有更详细的解释:

The "no internal processes" rule is in fact  more  subtle  than  stated
above.   More precisely, the rule is that a (nonroot) cgroup can't both
(1) have member processes, and  (2)  distribute  resources  into  child
cgroups—that is, have a nonempty cgroup.subtree_control file.  Thus, it
is possible for a cgroup  to  have  both  member  processes  and  child
cgroups,  but  before  controllers  can be enabled for that cgroup, the
member processes must be moved out of the cgroup  (e.g.,  perhaps  into
the child cgroups).

所以对于非根节点,要么开启 cgroup.subtree_control(默认为空),要么本身包含进程,二选一。所以模式只能设计成,在节点(比如说 test)内为启动器开一个单独的子节点(比如说 test/0),需要启动被测试程序的时候就在外面创建一个新的子节点(比如说 test/1),然后放进去。

文档中还介绍了线程模式的情况,但是我目前用不到,也没有测试过,所以不讨论。

委派(Delegation)

前面的测试都是在 root 下进行的,但是我希望我的程序能以非特权用户的身份执行并控制我创建的 cgroup(root 毕竟是很危险的)。这是可以做到的,将 cgroup 子树的管理权限给非特权用户的操作被文档称为「委派」,对于我们的需求来讲,用 chown 修改 testtest.procs 的所有者,使得它们可以被需要授权的用户写入就可以了(根据文档的要求,其他的接口类的文件,例如 memory.high 等,不应该被修改 ownership)。

听起来很简单?

# pwd
/sys/fs/cgroup/test
# chown taoky:taoky .
# chown taoky:taoky cgroup.procs
# # 用我自己的用户试试
> echo 1298886 > /sys/fs/cgroup/test/cgroup.procs
write: Permission denied
> # ?????

这是因为上面提到,写入 cgroup.procs 并不是单纯的「添加进程」,实际对应的操作是从别的 cgroup 节点里移动进程过来。但是这里我自己的用户没有从 root 所有的 cgroup 节点里移动进程的权限,所以失败了。

# echo 1226665 > /sys/fs/cgroup/test/cgroup.procs
# # 切到自己的用户
> cd /sys/fs/cgroup/test
> mkdir test2
> # 我可以新创建子节点
> echo 1226665 > test2/cgroup.procs
> # 把进程移动到子节点里(没有开 subtree_control)
> echo +memory > cgroup.subtree_control
warning: An error occurred while redirecting file 'cgroup.subtree_control'
open: Permission denied
> # 哦忘了,回去加一下
# chown taoky:taoky cgroup.subtree_control
# # 切回去
> echo +memory > cgroup.subtree_control
> # 可以了

实现

我用 Rust 完成了功能的实现。在初始化时,如果当前用户是 root,就直接用 Rust 的文件操作 API 在 /sys/fs/cgroup 下创建需要的节点(而且不需要给程序本身创建子节点,因为 root 可以顺利把生成的进程从其他的节点移动过来),否则就询问用户是否以 sudo 权限执行初始化脚本:

// 或许可以用 Polkit 那一套东西显示更好的权限申请对话框?
fn sudo(script: &str, explanation: &str) -> Result<()> {
    eprint!("{}", explanation);
    eprintln!(" Thus the following script will be run with bash by sudo:");
    eprintln!("{}", script);
    eprint!("Continue? [y/N] ");

    let mut input = String::new();
    std::io::stdin().read_line(&mut input)?;
    if input.trim() == "y" {
        let mut cmd = Command::new("sudo");
        cmd.args(["bash", "-c", script]);
        let res = cmd.spawn()?.wait()?;
        if !res.success() {
            Err(anyhow::anyhow!("Failed to run sudo script."))
        } else {
            Ok(())
        }
    } else {
        Err(anyhow::anyhow!("User cancelled."))
    }
}

// 糊出来的,所以脚本比较丑
fn sudo_initialize_cgroup() -> Result<()> {
    let uid = Uid::current();
    let mut script = format!(
        r#"
set -ex
rmdir /sys/fs/cgroup/adventofcode-2022/day* || true
rmdir /sys/fs/cgroup/adventofcode-2022 || true
mkdir /sys/fs/cgroup/adventofcode-2022
chown {} /sys/fs/cgroup/adventofcode-2022
chown {} /sys/fs/cgroup/adventofcode-2022/cgroup.procs
mkdir /sys/fs/cgroup/adventofcode-2022/day0 && chown {} /sys/fs/cgroup/adventofcode-2022/day0 && chown {} /sys/fs/cgroup/adventofcode-2022/day0/cgroup.procs
echo "+memory" > /sys/fs/cgroup/adventofcode-2022/cgroup.subtree_control
echo {} > /sys/fs/cgroup/adventofcode-2022/day0/cgroup.procs
"#,
        uid,
        uid,
        uid,
        uid,
        Pid::this()
    );
    for (day, part) in day_part_iterator() {
        script += format!(
            "mkdir /sys/fs/cgroup/adventofcode-2022/day{}-{} && chown {} /sys/fs/cgroup/adventofcode-2022/day{}-{} && chown {} /sys/fs/cgroup/adventofcode-2022/day{}-{}/cgroup.procs && echo 0 > /sys/fs/cgroup/adventofcode-2022/day{}-{}/memory.swap.max\n",
            day, part, uid, day, part, uid, day, part, day, part
        )
        .as_str();
    }

    sudo(script.as_str(), "This program needs to initialize cgroupv2 for memory usage analysis. This requires root permission.")
}

在执行程序时,需要把程序放进对应的 cgroup 节点里面。但是默认的 Rust Commandspawn() 之后就直接开始了,但是我们需要在实际 exec() 前跑我们自己的代码(其实默认情况下,Rust 会优先调用 posix_spawn() 而非 fork() 创建进程),因此需要用 unsafe 的 pre_exec() 来实现我们的目的:

unsafe {
    cmd.pre_exec(move || {
        std::fs::OpenOptions::new()
            .write(true)
            .open(
                Path::new(CGROUP_DIR)
                    .join(format!("day{}-{}", day, part))
                    .join("cgroup.procs"),
            )
            .unwrap()
            .write_all(format!("{}", std::process::id()).as_bytes())
            .unwrap();
        Ok(())
    });
}

我没有在多线程环境下测试过上面的代码!如果真的要用,可能要处理好 Path::new()format! 的部分,见下。)

pre_exec() 是 unsafe 的,很大程度上是因为 fork() 是 unsafe 的。在很多年前,有个 “safe” 的在程序执行前运行代码的接口 before_exec(),但是已经废弃了,起初的原因即在于 fork() 在多线程环境下危险的限制:

fork(2):

*  After a fork() in a multithreaded program, the child can safely call
   only  async-signal-safe  functions (see signal-safety(7)) until such
   time as it calls execve(2).

(这个列表也是允许在信号处理函数里面运行的库函数和系统调用的列表)

而这个异步信号安全函数列表有多少函数呢?大概对于我们的需求,可以开文件 open(2) 看一看 read(2) 写一写 write(2),但是不能做内存地址分配 malloc(3),也不能直接退出 exit(3)(要用 _exit(2) 替代)。

相信用过 Python multiprocessing 的同学肯定有被 fork() 的这个行为坑的。

最后的清理工作有一些小坑:

fn sudo_cleanup_cgroup() -> Result<()> {
    let script = r#"
set -ex
# move current process to root cgroup tree node
PIDS=$(</sys/fs/cgroup/adventofcode-2022/day0/cgroup.procs)
for i in $PIDS; do 
    echo $i > /sys/fs/cgroup/cgroup.procs || true; 
done
rmdir /sys/fs/cgroup/adventofcode-2022/day*
rmdir /sys/fs/cgroup/adventofcode-2022
"#;
    sudo(script, "This program needs to cleanup cgroupv2 (created at the beginning). This requires root permission.")
}

Cgroup 的节点必须要在没有子节点、也不包含任何进程的情况下才能删除。但是当 bash 执行 cat /sys/fs/cgroup/adventofcode-2022/day0/cgroup.procs 的时候,事实上获得的进程列表多了一个 cat。然后在下面写入到 /sys/fs/cgroup/cgroup.procs 的时候,不存在的 cat 就会导致报错。

那么用 PIDS=$(</sys/fs/cgroup/adventofcode-2022/day0/cgroup.procs) 呢?很不幸,这个时候 bash 会把自己裂出一份(看 strace 好像是 clone(2) 出来的)去读这个文件,于是得到的文件列表里还是有一个多余的进程。最后只能忽略写入到根节点 cgroup.procs 出现错误的情况。

getrusage(2)

大概是写完之后的第二天,我想到 GNU 的 time 实现可以显示程序的最大占用 RSS,当时猜测可能在 Linux 下是轮询 procfs,然后去读了读代码,发现好像有个叫 wait3() 的东西,好像可以从里面拿出一些统计数据。

让我翻翻文档.jpg 1

If rusage is not NULL, the struct rusage to which  it  points  will  be
filled  with  accounting information about the child.  See getrusage(2)
for details.

这啥.jpg

嗯?getrusage(2)?让我看看:

RUSAGE_CHILDREN
        Return resource usage statistics for all children of the calling
        process that have terminated and been waited for.  These statis‐
        tics  will include the resources used by grandchildren, and fur‐
        ther removed descendants, if all of the intervening  descendants
        waited on their terminated children.
...
ru_maxrss (since Linux 2.6.32)
        This is the maximum resident set size used (in kilobytes).   For
        RUSAGE_CHILDREN,  this  is  the resident set size of the largest
        child, not the maximum resident set size of the process tree.

白写了.jpg

所以其实完全可以用 getrusage(2) 来做,时间(用户时间和内核时间)也顺便帮你统计好了。只不过它返回的是所有已经结束的子进程合计的数据(我又去翻了内核源代码,因为一开始看成了「已经结束但是还没 wait 的进程的数据」,还特地写了一个用 waitid(2) WNOWAIT 的实现,然后发现结果怎么都不对)。所以需要先 fork() 出来,然后子进程 spawn 出被测进程,等被测进程结束之后 getrusage(2),然后把数据传回父进程。

实现

前面提到 fork(2) 在 Rust 的模型中是不安全的。对于 pre_exec() 来说,能够执行的闭包有一些约束(主要是 'static?):

unsafe fn pre_exec<F>(&mut self, f: F) -> &mut process::Command
where
    F: FnMut() -> io::Result<()> + Send + Sync + 'static,

我之前感觉 fork 的行为和 Rust 的模型是不太匹配的(即使没有 async-signal-safety 的问题):比如说,fork() 之后,就可能出现两个进程同时拿着一个 &mut 的情况,有可能导致问题——但是父进程和子进程默认不共享内存,所以可能这一点不是问题。主要可能带来麻烦的是多线程下的 fork() 以及有锁的情况。

所以现在我们需要自己 unsafe { fork()? } 了:

match unsafe { fork()? } {
    ForkResult::Parent { child, .. } => {
        waitpid(child, None)?;
    }
    ForkResult::Child => {
        // spawn cmd, and getrusage(), and send back metrics to parent
        unsafe { libc::_exit(0) };
    }
}

问题的关键是,怎么实现 IPC 呢?现在写的时候想想,应该是可以用管道来处理的(而且比下面的做法要合理很多),不过之前实际写的时候的做法是:

  • 初始化的时候用 memfd_create() 在内存里创建一个匿名文件;
  • fork() 的时候文件描述符也会一起复制,在子进程里清空文件、seek 到开头之后向文件写入数据(反正要传输的数据只要两个数字,如果发生了错误就写入错误字符串);
  • 写完之后子进程退出,父进程顺利 waitpid() 之后读文件,parse 读到的字符串。

也能用(主要是文件要清空和 seek 略微麻烦)。另外这种方法父进程和子进程不能直接一起写(要用 flock?我没有测试过它是否能应用在 memfd 上面)。

总结

这么看下来,好像还是写轮询最简单(也最「安全」?毕竟因为不用管 fork() 而不需要 unsafe)。另外写代码以及写本文的时候也读了一些 kernel 的代码,以及使用了一些 tracing kernel 的工具,这是比较意外的——首先我没有料到自己真的会去翻 elixir(以及本地 rg),其次现在做 tracing 也比我想象的方便太多了:获取内核函数调用信息不需要我去编译一个带超大调试符号的 kernel,也不需要再找一台电脑连着来 gdb。我不太喜欢黑盒子,所以能了解 how it works 也是很有意思的。

另外祝各位读者 2023 新年快乐!

附注:使用 systemd user session 实现非特权用户使用 cgroups

(Updated at 2023/01/05)

不可行的方案:用户命名空间(User namespace)

前几天写完之后我在想,能不能用用户命名空间 + Cgroup 命名空间 + 挂载命名空间的方式来实现?因为在用户创建的命名空间里面,他自己就可以像 root 一样做一些事情,再加上 cgroup 和挂载的隔离,或许是可行的?

> whoami
taoky
> # -U => User namespace, -C => Cgroup namespace, -r => map current user to root, -m => Mount namespace
> unshare -UCrm
Welcome to fish, the friendly interactive shell
Type help for instructions on how to use fish
# whoami
root
# cat /proc/self/cgroup
0::/
# # 确实进程看到自己的 cgroup 是处在根的(在新的命名空间里面了)
# mkdir /tmp/cgrouptest
# mount -t cgroup2 none /tmp/cgrouptest
# cd /tmp/cgrouptest
# ls
cgroup.controllers      cpu.stat             memory.pressure
cgroup.events           io.pressure          memory.reclaim
cgroup.freeze           irq.pressure         memory.stat
cgroup.kill             memory.current       memory.swap.current
cgroup.max.depth        memory.events        memory.swap.events
cgroup.max.descendants  memory.events.local  memory.swap.high
cgroup.pressure         memory.high          memory.swap.max
cgroup.procs            memory.low           memory.zswap.current
cgroup.stat             memory.max           memory.zswap.max
cgroup.subtree_control  memory.min           pids.current
cgroup.threads          memory.numa_stat     pids.events
cgroup.type             memory.oom.group     pids.max
cpu.pressure            memory.peak          pids.peak
# cat cgroup.procs
165570
(省略)
278195
# # 有一些外面的 PID,如果写入 subtree_control 会如何?
# echo +memory > cgroup.subtree_control
write: Device or resource busy
# # 可以看到,我们现在所处的并非真正的 cgroup 树根(即使看到的是 /)
# mkdir testinuserns
# # 切换终端
> cd /sys/fs/cgroup/
> find . -name 'testinuserns'
./user.slice/user-1000.slice/[email protected]/app.slice/app-gnome-org.kde.konsole-165570.scope/testinuserns

所以可以发现,所谓 cgroup 命名空间的隔离,在 v2 中只是给命名空间中的进程一个子树的 “view”。它没有实际的隔离效果,内部的操作仍然受到最外面这棵 cgroup 树的约束。而我们需要 memory,把整个 scope 里面的进程搬到子节点或许可行,但是这样可能破坏了 systemd 维护的 cgroup 树的结构。

Systemd? DBus?

事实上,本文最开始的做法也不为 systemd 所推荐

Specifically: 🔥 don’t create your own cgroups below the root cgroup 🔥. That’s owned by systemd, and you will step on systemd’s toes if you ignore that, and systemd will step on yours. Get your own delegated sub-tree, you may create as many cgroups there as you like. Seriously, if you create cgroups directly in the cgroup root, then all you do is ask for trouble.

Systemd 不喜欢有人去随便改 cgroup 树。我们可以通过创建瞬态(transient)scope 的方法来实现这一点,先用 systemd-run 试试:

> systemd-run --scope --user fish
Running scope as unit: run-rb1b698264f37427bbe14a4945bfdebbb.scope
Welcome to fish, the friendly interactive shell
Type help for instructions on how to use fish
> systemctl status --user run-rb1b698264f37427bbe14a4945bfdebbb.scope
● run-rb1b698264f37427bbe14a4945bfdebbb.scope - /usr/bin/fish
     Loaded: loaded (/run/user/1000/systemd/transient/run-rb1b698264f37427bbe14a4945bfdebbb.scope; transient)
  Transient: yes
     Active: active (running) since Thu 2023-01-05 01:31:41 CST; 7s ago
      Tasks: 5 (limit: 57432)
     Memory: 6.3M
        CPU: 96ms
     CGroup: /user.slice/user-1000.slice/[email protected]/app.slice/run-rb1b698264f37427bbe14a4945bfdebbb.scope
             ├─279164 /usr/bin/fish
             ├─279194 systemctl status --user run-rb1b698264f37427bbe14a4945bfdebbb.scope
             └─279195 less

Jan 05 01:31:41 shimarin.taoky.moe systemd[164790]: Started /usr/bin/fish.
> ls /sys/fs/cgroup/user.slice/user-1000.slice/[email protected]/app.slice/run-rb1b698264f37427bbe14a4945bfdebbb.scope
cgroup.controllers      cpu.stat             memory.pressure
cgroup.events           io.pressure          memory.reclaim
cgroup.freeze           irq.pressure         memory.stat
cgroup.kill             memory.current       memory.swap.current
cgroup.max.depth        memory.events        memory.swap.events
cgroup.max.descendants  memory.events.local  memory.swap.high
cgroup.pressure         memory.high          memory.swap.max
cgroup.procs            memory.low           memory.zswap.current
cgroup.stat             memory.max           memory.zswap.max
cgroup.subtree_control  memory.min           pids.current
cgroup.threads          memory.numa_stat     pids.events
cgroup.type             memory.oom.group     pids.max
cpu.pressure            memory.peak          pids.peak
> cat /sys/fs/cgroup/user.slice/user-1000.slice/[email protected]/app.slice/run-r62b28a939ee94d75a6611312c5de83db.scope/cgroup.procs
279164
279517
> exit  # 退出刚刚创建的 fish
> systemctl status --user run-rb1b698264f37427bbe14a4945bfdebbb.scope
Unit run-rb1b698264f37427bbe14a4945bfdebbb.scope could not be found.
> # 自动销毁了

systemd-run 在这里实际做的事情是用 DBus 和用户的 systemd 通信。因此只要我们的程序也用 DBus 去通信……就可以了?

虽然讲很简单,但是实际写起来相当麻烦,因为:

  • DBus 概念很复杂(我到现在也还是迷迷糊糊的);
  • Systemd 缺乏完善的 DBus API 文档(特别是对我们手头的需求);
  • Systemd 的报错很让人迷惑,甚至需要挂 gdb 看源码才能知道错误原因。

下面是一些小笔记。以及我最后的实现

可供参考的 systemd 相关的文档如下:

以及 D-Feet,和 dbus-monitor 也会有帮助。

作为参考,我的做法是用 Rust 的 zbus 库。用 zbus-xmlgen --session org.freedesktop.systemd1.Scope /org/freedesktop/systemd1 导出了一份 Manager 的类型标注,然后模仿着写:

let connection = Connection::session()?;
let systemd_manager = ManagerProxyBlocking::new(&connection)?;
let pids = [getpid().as_raw()];
let job = systemd_manager.start_transient_unit(
    "test-transient.scope",
    "replace",
    &[
        ("PIDs", pids[..].into()),
        ("Description", "Transient cgroup for benchmarking".into()),
    ],
    &[],
)?;

然后发现 systemd 给我们发回来一个极其迷惑的错误:

System.Error.ENXIO: No such device or address

这是哪里出了问题呢?是这个方案完全不可行?还是哪个参数错了?从这个毫无信息量的错误信息里面完全看不出来,最后我装了 systemd 的调试符号包,然后 gdb 直接挂到了用户 session 的 systemd 上,对着源代码调试,最后发现是在这里挂的:

r = sd_bus_message_enter_container(message, 'a', "u");

r 返回了小于 0 的值,代表这里执行出错了。这里的符号代表了 DBus 中的不同类型,具体的表格可以参考 sd_bus_message_append(3) 的帮助,其中 a 是数组,而 u 是 32 位无符号整数。

然而,getpid() 返回的类型是 32 位有符号整数。而 DBus 的信息是有类型签名的,所以修复方法是:

let pids = [getpid().as_raw() as u32];

就好了。这里 systemd 的设计可以说是非常糟糕:没有地方给出 start_transient_unit() 的第三个参数里面需要有哪些字段,字段的类型需要是什么,只能自己翻 systemd 的源代码才能读到答案。

另外,也发现 systemd 不会清理创建失败的 scope,所以要处理一下:

let _err = systemd_manager.reset_failed_unit("test-transient.scope");

start_transient_unit() 成功执行时会返回一个 Job 的 path。为了避免竞争问题,肯定要等 job 执行好了才能继续操作,但是并没有直接 “wait job” 的接口。需要先注册 job 被移除的信号,然后等信号:

let removed_jobs = systemd_manager.receive_job_removed()?;

// ...

for signal in removed_jobs {
    let args = signal.args()?;
    if args.job == job.as_ref() {
        break;
    }
}

不过至少 ObjectPath 还能比较是否相等。

在知道 scope 已经创建之后,接下来就要获取 scope 的 cgroup 路径。可以先获取到 scope 在 DBus 下的 ObjectPath:

let scope_dbus_path = systemd_manager.get_unit("test-transient.scope")?;

这里也花了很长时间理解,最后我的处理方法是:再用 zbus-xmlgen 导出一份 org.freedesktop.systemd1.Scope 的类型标注(开个 D-Feet 看看,然后 zbus-xmlgen --session org.freedesktop.systemd1 /org/freedesktop/systemd1/unit/已有的_某个_scope)。

之后就可以方便获取到 cgroup 路径了:

let systemd_scope = ScopeProxyBlocking::builder(&connection)
    .path(scope_dbus_path)?
    .build()?;
let cgroup_path = systemd_scope.control_group()?;

可能可以直接用 zbus::fdo 来读取对应的值,但是我发现读到了之后取出 String 很难,估计是因为没有类型信息的原因。之后的事情就简单很多了。

从 systemd 拿到的 scope 默认有 memory 和 pid,对于我的需求来讲够用了。


  1. 截图自《孤独摇滚》第三集,建议没看的都快去看。