今日内存子系统关注多项锁竞争优化和资源泄漏修复:__access_remote_vm 改用 per-VMA 锁、统一 walk_page_range_vma、khugepaged xarray 节点泄漏修复;同时 swap tier 引入 per-cgroup 设备优先级控制。


阅读全文 »

今日关注:kfree_rcu_nolock() 系列收到 Alexei Starovoitov 详尽 review,slab 在 NMI/hardirq 上下文的递归保护面临调整;eBPF 方面 sockmap 锁反转修复落地、conntrack opts 边界检查加强;文件系统侧 vmsplice 包装器在 s390 引发回归、btrfs 多路径错误修复系列提交。

阅读全文 »

今天的关键词是 slab 分配器数据结构重构:Vlastimil Babka 发布 v3,将 slab 内部 alloc_flags 机制、slab_alloc_context 统一及 __GFP_NO_OBJ_EXT 的替代方案推进到可合入状态,并已入 slab/for-next。同时 zram 试图绕过通用 swap 路径,直接注册自有的 swap I/O ops,可能为压缩后端带来性能与灵活性的提升。


阅读全文 »

今日关注:vmalloc 的 vmap_area 索引从红黑树迁移到 maple tree 的 RFC 系列首次发布,为内核内存管理的重映射路径带来可扩展性改进;网络方面,Jakub Kicinski 正式拒绝 TLS + sockmap 组合,清除长期存在的安全隐患,同时 libbpf 的 ring buffer 用户态库收到 6 个关键正确性修复。

阅读全文 »

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
50
51
52
53
54
55
# 解剖 mem_cgroup:支撑统一层级的三大数据结构

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

<!-- more -->

## 前置知识

阅读本文前,建议先阅读本系列:
- Part 0:读懂 memcg 之前:你必须掌握的 cgroup 基础
- Part 1:memcg v1 到 v2:缺陷剖析与设计演进

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

当你负责优化手机或云服务器的内存使用效率时,你需要在几毫秒内回答这些问题:某个 cgroup 当前用了多少页?它是怎么被限制的?当内存压力到来时,保护机制如何决定哪些内存可以回收?这些问题的答案深藏在三个核心数据结构中——`mem_cgroup`、`page_counter` 和 `css_set`。它们不是孤立的几个结构体,而是一整套记账、限制、保护、回收的骨架。如果只了解接口而不知其内部构造,你无法解释为什么 `memory.low` 保护有时会失效,也无法手工调试 `memory.stat` 中某个字段的异常值。

本文将从数据结构和原子语义出发,带你看清这三者如何协作,形成 memcg 的神经中枢。

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

- **cgroup 与 css_set**:cgroup 是资源控制组的逻辑节点(`/sys/fs/cgroup/memory` 下的每个目录),而 `css_set` 是任务(task)与所有子系统状态(CSS)的绑定集合。一个 task 只能属于一个 css_set,但一个 css_set 可被多个 task(如同一进程的线程)共享,以节省内存。
- **page_counter**:memcg 计数的最小原子单元,记录页数,支持树形父子传播(charge/uncharge 沿父链向上更新)。
- **mem_cgroup**:内存控制组的核心结构体,包含多个 `page_counter`(memory、swap 等)、统计、事件、NUMA 节点信息等。
- **mem_cgroup_per_node**:每个 NUMA 节点为每个 mem_cgroup 保留一份,包含 LRU 向量、回收迭代器等,是回收操作的直接作用对象。

## 核心机制详解

### 1. `page_counter`:原子计数与树形传播

`struct page_counter` 定义在 `include/linux/page_counter.h`,它是 memcg 记账的基本单位。结构体本身使用 `____cacheline_internodealigned_in_smp` 对齐,确保热字段独占 cacheline,减少多核竞争。

```ascii
+-------------------------------------------------------------+
| struct page_counter |
+-------------------------------------------------------------+
| atomic_long_t usage; // 当前用量(热点,独占 cacheline)|
| unsigned long failcnt; // [v1 only] 超限次数 |
| CACHELINE_PADDING(_pad1_) // 热/冷字段隔离垫片 |
| unsigned long emin; // effective min (保护计算后) |
| atomic_long_t min_usage; // 本节点 min 覆盖用量 |
| atomic_long_t children_min_usage; // 所有子节点 min_usage 之和|
| unsigned long elow; // effective low |
| atomic_long_t low_usage; // 本节点 low 覆盖用量 |
| atomic_long_t children_low_usage; // 所有子节点 low_usage 之和|
| unsigned long watermark; // 历史峰值 |
| unsigned long local_watermark; // 可重置的峰值 |
| CACHELINE_PADDING(_pad2_) // 写热/读热隔离 |
| bool protection_support; // 仅 memcg->memory 且 v2 时为 true |
| bool track_failcnt; // v1 跟踪 failcnt,v2 不跟踪 |
| unsigned long min; // 用户写入的 memory.min (页数) |
| unsigned long low; // 用户写入的 memory.low |
| unsigned long high; // 用户写入的 memory.high |
| unsigned long max; // 用户写入的 memory.max |
| struct page_counter *parent; // 父节点指针(构成树) |
+-------------------------------------------------------------+

为什么 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。对于 swapkmem 等计数器,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
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 cgroup_subsys_state css; // 嵌入的 cgroup 核心状态 |
| struct page_counter memory; // RAM 用量 (v1/v2) |
| union { |
| struct page_counter swap; // v2 swap 独立计数 |
| struct page_counter memsw; // v1 RAM+swap 合并计数 |
| }; |
| ... [统计字段] ... |
| struct work_struct high_work; // v2 high throttle 工作队列 |
| bool oom_group; // v2 memory.oom_group |
| int swappiness; // per-cgroup 回收倾向度 |
| #ifdef CONFIG_MEMCG_V1 |
| struct page_counter kmem; // v1 内核内存计数 (废弃) |
| struct page_counter tcpmem; // v1 TCP 内存计数 (废弃) |
| unsigned long soft_limit; // v1 软限制 |
| bool oom_lock; int under_oom; // v1 OOM 同步锁 |
| int oom_kill_disable; // v1 禁用 OOM kill |
| #endif |
| struct mem_cgroup_per_node *nodeinfo[]; // flexible array |
+---------------------------------------------------------------+

v2 新增字段high_work 用于异步的 memory.high throttle,避免在中断上下文直接 schedule_timeoutoom_group 控制 OOM 时是否杀死整个 cgroup;swappiness 实现了 per-cgroup 的回收策略。

v1 遗留字段:以 #ifdef CONFIG_MEMCG_V1 包裹。kmemtcpmem 在 v2 中已废弃,因为内核内存计费被合并到 memory 统计中;soft_limitmemory.low 替代;oom_kill_disablememory.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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
+---------------------------------------------------------------+
| struct mem_cgroup_per_node |
+---------------------------------------------------------------+
| struct mem_cgroup *memcg; // 反向指针(非 container_of)|
| struct lruvec_stats_percpu *lruvec_stats_percpu; // per-CPU buf|
| struct lruvec_stats *lruvec_stats; // 聚合 LRU 统计 |
| struct shrinker_info __rcu *shrinker_info; // per-memcg shrinker|
| struct lruvec lruvec; // LRU 向量(ANON/FILE/UNEVICTABLE)|
| unsigned long lru_zone_size[MAX_NR_ZONES][NR_LRU_LISTS]; |
| struct mem_cgroup_reclaim_iter iter; // round-robin 回收迭代器|
| struct obj_cgroup __rcu *objcg; // slab 对象计费用 |
| #ifdef CONFIG_MEMCG_V1 |
| struct rb_node tree_node; // 软限制红黑树节点 |
| unsigned long usage_in_excess; // 超出 soft_limit 的页数 |
| bool on_tree; |
| #else |
| CACHELINE_PADDING(_pad1_); // 保持对齐 |
| #endif |
+---------------------------------------------------------------+

每个 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_nodelruvec 的容器,而 lruvec 不直接拥有指向 mem_cgroup 的指针(它通过父级 mem_cgroup 反向引用)。在回收路径中,shrink_lruvec() 函数需要获取 mem_cgroup 以进行保护计算等操作,而 lruvec 本身没有 memcg 字段,必须通过 container_ofmem_cgroup_per_node 拿到 memcg 指针。但 mem_cgroup_per_node 不是 lruvec 的子结构,因此需要显式存储。

4. css_settask_struct 的绑定

1
2
3
4
5
6
7
8
9
10
11
     task_struct                         css_set
+--------------------+ +-----------------------+
| cgroups (RCU ptr) | ---------> | refcount |
| cg_list | ---| | subsys[0..n] |
+--------------------+ | | subsys[memory_cgrp_id]|
| | = (struct css *) |
| | &mem_cgroup->css |
| | tasks 链表 |
+------>| mg_tasks 链表 |
| dfl_cgrp |
+-----------------------+

task_struct 中的 cgroups 字段指向一个 css_setcss_set 中的 subsys 数组存储了每个子系统(如 memory、cpu、io)的 CSS 指针。对于 memcg,subsys[memory_cgrp_id] 就是 struct mem_cgroupcss 字段的地址(因为 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
page_counter_try_charge(memcg->memory, nr_pages, &fail_counter)
|
v
for (c = counter; c; c = c->parent) {
new = atomic_long_add_return(nr_pages, &c->usage);
if (new > c->max) { // 超限检查
page_counter_cancel(c, nr_pages); // 回滚当前节点
*fail = c;
return false;
}
if (c->protection_support)
propagate_protected_usage(c, new); // 更新保护统计
// 更新 watermark
if (new > c->watermark)
c->watermark = new;
}
  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mem_cgroup_calculate_protection(root, memcg)
|
v
page_counter_calculate_protection(&root->memory, &memcg->memory,
recursive_protection)
|
v
effective_protection() 算法:
protected = min(usage, setting); // 不能保护超过自己用的
if (siblings_protected > parent_effective) {
// 过订阅:按比例缩减
emin = protected * parent_effective / siblings_protected;
} else {
emin = protected;
if (recursive_protection && 父节点有剩余)
额外分配残额;
}
  • 必须从根到叶顺序调用:因为 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
2
3
4
5
6
7
8
9
10
11
12
13
mem_cgroup_css_alloc(parent_css)
|
v
mem_cgroup_alloc(parent) // 分配结构体,设置 nodeinfo
|
v
page_counter_init(&memcg->memory, &parent->memory, memcg_on_dfl)
// protection_support = memcg_on_dfl (v2=true, v1=false)
page_counter_init(&memcg->swap, &parent->swap, false) // 永远 false
#ifdef CONFIG_MEMCG_V1
page_counter_init(&memcg->kmem, &parent->kmem, false)
page_counter_init(&memcg->tcpmem, &parent->tcpmem, false)
#endif
  • 根 cgroup 创建时 parent = NULLpage_counter_init 会设置 parent = NULLmax = 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 外,swapkmem 等计数器不需要保护传播,但它们同样在 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 从某个子结构反推。lruvecmem_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 兼容性也更好。

小结

  1. page_counter 是 memcg 的原子计数单元,以 atomic_long_t usage 独占 cacheline 减少竞争;charge/uncharge 沿 parent 树形传播,同时通过 propagate_protected_usage 同步保护统计。protection_support 仅对 v2 的 memory 计数器启用。
  2. mem_cgroup 嵌入了 page_counter memoryswap,通过 #ifdef CONFIG_MEMCG_V1 保留 v1 遗留字段;末尾的柔性数组 nodeinfo[] 实现 NUMA 感知。
  3. mem_cgroup_per_node 包含每个 NUMA 节点的 LRU 向量(lruvec)和回收迭代器,是回收操作的实际执行者。
  4. css_set 作为 task 与 cgroup 之间的中间层,存储所有 subsystem 的 CSS 指针,task 通过 task_struct->cgroups 关联,迁移时只需替换整个 css_set,RCU 安全。
  5. 保护计算mem_cgroup_calculate_protection 触发,调用 page_counter_calculate_protection,其算法 effective_protection 必须在自上而下顺序中执行,受 recursive_protection 标志控制是否递归分配剩余额度。

理解这三个数据结构及其关系,是分析内存回收异常、诊断保护失效、优化 NUMA 分配策略的基础。下一部分我们将深入 memory.high 的 throttle 机制,看惩罚时间如何在 penalty_jiffies 下与超额平方成正比。

0%