操作系统内功 · 第 36 篇|内存回收:kswapd、LRU 链表与 working set

一次常见的线上事故复盘:应用 P99 延迟平时 20ms,某天开始随机冒出 200ms 的尖刺,CPU 不高、磁盘不忙、网络正常,最后发现根因是宿主机上另一个容器吃内存,触发了内核的直接内存回收(direct reclaim)——你的进程在 malloc 或缺页时被拉去「帮内核回收内存」,一次回收耗时几毫秒到几百毫秒,延迟尖刺就是这笔账单。内存回收是内核里最容易被应用工程师忽视、却最常在 P99 上「报复」的子系统:它平时安静得像不存在,一旦启动就可能让每一条请求都多付一笔隐性税。这一篇讲透它的机制:水位线如何触发回收、kswapd 后台线程的节律、LRU 链表如何挑选受害者,以及 workingset 机制如何度量「回收错了页面」的代价——最后落到观测清单:你怎么在事故发生之前看出回收路径的恶化。

一、水位线:回收的触发器

内核以 zone 为单位管理物理内存(x86-64 上主要是 ZONE_DMA、ZONE_DMA32、ZONE_NORMAL),每个 zone 有三条水位线:min、low、high。它们不是抽象概念,是 /proc/zoneinfo 里可以直接读到的数字:

$ grep -A 12 'Zone NORMAL' /proc/zoneinfo | head -13
Zone NORMAL
  pages free     823042
      min      42861
      low      53576
      high     64291
      spanned  16777216
      present  16750838
      managed  16331317
  ...

一台 64GB 的机器,NORMAL 区的 min 水位约 42861 页(约 167MB)、low 约 209MB、high 约 251MB——水位线大致按 min 的倍数排布,min 本身还预留了 jemalloc 风格的「永不外借」水(watermark_scale_factor 与 min 的关系见下)。触发规则是三条清晰的阈值线:free 高于 low 时无事发生;free 跌破 low,kswapd 被唤醒开始后台回收,直到 free 回到 high 才收工睡去;free 跌破 min,则不再有任何缓冲——任何此刻申请内存的进程会被拉入直接回收,在分配路径上同步地、亲自地回收内存,回收够了才放行那次分配。min 水位之下还藏着一份「应急储备」,只有 GFP_ATOMIC(中断上下文、绝不能睡眠的分配)和 PF_MEMALLOC(回收者自己)才允许动用,普通分配在 min 处一律阻塞。这里有一个 4.6 内核引入的关键参数:vm.watermark_scale_factor(默认 10,即 low 与 min 之间保持 0.1% 总内存的间隔)——调大到 125 可以显著拉宽 kswapd 的「工作区间」,让回收更早启动、一次回收更多,代价是 page cache 占用更低。数据库与低延迟服务常用的组合是 125~250。

两条回收路径的本质区别是谁在等待:kswapd 在后台慢慢收,应用完全无感;direct reclaim 在应用的分配调用栈里同步发生,应用的每一次 malloc/缺页都在为回收买单。延迟敏感服务的第一要务就是「让 free 永远停在 low 之上」,把所有回收工作留给 kswapd。

二、回收流向:从水位跌破到页面回收

把整个流程画出来,事件顺序一目了然:

flowchart TD
    A["应用 malloc / 缺页分配"] --> B{"free 页数 ?"}
    B -- "> low" --> C["正常分配,回收系统沉睡"]
    B -- "< low" --> D["唤醒 kswapd(后台)"]
    D --> E["扫描 LRU 链表回收页面"]
    E --> F{"free >= high ?"}
    F -- "否" --> E
    F -- "是" --> G["kswapd 休眠"]
    B -- "< min" --> H["直接回收 direct reclaim<br/>(发生在应用分配路径上,同步阻塞)"]
    H --> I["扫描并回收足够页面后放行本次分配"]
    E --> J["文件页:干净则直接释放<br/>脏页先写回再释放"]
    E --> K["匿名页:有 swap 则换出<br/>无 swap 且超过 Swappiness 上限时跳过"]

注意匿名页那一分支里的残酷细节:没有 swap 的机器,匿名页不能回收——所有 swap 到匿名页的比例上限由 vm.swappiness 控制,设为 0 时内核倾向于完全不回收匿名页。于是出现一个反直觉的局面:一台关闭 swap 的机器,在 file 页被回收殆尽、剩余几乎全是匿名页时,kswapd 会陷入「无页可收」的空转——反复扫描 LRU 却一无所获,这个状态可以从 /proc/vmstat 的 pgscan_kswapd 持续暴涨而 pgsteal_kswapd 不涨看出来。此时唯一的出路是直接回收路径触发 OOM Killer,或者应用自己降内存。这解释了「关闭 swap = 更快」是一个危险误解:swap 的存在给了内核在「回收文件页」之外的第二选项,往往是压垮前的缓冲垫。

三、LRU 链表:内核如何挑选受害者

内存充足时哪页该被回收?1968 年提出的 LRU(最近最久未使用)是理论答案,Linux 用两对链表实现了它的变体:每个内存节点(NUMA node)上挂着 active_anon / inactive_anon / active_file / inactive_file 四条链表,数字直接可读:

$ grep -E 'Active|Inactive' /proc/meminfo
Active:          12034528 kB
Inactive:         8223140 kB
Active(anon):     6023412 kB
Inactive(anon):   3120184 kB
Active(file):     6011116 kB
Inactive(file):   5102956 kB

新页先进入 inactive 链表头部;当页被访问时,硬件在 PTE 上置一个 accessed 位,内核的回收线程扫描到该位后给页一个「晋升机会」——把它挪到 active 链表,同时清位;active 链表尾部久未访问的页会被降级回 inactive 尾部。回收永远从 inactive 链表尾部开始拿页。这个「两链表 + 访问位」的设计与经典 LRU 的差别在于引入了二次机会:一次访问不足以让页免死,只有「再次访问」(accessed 位再次被置上)才证明它值得留在内存。内核还限制 active 链表不能比 inactive 大太多(大致按 swappiness 的函数调节比例),防止热页把 inactive 挤空导致扫描恶化。

/proc/vmstat 里的计数器能直接量化这个机制的健康度:pgscan_kswapd_n(kswapd 在第 n 号 node 上扫描过的页数)、pgsteal_kswapd_n(实际回收的页数)、pgscan_direct/pgsteal_direct(直接回收路径的对应值)。一个重要的健康指标是扫描效率 = pgsteal / pgscan:健康时接近 1(扫十页能收九页),恶化时趋近 0(扫了一百页才收到两页)——这说明 LRU 链表里堆满了「被引用位保护」的页,内核在高成本空转,这几乎总是意味着内存真的不够用了,而不是「回收策略没调好」。另一个数字 kswapd_inodesteal 记录 kswapd 顺手回收的 inode 缓存页,slabs_scanned 记录 slab 回收扫描的对象数——回收到最后连内核自己的缓存都会被刮,这是内存压力的末期征兆。

四、working set 与 refault:回收错页面的代价

传统 LRU 有一个结构性盲区:它只记录「最近」,不记录「最近多少次内」。设想一个周期性任务,每 10 分钟访问一次 100MB 的工作集,而机器内存只够放 80MB 的热数据——LRU 会在这个任务每次回来时发现它的页面刚被换走,于是刚换进来的页又立刻被换出,形成抖动(thrashing):磁盘忙得飞起,CPU 在等页,系统吞吐坍塌,但每一个单独的回收决策从 LRU 视角看都「正确」。

Linux 2.6.28 引入的 workingset 机制(由 Rik van Riel 与 Johannes Weiner 等人设计)正是针对这个盲区。核心思想:当页从 inactive 链表被回收时,记住它当时「在 inactive 链表里熬过了多少个 eviction 世代」——这个信息编码进 radix/xarray 的 shadow 槽位(只占 shadow 里原有的指针高位,不额外占内存)。当这页再次被访问(从磁盘读回来,即发生 refault)时,内核取出 shadow 里的世代数,与当前世代数相减:差值大于当前 inactive 链表长度,说明「如果当初没回收它,它本可以一直留在内存里」——这是一次回收错误;差值小于链表长度,说明它本来也快被挤出去了,这次回收无害。两个计数器因此分开:workingset_refault(所有回读)与 workingset_activate(其中「本可避免」的部分)。cgroup v2 把这两个数直接暴露在 memory.stat 里,IBM、Meta 的工程师在多个公开复盘里用 refault 距离定位过「cache 比例看似充足却持续抖动」的疑难杂症。观测命令:

$ cat /sys/fs/cgroup/myapp/memory.stat | grep workingset
workingset_refault_anon      1204110
workingset_refault_file    45120314
workingset_activate_anon     882012
workingset_activate_file   40112003
workingset_restore_anon      120411
workingset_restore_file     9011201

refault 持续非零增长意味着系统正在「反复搬移同一批页」,这是 working set 超过可用内存的直接证据——不是调参能解决的,要么加内存,要么压缩工作集,要么把冷数据显式 madvise(MADV_DONTNEED) 排除在 LRU 之外。

五、swappiness 的真实语义与应用侧缓解

vm.swappiness(默认 60)常被误读为「swap 的使用强度」。准确语义:它是回收时匿名页与文件页的优先级权重——值越大越倾向回收匿名页(换出到 swap),越小越倾向回收文件页。数学上,内核按「swappiness : 100-swappiness」的近似比例在 anon 与 file 之间分配回收预算:swappiness=100 时两类页同等对待,=0 时尽量不回收匿名页(但不等于禁止,在 file 页枯竭且没有其他选择时内核仍会换出匿名页——这一点从 3.5 内核起有明确的语义文档)。数据库、JVM 这类「自己管理缓存与堆」的服务常用 swappiness=1:让内核几乎只动文件页(page cache 可以随读随取),而把匿名页当作不可侵犯的常驻内存,避免 JVM 老年代某几页被换出后 GC 停顿期间的灾难性读盘。

应用工程师手里还有一批主动防御的接口,比等内核回收更精准:madvise(addr, len, MADV_SEQUENTIAL) 告诉内核这段是顺序读,预读激进些并读完即弃;MADV_DONTNEED / fadvise(POSIX_FADV_DONTNEED) 显式丢弃不再需要的页(数据库 WAL、日志文件处理后的清理);MADV_COLD(5.4+)把页降级到 inactive 链表尾部而不释放——它比 DONTNEED 温和,页还驻留,只是「优先牺牲候选」;MADV_PAGEOUT(5.4+)更进一步,匿名页立即换出、文件页立即写回释放。各类高性能代理(如 Envoy)在请求处理完毕后对请求缓冲区做 COLD/PAGEOUT 标记,把「什么该留」的判断权从内核手里接回来——内核的 LRU 只看访问时间戳,而应用知道业务语义。

六、观测清单:事故前的三个信号

回收路径的恶化有清晰的先行指标,建议纳入常规巡检。信号一:/proc/vmstat 的 allocstall_dma32、allocstall_normal(direct reclaim 发生次数)——这个数字长期为零是健康底线,任何非零增长都值得追查:谁在把内存吃穿到 min 水位以下。信号二:pgscan_direct / pgsteal_direct 的比值——扫描效率低于 0.3 且伴随 allocstall 增长,说明回收在空转、内存已到临界点。信号三:ps -eo pid,comm,wchan 看进程是否卡在 kswapd 相关等待,或 perf record -g 的火焰图里出现 try_to_free_pages / shrink_node 帧——火焰图上出现这一帧的程序,它那条请求的延迟尖刺就是内存回收贡献的。粗粒度的日常监控用 vmstat 1:si/so 两列(每秒换入/换出页数)长期非零且伴随 wa(iowait)升高,就是 swap 抖动;bi/bo 突增而业务 IO 没变,可能是 file 页回收在大量写回脏页。

一个容易忽略的宿主机因素:容器共享内核。你的容器内存限制是 4GB,但宿主机的 kswapd 是全机的——宿主机层面的内存压力会让 kswapd 扫描全系统 LRU,你的进程页可能在扫描中被降级、被回收(refault 计入你的 cgroup)。所以容器化服务的内存观测必须双层:cgroup 内的 memory.stat 看自己的压力事件(pgscan/pgsteal/workingset_refault),宿主机 /proc/vmstat 看邻居的拖累。P99 延迟尖刺在 CPU、磁盘、网络三板斧都排除后,内存回收路径是下一个该被系统性检查的地方——而它恰好有最丰富的免费观测面(procfs + cgroupfs),成本远低于埋点。

小结

内存回收的机制可以压缩成几句话:水位线(min/low/high)决定回收的紧迫度与路径——跌破 low 唤醒 kswapd 后台回收、跌破 min 触发应用分配路径上的 direct reclaim;LRU 四链表(active/inactive × anon/file)用访问位与二次机会机制挑选受害者,扫描效率(pgsteal/pgscan)量化回收的健康度;workingset 机制通过 refault 距离分辨「该回收的」与「不该回收的」,workingset_refault 持续增长是 working set 超内存的铁证。应用工程师的落点有三:把 allocstall 与 refault 纳入监控,让回收恶化在 P99 尖刺之前被看见;用 madvise/fadvise 把业务语义喂给内核,比等 LRU 猜更准;理解 swappiness 的真实语义(回收配比而非 swap 开关),为 JVM、数据库这类自管理内存的服务选择合适的配比。内存回收平时是沉默的背景线程,但它的每一次「启动失败」都会在延迟曲线上留下账单——这份账单在 procfs 里全程留痕,就看你会不会读。

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?