记账全路径:一次内存分配如何被 memcg 精确追踪
源码版本:本文所有源码引用均基于 c425609d。
前置知识
阅读本文前,建议先阅读本系列:
- Part 0:读懂 memcg 之前:你必须掌握的 cgroup 基础
- Part 1:memcg v1 到 v2:缺陷剖析与设计演进
- Part 2:搞懂 memcg 内存隔离,先吃透这 4 个核心数据结构
背景:为什么需要了解这个
当你是一位手机厂商的内存优化工程师,会发现一个诡异现象:某 App 的内存占用很高,但 memory.current 显示的数字却远小于实际的 RSS 总和。排查后发现,许多共享库(如 libc.so)的 page cache 计费到了系统进程(如 init)的 cgroup 下,而非真正使用它的 App——因为 init 进程最先访问了这个文件页,触发了 mem_cgroup_charge(),folio 就此绑定给了 init 所在的 memcg;App 后续访问同一个 folio 时,它已经计费过,不会再算给 App。谁第一个走到 charge 路径的终点 commit_charge(),这个 folio 就归谁——这正是本文要拆解的完整计费路径。
但归属一旦确定,后续的过程就是计费的核心:一个 folio 被分配后,内核如何告诉 memcg “这个页面花了你多少钱”?如果超过限制,怎么处理?这就是本文要剖析的完整计费路径。这条路径每秒可能被调用数十万次(手机上的 page fault、文件缓存、swapin 等),它的性能直接影响系统流畅度——所以内核设计了批量预充值(batch charge)和 per-cpu stock 来避免频繁的原子操作。
基础概念:读懂本文需要知道的
- folio:内核 5.16 后引入的概念,一个 folio 可能包含一个或多个 page(大页)。计费是以 folio 为单位进行的,通过
folio_nr_pages()获取包含的页数。 - obj_cgroup:一个轻量级对象,用于跟踪对象级别的内存(如 slab、socket buffer),与 mem_cgroup 一一对应。计费时先通过
get_obj_cgroup_from_memcg()从 memcg 获取 obj_cgroup。 - page_counter:分层原子计数器,用于追踪内存使用量(
usage)和限制(max)。v2 下每个 cgroup 有memory和swap两个struct page_counter(字段详解见 Part 2)。 - per-cpu stock:每个 CPU 上缓存的一块预授权内存额度,当计费或归还时优先操作本地 stock,stock 累计超过
MEMCG_CHARGE_BATCH(64 页)时回写到全局 page_counter,以避免频繁的原子操作。 - MEMCG_CHARGE_BATCH:每笔 stock 操作的批量大小,定义为 64。该值没有硬上限,stock 的
nr_pages字段为unsigned int类型,可以容纳更大值,但内核会在 stock 超过批量阈值时及时回写。
计费从哪里开始:malloc() 不是计费时机
很多人直觉上认为 malloc() 是内存分配的起点,计费也应该在那里。但内核并不这样做。
malloc() 调用 brk() 或 mmap() 向内核申请虚拟地址空间(VA),内核只是建立 VMA(虚拟内存区域),此时没有物理页,也没有计费。物理页的分配推迟到进程真正访问这段地址时——CPU 触发 page fault,内核才分配物理页并完成计费。这就是 Linux 的"按需分页"(demand paging)机制,对 glibc、bionic、musl 等所有 C 库均适用。
对于匿名页(堆、栈),完整的触发路径如下:
1 | 用户态调用 malloc() |
文件页(page cache)走另一条路径,但最终也是调用 mem_cgroup_charge()。计费的核心逻辑是统一的,差别只在"谁触发了 charge"。
核心机制详解
设计思路:为什么要批量预充值?
最直观的计费方式:每次分配页时,直接原子地修改 memcg->memory.usage。但这样有两个问题:
- 原子操作开销:每个 page fault 都要执行一次
atomic_long_add(),在多核系统中 cache line bouncing 严重。 - oom 判断延迟:必须遍历整棵 cgroup 树向上检查每个 ancestor 的限制。
内核的解决方案是 batch + per-cpu stock:每个 CPU 本地维护一块"零竞争"的缓存(struct memcg_stock_pcp),计费时先在本地缓存里扣,只有本地缓存不够用时才碰一次全局共享的 page_counter->usage。
具体机制(对应 consume_stock() / refill_stock(),mm/memcontrol.c):
- 每个 CPU 的 stock 有 7 个槽位(
NR_MEMCG_STOCK=7),可以同时缓存 7 个不同 memcg 的额度——因为一个 CPU 上可能交替运行属于不同 cgroup 的任务 consume_stock()命中时:只是给本地数组的nr_pages[i]做减法,没有任何原子操作,因为这块内存只有当前 CPU 会碰- miss 时(本地没缓存或额度不够):一次性向全局
page_counter申请batch = max(64, nr_pages)页,多申请的部分通过refill_stock()存回本地 stock,供后续 page fault 复用 - 7 个槽位都被占用且需要换入新 memcg 时,按
drain_idx轮转踢掉一个旧槽位(drain_stock()),把它的额度写回全局 counter 再腾位
效果:假设连续 64 次 4KB 匿名缺页都属于同一个 memcg,只有第 1 次会触发真实的 atomic_long_add_return(),后续 63 次都在本地 stock 里完成,不产生任何跨核 cache line 流量。这正是上一节"为什么要少用共享 cache line 上的原子操作"那个问题的具体解法。
数据结构关系
charge 路径横跨四类对象,每类之间有固定的指针关系。先看清楚"谁指向谁、字段叫什么",后面的代码路径才不会迷失。
task_struct → mem_cgroup(归属关系,静态)
task_struct 没有直接的 memcg 字段。它通过 .cgroups(一个 css_set*)记录自己属于哪些 cgroup。css_set.subsys[memory_cgrp_id] 是内嵌在 mem_cgroup 里的 cgroup_subsys_state,从这个 CSS 可以反推出 mem_cgroup。这是进程归属关系,在进程加入 cgroup 时建立,charge 时只是来查一下"我归属哪个 memcg"。
另一条路:mm_struct.owner 指向持有这块地址空间的 task_struct(即进程主线程),charge 入口实际从 mm_struct 出发,经由 mm->owner 才到 task 的 css_set。
mem_cgroup → obj_cgroup(一一对应,静态)
mem_cgroup.objcg 指向一个 obj_cgroup,二者一一对应,obj_cgroup.memcg 反指回来。obj_cgroup 是一个比 mem_cgroup 轻量得多的对象,专门用于 page / slab 计费。设计这一层的原因:slab 计费和 page 计费最终操作的都是 mem_cgroup 里的 page_counter,但调用来源不同;obj_cgroup 作为统一接口,让两条路都能通过 obj_cgroup.memcg 找到同一个 mem_cgroup。
folio → obj_cgroup(归属记录,charge 时写入)
folio.memcg_data 是一个 unsigned long,charge 完成后被写入 obj_cgroup*(低位留给标志位)。folio 一旦被 charge,这个字段就确定了"这块物理页记账给哪个 cgroup"。uncharge、LRU 回收、swapout 时,内核直接从这个字段读出 obj_cgroup,再拿 obj_cgroup.memcg 找到 mem_cgroup 做扣减,不需要重走 task → css_set 的查找路径。
per-cpu stock(每个 CPU 独立持有,与上面三类对象的指针关系无关)
上面三组指针(task→memcg、memcg→objcg、folio→memcg_data)都是"写一次、长期存活"的归属关系。per-cpu stock 不同——它是每个 CPU 在运行过程中临时维护的"预借额度"本地缓存,charge 命中时只动本 CPU 自己的局部变量,不修改上面任何 struct 的字段。
结构体定义如下(mm/memcontrol.c):
1 |
|
7 个槽位对应同一 CPU 上可能交替运行的 7 个不同 memcg 的任务。charge 时先查 cached[i] 有没有匹配当前 memcg 且 nr_pages[i] 还有余量,有则直接在本地减,整个过程没有原子操作。
1 | mm_struct |
读完这张图,再看后面的代码路径时,每个函数做的事就是沿着这些箭头查或写。
关键代码路径
入口:mem_cgroup_charge()
对普通用户来说,mem_cgroup_charge() 是最常用的计费 API,它对一个 folio 执行完整的计费流程。
1 | mem_cgroup_charge(folio, mm, gfp) |
charge_memcg():获取 objcg 并计费
1 | static int charge_memcg(struct folio *folio, struct mem_cgroup *memcg, gfp_t gfp) |
关键步骤:
- 从 memcg 获取 obj_cgroup(每个 memcg 只有一个 obj_cgroup)。
- 如果 obj_cgroup 不是根 cgroup,调用
try_charge_memcg()进行实际的额度检查。 - 成功后,调用
commit_charge()将 folio 与 obj_cgroup 绑定。commit_charge()要求调用方持有以下三者之一:folio lock、LRU isolation 或 folio 的独占引用,确保写入期间 folio 不会被并发迁移或重复计费。
这也解释了开篇提到的共享库计费偏差:首个触发缺页的进程先执行 commit_charge() 确定 folio 归属;后续进程访问同一块 page cache 时,folio 已绑定归属标签,不会重复计费。
为何根 cgroup 跳过? 根 cgroup(/)代表整个系统,其 memory.max 在内核里硬编码为 max(无上限),你永远无法用 memcg 限制根 cgroup 自身。因此 try_charge_memcg() 对它来说是空转:永远不会超限、永远不会触发回收。跳过这步既正确又省了一次 atomic 操作。folio 仍然会被打上根 cgroup 的 obj_cgroup 标签,只是不做额度检查。
try_charge_memcg():真正的计费引擎
mm/memcontrol.c,精简后的核心骨架:
1 | static int try_charge_memcg(struct mem_cgroup *memcg, gfp_t gfp_mask, |
几个细节:
- 批量充值:每次向
page_counter申请max(64, nr_pages)页而不是恰好 1 页,多出来的存入 per-cpu stock,下次分配优先从 stock 消耗,大幅减少 atomic 操作次数。 - 超限找祖先:
page_counter_try_charge沿 hierarchy 向上做原子加,第一个超限的节点通过counter指针传出,精确定位到是哪一级 memcg 触发了回收。 - memsw:v1 下若开启 swap 计费,还有一路对
memcg->memsw的对称检查(do_memsw_account()控制)。
page_counter_try_charge():原子递增+边界检查
mm/page_counter.c:
1 | bool page_counter_try_charge(struct page_counter *counter, |
函数在内部沿 counter → parent → grandparent → ... 链依次原子加并检查上限。任意一级超限时,仅回滚当前超限节点的用量,并通过 *fail 传出超限指针;超限节点以下已成功递增的所有节点,由调用方通过 page_counter_uncharge() 逆向撤销,保证全层级计数无残留。
失败时的层级回滚
当 try_charge_memcg() 失败时(即某个 ancestor 超限),从起始子节点开始逆向回滚超限节点以下所有已成功递增的层级,通过 page_counter_uncharge() 实现,保证全链路计数无残留。回滚完成后,内核会调用 mem_cgroup_out_of_memory() 尝试 OOM kill;如果 gfp 标志不允许阻塞(如原子上下文),则直接返回 -ENOMEM。
stock 机制:三个操作与调用时机
consume_stock()
在 try_charge_memcg() 的第一行作为快路径调用。
1 | static bool consume_stock(struct mem_cgroup *memcg, unsigned int nr_pages) |
命中返回 true,try_charge_memcg() 立即 return 0,整条 charge 路径结束。
refill_stock()
在两类场景下调用:
- charge 侧:
try_charge_memcg()批量向page_counter申请 64 页,但实际分配只需 1 页,多出的 63 页通过refill_stock()存入 stock - uncharge 侧:folio 释放(
obj_cgroup_uncharge_pages())、socket buffer 释放(mem_cgroup_uncharge_skmem())时,释放的页数同样进入 stock 而不是立即归还全局计数器
1 | static void refill_stock(struct mem_cgroup *memcg, unsigned int nr_pages) |
drain_stock()
把 slot 里预授权但尚未回写的页数通过 page_counter_uncharge() 真正还给全局计数器,然后清空该 slot。
1 | static void drain_stock(struct memcg_stock_pcp *stock, int i) |
触发时机:
refill_stock()中某个 slot 的nr_pages超过 64,或 7 个 slot 全满需要轮转驱逐drain_all_stock():try_charge_memcg()在回收后余量仍不足时,把所有 CPU 的 stock 强制 drain,让全局page_counter得到准确值再重试- CPU 下线时,该 CPU 的 stock 必须 drain,避免已授权但未回写的页数永久丢失
slab/kmem 计费:独立的字节级 stock
page_counter 只认页,不认字节。但 kmalloc(128) 只分配了 128 字节,远小于一页(4096 字节)。如果每次 kmalloc 都直接向 page_counter 充值一页,既过度计费,又频繁触发原子操作。内核用两级 stock 解决这个问题。
第一级:字节级 obj_stock
1 | struct obj_stock_pcp { |
第二级:页级 memcg_stock(和 folio 共用,前文已介绍)
两级 stock 如何协同,体现在 obj_cgroup_charge() 里:
1 | int obj_cgroup_charge(struct obj_cgroup *objcg, gfp_t gfp, size_t size) |
以 kmalloc(128) 为例,字节在两级 stock 间的流转:
1 | 第 1 次 kmalloc(128) |
socket 内存计费
socket buffer 不走 slab obj_stock,而是直接以页粒度调用 try_charge_memcg(),归还时调用 refill_stock()(进入页级 stock)。这是因为 socket buffer 的生命周期和大小都更接近页粒度,字节级 stock 对它收益不大。
哪些路径不被 memcg 追踪
内核常规分配:需显式指定 __GFP_ACCOUNT
memcg 的计费是显式 opt-in 的,内核分配默认不被追踪。只有分配时带上 __GFP_ACCOUNT 标志,__alloc_frozen_pages_noprof() 才会调用 __memcg_kmem_charge_page() 进行计费:
1 | /* mm/page_alloc.c */ |
不带 __GFP_ACCOUNT 的内核分配(如普通 GFP_KERNEL)对 memcg 完全透明。需要计费的内核代码使用 GFP_KERNEL_ACCOUNT(= GFP_KERNEL | __GFP_ACCOUNT)显式声明。
vmalloc:默认不计费
原因在于 vmalloc() 硬编码了 GFP_KERNEL,没有 __GFP_ACCOUNT:
1 | void *vmalloc_noprof(unsigned long size) { |
这是设计决策:vmalloc 服务于内核自身的虚拟地址空间需求(内核模块、大块内核数据结构),不代表某个具体容器的工作负载,把这些页归属到哪个 cgroup 没有意义。如果某个内核子系统确实需要让 vmalloc 计费,可以直接调用底层的 __vmalloc() 并传 GFP_KERNEL_ACCOUNT。
kvmalloc() 对小对象走 kmalloc(可带 __GFP_ACCOUNT,被追踪),大对象 fallback 到 vmalloc 时同样不计费。
中断上下文:默认归属根 cgroup
中断上下文的 memcg 归属由 current_obj_cgroup() 决定:
1 | __always_inline struct obj_cgroup *current_obj_cgroup(void) |
中断上下文没有"当前进程"的概念,归属取决于调用者是否提前设置了 int_active_memcg(极少数驱动会这样做)。绝大多数中断分配走 fallback 路径,归属到 root memcg——而 root 永远不被限制,charge 被直接跳过(obj_cgroup_is_root() 为 true,__memcg_kmem_charge_page() 提前返回)。
设计演进:为什么不是更简单的方案
为什么不直接用 atomic_long 操作每个 folio?
v1 的做法是 page_cgroup 结构体,每个 page 都有一个 page_cgroup,但引入了额外内存(32 字节/page)和复杂的 LRU 管理。v2 舍弃了 page_cgroup,改用 folio->memcg_data 直接指向 mem_cgroup,节省内存,但引入了 stock 机制来解决原子操作开销。
为什么 stock 大小是 64?
MEMCG_CHARGE_BATCH=64 的选取是经验值:太小(如 1)无法发挥批量优势;太大(如 256)会导致 stock 跨 CPU 同步时回写压力大。64 页约 256KB,与典型的大页(2MB)相比适中,且作为经验值平衡了批量优势和回写压力。MEMCG_CHARGE_BATCH 本身没有硬上限,nr_pages 是 unsigned int 类型,可容纳更大值,但内核通过 drain_stock 在超过批量阈值时及时回写,避免 stock 无限累积。
为什么需要 obj_cgroup?
obj_cgroup 的存在是为了支持 slab 和 socket 等非 page 粒度的内存对象。这些对象无法通过 folio 来跟踪,所以引入了一个更轻量的代理对象 obj_cgroup,它允许在同一个 folio 中容纳多个不同 cgroup 的 slab 对象(通过 obj_cgroup->memcg 回溯到具体的 mem_cgroup)。计费时,先通过 get_obj_cgroup_from_memcg() 获取 obj_cgroup,然后调用 try_charge_memcg() 执行额度检查。
与 v1 的对比
计费单元
- v1:
page+page_cgroup(每个 page 附带一个 32 字节结构体) - v2:
folio+obj_cgroup(folio->memcg_data 直接编码归属,无额外结构体)
归属记录
- v1:独立的
page_cgroup数组,与 page 一一对应 - v2:
folio->memcg_data字段编码obj_cgroup指针,零额外内存
计费入口
- v1:分散在各分配路径
- v2:统一收口到
mem_cgroup_charge()
批量优化
- v1:无 stock,每次分配直接原子操作全局计数器
- v2:per-cpu stock(batch=64),热点路径 0 次全局原子操作
层级回滚
- v1:手动遍历 parent 链逐级回滚
- v2:
page_counter_try_charge()回滚超限节点,调用方逆向撤销全链路
socket 计费
- v1:无独立路径
- v2:
mem_cgroup_charge_skmem(),直接页粒度计费,归还走refill_stock()
小结
-
完整计费路径:
mem_cgroup_charge()→charge_memcg()→try_charge_memcg()→page_counter_try_charge(),核心引擎为try_charge_memcg(),同时处理 stock 缓存与层级额度检查。 -
per-cpu stock 性能优化:每个 CPU 本地缓存预授权额度,热点路径零全局原子操作;不足时批量充值(64 页),超额时轮转驱逐,平衡性能与统计准确性。
-
超限三级重试:先回收、再刷空全量 stock(避免假超限)、最后触发 OOM kill,逐级兜底。
-
folio 归属规则:首个完成
commit_charge()的进程确定 folio 归属,共享页不重复计费,是绝大多数 memcg 统计偏差的根源。 -
设计权衡:「批量 + 本地缓存 + 层级计数器」架构,在内存开销、分配性能与计费精度之间取得平衡。
下一篇将深入限额执行机制:从用户态 echo 512M > memory.max 写入,到内核 page_counter_try_charge() 超限时如何硬性阻断(goto retry 还是 goto nomem 的分水岭);以及更微妙的 memory.high 软节流——超限进程并非立即被杀,而是通过 penalty_jiffies = r² × 64 × HZ 计算惩罚延迟,调用 schedule_timeout_killable() 主动让出 CPU;还会讲解 mem_find_max_overage() 如何在整棵层级树中找出「超限最多的那个节点」,以及 memory.high 和 memory.max 在触发时机与回滚行为上的本质差异。