Linux 6.6内核内存管理深度解析(十一):memcg 内存控制组 — 配额 + 记账

本文深入解析Linux 6.6内核中memcg(内存控制组)的核心机制,聚焦配额(quota)与记账(accounting)两大功能。详细阐述页分配时的charge流程、层级化向上累加记账、memory.high软限节流、per-cpu stock性能优化,以及超限时的三阶段处理(组内回收→清缓存重试→组内OOM)。强调其在容器化(Docker/K8s)与AI训练平台多租户场景下的关键隔离作用。

引言:一台机器跑 100 个容器,怎么保证某个容器不把内存吃光?

Docker/K8s 时代,一台宿主机上跑几十上百个容器。每个容器都觉得自己"独占一台机器",但物理内存是大家共享的——如果某个容器里的进程狂吃内存(内存泄漏、恶意程序),会不会把整台机器拖垮、让别的容器也跟着 OOM?

答案就是 memcg(memory cgroup,内存控制组):给每个 cgroup(容器)设一个内存配额,内核在记账(charge)时检查"这个组用了多少",超了就在这个组内部回收、甚至只杀这个组里的进程,把影响限制在配额内,不让它波及整机。

它做的事一句话:每个页分配时"记账"到它所属的 cgroup,cgroup 内存达到上限时,只在这个组内回收/OOM。这篇讲的就是:配额(page_counter)→ 记账(charge/uncharge)→ 超限(回收 + OOM)→ 软限(high 节流)。


全景图

超 memory.max

还不够

成功

进程分配页
do_anonymous_page / filemap

mem_cgroup_charge
memcontrol.h:681

__mem_cgroup_charge :7011
→ get_mem_cgroup_from_mm :1032
拿页所属的 memcg

charge_memcg :6990
→ try_charge :2827
→ try_charge_memcg :2637

consume_stock :2652
先吃 per-cpu 缓存

page_counter_try_charge :2657
沿 parent 链记账

try_to_free_mem_cgroup_pages :2691
只回收本 memcg 的页
(vmscan.c:7126)

mem_cgroup_oom :2739
只杀本组的进程 :1925

commit_charge :2846
folio->memcg_data = memcg

超 memory.high 软限
mem_cgroup_handle_over_high :2558
节流 + 回收

一句话:分配页 → mem_cgroup_charge 找到页所属的 memcg → try_charge_memcg 先吃 per-cpu 缓存、再沿 parent 链 page_counter_try_charge 记账 → 超 memory.max 就先 try_to_free_mem_cgroup_pages 回收本组、再不行 mem_cgroup_oom 杀本组进程 → 记账成功 commit_charge 把页挂到 memcg。


一、memcg 是什么:配额 + 记账

1.1 两个词概括:quota(配额)+ accounting(记账)

memcg 做的本质是两件事:

  • 记账:每个页属于哪个 cgroup,用多少,记在 page_counter 的 usage 里。页分配时 charge(加),释放时 uncharge(减)。
  • 配额:每个 cgroup 设上限(memory.max),记账时检查有没有超,超了就触发回收/OOM。

核心结构是 struct mem_cgroup(memcontrol.h:209),它的"账户"是 page_counter:

// struct mem_cgroup(memcontrol.h:209)—— 关键字段
struct mem_cgroup {
	struct cgroup_subsys_state css;
	struct mem_cgroup_id id;

	/* Accounted resources */
	struct page_counter memory;		/* 内存账户(v1 & v2 都有) */
	union {
		struct page_counter swap;	/* v2:swap 单独算 */
		struct page_counter memsw;	/* v1:memory+swap 合并 */
	};
	...
	unsigned long soft_limit;		/* v1 软限 */
	int	swappiness;			/* 组内 swap 倾向 */
	bool	oom_group;			/* OOM 时是否杀整个组 */
	int	oom_kill_disable;		/* 禁用 OOM killer */
	struct memcg_vmstats *vmstats;		/* memory.stat 统计 */
	...
	struct mem_cgroup_per_node *nodeinfo[];	/* 每 node 的 LRU/水位 */
};

// struct page_counter(page_counter.h:10)—— 一个"账户"
struct page_counter {
	atomic_long_t usage;		/* 当前用了多少页 */
	...
	unsigned long min;		/* 保护下限:min 以内不回收 */
	unsigned long low;		/* 保护下限:low 以内尽量不回收 */
	unsigned long high;		/* 软限:超了节流 + 回收 */
	unsigned long max;		/* 硬限:超了回收 + OOM */
	struct page_counter *parent;	/* 父 cgroup,形成层级 */
};

page_counter 里的 min/low/high/max 就是配额的四档,usage 就是记账的账本。

1.2 层级记账:charge 沿 parent 链向上累加

page_counter 的 parent 指针把父子 cgroup 串成层级。关键是:子 cgroup charge 时,会沿 parent 链向上每一层都累加 usage:

// page_counter_charge(page_counter.c:70)—— 沿链向上累加
void page_counter_charge(struct page_counter *counter, unsigned long nr_pages)
{
	struct page_counter *c;
	for (c = counter; c; c = c->parent) {   // 从自己一路加到根
		long new = atomic_long_add_return(nr_pages, &c->usage);
		...
	}
}

// page_counter_try_charge(page_counter.c:97)—— 沿链向上检查 + 记账
bool page_counter_try_charge(struct page_counter *counter, ...)
{
	struct page_counter *c;
	for (c = counter; c; c = c->parent) {
		long new = atomic_long_add_return(nr_pages, &c->usage);
		if (new > c->max) { ... return false; }  // 任何一层超 max 都失败
	}
	return true;
}

所以父 cgroup 的 usage = 自己 + 所有子孙。这意味着:给父容器设 4GB 上限,它下面所有子容器的用量加起来不能超 4GB——配额是可以层层嵌套的。

Why 记账要"沿链向上":如果只在子 cgroup 记账,父 cgroup 就不知道子孙用了多少,父的配额就形同虚设。沿 parent 链累加,让每一层的配额都真实约束"这一层及以下所有用量"。这也解释了为什么 page_counter 的 usage 是 atomic_long_t(原子计数)——不同 CPU 上的进程并发 charge 同一个父 cgroup,必须无锁累加。


二、charge:分配页时记账

2.1 入口:mem_cgroup_charge

分配一个页(匿名页缺页、page cache 分配等)时,内核调 mem_cgroup_charge 把它记到对应的 cgroup:

// mem_cgroup_charge(memcontrol.h:681)—— inline 包装
static inline int mem_cgroup_charge(struct folio *folio, struct mm_struct *mm,
				    gfp_t gfp)
{
	return __mem_cgroup_charge(folio, mm, gfp);
}

// __mem_cgroup_charge(memcontrol.c:7011)
int __mem_cgroup_charge(struct folio *folio, struct mm_struct *mm, gfp_t gfp)
{
	struct mem_cgroup *memcg;
	int ret;

	memcg = get_mem_cgroup_from_mm(mm);   // ① 找到页属于哪个 cgroup
	ret = charge_memcg(folio, memcg, gfp); // ② 记账
	css_put(&memcg->css);
	return ret;
}

2.2 怎么知道页属于哪个 cgroup?

get_mem_cgroup_from_mm(memcontrol.c:1032)从 mm 反查 cgroup:mem_cgroup_from_task(mm->owner)——页属于"创建它的进程"所属的 cgroup。进程在哪个 cgroup(容器),它分配的页就记到哪个 cgroup。

2.3 记账成功:commit_charge 把页挂到 memcg

// charge_memcg(memcontrol.c:6990)
static int charge_memcg(struct folio *folio, struct mem_cgroup *memcg, gfp_t gfp)
{
	long nr_pages = folio_nr_pages(folio);

	ret = try_charge(memcg, gfp, nr_pages);  // ① 先试记账(可能回收/OOM)
	if (ret)
		goto out;

	css_get(&memcg->css);
	commit_charge(folio, memcg);              // ② 记账成功,页挂到 memcg
	mem_cgroup_charge_statistics(memcg, nr_pages);
	...
}

// commit_charge(memcontrol.c:2846)
static void commit_charge(struct folio *folio, struct mem_cgroup *memcg)
{
	folio->memcg_data = (unsigned long)memcg;  // 页记住自己属于哪个 cgroup
}

记账成功后,folio->memcg_data 存了指向 memcg 的指针。以后回收、LRU、swap 都能通过 folio_memcg(memcontrol.h:436)从页反查它属于哪个 cgroup——页和 cgroup 双向绑定。


三、超限三板斧:回收 → 再试 → OOM

try_charge_memcg(memcontrol.c:2637)是 charge 的心脏,超限时的处理全在这里:

static int try_charge_memcg(struct mem_cgroup *memcg, gfp_t gfp_mask,
			    unsigned int nr_pages)
{
	...
retry:
	if (consume_stock(memcg, nr_pages))      // ① 先吃 per-cpu 缓存
		return 0;

	if (page_counter_try_charge(&memcg->memory, batch, &counter))
		goto done_restock;                  // ② 记账成功

	mem_over_limit = mem_cgroup_from_counter(counter, memory); // 哪层超了
	...
	if (!gfpflags_allow_blocking(gfp_mask))
		goto nomem;                         // 不能阻塞就失败

	memcg_memory_event(mem_over_limit, MEMCG_MAX);   // 触发 memory.events

	nr_reclaimed = try_to_free_mem_cgroup_pages(mem_over_limit, nr_pages,
						    gfp_mask, reclaim_options);  // ③ 组内回收
	if (mem_cgroup_margin(mem_over_limit) >= nr_pages)
		goto retry;                         // 回收够了,重试记账

	if (!drained) {
		drain_all_stock(mem_over_limit);    // ④ 清 per-cpu 缓存再试
		drained = true;
		goto retry;
	}
	...
	if (nr_retries--)
		goto retry;                         // ⑤ 多轮重试

	if (mem_cgroup_oom(mem_over_limit, gfp_mask, order)) {  // ⑥ 组内 OOM
		passed_oom = true;
		nr_retries = MAX_RECLAIM_RETRIES;
		goto retry;                         // OOM 杀了进程,回来再试
	}
nomem:
	if (!(gfp_mask & (__GFP_NOFAIL | __GFP_HIGH)))
		return -ENOMEM;                     // 最终失败
force:
	page_counter_charge(&memcg->memory, nr_pages);  // 关键分配:强制超额
	return 0;
}

超限时的处理顺序(三板斧):

  1. 组内回收(try_to_free_mem_cgroup_pages):超 memory.max 先回收,只回收这个 memcg 自己的页(不是全局回收)。
  2. 清 per-cpu 缓存重试(drain_all_stock):per-cpu 缓存里可能还有没算进 usage 的"账",清掉再试。
  3. 组内 OOM(mem_cgroup_oom):回收还是不够,就杀进程——但只杀这个 memcg 里的进程。

注意最后还有个 force 分支:__GFP_NOFAIL / __GFP_HIGH 的关键分配(如回收路径自己需要内存)可以临时超额,因为"让回收继续推进"比"卡死回收"更重要。

Why 超限是"回收→再试→OOM"而非直接 OOM:直接杀进程太粗暴,而且 memcg 的页可能是干净的(page cache)或可换出的(匿名页接 #7 swap),回收就能腾出空间。只有回收(含清 stock)都救不回来,才轮到 OOM。这套"回收优先、OOM 兜底"和全局内存压力处理(#8 页回收)是同一个套路,只是作用域从"整机"缩到"这一个 cgroup"。


四、memcg 回收:只回收自己的页

4.1 入口:try_to_free_mem_cgroup_pages

// try_to_free_mem_cgroup_pages(vmscan.c:7126)
unsigned long try_to_free_mem_cgroup_pages(struct mem_cgroup *memcg,
					   unsigned long nr_pages, ...)
{
	struct scan_control sc = {
		.nr_to_reclaim = max(nr_pages, SWAP_CLUSTER_MAX),
		.target_mem_cgroup = memcg,      // 关键:只回收这个 memcg
		.priority = DEF_PRIORITY,
		.may_swap = !!(reclaim_options & MEMCG_RECLAIM_MAY_SWAP),
		...
	};
	nr_reclaimed = do_try_to_free_pages(zonelist, &sc);  // 复用 #8 的回收引擎
	return nr_reclaimed;
}

关键在 sc.target_mem_cgroup = memcg——回收目标从"全局"改成"这一个 cgroup"。它复用 #8 篇的回收引擎(do_try_to_free_pages),但扫描对象限制在这个 memcg 的页里。

4.2 遍历层级时尊重 min/low 保护

shrink_node_memcgs(vmscan.c:6461)是全局回收(如 kswapd)遍历所有 memcg 时的入口,它体现了 memcg 的保护机制:

// shrink_node_memcgs(vmscan.c:6461)—— 关键片段
memcg = mem_cgroup_iter(target_memcg, NULL, NULL);
do {
	struct lruvec *lruvec = mem_cgroup_lruvec(memcg, pgdat);
	...
	mem_cgroup_calculate_protection(target_memcg, memcg);

	if (mem_cgroup_below_min(target_memcg, memcg)) {
		continue;                    // ① 低于 memory.min:硬保护,绝不回收
	} else if (mem_cgroup_below_low(target_memcg, memcg)) {
		if (!sc->memcg_low_reclaim) {
			sc->memcg_low_skipped = 1;
			continue;                // ② 低于 memory.low:软保护,跳过
		}
		memcg_memory_event(memcg, MEMCG_LOW);
	}
	...
	shrink_lruvec(lruvec, sc);          // ③ 回收这个 lruvec
	shrink_slab(sc->gfp_mask, pgdat->node_id, memcg, sc->priority);
} while ((memcg = mem_cgroup_iter(target_memcg, memcg, NULL)));

memory.min / memory.low 是保护:低于它的用量不该被回收(保护关键容器不被邻居"抢内存")。全局回收时,below_min 的 cgroup 直接跳过(硬保护),below_low 的优先跳过(软保护,只在有别的可回收时才保)。

Why 需要 min/low 保护:如果只有 max 上限,全局回收(如 kswapd)会无差别地从所有 cgroup 抢页——一个安静但重要的容器,可能被邻居的回收"误伤"(它的热页被换出)。min/low 是反方向的配额:不仅限制"用多少",还保证"至少留多少"。这也是 cgroup v2 相对 v1 的重要改进(v1 只有 soft_limit,语义弱很多)。


五、memcg OOM:只杀组内的进程

回收救不回来,就 OOM。但 memcg OOM 和全局 OOM 的区别是作用域:

// mem_cgroup_oom(memcontrol.c:1925)
static bool mem_cgroup_oom(struct mem_cgroup *memcg, gfp_t mask, int order)
{
	if (order > PAGE_ALLOC_COSTLY_ORDER)
		return false;                   // 高阶大页不触发 memcg OOM

	memcg_memory_event(memcg, MEMCG_OOM);   // 记录 memory.events 的 oom

	if (READ_ONCE(memcg->oom_kill_disable)) {
		if (current->in_user_fault) {
			current->memcg_in_oom = memcg;  // 禁用 OOM:记下来,缺页末尾睡
			...
		}
		return false;
	}
	...
	ret = mem_cgroup_out_of_memory(memcg, mask, order);  // 真正选进程杀
	...
	return ret;
}

// mem_cgroup_out_of_memory(memcontrol.c:1718)
static bool mem_cgroup_out_of_memory(struct mem_cgroup *memcg, ...)
{
	struct oom_control oc = {
		.memcg = memcg,         // 关键:OOM 作用域限定在这个 memcg
		...
	};
	...
	ret = task_is_dying() || out_of_memory(&oc);  // 复用全局 OOM 引擎,但只杀组内
	return ret;
}

oc.memcg = memcg 让 out_of_memory(全局 OOM killer 的引擎)只在组内选 victim——被杀的是这个容器里的进程,别的容器不受影响。这正是容器的核心价值:一个容器 OOM,只死它自己,整机不死。

还有一个 oom_kill_disable(cgroup v1 的特性):禁用 OOM killer 时,分配失败不杀进程,而是把 current->memcg_in_oom 记下来,在缺页处理末尾由 mem_cgroup_oom_synchronize(:1995)让进程睡在等待队列上,等用户态(比如容器编排器)来处理。

Why OOM 要限定在组内:全局 OOM 会杀"最该死"的进程(oom_score 最高),但如果那个进程在别的容器里,杀它等于"邻居替坏容器背锅"。memcg OOM 把 victim 池缩小到"这个 cgroup 里的进程",让配额(max)真正有隔离语义——谁的配额爆了,谁家出人命。


六、memory.high 软限 + per-cpu 记账缓存

6.1 memory.high:软限,节流而不 OOM

memory.max 是硬限(超了 OOM),memory.high 是软限:超了不杀进程,而是节流(throttle)分配者 + 异步回收,把用量"压"回 high 以下。

charge 成功后(done_restock 分支,try_charge_memcg :2786 起)会检查有没有超 high,超了调 mem_cgroup_handle_over_high(memcontrol.c:2558):

// mem_cgroup_handle_over_high(memcontrol.c:2558)—— 关键片段
nr_reclaimed = reclaim_high(memcg, ...);        // ① 回收一批

penalty_jiffies = calculate_high_delay(memcg, nr_pages,
				mem_find_max_overage(memcg));  // ② 按超额算惩罚延迟
penalty_jiffies = min(penalty_jiffies, MEMCG_MAX_HIGH_DELAY_JIFFIES);
...
if (penalty_jiffies <= HZ / 100)
	goto out;
schedule_timeout_killable(penalty_jiffies);     // ③ 睡一会儿,节流分配者

思路是:超 high 不 OOM,而是回收一部分 + 让分配进程睡一小会儿,越超得多睡得越久(calculate_high_delay :2522 算延迟),从而把组内分配速度压下来,给回收争取时间。后台还有个 high_work_func(:2409)异步回收,把代价从分配路径挪到后台。

6.2 per-cpu stock:记账的性能优化

charge/uncharge 是每个页分配/释放都要走的热路径。如果每次都直接 atomic_long_add 到 page_counter->usage,多核机器上会疯狂争用这个原子变量。所以 memcg 用 per-cpu stock 缓存:

  • consume_stock(try_charge_memcg :2652 调用):charge 时先从本 CPU 的 stock 里"预借"一批页(MEMCG_CHARGE_BATCH = 64,memcontrol.h:344),省掉原子操作。
  • refill_stock(:2319):stock 用完,批量向 page_counter 补充。
  • drain_stock(:2258)/ drain_all_stock:把 per-cpu 缓存的账"冲"回全局计数。

本质和 buddy 的 pcp(#2 篇)、slab 的 per-cpu 缓存是同一个思路:把高频的全局计数,下沉成 per-cpu 批量操作,摊薄锁/原子开销。所以超限重试时会先 drain_all_stock——把散在各 CPU 缓存里的账冲回来,避免"明明有余量却因为账没冲回而误判超限"。


七、层级 v1/v2 + 与 AI Infra 的关系 + 记住三点

7.1 cgroup v1 vs v2

cgroup v1cgroup v2(unified)
账户memory + memsw(memory+swap 合并)分开memory + swap 分开
上限memory.limit_in_bytesmemory.max
软限soft_limit(语义弱,回收时才用)memory.high(节流)
保护无memory.min / memory.low
层级线程可挂不同 cgroup(混乱)进程统一挂一个 cgroup(干净)

v2 是当前默认(也是 K8s 的方向),它的 min/low/high/max 四档配额语义清晰:min 硬保、low 软保、high 节流、max 硬限。

7.2 与 AI Infra:容器 / 训练平台

  • 容器隔离:Docker/K8s 的 --memory 就是设 memory.max,Pod 内存限制就是 memcg。超限 → 容器内 OOM → K8s 检测到重启 Pod(OOMKilled)。
  • 训练平台多租户:一台 GPU 机器跑多个训练任务,用 memcg 给每个任务划内存配额,防止一个任务的 dataloader 把整机内存吃光拖垮别的任务。
  • 与前面各篇接轨:#8 页回收(memcg 回收复用同一引擎)、#7 swap(memcg 回收的 may_swap)、#5 page cache(file 页记账)、#10 KSM(KSM 页也按 memcg 记账,虚拟化去重和配额叠加)。

7.3 记住三点

  1. memcg = 配额 + 记账,page_counter 分层:每个 cgroup 一个 page_counter(page_counter.h:10),usage 记账、min/low/high/max 四档配额、parent 链分层。charge 沿 parent 链向上累加(page_counter_charge page_counter.c:70 的 for (c; c = c->parent)),所以父的 usage = 自己 + 所有子孙。

  2. charge 路径 = 找 cgroup → 记账 → 挂页:mem_cgroup_charge(memcontrol.h:681)→ __mem_cgroup_charge(:7011)→ get_mem_cgroup_from_mm(:1032,从 mm->owner 找 cgroup)→ charge_memcg(:6990)→ try_charge(:2827)→ 成功 commit_charge(:2846,folio->memcg_data = memcg 双向绑定)。

  3. 超限三板斧:回收 → 清 stock 重试 → 组内 OOM:try_charge_memcg(:2637)超 memory.max 先 try_to_free_mem_cgroup_pages(vmscan.c:7126,target_mem_cgroup 限定只回收本组)→ drain_all_stock 清缓存再试 → mem_cgroup_oom(:1925 → mem_cgroup_out_of_memory :1718,oc.memcg 只杀本组进程)。memory.high 是软限(mem_cgroup_handle_over_high :2558 节流不 OOM),min/low 是保护(shrink_node_memcgs vmscan.c:6461 的 below_min/below_low 跳过)。


附:本篇关键源码索引

符号位置
struct mem_cgroup / struct page_counterinclude/linux/memcontrol.h:209 / include/linux/page_counter.h:10
mem_cgroup_charge / __mem_cgroup_chargeinclude/linux/memcontrol.h:681 / mm/memcontrol.c:7011
get_mem_cgroup_from_mm / mem_cgroup_from_taskmm/memcontrol.c:1032 / :999
charge_memcg / try_charge / try_charge_memcgmm/memcontrol.c:6990 / :2827 / :2637
commit_charge / cancel_chargemm/memcontrol.c:2846 / :2836
page_counter_charge / page_counter_try_charge(沿 parent 链)mm/page_counter.c:70 / :97
try_to_free_mem_cgroup_pages / shrink_node_memcgsmm/vmscan.c:7126 / :6461
mem_cgroup_oom / mem_cgroup_out_of_memory / mem_cgroup_oom_synchronizemm/memcontrol.c:1925 / :1718 / :1995
mem_cgroup_handle_over_high / calculate_high_delay / high_work_funcmm/memcontrol.c:2558 / :2522 / :2409
drain_stock / refill_stock(per-cpu 记账)mm/memcontrol.c:2258 / :2319
uncharge_folio / uncharge_batch / __mem_cgroup_unchargemm/memcontrol.c:7127 / :7104 / :7186
mem_cgroup_below_min / mem_cgroup_below_lowinclude/linux/memcontrol.h:655 / :645
folio_memcg / MEMCG_CHARGE_BATCH(=64)include/linux/memcontrol.h:436 / :344
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值