内存管理-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)    收藏  举报

导航