背景

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%