记账全路径:一次内存分配如何被 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 有 memoryswap 两个 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
2
3
4
5
6
7
8
9
10
11
12
13
用户态调用 malloc()
└─ brk() / mmap() ← 只建 VMA,不分配物理页
↓ 首次访问该地址
CPU 触发 page fault
└─ handle_mm_fault()
└─ do_anonymous_page()
├─ alloc_folio() ← 分配物理页
└─ mem_cgroup_charge() ← 计费入口(本文重点)
└─ __mem_cgroup_charge()
└─ charge_memcg()
├─ try_charge_memcg()
│ └─ page_counter_try_charge()
└─ commit_charge() ← folio 绑定 objcg

文件页(page cache)走另一条路径,但最终也是调用 mem_cgroup_charge()。计费的核心逻辑是统一的,差别只在"谁触发了 charge"。

核心机制详解

设计思路:为什么要批量预充值?

最直观的计费方式:每次分配页时,直接原子地修改 memcg->memory.usage。但这样有两个问题:

  1. 原子操作开销:每个 page fault 都要执行一次 atomic_long_add(),在多核系统中 cache line bouncing 严重。
  2. 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
2
3
4
5
6
7
8
9
10
11
#define NR_MEMCG_STOCK 7

struct memcg_stock_pcp {
local_trylock_t lock;
uint8_t nr_pages[NR_MEMCG_STOCK]; /* 每个槽预借了多少页 */
struct mem_cgroup *cached[NR_MEMCG_STOCK]; /* 对应哪个 memcg */

struct work_struct work;
unsigned long flags;
uint8_t drain_idx; /* 轮转踢槽用的下标 */
};

7 个槽位对应同一 CPU 上可能交替运行的 7 个不同 memcg 的任务。charge 时先查 cached[i] 有没有匹配当前 memcg 且 nr_pages[i] 还有余量,有则直接在本地减,整个过程没有原子操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
mm_struct
.owner
└→ task_struct
.cgroups
└→ css_set
.subsys[memory]
└→ [mem_cgroup]

[mem_cgroup]
.memory (page_counter)
.swap (page_counter)
.objcg
└→ obj_cgroup
.memcg → [mem_cgroup] (回指)

folio
.memcg_data
└→ obj_cgroup ← charge 完成后写入

per-cpu memcg_stock_pcp
.cached[0..6] → mem_cgroup
.nr_pages[0..6]

读完这张图,再看后面的代码路径时,每个函数做的事就是沿着这些箭头查或写。

关键代码路径

入口:mem_cgroup_charge()

对普通用户来说,mem_cgroup_charge() 是最常用的计费 API,它对一个 folio 执行完整的计费流程。

1
2
3
4
5
6
7
mem_cgroup_charge(folio, mm, gfp)
|
+-> __mem_cgroup_charge(folio, mm, gfp) mm/memcontrol.c
|
+-> get_mem_cgroup_from_mm(mm) // 从 mm->memcg 获取所属 cgroup
+-> charge_memcg(folio, memcg, gfp) // 核心计费
+-> css_put(&memcg->css) // 释放引用

charge_memcg():获取 objcg 并计费

1
2
3
4
5
6
7
8
9
static int charge_memcg(struct folio *folio, struct mem_cgroup *memcg, gfp_t gfp)
{
struct obj_cgroup *objcg;

objcg = get_obj_cgroup_from_memcg(memcg); // 获取 obj_cgroup
if (!obj_cgroup_is_root(objcg))
ret = try_charge_memcg(memcg, gfp, folio_nr_pages(folio));
commit_charge(folio, objcg); // 写入 folio->memcg_data,完成归属绑定
}

关键步骤:

  1. 从 memcg 获取 obj_cgroup(每个 memcg 只有一个 obj_cgroup)。
  2. 如果 obj_cgroup 不是根 cgroup,调用 try_charge_memcg() 进行实际的额度检查。
  3. 成功后,调用 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
static int try_charge_memcg(struct mem_cgroup *memcg, gfp_t gfp_mask,
unsigned int nr_pages)
{
unsigned int batch = max(MEMCG_CHARGE_BATCH, nr_pages);
struct page_counter *counter;
struct mem_cgroup *mem_over_limit;
...

retry:
/* ① 快路径:从 per-cpu stock 直接扣,0 atomic 操作 */
if (consume_stock(memcg, nr_pages))
return 0;

/* ② 慢路径:原子递增 page_counter,并沿 hierarchy 向上检查 */
if (page_counter_try_charge(&memcg->memory, batch, &counter))
goto done_restock; /* 未超限,成功 */

/* ③ 超限:找出是哪一级 ancestor 超了 */
mem_over_limit = mem_cgroup_from_counter(counter, memory);

/* ④ 触发该 ancestor 的内存回收 */
nr_reclaimed = try_to_free_mem_cgroup_pages(mem_over_limit,
nr_pages, gfp_mask, reclaim_options, NULL);
if (mem_cgroup_margin(mem_over_limit) >= nr_pages)
goto retry; /* 回收成功,重试 */

/* ⑤ 刷空所有 CPU 的 per-cpu stock,把预借额度归还全局计数器 */
if (!drained) {
drain_all_stock(mem_over_limit);
drained = true;
goto retry; /* 归还后再试,很多"超限"其实是 stock 未回写造成的假超限 */
}

/* ⑥ 仍超限:触发 OOM kill */
if (mem_cgroup_oom(mem_over_limit, gfp_mask, ...)) {
goto retry; /* OOM kill 了进程,再试一次 */
}
return -ENOMEM; /* 彻底失败 */

done_restock:
/* ⑦ 批量充值(MEMCG_CHARGE_BATCH 通常 = 64 页),多余的补回 stock */
if (batch > nr_pages)
refill_stock(memcg, batch - nr_pages);
/* ⑧ 检查是否超过 memory.high,触发渐进式限流(进程上下文同步,否则异步)
* 累计超额量超过一个 batch 才进入限流路径,避免每次小额 charge 都触发慢路径 */
if (current->memcg_nr_pages_over_high > MEMCG_CHARGE_BATCH)
mem_cgroup_handle_over_high(gfp_mask);
return 0;
}

几个细节:

  • 批量充值:每次向 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bool page_counter_try_charge(struct page_counter *counter,
unsigned long nr_pages,
struct page_counter **fail)
{
struct page_counter *c;

/* 函数内部自底向上遍历 hierarchy,不需要外部逐节点调用 */
for (c = counter; c; c = c->parent) {
long new = atomic_long_add_return(nr_pages, &c->usage);
if (new > c->max) {
atomic_long_sub(nr_pages, &c->usage); /* 回滚当前节点 */
*fail = c; /* 传出超限节点 */
return false;
}
}
return true;
}

函数在内部沿 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
static bool consume_stock(struct mem_cgroup *memcg, unsigned int nr_pages)
{
struct memcg_stock_pcp *stock;
uint8_t stock_pages;
bool ret = false;
int i;

/* 请求超过批量上限,或 per-cpu lock 竞争,直接走慢路径 */
if (nr_pages > MEMCG_CHARGE_BATCH ||
!local_trylock(&memcg_stock.lock))
return ret;

stock = this_cpu_ptr(&memcg_stock);

for (i = 0; i < NR_MEMCG_STOCK; ++i) {
if (memcg != READ_ONCE(stock->cached[i]))
continue; /* 找到匹配的 slot */

stock_pages = READ_ONCE(stock->nr_pages[i]);
if (stock_pages >= nr_pages) {
WRITE_ONCE(stock->nr_pages[i], stock_pages - nr_pages);
ret = true; /* 扣减成功,0 次全局原子操作 */
}
break;
}

local_unlock(&memcg_stock.lock);
return ret;
}

命中返回 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
static void refill_stock(struct mem_cgroup *memcg, unsigned int nr_pages)
{
struct memcg_stock_pcp *stock;
struct mem_cgroup *cached;
uint8_t stock_pages;
bool success = false;
int empty_slot = -1;
int i;

/* 超过批量上限或 lock 竞争,直接调 page_counter_uncharge() 归还 */
if (nr_pages > MEMCG_CHARGE_BATCH ||
!local_trylock(&memcg_stock.lock)) {
memcg_uncharge(memcg, nr_pages);
return;
}

stock = this_cpu_ptr(&memcg_stock);
for (i = 0; i < NR_MEMCG_STOCK; ++i) {
cached = READ_ONCE(stock->cached[i]);
if (!cached && empty_slot == -1)
empty_slot = i; /* 记录第一个空 slot */
if (memcg == cached) {
stock_pages = READ_ONCE(stock->nr_pages[i]) + nr_pages;
WRITE_ONCE(stock->nr_pages[i], stock_pages);
if (stock_pages > MEMCG_CHARGE_BATCH)
drain_stock(stock, i); /* slot 溢出,立即回写 */
success = true;
break;
}
}

if (!success) {
i = empty_slot;
if (i == -1) {
/* 7 个 slot 全满,轮转驱逐最老的 slot */
i = stock->drain_idx++;
if (stock->drain_idx == NR_MEMCG_STOCK)
stock->drain_idx = 0;
drain_stock(stock, i);
}
css_get(&memcg->css);
WRITE_ONCE(stock->cached[i], memcg);
WRITE_ONCE(stock->nr_pages[i], nr_pages);
}

local_unlock(&memcg_stock.lock);
}

drain_stock()

把 slot 里预授权但尚未回写的页数通过 page_counter_uncharge() 真正还给全局计数器,然后清空该 slot。

1
2
3
4
5
6
7
8
9
10
11
static void drain_stock(struct memcg_stock_pcp *stock, int i)
{
struct mem_cgroup *old = READ_ONCE(stock->cached[i]);
uint8_t stock_pages = READ_ONCE(stock->nr_pages[i]);

if (stock_pages)
memcg_uncharge(old, stock_pages); /* 真正归还 page_counter */

css_put(&old->css);
WRITE_ONCE(stock->cached[i], NULL); /* 清空 slot */
}

触发时机:

  • 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
2
3
4
5
struct obj_stock_pcp {
uint16_t nr_bytes[NR_OBJ_STOCK]; /* 预付但还未用完的字节数 */
struct obj_cgroup *cached[NR_OBJ_STOCK];
...
};

第二级:页级 memcg_stock(和 folio 共用,前文已介绍)

两级 stock 如何协同,体现在 obj_cgroup_charge() 里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int obj_cgroup_charge(struct obj_cgroup *objcg, gfp_t gfp, size_t size)
{
/* ① 字节级 stock 有余量,直接扣减,0 次原子操作 */
if (likely(consume_obj_stock(objcg, size)))
return 0;

/* ② stock 不足,向 page_counter 预付整页 */
ret = __obj_cgroup_charge(objcg, gfp, size, &remainder);
/*
* __obj_cgroup_charge 内部:
* charge_size = PAGE_ALIGN(size) // 128 → 4096,5000 → 8192
* obj_cgroup_charge_pages(..., charge_size >> PAGE_SHIFT)
* // 向 page_counter 充值 charge_size/PAGE_SIZE 页
* *remainder = charge_size - size // 4096 - 128 = 3968 字节
*/

/* ③ 多预付的字节存入 obj_stock,供后续 kmalloc 消耗 */
if (!ret && remainder)
refill_obj_stock(objcg, remainder, false);
return ret;
}

kmalloc(128) 为例,字节在两级 stock 间的流转:

1
2
3
4
5
6
7
8
9
10
11
12
13
第 1 次 kmalloc(128)
obj_stock 空 → 命中②
PAGE_ALIGN(128) = 4096 → page_counter += 1 页
remainder = 4096 - 128 = 3968 字节存入 obj_stock

第 2 次 kmalloc(256)
obj_stock 有 3968 字节 ≥ 256 → 命中①
obj_stock = 3968 - 256 = 3712 字节,0 次原子操作

... 继续消耗,直到 obj_stock < 下次请求大小

第 N 次 kmalloc(4000)
obj_stock < 4000 → 命中②,再向 page_counter 预付 1 页

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
2
3
4
5
6
/* mm/page_alloc.c */
if (memcg_kmem_online() && (gfp & __GFP_ACCOUNT) && page &&
unlikely(__memcg_kmem_charge_page(page, gfp, order) != 0)) {
free_frozen_pages(page, order);
page = NULL;
}

不带 __GFP_ACCOUNT 的内核分配(如普通 GFP_KERNEL)对 memcg 完全透明。需要计费的内核代码使用 GFP_KERNEL_ACCOUNT(= GFP_KERNEL | __GFP_ACCOUNT)显式声明。

vmalloc:默认不计费

原因在于 vmalloc() 硬编码了 GFP_KERNEL,没有 __GFP_ACCOUNT

1
2
3
void *vmalloc_noprof(unsigned long size) {
return __vmalloc_node_noprof(size, 1, GFP_KERNEL, NUMA_NO_NODE, ...);
}

这是设计决策:vmalloc 服务于内核自身的虚拟地址空间需求(内核模块、大块内核数据结构),不代表某个具体容器的工作负载,把这些页归属到哪个 cgroup 没有意义。如果某个内核子系统确实需要让 vmalloc 计费,可以直接调用底层的 __vmalloc() 并传 GFP_KERNEL_ACCOUNT

kvmalloc() 对小对象走 kmalloc(可带 __GFP_ACCOUNT,被追踪),大对象 fallback 到 vmalloc 时同样不计费。

中断上下文:默认归属根 cgroup

中断上下文的 memcg 归属由 current_obj_cgroup() 决定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
__always_inline struct obj_cgroup *current_obj_cgroup(void)
{
if (in_task()) {
/* 进程上下文:用 current->objcg,即当前任务所属 memcg */
objcg = READ_ONCE(current->objcg);
return objcg ?: root_mem_cgroup->objcg;
}

/* 中断上下文:读 per-cpu 的 int_active_memcg */
memcg = this_cpu_read(int_active_memcg);
if (unlikely(memcg))
goto from_memcg;

/* 未设置则 fallback 到 root memcg */
return root_mem_cgroup->objcg;
}

中断上下文没有"当前进程"的概念,归属取决于调用者是否提前设置了 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_pagesunsigned 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()

小结

  1. 完整计费路径mem_cgroup_charge()charge_memcg()try_charge_memcg()page_counter_try_charge(),核心引擎为 try_charge_memcg(),同时处理 stock 缓存与层级额度检查。

  2. per-cpu stock 性能优化:每个 CPU 本地缓存预授权额度,热点路径零全局原子操作;不足时批量充值(64 页),超额时轮转驱逐,平衡性能与统计准确性。

  3. 超限三级重试:先回收、再刷空全量 stock(避免假超限)、最后触发 OOM kill,逐级兜底。

  4. folio 归属规则:首个完成 commit_charge() 的进程确定 folio 归属,共享页不重复计费,是绝大多数 memcg 统计偏差的根源。

  5. 设计权衡:「批量 + 本地缓存 + 层级计数器」架构,在内存开销、分配性能与计费精度之间取得平衡。

下一篇将深入限额执行机制:从用户态 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.highmemory.max 在触发时机与回滚行为上的本质差异。