memcg v1 缺陷解析:从混乱到统一迁移指南

源码版本:本文所有源码引用均基于 6e845bcb,不同内核版本行号可能不同。

前置知识

阅读本文前,建议先阅读本系列:

  • Part 0:读懂 memcg 之前:你必须掌握的 cgroup 基础

其它背景知识:本系列 Part 0 已经介绍了 cgroup v1 多层级结构与局限、v2 统一层级的改进;了解 cgroup v1/memcg v1 基本操作概念会有帮助,但本文会从零解释 memcg 内部机制。

背景:为什么需要了解这个

一个真实场景:某手机厂商的内存优化团队收到大量用户反馈——某款应用在后台运行时,系统突然卡顿甚至被杀死。排查发现,该应用被分配了 512MB 的 memory.limit_in_bytes 硬限制,但限制本身是准确的;问题出在当应用内存接近限制时,系统并没有及时回收,而是等到触发 OOM 后一刀切。进一步分析,该设备运行的还是 Android 基于 cgroup v1 的老内核。团队发现 soft_limit_in_bytes 几乎无效,kmem.limit_in_bytes 早已废弃但还在用,memsw.limit_in_bytes 把 RAM 和 swap 混在一起导致策略无法精细控制……

这就是 memcg v1 积累的历史包袱。理解 v1 的缺陷,不是为了怀旧,而是为了在迁移到 v2 时知道每个接口、每个机制的设计初衷和坑在哪里。云厂商同样面临类似问题:大量 legacy 容器运行在 v1 上,若要启用 memory.min/memory.low 等保护机制,必须理解 v1 为什么没有这些功能。本文就从最核心的数据结构开始,逐步拆解 v1 的设计遗产与缺陷。

基础概念:读懂本文需要知道的

cgroup 与 memcg

cgroup(control group)是内核提供的一种任务分组机制,允许按组对进程进行资源限制。memory cgroup(memcg)是其中一个子系统,专门负责内存资源的追踪、限制和回收。每个 cgroup 对应一个 mem_cgroup 实例。

page_counter:计数与限制的原子核

mem_cgroup 内部使用 struct page_counter 来追踪各种内存类型的用量。page_counter 是一个原子计数器,加上一组软硬限制值,并形成树状层级(子 cgroup 的计数会累加到父节点)。其核心字段:

1
2
3
4
5
6
7
8
9
10
struct page_counter {
atomic_long_t usage; // 当前物理内存页数(原子操作)
unsigned long min; // v2 硬性保护(v1 未启用)
unsigned long low; // v2 软性保护(v1 未启用)
unsigned long high; // v2 软性减速阈值(v1 无)
unsigned long max; // 硬限制(v1 的 limit_in_bytes)
unsigned long failcnt; // v1 特有,charge 失败次数
bool protection_support; // v2 为 true 表示启用保护机制
struct page_counter *parent;// 指向父计数器,形成树
};

在 v1 中,protection_support 固定为 false,failcnt 被追踪(track_failcnt = true)。

mem_cgroup 结构概览

struct mem_cgroup 是每个 cgroup 内存控制的核心对象,其字段众多,本文只聚焦 v1 相关的关键部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
    struct mem_cgroup
+--------------------------+
| struct page_counter |
| memory (物理内存页数) |
+--------------------------+
| union { |
| struct page_counter |
| swap (v2) |
| / memsw (v1, RAM+swap)|
| } |
+--------------------------+
| (CONFIG_MEMCG_V1 only) |
| struct page_counter kmem | 内核内存
| struct page_counter |
| tcpmem (已废弃) |
| unsigned long soft_limit | 软限制(页数)
| int oom_kill_disable | 禁用 OOM(已废弃)
| bool oom_lock; | OOM 状态锁
| int under_oom; | OOM 标志
| struct mem_cgroup_ |
| thresholds thresholds | 阈值通知
+--------------------------+

v1 有四个独立的 page_countermemory(物理 RAM)、memsw(RAM + swap,v2 改为单独的 swap)、kmem(内核内存)、tcpmem(TCP 内存,已废弃)。每个都有自己的 max 限制,互不关联。

核心机制详解

1. 计费路径:一次分配,两次付款

当进程分配物理内存(比如通过 page faultkmalloc),内核需要将这次分配计入当前 memcg。所有 v1 和 v2 的计费都收敛到 try_charge_memcg() 函数(mm/memcontrol.c)。

v1 的关键差异在于 do_memsw_account() 函数(定义在 mm/memcontrol-v1.h)返回 true,意味着 v1 模式下,每一次物理页面分配不仅要计入 memory 计数器,还要计入 memsw 计数器——因为 memsw 的设计含义是“RAM + swap 的合计”,内核无法在分配时知道这个页面将来是否会被 swap out,所以干脆在分配时就把 RAM 部分同时记进 memsw

调用流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
try_charge_memcg(memcg, nr_pages)
|
+-- do_memsw_account() == true (v1)
| |
| +-- page_counter_try_charge(&memcg->memsw, nr_pages, &failed)
| | (同时对 memsw 和 memory 进行累加)
| |
| +-- page_counter_try_charge(&memcg->memory, nr_pages, &failed)
| | (只对 memory 累加,此时 memory 的 usage 会被第二次加 nr_pages)
| |
| v 实际上 memory 被加了两次?不,注意:
| page_counter_try_charge 对每个计数器独立操作,
| memsw 和 memory 是两个独立的对象,各加一次。
|
+-- do_memsw_account() == false (v2)
|
+-- page_counter_try_charge(&memcg->memory, nr_pages, &failed)
(仅 memory,无 memsw)

所以 v1 下,每分配一页物理内存,memory.usagememsw.usage 都会增加相同数值。当页面被 swap out 时,v1 会从 memsw 中减去该页但保持 memory 不变,以此体现“内存被换出,RAM 占用减少但 swap 占用增加”。但这套机制把 RAM 和 swap 强耦合在一起,使得限制策略无法独立配置,比如不能设置“RAM 最多 2G,swap 最多 1G”,只能设置“RAM+swap 合计最多 2G”。

2. 软限制(soft_limit):形同虚设的 best-effort

软限制的设计意图是:当 cgroup 的内存用量超过 soft_limit_in_bytes 后,在系统整体内存压力下,内核应该优先回收该 cgroup 的页面,实现一种“尽力而为”的回收偏好。

v1 的实现维护了一个全局树结构:soft_limit_tree,每 NUMA 节点一棵红黑树,节点以“超限量 = 当前 memory.usage - soft_limit”为 key 排序,超限最多的节点在最右。

1
2
3
4
5
6
7
8
9
soft_limit_tree (全局)
+-----------------------------+
| rb_tree_per_node[0] | --> 红黑树
| mem_cgroup_per_node | node 按 excess 排序
| *root |
+-----------------------------+
| rb_tree_per_node[1] |
| ... |
+-----------------------------+

回收触发点:memcg1_soft_limit_reclaim() 在 kswapd 的 balance_pgdat() 和 direct reclaim 的 shrink_zones() 中被调用。但存在两个严重的跳过条件:

1
2
3
4
5
// mm/memcontrol-v1.c (简写)
if (lru_gen_enabled())
return 0; // MGLRU 开启时,自己管自己的软限制
if (order > 0)
return 0; // 只处理 order-0 回收,不处理高阶分配压力

第一个条件:当内核启用多代 LRU(MGLRU)后,v1 的软限制回收完全被跳过,由 MGLRU 自己实现类似语义(lru_gen_rotate_memcg 将超限 memcg 移到回收队列头部)。但 MGLRU 的“软限制”只是优先回收,并不保证精确性。

第二个条件:只有 order=0 的普通回收才会触发软限制检查;大页分配(order>0)走另一个路径,完全无视软限制。

此外,软限制的回收是 best-effort 的,即使触发了,也只是尝试回收一定数量的页面,不保证降到软限制以下。更关键的是,soft_limit_in_bytes 接口本身已经在 v2 中被移除,其写入时内核会报 pr_warn_once("deprecated and will be removed")

3. v1 的根本缺陷

无保护机制(protection_support = false)

v1 的 page_counter 初始化时 protection_support 被置为 false(mm/memcontrol.c 中的 page_counter_init 调用)。这意味着 memory.minmemory.low 这两个 v2 中关键的硬/软保护接口在 v1 中完全不存在。v1 只能通过硬限制(limit_in_bytes)来控制上限,无法保证某个 cgroup 即使被其他 cgroup 争抢也能获得最低内存保有量。在手机场景中,这意味着前台应用无法被保护,后台应用可以轻易挤占前台内存,导致卡顿或异常杀死。

内核内存独立计费的麻烦

kmem.limit_in_bytes 用于限制 slab、内核栈等内核内存。但内核内存的生命周期通常不直接受用户态控制(比如 dentry cache、inode cache)。在 v1 中,kmemmemory 各自独立,一个 cgroup 的 kmem 用量可能很高但 memory 用量低,用户很难同时配置两个限制以达到合理行为。例如,如果只设置 memory.limit_in_bytes 而不设置 kmem.limit_in_bytes,内核内存可以无限增长,最终挤占系统内存。kmem.limit_in_bytes 接口也已被标记为 deprecated。

接口纷繁废弃

v1 中大量接口后来被弃用(soft_limit_in_bytes, kmem.limit_in_bytes, kmem.tcp.limit_in_bytes, oom_control, pressure_level, move_charge_at_immigrate)。这些接口在 v2 中被删除或重构,迁移时需要逐个适配,加深了混乱。

memsw 耦合导致策略不灵活

如前所述,memsw 将 RAM 和 swap 合并统计,使得无法单独限制 swap 使用量。这意味着如果某个 cgroup 需要大量 swap,但 RAM 用量不高,管理员无法给出精细限制——只能让两者共享一个总上限,或者不设限。v2 中将 memsw 拆成了 memory.maxmemory.swap.max,实现了完全解耦。

关键代码路径

计费入口:try_charge_memcg

路径:mm/memcontrol.c → 外部函数调用如 mem_cgroup_charge() 等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
try_charge_memcg(memcg, nr_pages, gfp_mask, ...)
|
+-- 批处理优化:优先使用 per-cpu stock (MEMCG_CHARGE_BATCH=64)
| stock 命中则直接返回,减少原子操作和父链遍历。
|
+-- do_memsw_account()?
| |
| +-- true (v1): 先 page_counter_try_charge(&memcg->memsw, ...)
| | + 再 page_counter_try_charge(&memcg->memory, ...)
| |
| +-- false (v2): 只 page_counter_try_charge(&memcg->memory, ...)
|
+-- page_counter_try_charge 内部 (mm/page_counter.c):
for (c = counter; c; c = c->parent) {
new = atomic_long_add_return(nr_pages, &c->usage);
if (new > c->max) {
// 超过限制,回滚(从当前节点向上逐级归还)
rollback;
return false;
}
// 更新 failcnt(v1 下 track_failcnt=true)
// 如果 protection_support=true,调用 propagate_protected_usage()
}

注意这里没有调用 propagate_protected_usage(),因为在 v1 下 protection_support 为 false。

软限制回收入口:memcg1_soft_limit_reclaim

路径:mm/memcontrol-v1.c → 调用者 balance_pgdat() / shrink_zones()mm/vmscan.c)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
memcg1_soft_limit_reclaim(gfp_mask, order, ...)
|
+-- if (lru_gen_enabled()) return 0; // 直接跳过
|
+-- if (order > 0) return 0; // 只处理 order-0
|
+-- for_each_node_state(nid, N_MEMORY):
| |
| +-- mz = mem_cgroup_largest_soft_limit_node(mctz);
| | 在 soft_limit_tree 红黑树中找到超限最多的 memcg
| |
| +-- excess = soft_limit_excess(mz); // 当前 usage - soft_limit
| |
| +-- try_to_free_mem_cgroup_pages(memcg, ...);
| | 尝试回收 SWAP_CLUSTER_MAX 页,然后重新检查
| |
| +-- 如果 excess 仍然大于0,继续清理,但不超过循环次数

该函数返回回收的页数。但由于 MGLRU 和高阶跳过的存在,大部分情况下它根本不会执行真正的回收。

设计演进:为什么不是更简单的方案

为什么 v1 没有直接用两个独立计数器(memory 和 swap)来替代 memsw?
历史原因:当 memcg 加入 Linux 内核时(大约 2008 年,2.6.29),swap 和 RAM 的边界被认为应该统一管理,避免出现“RAM 用满但 swap 空闲”的场景。当时的设计者认为一个应用整个内存资源(包括被 swap 出去的部分)应被一个上限约束。但后来发现,云场景和手机场景需要精细控制:RAM 是昂贵资源,swap 是廉价但慢速的扩展。于是 v2 将二者拆开。

为什么 v1 不实现 memory.min/memory.low 保护?
v1 的最初版本没有保护机制。后来(2015 年左右)有人尝试加入类似 memory.min 的补丁,但由于 v1 的层级结构和计费路径不允许干净地计算父 cgroup 的剩余保护比例,最终被拒绝。v2 借助统一层级(cgroup v2 hierarchy)和 page_counter 的树状传播机制才得以实现。

为什么软限制采用红黑树而且没有可靠性保证?
软限制的设计目标是“尽力而为”,在全局内存压力下辅助回收,并不承诺精确性。使用红黑树排序使得回收最有可能“压力大”的 cgroup,但无法保证每个 cgroup 都降到软限制以下。这种做法在内存压力轻时可能有用,但压力大时直接走硬限制路径,软限制几乎不发挥作用。加上 MGLRU 的接管,v1 软限制实际上已经被社区放弃。

小结

  1. v1 的 mem_cgroup 结构包含四个独立计数器:memory、memsw(RAM+swap 耦合)、kmem(内核内存,已废弃)、tcpmem(已废弃),导致计费逻辑复杂且接口纷繁。
  2. v1 计费路径存在双计费try_charge_memcgdo_memsw_account 为真时,一次物理页分配同时计入 memory 和 memsw 两个计数器,造成语义混乱且无法独立限制 swap。
  3. 软限制(soft_limit)形同虚设:基于全局红黑树 best-effort 回收,但 MGLRU 开启时完全跳过,高阶分配也跳过,其接口已被 deprecated。
  4. v1 完全没有保护机制protection_support = false,没有 memory.min/memory.low,前台应用无法受保护,这在云原生和移动场景是致命缺陷。
  5. v2 通过统一层级、page_counter 保护传播、拆解 memsw 为 memory + swap.max,系统性地解决了 v1 的混乱。理解 v1 的遗产,才能在实际迁移中避免踩坑,平滑过渡到 v2。

下一讲我们将深入 v2 的统一层级结构,看它如何用一套简洁的树实现全面资源隔离与保护。