内存管理-77-kernel-internals系列-Memory专题-Caching & Reclaim-4-Reclaim Throttling
一、Reclaim Throttling and Balancing 章节翻译
注: 翻译自 https://kernel-internals.org/mm/reclaim-throttling/
回收节流与均衡(Reclaim Throttling and Balancing), 当回收跟不上时,内核如何让分配者慢下来。
当内存压力很高时,有两件事绝不能同时发生:单个正在分配内存的进程不能独占 CPU、一直在回收路径里空转;回收过载时,分配者也不能继续一路向前冲。内核用**节流(throttling)**同时解决了这两个问题 —— 暂时让分配进程暂停,给回收留出推进的时间,并协调 kswapd 和直接回收者之间的负载均衡。
1. 关键源文件
//mm/vmscan.c reclaim_throttle() —— 核心节流睡眠; throttle_direct_reclaim() —— pfmemalloc 保留内存节流; consider_reclaim_throttle() —— NOPROGRESS 路径; balance_pgdat() —— kswapd 节点均衡; shrink_node() —— 按节点回收; allow_direct_reclaim() —— 唤醒条件; pgdat_balanced() —— 水线检查; writeback_throttling_sane() —— 脏页节流是否可用 //mm/memcontrol.c __mem_cgroup_handle_over_high() —— memory.high 强制执行; reclaim_high() —— cgroup 回收循环; calculate_high_delay() —— 按比例惩罚; mem_find_max_overage() / swap_find_max_overage() —— 祖先超限扫描 //mm/page_alloc.c __alloc_pages_slowpath() —— 慢速分配路径; __alloc_pages_direct_reclaim() / __perform_reclaim() —— 直接回收入口; try_to_free_pages() 调用点 //include/linux/mmzone.h enum vmscan_throttle_state、 enum pgdat_flags、 enum lruvec_flags、 reclaim_wait[] 等待队列、 pfmemalloc_wait 等待队列
2. 为什么需要节流
如果没有节流,一个快速分配内存的应用会不断循环进入直接回收,占满整个 CPU 核只为释放页面。其他进程会被饿死,真正的工作无法推进。更微妙的是,回收本身可能进入一种无法恢复的状态:如果它找到的每一页都在回写(writeback)中,再激进地扫描也只是浪费 CPU。正确的答案是停下来等待。
节流实现两个目标:
(1) 公平性 —— 防止一个进程垄断回收路径。
(2) 背压(Backpressure) —— 当回收跟不上时,让分配者慢下来,而不是在无用功上空转。
内核区分了几种不同的节流原因,每种都有自己的等待队列和超时:
enum vmscan_throttle_state {
VMSCAN_THROTTLE_WRITEBACK, /* 页面在 LRU 上循环的速度超过回写排出的速度 */
VMSCAN_THROTTLE_ISOLATED, /* 太多并行回收者同时从 LRU 上 isolate 页面 */
VMSCAN_THROTTLE_NOPROGRESS,/* 高优先级下回收毫无进展 */
VMSCAN_THROTTLE_CONGESTED, /* 脏页背后的设备拥塞 */
NR_VMSCAN_THROTTLE,
};
pg_data_t 结构体(每个 NUMA 节点一个)为每种节流原因各持有一个等待队列,外加一个直接回收 pfmemalloc 节流的独立队列:
/* include/linux/mmzone.h */ wait_queue_head_t reclaim_wait[NR_VMSCAN_THROTTLE]; wait_queue_head_t pfmemalloc_wait;
3. 直接回收 vs. kswapd
内核有两种回收执行者:
分配路径 后台 --------------- ---------- alloc_pages() kswapd(每节点内核线程) │ │ └─ __alloc_pages_slowpath() └─ balance_pgdat() │ │ └─ __alloc_pages_direct_reclaim() └─ kswapd_shrink_node() │ │ └─ __perform_reclaim() └─ shrink_node() │ └─ try_to_free_pages() │ └─ shrink_zones() │ └─ shrink_node()
kswapd 在后台运行,回收页面直到每个 zone 的空闲页回升到 high 水线以上。它不会阻塞任何分配进程。
直接回收(Direct reclaim) 运行在分配进程自己的上下文中:当 __alloc_pages_slowpath() 无法从现有空闲页满足分配时,调用 alloc_pages() 的进程会进入 try_to_free_pages(),并可能在那里被节流。
优先级递增: 两条路径都使用回收优先级,从 DEF_PRIORITY(12)开始向 0 下降。在优先级 p 下,每条 LRU 链表扫描 list_size >> p 个页面 —— 优先级越低,扫描的内存比例越大,直到在优先级 0 时扫描整个链表。
3.1 reclaim_throttle() —— 核心睡眠函数
mm/vmscan.c 中的 reclaim_throttle() 是所有非 pfmemalloc 节流都要经过的单一函数:
void reclaim_throttle(pg_data_t *pgdat, enum vmscan_throttle_state reason)
它从 pgdat->reclaim_wait[reason] 中选择对应的等待队列,让调用任务以 TASK_UNINTERRUPTIBLE 睡眠,超时时间取决于原因:
--------------------------------------------------------------------------------------------------------------------------------------- 节流原因 超时 触发时机 --------------------------------------------------------------------------------------------------------------------------------------- VMSCAN_THROTTLE_WRITEBACK HZ/10(100ms) 页面在 LRU 上循环的速度超过回写排出的速度(sc->nr.immediate 非零) VMSCAN_THROTTLE_ISOLATED HZ/50(20ms) 太多并行回收者同时从 LRU 上 isolate 了页面 VMSCAN_THROTTLE_NOPROGRESS 1 个 jiffy 在优先级 1 的直接回收中回收了 0 页 VMSCAN_THROTTLE_CONGESTED 1 个 jiffy 所有脏页背后的设备都拥塞(LRUVEC_CGROUP_CONGESTED 或 LRUVEC_NODE_CONGESTED 被置位) ---------------------------------------------------------------------------------------------------------------------------------------
内核线程豁免: reclaim_throttle() 会跳过任何带有 PF_USER_WORKER 或 PF_KTHREAD 标志的任务(kswapd 本身除外)。日志线程、回写工作线程和其他内核线程必须保持可运行,因为回收能否推进依赖它们。
对于 VMSCAN_THROTTLE_WRITEBACK,代码还会记录自节流开始以来完成了回写的页数(pgdat->nr_reclaim_start),这样 __acct_reclaim_writeback() 可以在清干净足够多页面后提前唤醒被节流的任务,而不必每次都等满整个超时。
谁来唤醒被节流的任务?
kswapd 中的 balance_pgdat() 会在 allow_direct_reclaim() 变为真时唤醒 pfmemalloc_wait 队列。reclaim_wait[] 队列则在回收效率超过 12%(扫描-回收比 > 1/8)时由 consider_reclaim_throttle() 唤醒,以及在 kswapd 清除拥塞状态时被唤醒。
3.2 throttle_direct_reclaim() —— pfmemalloc 保留内存节流
这是一条独立的节流路径,目的不同。内核为通过网络做交换所需的网络栈分配,在低端 zone 预留了一部分页面(pfmemalloc 保留)。如果这些保留被耗尽,再允许直接回收就是适得其反 —— 应该让 kswapd 不受干扰地处理。
static bool throttle_direct_reclaim(gfp_t gfp_mask, struct zonelist *zonelist, nodemask_t *nodemask)
该函数在 try_to_free_pages() 顶部、任何回收工作开始之前被调用。以下情况会直接返回(不节流):
(1) 调用任务带有 PF_KTHREAD(内核线程)。
(2) 有致命信号待处理(让进程退出、自然释放内存)。
(3) allow_direct_reclaim() 返回 true —— 所有直到并包括 ZONE_NORMAL 的 zone 的空闲页之和,超过这些 zone 累计 pfmemalloc 保留的一半。
当真正节流时,进程睡在 pgdat->pfmemalloc_wait 上:
/* 调用者不能进文件系统 —— 可能持有日志锁 */
if (!(gfp_mask & __GFP_FS))
wait_event_interruptible_timeout(pgdat->pfmemalloc_wait, allow_direct_reclaim(pgdat), HZ);
else
/* 阻塞直到 kswapd 取得进展 */
wait_event_killable(pgdat->pfmemalloc_wait, allow_direct_reclaim(pgdat));
每次节流事件都会让 vmstat 计数器 PGSCAN_DIRECT_THROTTLE 加一(在 /proc/vmstat 中显示为 pgscan_direct_throttle)。
allow_direct_reclaim() 会遍历从最低 zone 到 ZONE_NORMAL 的所有 zone,累加空闲页和 pfmemalloc 保留,然后检查 free_pages > pfmemalloc_reserve / 2 是否成立。如果节点已处于无望状态(kswapd_test_hopeless()),则总是允许直接回收。
致命信号处理: 如果 throttle_direct_reclaim() 返回 true,说明睡眠期间收到了致命信号。此时 try_to_free_pages() 返回 1,页面分配器会跳过 OOM killer —— 因为该进程本来就快死了。
3.3 consider_reclaim_throttle() —— 无进展节流
在直接回收中每次 shrink_zones() 之后,consider_reclaim_throttle() 会评估是否需要节流:
static void consider_reclaim_throttle(pg_data_t *pgdat, struct scan_control *sc)
(1) 如果回收效率高于 12.5%(sc->nr_reclaimed > sc->nr_scanned >> 3),就唤醒所有睡在 VMSCAN_THROTTLE_NOPROGRESS 上的任务。
(2) 如果已经到优先级 1(距离扫描整个 LRU 只差一步),而 sc->nr_reclaimed 仍然是 0,就调用 reclaim_throttle(pgdat, VMSCAN_THROTTLE_NOPROGRESS)。
(3) kswapd 和 cgroup 回收豁免 —— 它们在 I/O 上停滞时改用 VMSCAN_THROTTLE_WRITEBACK。
4. 内存 Cgroup 节流 —— memory.high 路径
4.1 按比例的延迟
cgroup v2 的 memory.high 是软限制。超过它不会立即杀掉或硬停住 cgroup,而是让超限的进程按超限幅度成比例地慢下来。
当一次分配导致 cgroup 超过 memory.high,charge 路径会设置 current->memcg_nr_pages_over_high。在返回用户态时(或计数超过 MEMCG_CHARGE_BATCH 时提前),调用 __mem_cgroup_handle_over_high():
分配超过 memory.high
│
▼
memcg_nr_pages_over_high += batch
│
│(返回用户态时,或 count > MEMCG_CHARGE_BATCH 时)
│
▼
__mem_cgroup_handle_over_high()
│
├── reclaim_high() ← try_to_free_mem_cgroup_pages()
│ 沿 cgroup 树向上走
│
├── calculate_high_delay() ← 以 jiffies 为单位的指数惩罚
│
└── schedule_timeout_killable(penalty_jiffies)
reclaim_high() 沿 cgroup 层级向上,对每个超过各自 memory.high 的祖先做回收。在最多 MAX_RECLAIM_RETRIES(16)次回收尝试后,如果用量仍高于限制,calculate_high_delay() 会计算睡眠时长。
4.2 延迟公式
惩罚由 calculate_high_delay() 计算:
penalty_jiffies = max_overage * max_overage * HZ; penalty_jiffies >>= MEMCG_DELAY_PRECISION_SHIFT; /* 20 bits */ penalty_jiffies >>= MEMCG_DELAY_SCALING_SHIFT; /* 14 bits */ /* 按 nr_pages 相对 MEMCG_CHARGE_BATCH 缩放 */ return penalty_jiffies * nr_pages / MEMCG_CHARGE_BATCH;
max_overage 是 cgroup(或其最糟糕的祖先)超过 memory.high 程度的定点数比值,计算如下:
overage = (usage - high) << MEMCG_DELAY_PRECISION_SHIFT; overage = overage / high; /* 相对限制的比例 */
二次方意味着惩罚随超限幅度快速增长:超限 2 倍的 cgroup 受到的惩罚远大于刚好超限一点的。最终结果会被钳制在每次返回用户态最多 MEMCG_MAX_HIGH_DELAY_JIFFIES(2 秒),所以应用永远不会永久挂住 —— 只是运行得非常慢。
内存和 swap 分开惩罚:
calculate_high_delay() 会被调用两次:一次用 mem_find_max_overage() 的结果(内存超限),一次用 swap_find_max_overage() 的结果(swap 超限)。层级中的祖先被分别独立遍历,两项惩罚相加。
小幅超限免费:
如果 penalty_jiffies <= HZ/100(10ms),任务根本不睡。这避免了对只是轻微超限、且可能已经在成功回收的 cgroup 施加惩罚。
5. 写节流与回收停滞
6.1 PGDAT_WRITEBACK 与回写节流
当 shrink_node() 发现它检查的每一页都在回写中(sc->nr.writeback == sc->nr.taken),就会在该节点上置位 PGDAT_WRITEBACK 标志。这是在告诉 kswapd:脏页回写跟上了 LRU 扫描的速度,页面在链表上循环的速度超过了存储能排出的速度。
如果 sc->nr.immediate 非零 —— 即页面既在回写中、又已经被回收到非活跃 LRU 尾部 —— shrink_node() 会调用:
reclaim_throttle(pgdat, VMSCAN_THROTTLE_WRITEBACK);
最多睡 100ms,或直到 __acct_reclaim_writeback() 在足够多页面完成回写后唤醒该队列。
6.2 LRUVEC_NODE_CONGESTED 与 VMSCAN_THROTTLE_CONGESTED
如果扫描到的所有脏页又脏又拥塞(sc->nr.dirty == sc->nr.congested),节点会被标记为拥塞:kswapd 置位 LRUVEC_NODE_CONGESTED,cgroup 回收置位 LRUVEC_CGROUP_CONGESTED。检测到任一标志的直接回收者会调用:
reclaim_throttle(pgdat, VMSCAN_THROTTLE_CONGESTED);
kswapd 自己在这里不被节流 —— 它可以继续扫描,因为停住 kswapd 会让所有回收进展停止。只有用户态上下文的直接回收者会睡。
6.3 balance_dirty_pages() —— 上游节流
mm/page-writeback.c 中的写节流路径是减慢“脏页速度超过回写排出速度”的进程的主要机制。balance_dirty_pages() 由文件系统写路径调用 —— 它不被回收路径直接调用。
但两者会相互作用:如果 balance_dirty_pages() 不可用(例如使用旧的 cgroup v1 内存控制器、回写与 memcg 解耦时),writeback_throttling_sane() 返回 false,shrink_folio_list() 会回退到直接停在回写 I/O 上,而这缺乏正常脏页节流的公平性和带宽成比例性。
7. 压力下的公平回收
7.1 回收优先级与 Cgroup 层级
当多个 cgroup 同时处于内存压力下,内核并不是简单地从内存最多的 cgroup 回收。shrink_node_memcgs() 会遍历该节点 lruvec 树中的所有 memcg,对每个调用 shrink_lruvec()。从每个 cgroup 扫描的数量,正比于该 cgroup 在该节点上可回收页的占比,并按其配置的 swappiness 调整。
回收优先级从 DEF_PRIORITY(12)开始,随压力增大向 0 递减。在每个优先级上,扫描每条 LRU 链表的 1 >> priority 比例。优先级越低扫描比例越大 —— 优先级 0 扫描整个链表。
7.2 reclaim_high() 中的 Cgroup 层级遍历
对于 memory.high 强制执行,reclaim_high() 不只回收直接超限的 cgroup。它通过 parent_mem_cgroup() 一直走到根,对每个也超过各自 memory.high 的祖先做回收。这防止深层嵌套的 cgroup 只因为直接父级还在自身限制内就逃过祖先的限制:
do {
if (page_counter_read(&memcg->memory) > READ_ONCE(memcg->memory.high))
nr_reclaimed += try_to_free_mem_cgroup_pages(...);
} while ((memcg = parent_mem_cgroup(memcg)) && !mem_cgroup_is_root(memcg));
mem_find_max_overage() 在计算惩罚延迟时会做类似的祖先遍历,挑出超限最严重(最超过限制)的祖先来决定惩罚力度。
7.3 balance_pgdat() —— kswapd 节点均衡
kswapd 的主循环调用 balance_pgdat(),在节点上回收页面,直到至少一个 zone 达到均衡。zone 均衡的定义是:经 pgdat_balanced() 测试,空闲页超过 high 水线:
static bool pgdat_balanced(pg_data_t *pgdat, int order, int highest_zoneidx)
它从低到高检查 zone,只要有一个 zone 满足 high_wmark_pages() 阈值下的 __zone_watermark_ok() 就返回 true。
一旦 pgdat_balanced() 为真,kswapd 调用 prepare_kswapd_sleep(),它会:
(1) 唤醒所有睡在 pgdat->pfmemalloc_wait 上的进程(它们在等 pfmemalloc 保留被补足)。
(2) 调用 clear_pgdat_congested() 清除 PGDAT_WRITEBACK、LRUVEC_NODE_CONGESTED 和 LRUVEC_CGROUP_CONGESTED。
balance_pgdat() 还会在循环中途、每当 allow_direct_reclaim() 变为真时就唤醒 pfmemalloc_wait,所以被节流的分配者不必等到 kswapd 把整个节点完全均衡后才能醒来。
8. 如何观察节流
8.1 PSI —— 最直接的信号
PSI(Pressure Stall Information)捕捉的正是节流所应对的现象:任务因等待内存而停滞。完整解释见 PSI。
在回收节流期间,睡在 reclaim_throttle() 或 throttle_direct_reclaim() 中的任务,会经 psi_memstall_enter() / psi_memstall_leave() 计为内存停滞。
# 快照 —— "full" 表示所有可运行任务都被停住了 cat /proc/pressure/memory # some avg10=8.42 avg60=3.11 avg300=1.05 total=12450183 # full avg10=2.17 avg60=0.84 avg300=0.27 total=3241885
full 内存压力上升,是节流正在生效、系统回收不够快的可靠信号。
8.2 /proc/vmstat 计数器
grep -E 'pgscan|pgsteal|allocstall|throttle' /proc/vmstat ------------------------------------------------------------------------------------ 计数器 含义 ------------------------------------------------------------------------------------ pgscan_kswapd kswapd 扫描的页数 pgsteal_kswapd kswapd 回收的页数 pgscan_direct 直接回收扫描的页数 pgsteal_direct 直接回收收回的页数 pgscan_direct_throttle throttle_direct_reclaim() 阻塞进程的次数 allocstall_* 按 zone 类统计的 try_to_free_pages() 进入次数 ------------------------------------------------------------------------------------
如何解读比例:
(1) 健康系统的 pgscan_kswapd 远大于 pgscan_direct。kswapd 在后台扛住了压力,直接回收很少触发。
(2) pgscan_direct 相对 pgscan_kswapd 很高,说明 kswapd 跟不上了,分配进程在自己做回收。这是持续内存压力的标志。
(3) pgscan_direct_throttle 上升,说明 pfmemalloc 保留被反复耗尽 —— 系统可能已接近 kswapd 能处理的极限。
(4) allocstall_* 计数高,说明频繁进入 try_to_free_pages()。每次加一对应一个进程进入直接回收(是回收入口,不是节流事件本身)。
观察 steal/scan 比: pgsteal/pgscan 即回收效率。比值低于约 50% 说明回收扫描了很多页却释放不掉 —— 常因被引用的页、被 pin 住的页或沉重的写压力。这正是驱动 VMSCAN_THROTTLE_NOPROGRESS 的低效率。
四种回收效率:
# grep -E 'pgscan|pgsteal' /proc/vmstat pgsteal_kswapd 91667 //kswapd回收效率 pgscan_kswapd 92273 pgsteal_direct 25967 //direct回收效率 pgscan_direct 26304 pgscan_anon 0 //anon回收效率 pgsteal_anon 0 pgscan_file 118577 //file回收效率 pgsteal_file 117634
9. 调优
9.1 vm.swappiness
控制内核回收匿名(swap-backed)页 vs. 文件 backed 页的相对代价。内部变量是 vm_swappiness,默认 60。
0 —— 强烈倾向回收文件缓存;只有别无选择时才换出匿名页(注意:自 Linux 3.5 起不代表完全禁用 swap)。
60 —— 默认均衡;两类页都是候选,略偏向文件页。
100 —— 匿名页和文件页视为回收代价相等。
200 —— 即使文件缓存可回收,也积极换出匿名页(5.8 引入,用于主动回收场景,实测6.1内核可设200,5.4内核不行)。
较低的 swappiness 以丢更多文件缓存为代价,减少换出的 I/O。如果文件写占主导,在磁盘慢、swap 充裕的系统上可能增加 VMSCAN_THROTTLE_WRITEBACK 事件。
# 查看当前值 sysctl vm.swappiness # 临时设置 sysctl -w vm.swappiness=10 # 重启后仍生效 echo "vm.swappiness = 10" >> /etc/sysctl.d/99-memory.conf
9.2 vm.vfs_cache_pressure
控制内核回收 dentry 和 inode(VFS slab 缓存)相对 page cache 的积极程度。内部变量是 fs/dcache.c 中的 sysctl_vfs_cache_pressure,默认 100。
< 100 —— 内核回收 slab 缓存不如 page cache 积极。元数据密集型负载(大量小文件)适合调低。
100 —— 默认;slab 和 page cache 等权。
> 100 —— 更积极地回收 slab 缓存。以元数据被驱逐后更多的 VFS 查找开销为代价,减少 dentry/inode 内存。
sysctl vm.vfs_cache_pressure
sysctl -w vm.vfs_cache_pressure=50
9.3 vm.dirty_ratio 与 vm.dirty_background_ratio
控制 balance_dirty_pages()(主要的写节流)何时激活。它们直接影响回收遇到回写中页面的概率,从而影响 VMSCAN_THROTTLE_WRITEBACK 的触发频率。
----------------------------------------------------------------------------------------------------------------------------- Sysctl 内部变量 默认 含义 ----------------------------------------------------------------------------------------------------------------------------- vm.dirty_background_ratio dirty_background_ratio 10% 脏页占可脏内存比例达到此时,flusher 线程开始后台回写 vm.dirty_ratio vm_dirty_ratio 20% 达到此时,在 balance_dirty_pages() 中节流写进程 -----------------------------------------------------------------------------------------------------------------------------
(1) 调低 dirty_background_ratio 会更早开始后台回写,保持脏页池更小,减少回收卡在回写上的停滞。
(2) 调低 dirty_ratio 会更早节流写者,代价是应用写延迟更高。
(3) 在大内存系统(数百 GB)上,按百分比的比例会允许巨大的脏页池。此时考虑用 vm.dirty_bytes 和 vm.dirty_background_bytes 做绝对值限制。
# 在 256GB 系统上限制脏数据 sysctl -w vm.dirty_background_bytes=1073741824 # 1 GB sysctl -w vm.dirty_bytes=4294967296 # 4 GB
10. 串起来:一次压力升级序列
(1) 分配需求上升;kswapd 醒来运行 balance_pgdat()。 └── pgscan_kswapd 上升;系统健康。 (2) kswapd 落后;空闲页跌破 min 水线。 └── __alloc_pages_slowpath() → __alloc_pages_direct_reclaim() └── 进入 try_to_free_pages();allocstall_* 加一。 (3) 直接回收发现页面在回写中。 └── sc->nr.immediate > 0 └── reclaim_throttle(VMSCAN_THROTTLE_WRITEBACK) —— 睡 100ms。 └── PSI 内存停滞开始记账。 (4) 在优先级 1 上回收毫无进展。 └── consider_reclaim_throttle() → VMSCAN_THROTTLE_NOPROGRESS。 (5) pfmemalloc 保留被耗尽。 └── throttle_direct_reclaim() → 睡在 pfmemalloc_wait。 └── pgscan_direct_throttle 加一。 └── kswapd 被唤醒(allow_direct_reclaim → 唤醒 kswapd_wait)。 (6) kswapd 把节点救回来;pgdat_balanced() 变为真。 └── wake_up_all(&pgdat->pfmemalloc_wait)。 └── clear_pgdat_congested()。 └── 直接回收者醒来,重试分配。
从第 3 步起,PSI memory.full 压力上升。通过 /proc/vmstat 计数器差值和 PSI 监控这一序列,可以准确判断系统到了哪个节流阶段、在每个阶段停留了多久。
11. 参考延伸
mm/vmscan.c、mm/memcontrol.c、Documentation/admin-guide/cgroup-v2.rst、LWN《Throttling memory-hungry cgroups》、以及该站的 reclaim 与 memcg 篇。
posted on 2026-10-08 15:51 Hello-World3 阅读(3) 评论(0) 收藏 举报
浙公网安备 33010602011771号