我最近在整理内存管理方向的学习笔记时,又顺着 swap_map 的讨论把某开源操作系统的交换子系统整体看了一遍。这是这个系列的第二篇,上一篇还停留在交换参数怎么调、swappiness 怎么看,这一篇更硬核一点:如果不再维护一张逐槽位的 swap_map,换入换出路径该怎么重新设计。
写之前先说明,这篇文章不绑定任何具体内核版本,也不去逐字节解读某一份补丁。我更想讲清楚这一类改造背后的判断依据:老结构为什么出现、瓶颈在哪里、新设计靠什么赢得收益、以及在自己机器上怎么验证。如果你只是听说过 swap_map 这个词,但没弄清它到底在交换路径里处于什么位置,这篇应该能帮你把拼图补上。
1. swap_map为什么还在内核里:一张大数组的由来与代价
1.1 它在旧设计里承担了什么
swap_map 本质上是一张与交换区槽位一一对应的数组。我们先把交换区想象成由固定大小槽位组成的仓库,通常每个槽位就是一页大小。内核换出一页时,要决定把它放进仓库的哪个位置,这个决定必须可恢复:下次读到这个页时,根据页表项里的交换条目就能找到槽位。但仅仅找到还不够,内核还要知道这个槽位是否空闲、有没有损坏、正在被多少处映射引用。旧设计把这些信息全部放在 swap_map。
具体来说,数组的下标就是槽位编号,数组项保存这个槽位的状态和引用计数。一页被换出时,内核拿一个空闲槽位,把对应计数置为 1;同一个物理页被多份页表映射时,典型场景是 fork 之后的共享页,计数会继续增加;当所有映射都不在了,计数再降下来。需要注意的是,计数归零不代表槽位立刻可以复用,因为页面内容可能还留在交换区里等待被再次读取或最终丢弃。这个语义用一句话说:swap_map 就是交换区的“房态表”。
1.2 为什么当年的内核选择这种结构
在那个年代,这种设计是最合理的方案。首先,数组天然可以用交换条目里的偏移量做下标,把一个复杂的“依据信息定位槽位”问题退化成一次内存读取。其次,当时内存和交换区规模都很小,一张连续数组占用的空间几乎可以忽略。最后,交换子系统向来把正确性和可复审性放在第一位,一个结构简单的线性表比一棵多路平衡树更容易让人相信没有边界错误。
所以我不太喜欢把旧实现说成“历史包袱”。它是在当时约束条件下的正确工程决策:数据量小、并发要求低、调试手段有限,选择线性数组意味着每个状态变化都能用简单的下标操作完成,团队很容易推理。问题不是它写错了,而是几十年的硬件规模变化让这个决策的前提失效了。
1.3 大内存、高并发之后,问题开始集中爆发
当系统从几十 GB 内存、几 GB 交换区,走向几百 GB 内存、数 TB 交换区时,问题开始集中出现。最直观的是内存占用:假设每个槽位用一个 32 位计数器记账,1TB 交换区、4KB 槽位,就需要大约 256M 个槽位,对应 1GB 连续元数据。这还只是 swap_map 本身,没有计算其它辅助结构。在内存紧张时,回收路径要为一个本应帮它减压的结构额外背上大块冷数据,实在谈不上划算。
比内存占用更麻烦的是更新路径。当 swap 开始活动时,每换出一页都要改一次 map;换入时又要改一次。多核系统上,多个 CPU 可能同时回收内存并操作同一个设备的 map,即使设备之间有锁分开,共享缓存行造成的 ping-pong 也很明显。内存管理路径本身就是最怕抖动的地方,一次额外的原子操作或缓存失效,放在高频回收路径上都会被放大。
还有一个常被忽略的问题:随机访问模式。如果分配器把槽位分散在不同位置,修改 map 时会造成大量 cache miss;如果再叠加锁竞争,情况会更坏。批量操作也不友好。现代内核开始支持大页级别的交换、一次换出一组连续页,但逐槽位 map 迫使你以页为单位一个个更新状态。想要批量语义,就得先把 map 的逐页更新路径改掉。压缩交换层也受到影响,因为压缩页与 swap 槽位协同工作时,map 的逐槽更新会被叠加进一条原本可以精简的路径里。
1.4 所以改造的切入点不是“优化数组”
既然问题来自数组本身,靠更好的锁、更快的查找并不能根治。你真正要面对的是一个全量数组在交换区规模变大后,不再适合作为唯一事实来源。需要更小的元数据体量、更粗的管理粒度、更低的更新频率。这大概就是“end of the swap map”这句话的初衷:让这张逐槽位大表退役,而不是继续给它打补丁。
2. 把逐槽位计数改成区间状态:改造思路推演
2.1 先想清楚要替换掉的到底是什么
很多人以为去掉 swap_map,就是把一个数组换成红黑树或者 xarray,这其实是把问题想小了。swap_map 在同一份数据里承担了三个职责:槽位的分配状态、引用计数、坏页标记。其中引用计数是更新最频繁的部分,也是最不适合放在低频结构里的部分。真实项目里的改造并不会把三件事一刀切,更现实的分解是:
- 分配状态和坏页信息可以保留一个紧凑结构,比如位图或区块标记;
- 引用计数从 map 中挪走,交还给真正拥有页生命周期的对象来管理;
- 分配和释放在更大粒度上操作,用区间记录来减少状态变化次数。
看到这里你应该明白,所谓去掉 swap_map,不是说交换区不需要记账了,而是说不再需要为每个槽位都维护一个多层含义的计数项。
2.2 区间记录的直觉
把交换区想象成存包柜。旧方案是每个柜门一格记录卡,新方案是几排柜子共用一个状态块。当整排柜子的状态一致时,只需要一行描述“3 排到 8 排空闲”;只有状态不一致时,才需要拆得更细。文件系统里的 extent 就是这个思路,连续块用一段记录,而不是每个块一条元数据。
这样设计隐含一个关键假设:交换工作负载具有局部性。分配器申请 swap 槽位时总会倾向抓取连续跨度;释放时,属于同一个页集合的多页也常常一起释放。这个局部性假设一旦成立,区间数量会远远小于槽位数。如果不成立,区间结构就会退化到接近逐槽位记录,那就必须有一套合并策略来兜底。所以区间化不是魔法,它是在为“真实负载有局部性”下注。
2.3 一个可以跑起来的教学版模型
我把这个思路压缩成一个教学用的 Python 类,不追求内核级性能,只展示分配和释放的形态变化:
class SwapDevice: def __init__(self, total_slots): self.regions = [(0, total_slots, "free")] def allocate(self, n): for i, (start, count, kind) in enumerate(self.regions): if kind != "free" or count < n: continue live = (start, n, "live") rest = (start + n, count - n, "free") self.regions = ( self.regions[:i] + [live] + ([rest] if count > n else []) + self.regions[i + 1:] ) return start raise RuntimeError("no enough swap slots") def release(self, start, count): for i, (s, c, kind) in enumerate(self.regions): if s == start and c == count and kind == "live": self.regions[i] = (start, count, "free") return raise KeyError("region not found or still referenced")为了便于阅读,这段代码刻意省略了引用计数分裂和相邻空闲区间的合并。真实内核需要回答更麻烦的问题:当一个区间里部分页还在被引用、部分页已经空闲时,必须先把区间切分成更小的单位,再决定哪些可以转成 free。合并逻辑也很关键,释放一批页之后,如果前后邻居都是 free,要合成一个大区间,否则碎片会越切越碎。这是区间方案里真正吃功夫的地方。
2.4 为什么说这不是一次简单数据结构替换
难点在于状态一致性。区间方案最好的情况是整段区间处于同一状态,但如果区间内某些页的引用计数是 1、某些页是 2,一个字段描述不了这种混合状态。实际设计里无非两条路:要么不允许区间混合状态,一旦出现就拆小;要么把引用计数从 map 里挪走,让 map 只回答“这个槽位现在能不能用”。
后者更接近我对主流改造方向的理解——让页本身记录映射计数,swap_map 退回到更轻的状态标记。这才是“end of the swap map”的实质:不再让一个低频、全量、全局的数据结构去承载高频的逐页更新。决策原则可以概括成一句话:状态应该放在离状态变化最近的地方,而不是全部堆在一个集中式数组里。
3. 改造后的分配与释放路径长什么样
3.1 分配:从线性找洞到区间切分
旧路径里,交换分配器要从 swap_map 中找到空闲槽位。朴素方法是线性扫描,后来内核用 cluster 信息辅助,但从数据结构上看,它仍然是一部逐项检查的机器。新路径就不一样了,分配动作变成两次区间操作:先在区间树中查找一个容量足够的 free 区间,然后把它切分成“live 区间 + 剩余 free 区间”。一次查找可以为一批连续槽位建好映射,而不是逐页去翻转状态。
用前面那个教学类的表达,allocate(n) 几乎是唯一入口。对内核来说,n 往往来自批量回收请求,可能是一次换出一组页。这时候区间切分天然具备批量语义:一次找到 n 个连续槽位,返回起始位置,其余部分留在 free 区间里。对比旧路径中每个页都做一次“找槽位、改计数”的完整流程,改造后的分配路径干净很多。
3.2 释放:从逐槽减计数到区间合并
释放路径更考验设计。旧实现面对一批要释放的页,需要逐个把 swap_map 中的引用计数减掉,再判断是否归零、是否重新变成 free 槽位。新实现先看这批页是否落在一个完整的 live 区间内。如果整个 live 区间都释放干净,一步转成 free 并尝试与相邻 free 区间合并;如果只是区间的一部分,需要先分裂再释放。
这里有一个容易忽略的细节:释放路径必须保证一致性。假设一个 live 区间覆盖 100 个槽位,某些页还有 page cache 引用,某些已经彻底释放,你不能直接把整段标记为 free。所以真实实现往往会把区间拆成更小的 subregion,再针对每个 subregion 判断。这也是为什么区间合并策略要做对:如果不积极合并空闲邻居,长时间运行后区间数量会膨胀;膨胀到一定程度,区间模型的优势就没了。
3.3 复杂度与内存占用对比
| 维度 | 旧:逐槽位 swap_map | 新:区间状态 |
|---|---|---|
| 内存占用 | 固定,随交换区大小线性增长 | 随活跃区间数变化,通常远低于前者 |
| 分配空闲槽 | 依赖 cluster 信息或扫描 | 区间树查找一次并切分 |
| 释放映射 | 每页减计数、单独更新 | 批量合并或拆分 |
| 批量换出/换入 | 不友好 | 天然支持 |
| 高碎片环境 | 稳定但缓慢 | 区间数膨胀,可能退化 |
这里可以算一笔账。1TB 交换区、4KB 槽位,槽位总数约 256M。旧方案用一个 32 位计数项,就是 1GB 元数据;即使压缩成 16 位计数,也要 512MB。区间方案在健康负载下可能只需要几千条区间记录,即使每条记录 64 字节,也是几十 KB 级别。但必须强调最坏情况:如果负载把区间碎片化到每个槽位都独立,区间树会比数组更贵。所以真实实现通常会配合分配策略,尽量从 cluster 边界取连续槽位,让区间保持粗粒度。
3.4 一个重要错觉:区间化后分配会变慢
有人看到“查找 + 切分”会觉得比数组下标慢很多。实际上旧路径真正慢的地方不在下标操作,而在 cache miss、锁竞争和逐页更新。区间查找带来的 O(log M) 因子,完全可以通过减少状态翻转次数和缓存友好性赚回来。可以理解为用单笔操作的额外计算,换掉高频路径上的大量原子操作和缓存行同步。只要批量长度足够,这笔交易是划算的。
4. 本地验证:先搞清楚自己的系统在怎样交换
4.1 先确认内核版本和能力
不同内核版本的交换实现差异很大,甚至同一个概念在不同版本里的函数名都对不上。做验证前,先明确自己当前系统处于什么状态:
uname -r free -h cat /proc/swaps如果你的发行版同时装了旧内核和新内核,最好在启动时切换到一个你打算验证的内核,然后重新加载 swap 设备。别在不知情的情况下拿生产内核做实验,很多网上流传的追踪命令都是基于老版本符号的,在新内核上会直接失败。
4.2 从统计文件入手
先不用上任何复杂工具,从系统自带的计数开始看:
free -h vmstat 1 cat /proc/pressure/memory grep -E "pswpin|pswpout" /proc/vmstatsi和so是每秒换入换出的块数,pswpin和pswpout是累计换入换出次数。先确认系统是否真的有 swap 活动,再考虑更深层的定位。很多机器平时 swap 活动为零,这时候讨论 swap_map 优化只是一种理论储备,而不是性能热点。
4.3 用 cgroup 制造可复现的压力
如果你想观察 swap 路径,最好不要直接让整机 OOM,那样会拖垮所有进程。更稳妥的办法是建一个内存受限的 cgroup,把测试进程关进去:
mkdir -p /sys/fs/cgroup/swap_test echo 512M > /sys/fs/cgroup/swap_test/memory.max echo $$ > /sys/fs/cgroup/swap_test/cgroup.procs python3 -c "data = [bytearray(1024 * 1024) for _ in range(1024)]; input()"这段命令会让当前 shell 进入 cgroup,然后在一个 Python 进程里分配约 1GB 内存并挂起等待。由于 cgroup 内存上限只有 512M,系统会开始回收并可能换出。此时你可以在另一个终端里用vmstat 1观察so是否持续增长。测试结束后,退出 Python 进程并清理 cgroup:
# 在另一个终端结束 python 进程后 echo 0 > /sys/fs/cgroup/swap_test/cgroup.procs rmdir /sys/fs/cgroup/swap_test如果你的系统用的是 cgroup v1,路径和限制字段会不同,以实际挂载情况为准。
4.4 用事件追踪确认分配入口
如果统计数字显示 swap 活动频繁,接下来要确认是哪些函数在承担分配和释放。可以挂载内核的 tracefs,查看可用的过滤函数列表,找到与交换分配相关的符号,然后用动态追踪机制在分配入口挂一个探针做计数。
这里的关键不是背函数名,而是学会确认符号在当前内核里存在。不同版本函数名可能完全不同,挂探针之前先 grep 一遍available_filter_functions,如果找不到就换相近的关键词。这一步能避免你照抄旧命令然后一脸茫然。
4.5 如何判断批量语义是否生效
观察一次内存压力下pswpout的增长节奏。如果一次压力过程中pswpout出现上千页的突发,说明回收路径有能力成批换出;如果每轮都只是几十页零散换出,说明当前路径还是偏单页操作。再结合pgmajfault观察换入成本,也能侧面判断批量换入是否生效。批量换入生效时,一个 major fault 往往能带进一组邻近页,而不是只补一页。
4.6 验证误区
不要为了观察 swap_map 开销,把系统内存设成几 GB,然后期待看到几百 MB 的元数据。只要 swap 后端本身不大,map 的固定开销就高不起来。要对比改造前后的收益,最好准备一个几十 GB 的 swap 后端,再用真实的分配释放负载把冷页推进去,最后用性能分析工具测量回收路径的热点分布。低压力下测不出差别,因为热点根本没被激活。
5. 实际迁移中比数据结构更麻烦的三个问题
5.1 把 swap_map 和“内存大小”绑在一起是常见误解
swap_map 只和交换区大小呈线性关系,和物理内存大小没有直接关系。一台机器有 128GB 内存但 swap 只有 2GB,map 的成本很小;真正值得担心的是每一条换出路径上的更新竞争。我见过有人为了省 swap_map 内存直接把 swap 卸载,结果内存压力全打到了 page cache 回收上,延迟反而更难看了。判断要不要关注这个改造,先看 pswpout 和回收路径 CPU,而不是只看 swap 分区占了多少。
5.2 区间方案最怕碎片化
swap 文件被反复使用时,容易留下大量分散的小空洞。一旦自由区间被切得很碎,区间记录数就会上升,合并也变频繁。遇到这种场景,最直接的解决办法是swapoff再重新swapon,或者重建一个更大的 swap 文件。另一个思路是让分配器尽量从簇边界取槽位,避免碎片被随机分配进一步放大。我在实验里就遇到过类似情况:同一份工作负载,先把交换区放在连续分区上稳定运行,之后换成一个普通文件,换出性能开始明显波动,根源就是区间数量膨胀。
5.3 兼容性与 ABI 约束
修改 swap 元数据格式会影响很多周边工具:swap 设备重新挂载后的识别、休眠镜像的读取、崩溃转储工具对交换分区的解析。新数据结构上线之前,至少要验证三件事:旧 swap 设备能否被新内核挂载或优雅拒绝;新内核写过的 swap 设备能否被旧工具正确识别;休眠和恢复流程是否还能找到需要的恢复页。我一般会在虚拟机上编译新内核,先跑一轮完整的休眠唤醒,再考虑真实环境。这个验证步骤不能省,否则一次休眠失败比省下的几百 MB 内存代价高得多。
5.4 压缩交换层会让问题放大
当你启用了压缩交换层,内存回收时压缩页要跟交换后端协同工作,分配和释放路径会被压缩层进一步拉高频率。swap_map 的逐槽更新原本就重,叠加在压缩交换路径上会更明显。所以关注 swap_map 改造的人,不应该只从“省 512MB 内存”这个角度理解收益,更实际的价值在高并发回收场景下 CPU 消耗和延迟的下降。如果你统计到回收路径的 CPU 大量消耗在更新计数上,这个改造方向就值得追。
5.5 不是所有内核都需要你立即关心
如果机器内存不过载,swap 活动接近零,这个主题更多是知识储备。真正有用的是当你做性能分析时,知道pswpout很高、回收路径 CPU 很高的时候,问题可能卡在哪一层。数据结构只是定位工具之一,重要的是先建立完整的判断链路:从统计计数到探针热点,再到具体实现。这样遇到 swap 相关性能问题时,你才不会被“调大 swappiness”这种表面操作带偏。
最后再分享一点我的体会
我自己的感受是,这种改造最迷人、也最容易被低估的地方,是它逼着人重新梳理“状态该放在谁身上”。老代码把一切状态堆在一个大数组里,因为简单;新设计宁可多几次区间查找,也要把高频引用计数从低频结构里挪出去。
整理完这一篇,我又给自己留了一个检查清单:看到任何 per-object 全量数组,先问三个问题——它是不是全量分配?更新是否在回收或分配热点上?能不能用区间或者更小的位图表达?如果你也需要做类似的内存子系统升级,我建议你也从这三个问题开始,而不是急着改数据结构。把这个清单带到下一个系统设计里,可能比记住任何一组函数名都有用。