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 | struct page_counter { |
在 v1 中,protection_support 固定为 false,failcnt 被追踪(track_failcnt = true)。
mem_cgroup 结构概览
struct mem_cgroup 是每个 cgroup 内存控制的核心对象,其字段众多,本文只聚焦 v1 相关的关键部分:
1 | struct mem_cgroup |
v1 有四个独立的 page_counter:memory(物理 RAM)、memsw(RAM + swap,v2 改为单独的 swap)、kmem(内核内存)、tcpmem(TCP 内存,已废弃)。每个都有自己的 max 限制,互不关联。
核心机制详解
1. 计费路径:一次分配,两次付款
当进程分配物理内存(比如通过 page fault 或 kmalloc),内核需要将这次分配计入当前 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 | try_charge_memcg(memcg, nr_pages) |
所以 v1 下,每分配一页物理内存,memory.usage 和 memsw.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 | soft_limit_tree (全局) |
回收触发点:memcg1_soft_limit_reclaim() 在 kswapd 的 balance_pgdat() 和 direct reclaim 的 shrink_zones() 中被调用。但存在两个严重的跳过条件:
1 | // mm/memcontrol-v1.c (简写) |
第一个条件:当内核启用多代 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.min 和 memory.low 这两个 v2 中关键的硬/软保护接口在 v1 中完全不存在。v1 只能通过硬限制(limit_in_bytes)来控制上限,无法保证某个 cgroup 即使被其他 cgroup 争抢也能获得最低内存保有量。在手机场景中,这意味着前台应用无法被保护,后台应用可以轻易挤占前台内存,导致卡顿或异常杀死。
内核内存独立计费的麻烦
kmem.limit_in_bytes 用于限制 slab、内核栈等内核内存。但内核内存的生命周期通常不直接受用户态控制(比如 dentry cache、inode cache)。在 v1 中,kmem 与 memory 各自独立,一个 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.max 和 memory.swap.max,实现了完全解耦。
关键代码路径
计费入口:try_charge_memcg
路径:mm/memcontrol.c → 外部函数调用如 mem_cgroup_charge() 等。
1 | try_charge_memcg(memcg, nr_pages, gfp_mask, ...) |
注意这里没有调用 propagate_protected_usage(),因为在 v1 下 protection_support 为 false。
软限制回收入口:memcg1_soft_limit_reclaim
路径:mm/memcontrol-v1.c → 调用者 balance_pgdat() / shrink_zones()(mm/vmscan.c)。
1 | memcg1_soft_limit_reclaim(gfp_mask, order, ...) |
该函数返回回收的页数。但由于 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 软限制实际上已经被社区放弃。
小结
- v1 的 mem_cgroup 结构包含四个独立计数器:memory、memsw(RAM+swap 耦合)、kmem(内核内存,已废弃)、tcpmem(已废弃),导致计费逻辑复杂且接口纷繁。
- v1 计费路径存在双计费:
try_charge_memcg中do_memsw_account为真时,一次物理页分配同时计入 memory 和 memsw 两个计数器,造成语义混乱且无法独立限制 swap。 - 软限制(soft_limit)形同虚设:基于全局红黑树 best-effort 回收,但 MGLRU 开启时完全跳过,高阶分配也跳过,其接口已被 deprecated。
- v1 完全没有保护机制:
protection_support = false,没有memory.min/memory.low,前台应用无法受保护,这在云原生和移动场景是致命缺陷。 - v2 通过统一层级、page_counter 保护传播、拆解 memsw 为 memory + swap.max,系统性地解决了 v1 的混乱。理解 v1 的遗产,才能在实际迁移中避免踩坑,平滑过渡到 v2。
下一讲我们将深入 v2 的统一层级结构,看它如何用一套简洁的树实现全面资源隔离与保护。