cgroup

本页目录

叶子组没有耗尽自己的额度,任务却仍被限制,可能是祖先资源已经用完。cgroup 的层级、控制器启用与实际节流要连起来看;进入 cgroup namespace 只改变路径视图。

本篇以 v2 统一层级为主,v1 可使用多个控制器层级。实际挂载方式应查询当前环境。

查阅目标阅读位置
限制来源与管理权限层级、委派
CPU、内存与任务预算各控制器
是否真正受阻PSI、统计与源码入口

层级限制会沿祖先生效

子 cgroup 的配置不能绕过父级限制。父级 CPU 额度可能被多个子组共同消耗,父级内存限制也约束整个子树;只检查叶子文件会漏掉真正瓶颈。

cgroup.controllers 表示当前层级可用于子组的控制器,cgroup.subtree_control 决定向子层级启用哪些。mkdir 不意味着所有控制器文件和策略自动齐备。控制器需要沿层级自上而下启用。

普通 domain 控制器通常遵循 no-internal-process 约束:内部节点分配资源给子组时,不能同时放普通工作进程与子组竞争;根和 threaded 模式有专门规则,不能一条概括所有情况。cgroup v2

委派比直接修改系统根目录更重要

现代系统常由 systemd 等管理 cgroup 树。应用应使用获委派的子树或管理器接口,不能假定自己可重新 mount /sys/fs/cgroup 或随意改上层控制器。

在允许的目标组写 cgroup.procs 会移动整个进程的线程;threaded 模式中 cgroup.threads 有不同语义。迁移权限还涉及共同祖先等要求,不是目录可写就能拉入任意进程。进程迁移也不保证已有内存记账随之全部转移。

CPU:权重、额度与放置分别配置

接口例子含义
cpu.weight200相对同级竞争者的权重,默认 100
cpu.max50000 100000每 100000 微秒周期最多消耗 50000 微秒 CPU 时间
cpuset.cpus0-3请求可用 CPU 集合,还需看 effective 与祖先限制

weight=200 不保证获得两颗 CPU,也不保证任何时刻都比另一组快两倍;权重主要在相应任务共同竞争时体现,空闲资源可被使用。

cpu.max 的额度由组内相关任务共同消耗。两个线程可在两个 CPU 同时运行并更快用完额度,随后被节流;它不是“每颗 CPU 固定跑 50%”。观察 cpu.stat 的 nr_throttled、throttled_usec 等增量,才能判断额度是否实际成为瓶颈。

cpuset 限制放置范围不等于独占这些 CPU,分区与独占配置另有规则。cpuset.mems 也应结合有效节点与 NUMA 分配策略理解。

子组各剩 30 ms,为什么还是一起被节流

设父组 P 的 cpu.max 为 100000 100000,两个子组 A、B 都是 80000 100000。为了突出预算关系,假设周期同时从 t=0 开始,禁用额外 burst,A、B 各有一个持续可运行的普通公平调度任务,并各占一个可用 CPU;忽略调度与 runtime slice 分发的细节。

在前 50 ms 墙钟时间里,A 消耗 50 ms CPU 时间,B 也消耗 50 ms,父组的 100 ms 总额度已经用完。A、B 自己都还剩 30 ms,却不能越过父组继续运行。到下一周期补充额度之前,继续提高子组上限也没有消除这个祖先瓶颈。

两个 CPU 并行消耗同一份父组额度 任务 A 运行:消耗 50 ms CPU 父组额度耗尽,停止推进 任务 B 运行:消耗 50 ms CPU 父组额度耗尽,停止推进 0 ms 50 ms 100 ms 50 ms 墙钟 × 两个 CPU = 父组 100 ms CPU 预算 相同周期、无 burst 的理想模型;子组此时各剩 30 ms,仍受祖先约束。
def one_period(active):
    parent_left = 100
    leaf_left = {"A": 80, "B": 80}
    used = {"A": 0, "B": 0}
    for _ in range(100):  # 100 个 1 ms 时间格,每个任务最多占一个 CPU
        for name in active:
            if parent_left > 0 and leaf_left[name] > 0:
                parent_left -= 1
                leaf_left[name] -= 1
                used[name] += 1
    return used, leaf_left, parent_left

assert one_period(("A", "B")) == ({"A": 50, "B": 50}, {"A": 30, "B": 30}, 0)
assert one_period(("A",)) == ({"A": 80, "B": 0}, {"A": 0, "B": 80}, 20)

只有 A 运行时,它反而可以消耗 80 ms,之后被自己的上限限制,父组还剩 20 ms。这两个轨迹说明诊断应同时看任务竞争情况与各层 cpu.stat 增量。模型的 50 ms 是理想时间格结果,不是 Linux 对任意周期对齐、slice 缓存、调度类或机器负载的精确时序保证。CPU 控制器

权重则是另一种层级计算。若根下 P、Q 权重相同,P 下 A、B 权重相同,Q 下只有 C,在各组持续竞争同一份 CPU 容量、没有其他约束的比例模型中,P、Q 先各得一半,A、B 再分 P 的一半,最终是 A:1/4、B:1/4、C:1/2。把三个叶子的相同权重直接平摊成各 1/3,会丢掉中间层级;某组不再需要资源时,这个饱和竞争比例也就不再适用。

分别运行 A 与 B,看它们如何共同消耗祖先额度。某个子组还有余额时,祖先仍可能已被兄弟耗尽;子组配额不是预留给它的独立 CPU 资源。

R 是本周期剩余 CPU 时间。真实多层层级要沿所有相关祖先检查;图中只保留父子两层。

正在呈现知识画面
子组还有额度,为什么也不能继续运行?

子组的配置额度不等于预留份额,也不能绕过祖先。可用量受 min(子组剩余, 祖先剩余) 限制,祖先被一个兄弟消耗后会影响另一个。

内存:high 是压力控制,max 是边界

memory.high 与 memory.max 的职责不同 超过 memory.high 回收与节流压力;可暂时超过 触及 memory.max 尝试限制与回收;失败可能 memcg OOM 结合 memory.events 和 memory.pressure 区分越界次数、OOM/kill 事件与实际等待时间 high 本身不触发 OOM kill;不是简单按阈值顺序执行的完整内核调用图。
接口达到相关条件后的含义
memory.high施加回收与节流压力,可暂时突破,本身不触发 OOM killer
memory.max硬限制下先尝试回收等,无法满足时可能进入 memcg OOM

两者不能合并成“超过一字节马上杀进程”的单一开关。

memory.low 是最佳努力回收保护,memory.min 提供更强的保护语义,但有效保护受祖先与竞争分配影响,不是预先分配一块物理 RAM。memory.swap.max 对 swap 又有独立控制。

memory.oom.group 可要求相应 OOM 处理按组对待工作负载,不能从“high 回收失败”直接画箭头到“因为 oom.group 杀全组”。需要区分事件发生的层级与触发原因。

回收选择取决于页面状态、活跃性、可写回性、swap 和策略等,不保证固定先清所有 cache、再换匿名页。memory.current 包含相应被记账的内存,不等于某个进程 RSS。

I/O 与任务数量

io.max 按设备限制字节或 IOPS,格式中的主次设备号应来自实际目标设备;io.weight 的效果依赖块层和相应调度/策略支持。缓存写入、回写与底层设备路径会影响可见表现,不能把任意 read/write 调用速率直接当作块设备速率。

pids.max 限制内核任务数量,线程也计入。达到限制会阻止相应 fork/clone 创建,典型错误为 EAGAIN;设置更低上限或移入已有任务不等于自动杀死超额任务。应观察 pids.current 与 pids.events。

PSI 观察等待,而不只是用量

指标解释
some至少一个任务因该资源受阻的时间占比
full相应范围所有非空闲任务都受阻的时间
avg10/60/300相应平均百分比
total累计微秒,不是百分比

先明确范围和单位,才能把等待与资源用量放在一起分析。

CPU 使用率高可能是有用工作充分执行,PSI 则帮助观察等待损失;内存使用高也可能主要是有价值缓存,不应直接等同于内存压力。系统级 CPU full 有特殊语义,不能机械套用内存 full 的解释。PSI

some 与 full:两个任务的等待区间怎样合并

以某 cgroup 的 I/O 压力为例,观察 100 ms,假设只有 A、B 两个非空闲任务,二者全程都有工作需求。A 在 [0,40) ms 等待 I/O,B 在 [20,60) ms 等待 I/O,其余时间各自能推进工作。

区间 msAB对压力时间的贡献
0–20受阻可推进some 增加 20 ms
20–40受阻受阻some、full 各增加 20 ms
40–60可推进受阻some 增加 20 ms
60–100可推进可推进都不增加

所以 some=60 ms,full=20 ms。把两个任务各自的 40 ms 等待相加会得到 80 ms,那是任务等待时间之和,不是 some 对墙钟区间的并集计数。

def pressure_ms(blocked, window=100):
    assert blocked and window > 0
    points = {0, window}
    for intervals in blocked:
        for start, end in intervals:
            assert 0 <= start <= end <= window
            points.update((start, end))
    points = sorted(points)
    some = full = 0
    for start, end in zip(points, points[1:]):
        waiting = [any(a <= start < b for a, b in intervals) for intervals in blocked]
        if any(waiting):
            some += end - start
        if all(waiting):
            full += end - start
    return some, full

waits = [[(0, 40)], [(20, 60)]]
assert pressure_ms(waits) == (60, 20)
assert pressure_ms(waits + [[]]) == (60, 0)  # C 一直能推进,full 变为零
assert pressure_ms([[(0, 10)], [(10, 20)]]) == (20, 0)
assert pressure_ms([[], []]) == (0, 0)
assert pressure_ms([[(0, 40), (20, 60)]]) == (60, 60)

代码对区间分段后计数,是固定非空闲任务集合下的教学模型。真实内核还要根据任务状态变化和统计范围记账,不能把所有创建过的线程都永久计入 full 的分母。例如一个已无工作而主动空闲的线程,不应被当成“一直在推进”来强行把 full 降为零。PSI 定义

若两次采样相隔 100000 μs,some 的 total 增加 60000 μs,得到的是这段采样窗口内约 60% 的受阻时间。它不是文件里 avg10 的重新计算公式;avg10/60/300 是内核维护的平滑平均。还应检查读取间隔、计数器重置和组的生命周期,不把不同范围的 total 相减。

只读检查当前进程

cat /proc/self/cgroup
findmnt -t cgroup2
cat /proc/pressure/cpu
cat /proc/pressure/memory
cat /proc/pressure/io

根据实际 cgroup 挂载根和进程路径定位所属组,再读取 cpu.stat、memory.events、memory.pressure、pids.current 等可用文件。容器可能只暴露一段层级,/proc 路径也受 namespace 影响,不要无条件把字符串直接拼到宿主根目录。

诊断顺序可以是:确认所属组和祖先约束,检查控制器是否启用,再比较统计增量与实际请求延迟。读取计数器不会解释全部因果,但能区分“配置了限制”和“真的被限制”。

内核阅读入口

kernel/cgroup 管核心层级与成员关系,mm/memcontrol.c 管内存记账与控制,block/blk-cgroup.c 管块层关联。controller 的 css 是子系统状态,不是所有控制器都把同一个对象挂在 request_queue 上。

从用户可见接口契约进入源码,再查看具体版本的 charge、reclaim 或调度路径,比把几十行注释伪装为通用 try_charge 调用图更可靠。隔离视图的另一半见 命名空间。