搞懂 memcg 内存隔离,先吃透这 4 个核心数据结构
1 | # 解剖 mem_cgroup:支撑统一层级的三大数据结构 |
为什么 usage 用 atomic_long_t 且独占 cacheline? 因为 charge/uncharge 每分配一页都要原子加减,多核同时操作时若和别的字段共享 cacheline,会导致大量缓存失效(cache bouncing)。内核社区将这个字段放在结构体最前面,并通过 ____cacheline_aligned_in_smp 强制对齐,就是为了避免与其他冷字段(如 failcnt)共享同一 cacheline。
树形传播逻辑:page_counter_charge() 函数从当前节点开始,通过 for (c = counter; c; c = c->parent) 逐级向上更新 usage。每级成功后,如果 protection_support 为 true,会调用 propagate_protected_usage() 同步 min_usage / low_usage 并更新父节点的 children_* 统计。这样父子节点的保护信息始终一致,无需在计算 protection 时再遍历整棵树。
protection_support 的三重控制:这个布尔字段仅对 memcg->memory 且在 v2 层级(cgroup_subsys_on_dfl)时为 true。对于 swap、kmem 等计数器,protection_support 永远为 false。这意味着保护机制(min/low)只作用于 RAM 使用量,不会影响 swap 或内核内存的回收行为。
2. mem_cgroup:控制组本体
struct mem_cgroup 定义在 include/linux/memcontrol.h,它嵌入了一个 struct cgroup_subsys_state css(首字段),通过 container_of 宏可以实现 css → mem_cgroup 的转换。
1 | +---------------------------------------------------------------+ |
v2 新增字段:high_work 用于异步的 memory.high throttle,避免在中断上下文直接 schedule_timeout;oom_group 控制 OOM 时是否杀死整个 cgroup;swappiness 实现了 per-cgroup 的回收策略。
v1 遗留字段:以 #ifdef CONFIG_MEMCG_V1 包裹。kmem 和 tcpmem 在 v2 中已废弃,因为内核内存计费被合并到 memory 统计中;soft_limit 被 memory.low 替代;oom_kill_disable 被 memory.oom_group 等机制取代。这些字段仅在编译旧内核时保留,不会出现在 v2 代码路径中。
nodeinfo[] 柔性数组:末尾的 struct mem_cgroup_per_node *nodeinfo[] 占用了 C99 柔性数组,长度等于 nr_node_ids。每个 NUMA 节点一个指针,通过 memcg->nodeinfo[nid] 访问。分配时在 mem_cgroup_alloc() 中为每个在线 node 调用 alloc_mem_cgroup_per_node_info()。
3. mem_cgroup_per_node:NUMA 感知的核心
1 | +---------------------------------------------------------------+ |
每个 mem_cgroup 在每个 NUMA node 对应一个 mem_cgroup_per_node。它承载了真正的回收操作:lruvec 是 LRU 链表(PER_NODE + PER_MEMCG 维度),回收时先根据 zone 和 LRU 类型定位到 lruvec,再开始扫描。iter 是一个 round-robin 迭代器,防止回收时总是跳过某个 sub-cgroup。
为什么需要反向指针 memcg? 因为 mem_cgroup_per_node 是 lruvec 的容器,而 lruvec 不直接拥有指向 mem_cgroup 的指针(它通过父级 mem_cgroup 反向引用)。在回收路径中,shrink_lruvec() 函数需要获取 mem_cgroup 以进行保护计算等操作,而 lruvec 本身没有 memcg 字段,必须通过 container_of 从 mem_cgroup_per_node 拿到 memcg 指针。但 mem_cgroup_per_node 不是 lruvec 的子结构,因此需要显式存储。
4. css_set 与 task_struct 的绑定
1 | task_struct css_set |
task_struct 中的 cgroups 字段指向一个 css_set。css_set 中的 subsys 数组存储了每个子系统(如 memory、cpu、io)的 CSS 指针。对于 memcg,subsys[memory_cgrp_id] 就是 struct mem_cgroup 的 css 字段的地址(因为 mem_cgroup 的首字段是 css)。
当 task 迁移到另一个 memcg 时,内核并不直接修改 task 的 mem_cgroup 引用,而是将整个 css_set 替换为新组合。RCU 确保了其他读者(如缺页异常、回收)能安全地通过 css_set 读到旧的 memcg 指针,直到宽限期结束。这比逐个替换 subsystem 状态更高效,也更容易保证一致性。
mem_cgroup_from_task() 的实现路径:p->cgroups->subsys[memory_cgrp_id] 得到 struct cgroup_subsys_state *,再用 mem_cgroup_from_css() 宏(实质是 container_of(css, struct mem_cgroup, css))得到 struct mem_cgroup *。
关键代码路径
路径一:page_counter 的 charge 流程(mm/page_counter.c)
1 | page_counter_try_charge(memcg->memory, nr_pages, &fail_counter) |
- try_charge:先尝试添加,若某节点超限则仅回滚该节点(调用
page_counter_cancel),因为父节点尚未被更新(循环是先加当前节点、检查、成功后再进入父节点),无需沿 parent 向上回滚。这保证了原子性:要么全部成功,要么全部回退。 - propagate_protected_usage:更新本节点的
min_usage = min(usage, min),并计算 delta(新旧差值),然后原子增减父节点的children_min_usage。同样处理 low 方向。这确保了父节点能实时掌握子节点的保护覆盖情况,无需全局扫描。
路径二:保护计算入口(mm/memcontrol.c:mem_cgroup_calculate_protection)
1 | mem_cgroup_calculate_protection(root, memcg) |
- 必须从根到叶顺序调用:因为
effective_protection()要读取父节点的 emin 和兄弟节点的children_min_usage,这些值只有在父节点先计算后才是正确的。如果子节点先算,父节点的 emin 还没更新,子节点会得到错误的值。 recursive_protection:由 cgroup 挂载标志CGRP_ROOT_MEMORY_RECURSIVE_PROT控制。若开启,父节点未使用的保护额度可以下发给子节点,形成递归保护;否则子节点只能拿到自己的份额,父节点留下的保护额度相当于浪费。
路径三:初始化 mem_cgroup(mm/memcontrol.c:mem_cgroup_css_alloc)
1 | mem_cgroup_css_alloc(parent_css) |
- 根 cgroup 创建时
parent = NULL,page_counter_init会设置parent = NULL且max = PAGE_COUNTER_MAX。 protection_support只在memcg->memory且 v2(memcg_on_dfl)时为 true,其他计数器永远不启用保护机制。这是设计选择:swap 和 kmem 不参与 min/low 保护,因为保护只针对 RAM 使用量。
设计演进:为什么不是更简单的方案
为什么 page_counter 不直接用 unsigned long 而是 atomic_long_t?
多核同时 charge/uncharge 时,如果不原子操作会发生 race(比如两个 CPU 同时读 usage,各加 64 页,然后写回,导致少记 64 页)。atomic_long_t 保证了加减的原子性。但原子操作开销大,内核用 per-CPU stock(MEMCG_CHARGE_BATCH = 64 页)来批量处理,减少对 usage 的原子操作频次。
为什么 protection_support 需要显式 bool 而不是根据 v2/v1 判断?
因为除了 memcg->memory 外,swap、kmem 等计数器不需要保护传播,但它们同样在 v2 下存在。如果仅根据 v2 判断,会导致 swap 的 protection_support 也为 true,浪费计算且无意义。显式 bool 让每个 page_counter 的初始化可以精确控制。
为什么 mem_cgroup_per_node 不直接用 container_of 反过来找 mem_cgroup?
因为 mem_cgroup_per_node 通过 memcg->nodeinfo[nid] 指针访问,而不是通过 container_of 从某个子结构反推。lruvec 是 mem_cgroup_per_node 的成员,但 lruvec 本身并不在 mem_cgroup 中,所以无法从 lruvec 通过 container_of 直接得到 mem_cgroup。因此需要显式存储 memcg 指针。
为什么 css_set 不直接把 mem_cgroup 指针放在数组里?
css_set 设计为与 subsystem 无关的通用中间层,对每个 subsystem 只存一个 CSS 指针(struct cgroup_subsys_state *)。这样做的好处是:task 迁移时只需换掉整个 css_set,而每个 subsystem 内部的 CSS 结构体(如 mem_cgroup)无需移动。RCU 兼容性也更好。
小结
page_counter是 memcg 的原子计数单元,以atomic_long_t usage独占 cacheline 减少竞争;charge/uncharge 沿parent树形传播,同时通过propagate_protected_usage同步保护统计。protection_support仅对 v2 的 memory 计数器启用。mem_cgroup嵌入了page_counter memory和swap,通过#ifdef CONFIG_MEMCG_V1保留 v1 遗留字段;末尾的柔性数组nodeinfo[]实现 NUMA 感知。mem_cgroup_per_node包含每个 NUMA 节点的 LRU 向量(lruvec)和回收迭代器,是回收操作的实际执行者。css_set作为 task 与 cgroup 之间的中间层,存储所有 subsystem 的 CSS 指针,task 通过task_struct->cgroups关联,迁移时只需替换整个 css_set,RCU 安全。- 保护计算由
mem_cgroup_calculate_protection触发,调用page_counter_calculate_protection,其算法effective_protection必须在自上而下顺序中执行,受recursive_protection标志控制是否递归分配剩余额度。
理解这三个数据结构及其关系,是分析内存回收异常、诊断保护失效、优化 NUMA 分配策略的基础。下一部分我们将深入 memory.high 的 throttle 机制,看惩罚时间如何在 penalty_jiffies 下与超额平方成正比。