背景
memcg 的回收触发至今以被动路径为主:分配使用量越过 memory.max 或 memory.high 时,在分配者上下文里同步回收;全局内存压力由 kswapd 或 direct reclaim 沿 memcg 树扫描;唯一的主动入口是用户态向 memory.reclaim 写入目标字节数,内核在写入者上下文同步回收,并支持 swappiness= 选项调节 anon 与 file 的回收倾向。
memcg 回收的既有触发路径
┌────────────┐
│ memcg 回收 │
└─────┬──────┘
┌──────────────────────────────┴──────────────────────────────┐
▼ ▼ ▼
┌───────────────────────┐ ┌─────────────────────┐ ┌───────────────────────────┐
│ 分配越过 memory.max │ │ 全局内存压力 kswapd │ │ 用户态写 memory.reclaim, │
│ 或 memory.high 触发, │ │ 或 direct reclaim │ │ 同步主动回收 │
│ 同步回收 │ │ 扫描 memcg 树 │ │ │
└───────────────────────┘ └─────────────────────┘ └───────────────────────────┘
memory.reclaim 把决策留给了用户态:守护进程轮询统计、算出差额、写接口,回收全程同步占住写入者,CPU 开销也记在写入者所在 cgroup。
BPF 一侧,bpf_mem_cgroup_page_state() 等 kfunc 已能读 memcg 统计,bpf_wq 能把回调推迟到 workqueue 异步执行,缺的是中间一环:观测到压力信号后,BPF 无法对指定 memcg 发起回收。本系列补的正是这一环,做异步、带外的主动回收,与在 charge 路径上内联决策的 memcg struct_ops 钩子(另一个系列)互补。
需求也并非凭空而来:若干厂商维护的内核各自带着私有实现,在 memcg 进入特定状态时触发异步回收;这些实现出于历史原因存在,但底层需求真实。主线的 BPF 驱动主动回收配合讨论中的 memcg BPF 钩子,有望承接这些场景,减少内核碎片化。
问题
- 回收只由固定阈值或用户态写接口触发,无法按运行时信号自主决策。
- memory.reclaim 依赖守护进程轮询,回收同步占用并记账给写入者。
- BPF 能观测 memcg 统计,却没有驱动回收的 kfunc。
- 厂商内核各自维护私有异步回收实现,主线缺少统一承接机制。
方案
把回收的决策权交给 BPF。
新增 sleepable kfunc bpf_proactive_reclaim(),让 BPF 程序按自己观测到的信号对指定 memcg 发起回收;执行载体复用既有的 bpf_wq,回收回调在 workqueue 里异步执行,其 CPU 开销暂时随执行上下文记账,绑定 cgroup 的定向记账留待 bpf_kthread/bpf_waitq 等异步原语另行实现。
kfunc 执行一趟主动回收,参数语义与用户态写 memory.reclaim 一致:size 以字节计,返回值同样以字节计(与 bpf_mem_cgroup_usage() 等统计 kfunc 单位一致,混用不用手工换算),允许动用 swap,内部以 GFP_KERNEL 与 MEMCG_RECLAIM_MAY_SWAP | MEMCG_RECLAIM_PROACTIVE 调用 try_to_free_mem_cgroup_pages(),与 memory.reclaim 的处理路径同参;anon 与 file 的回收比例默认跟随 cgroup 自身的 swappiness 设置,也可经 swappiness 参数显式指定,0 到 200 为显式倾向,201 表示只回收 anon,传 -1 跟随 cgroup 自身设置,swappiness 越界或 size 不足一页均返回 -EINVAL。三个 swappiness 常量从宏改为枚举导出到 BTF,BPF 程序经 vmlinux.h 直接引用。
与 memory.reclaim 不同的是只走一趟,不为凑够 size 重试。size 还会被钳制到 MEMCG_CHARGE_BATCH(32 页),与 memory.high 的 workqueue 回落 high_work_func() 每次回收请求同规格;注意钳制的只是回收目标,实际扫描工作量与耗时并不封顶。要回收更多就逐批再调,而不是传入更大的 size。
从 bpf_wq 驱动时约定每个回调只回收一批,随后把同一 work item 重新入队,而不是在回调内循环:长回调会拖住共享 workqueue 上的其他工作,回收路径持 lru_lock 时中断关闭,争用还会拖延 IPI 处理。每个目标 memcg 各配一个 bpf_wq item,互不串行堆积;是否提交下一批由 BPF 程序决定,随时可以停,例如目标 cgroup 开始 dying。
回收不能嵌套:try_to_free_mem_cgroup_pages() 进入时覆盖 current->reclaim_state、退出时清空,回收进行中再调一次会踩坏外层回收的状态(MGLRU 会解引用 current->reclaim_state->mm_walk)。kfunc 没有为此设运行时检查,嵌套由调用范围在结构上排除,即下面的 SYSCALL 限定。
kfunc 以 KF_SLEEPABLE 注册,回收过程允许睡眠。调用上下文因此不能持有文件系统锁(如持 inode_lock 的 LSM 钩子),回收路径可能进入文件系统 shrinker,重取该锁即死锁。kfunc 因此单独注册在 BPF_PROG_TYPE_SYSCALL 之下:系统调用类程序运行在干净的进程上下文,它经 bpf_wq、task_work 派发的回调以同一程序类型在进程上下文运行,仍可调用;fentry 等通用 sleepable 程序(如挂在回收路径或 inode_rmdir 上)调不到它,嵌套回收与文件系统锁死锁在结构上都不可能发生。
配套 selftest 把整套机制拼成完整流程:bpf_timer 以 2ms 周期触发 bpf_wq 回调,回调读取高优先级 cgroup 的 WORKINGSET_REFAULT_FILE 计数,增量上涨意味着它的 workingset 正被挤占,随即对低优先级 cgroup 发起一轮回收:一轮最多 32 批、每批 128 KiB,每个回调只跑一批再把 work item 重新入队,剩余额度记在 map 里跨回调传递,为高优先级 cgroup 腾内存。每次回收尝试的结果经 ringbuf 上报,测试按超时等待回收事件而不是轮询计数。测试只断言 BPF 真的发起了回收调用并回收到字节(reclaim_calls 与 reclaimed_bytes 计数),单凭计时区分不了真回收与空跑;负载计时对比属基准性质,只记录日志,不作为失败依据。
回收前还会检查目标 cgroup 自身 css 的 CSS_ONLINE 标志:bpf_cgroup_from_id() 要到 rmdir() 内部才会查找失败,对正在拆除的 cgroup 仍可能查找成功,而 rmdir 会清掉这个标志,据此显式判 dying 并跳过。CSS_DYING 由 kill_css_sync() 设置,cgroup 自身的 css 不会置位,无需检查。配套的 memcg_async_reclaim_dying 测试在回收进行中删除目标 cgroup,验证回收随之停下:rmdir 返回后 bpf_cgroup_from_id() 不再解析出目标,后续回收尝试上报 TARGET_GONE,reclaim_calls 与 reclaimed_bytes 不再增长;CSS_ONLINE 判 dying 的窗口只在 rmdir 内部几条语句之间,用户态无法稳定命中,测试对这个分支只计数、不断言。在被保护的 cgroup 上观测压力,从被牺牲的 cgroup 回收,整个策略都在 BPF 内完成。
BPF 驱动的异步主动回收流程
┌─────────────────────────────────────┐
│ bpf_timer 周期触发 bpf_wq 异步回调 │
└──────────────────┬──────────────────┘
▼
┌─────────────────────────────────────┐
│ 回调读取 memcg 统计判断压力信号 │ 如 WORKINGSET_REFAULT_FILE 增量超阈值
└──────────────────┬──────────────────┘
▼
┌─────────────────────────────────────┐
│ 每个回调经 kfunc 回收一批并重新入队 │ 目标 cgroup dying 即停止
└─────────────────────────────────────┘
收益
基准来自配套 selftest 的 TEST_MEMCG_ASYNC_RECLAIM_BENCH=1 模式:QEMU VM(8 GiB 内存、10 vCPU)下同一负载先不带 BPF 程序跑一遍、再带 BPF 程序跑一遍,共 10 轮。被收割的低优先级负载与受压负载共享父级 memory.max,受压负载提前结束后它独享限额。
| 指标 | baseline(无 BPF 回收) | BPF 异步回收 | 改善 |
|---|---|---|---|
| 受压高优先级负载完成时间中位数 | 15.3s | 2.0s | -86.9% |
| 被收割低优先级负载完成时间中位数 | 12.2s | 7.5s | -38.5% |
10 轮原始数据(完成时间,单位秒):
| 轮次 | baseline 受压 | baseline 被收割 | BPF 受压 | BPF 被收割 | 受压提速 |
|---|---|---|---|---|---|
| 1 | 15.331030 | 10.662426 | 2.311867 | 4.912402 | 84.9% |
| 2 | 10.788771 | 15.127507 | 3.061484 | 4.518388 | 71.6% |
| 3 | 16.553653 | 12.281844 | 2.586470 | 9.890286 | 84.4% |
| 4 | 18.032006 | 14.058724 | 1.713115 | 3.967476 | 90.5% |
| 5 | 15.253594 | 12.101572 | 1.880957 | 4.478044 | 87.7% |
| 6 | 5.454157 | 9.781724 | 2.035418 | 10.322757 | 62.7% |
| 7 | 10.010656 | 3.029734 | 1.947231 | 10.162948 | 80.5% |
| 8 | 18.201170 | 15.182740 | 1.510723 | 9.390685 | 91.7% |
| 9 | 10.772079 | 5.418259 | 2.325059 | 13.225425 | 78.4% |
| 10 | 16.360502 | 14.506304 | 0.864310 | 5.624312 | 94.7% |