S24

内存管理深入

点击正文段落选定收藏或划线位置;未选定时使用屏幕内第一段。

课内目录

1 内存管理深入要解决什么

服务刚启动时只占几十 MiB,运行后却逐渐接近配额;主机明明还有可用内存,进程却被终止;文件读完后,内存曲线没有立即下降。这些现象不能只用“内存不够”解释。内存管理把程序看到的地址、实际驻留的物理页、文件缓存和资源限制联系起来。本课的目标是建立这几者之间的对应关系,让你能从进程、文件系统和服务配额中收集证据,再决定调整程序还是调整预算。

先把“申请”“访问”“驻留”分开。程序向分配器请求空间,分配器可能复用已有区域,也可能向操作系统申请新的虚拟地址范围。取得地址并不意味着相应内容已经全部进入物理内存。程序真正读写地址时,硬件和操作系统共同确认访问是否允许、对应页是否就绪。只有把这些阶段分开,才能解释“申请很大但 RSS 很小”和“首次处理请求突然变慢”为什么可能同时出现。

虚拟地址是进程使用的地址,物理内存是机器实际提供的存储空间。不同进程可以使用相同的虚拟地址而互不覆盖,因为映射关系不同。映射还带有读、写、执行等权限。访问没有映射或不允许访问的地址,可能收到 SIGSEGV;申请空间失败则可能通过返回值或异常暴露。不要把这两类失败都称为缺页,也不要认为所有内存故障都会先给程序正常返回错误。

运维时至少保留 3 个视角:进程映射了多少地址空间,进程当前驻留了多少页,所在资源组已经承担多少内存费用。Linux 的 VSZ 或 VmSize 主要描述虚拟地址空间规模,RSS 或 VmRSS 描述驻留规模;cgroup 的记账还可能涉及文件缓存和内核内存等项目。它们回答的问题不同,曲线数值不必相等。共享页又会让多个进程的 RSS 出现重复计入,不能直接把全部 RSS 相加当作主机真实用量。

从地址到配额的观察范围
从地址到配额的观察范围

本课使用 Linux 接口说明机制。页大小、统计字段和文件权限会随系统配置变化,命令中的路径也要对应实际进程。实践重点是比较同个对象在操作前后的变化,并记录时间窗口,而不是记住某台机器上的固定数值。对在线服务,先使用只读观测;需要制造内存压力时,使用可丢弃的测试环境,并为工作量设定明确上限。

2 页表、缺页与换页边界

操作系统按页管理地址映射。假设基础页为 4 KiB,地址可以理解为“虚拟页编号加页内偏移”。页表保存虚拟页与物理页之间的关系以及访问权限,硬件据此完成地址转换。实际机器通常有多级页表,本课只需要把它理解为可按需建立的映射结构。页表本身也消耗内存,因此映射非常分散的大量地址并非完全免费,即使其中只有部分数据真正驻留。

当地址转换发现当前条件不能直接完成访问时,处理器会让操作系统接手,这就是缺页异常的入口。合法地址的首次访问可能需要建立映射;读取文件映射时,所需内容可能尚未进入内存;写入共享的只读副本时,可能需要准备私有页。它们都可能进入缺页处理路径。缺页不是“程序出错”的同义词,关键在于地址是否合法、缺少什么资源,以及处理是否需要等待存储设备。

Linux 常把缺页统计分成 minor 和 major。minor fault 通常无需为这次缺页从存储设备读取页内容,例如建立某些已在内存中的映射;major fault 通常需要相应的存储读取。minor 仍然消耗 CPU 时间,也可能涉及分配和清零,不能称为没有成本。major 的延迟又依赖存储、排队和并发,不能把缺页次数乘上固定耗时就当作准确的请求延迟。

匿名页通常用于堆、栈以及匿名映射;文件页有文件作为内容来源。干净的文件页可以在回收后重新从文件读取,脏文件页通常需要按相应规则写回。匿名页若需要保留内容且允许交换,可能写入 swap;没有可用交换空间时,不能指望系统把所有匿名页都安全腾走。回收缓存、换出匿名页和终止进程是不同动作,不能把它们统称为“清内存”。

换页也不是每次缺页的必经步骤。首次写入新匿名区域通常不需要把旧内容从 swap 读回;再次访问被换出的页才可能发生换入。反过来,文件读取发生的存储等待也不必都表现为该进程的 major fault,普通 read 和映射访问的路径不同。因此观察缺页时要同时说明访问方式,避免把进程缺页计数当成所有磁盘读取的总计。

合法访问、缺页处理与失败的边界
合法访问、缺页处理与失败的边界

还有个常见场景是进程创建后的写时复制。父子进程最初可以共享部分物理页,随后某方写入时才产生私有副本。进程数增加后,初始 RSS 相加可能高估独占内存;大量写入又可能让真实占用快速上升。理解到“共享何时变成私有”就足以解释许多现场,不需要提前展开页表层级或内核分配实现。

对程序而言,成功获得虚拟地址只是起点。受系统分配策略、资源限制和后续访问影响,首次实际使用时仍可能遇到内存压力。系统可能回收、等待,最后触发内存不足处理。排查时应保留申请失败、缺页等待、非法访问和被外部终止这几条独立线索,再用时间和日志把它们连接起来。

3 分配器与碎片

大多数程序调用的 malloc、语言对象创建或容器扩容,并不会逐次直接申请操作系统页面。用户态分配器通常从较大的区域中切出小块,再维护空闲块以便复用。对象被释放后,空间可能回到分配器的空闲集合,也可能在满足条件时归还操作系统。因此“业务对象已经释放”和“RSS 立即下降”之间没有必然关系。

内部碎片指分配给对象的块比实际需要更大,差额留在块内。外部碎片指空闲空间分散在各处,虽然合计不少,却难以满足某种连续空间请求。这里的连续首先要说明是哪层:应用可能需要连续虚拟地址,某些系统功能又可能需要连续物理页,两者不能混用。普通程序的大块虚拟分配通常不要求整块物理内存连续。

考虑个简化例子:程序反复创建 1000 个小对象,再保留其中少量长寿命对象。短寿命对象释放后,空闲块散落在仍有活对象的页面中。分配器可以把空闲块用于后续对象,但可能无法整页归还。此时活对象字节数下降,RSS 却保持较高平台。若下一轮同类工作能复用这些空间,平台可能稳定;若每轮都继续抬高,则需要进一步查保留引用、缓存策略和分配形态。

泄漏指程序不再需要的资源仍被持续保留,无法按预期释放或复用。RSS 上升本身不能证明泄漏,因为预热、缓存、线程栈和文件访问都可能改变驻留量。反过来,“运行时会做垃圾回收”也不能证明没有泄漏:仍可达的对象、没有上限的索引以及遗漏关闭的映射,都可能长期保留资源。先用相同输入重复运行,观察活对象、驻留和吞吐是否形成稳定关系。

对象释放、空闲块与驻留页的关系
对象释放、空闲块与驻留页的关系

工程上先减少不必要的峰值副本,再考虑更复杂的分配优化。例如处理文件时同时保留原始内容、解压结果和解析对象,峰值可能远高于文件大小。分块读取和及时丢弃中间结果往往比更换分配器更容易验证。对象池则有自己的代价:它减少重复分配,却也可能长期持有过多空间,必须设置容量和回收策略。

线程数量也会影响预算。线程栈的虚拟保留范围与实际触及部分不同,分配器还可能为了并发维护多个管理区域。不能把“线程栈上限乘线程数”直接当作当前 RSS,也不能忽略线程增加后的真实驻留增长。优先记录线程数、典型调用深度和并发变化,再判断它们是否解释内存平台;本课不展开各类分配器的内部算法。

4 动手:观察 RSS/缺页与映射变化

本实验将“建立映射”和“写入页面”分开。需要 Linux 和 Python 3;实验只创建 32 MiB 的私有匿名映射,并等待你在另个终端观察。先确认测试环境有足够余量,尤其要看容器或服务的限制。程序打印 PID 后暂停;每按 1 次回车进入下个阶段。阶段结束后退出程序,映射和进程资源会随之释放。

import mmap
import os

size = 32 * 1024 * 1024
page = os.sysconf("SC_PAGE_SIZE")
print("pid:", os.getpid(), "page bytes:", page, flush=True)
input("记录基线,回车建立映射:")
buf = mmap.mmap(-1, size, flags=mmap.MAP_PRIVATE | mmap.MAP_ANONYMOUS)
input("已映射但未主动触碰,观察后回车写入:")
for offset in range(0, size, page):
    buf[offset] = 1
input("已逐页写入,观察后回车关闭映射:")
buf.close()
input("映射已关闭,观察后回车退出:")

将代码保存为 memory_demo.py 并运行 python3 memory_demo.py。在另个 shell 中,把下面的 12345 换成实际 PID。对每个暂停阶段执行同组命令,保留输出及时间。status 提供进程规模,smaps_rollup 提供映射汇总,maps 提供地址范围和权限。部分系统因权限或内核配置无法读取某些文件,遇到拒绝访问时记录限制,不要据此推断该进程没有相应内存。

pid=12345
date -Is
ps -p "$pid" -o pid,vsz,rss,nlwp,comm
grep -E '^(VmSize|VmRSS|RssAnon|RssFile|VmSwap):' "/proc/$pid/status"
cat "/proc/$pid/smaps_rollup"
cat "/proc/$pid/maps"

ps 中 VSZ 和 RSS 常以 KiB 表示,读取时以工具说明为准。smaps_rollup 的 PSS 会按共享比例分摊共享页,有助于讨论多个进程的合计占用;它仍然不是 cgroup 记账的替代品。观察完整映射时,关注新出现的匿名区域及其权限,不必逐行解释 Python 运行时加载的所有库。读取这些接口本身也需要时间,不适合无节制地高频采样。

预期现象是:建立映射后,虚拟地址规模先增加,RSS 未必按同样幅度增长;逐页写入后,匿名驻留明显增加;关闭映射后,对应范围消失。解释器自身分配和采样时刻会带来噪声,所以不要求差值恰好等于 32 MiB。系统的大页策略也可能改变实际处理粒度。这里要检验的是阶段关系,不是推导固定的缺页次数。

如果安装了 pidstat,可以在触碰前运行 pidstat -r -p "$pid" 1 8,观察采样窗口内 minor 和 major fault 的速率。命令最后的 8 表示采样次数,必须让写入动作落在窗口内。首次写匿名页通常主要带来 minor fault;没有看到 major fault,不代表机器没有任何存储活动。进程运行时间、采样间隔和速率单位应与截图或记录一起保留。

把实验阶段对齐到证据
把实验阶段对齐到证据

再把视角移到资源组。cat "/proc/$pid/cgroup" 可以帮助定位归属,但容器中的命名空间和挂载布局会影响路径。若系统采用 cgroup v2,需先确定实际组目录,再读取该目录下的 memory.current、memory.max、memory.stat 和 memory.events。memory.max 中的 max 表示该层未设硬上限,祖先组仍可能有限制。不要假定进程一定属于 /sys/fs/cgroup 的根组。

如需观察文件映射,使用小型普通文件的只读映射,比较访问前后驻留和 RssFile 等信息即可。不要为了追求“冷缓存”而在共享主机清空全局缓存。第一次测试可能已被其他进程或之前运行预热;这会改变缺页表现,也正好说明实验必须记录初始状态。普通 read 会经过页缓存并把内容复制到用户缓冲区,不能照搬为文件映射的驻留结论。

5 大页与 TLB 压力

每次访问内存都要完成地址转换,但硬件不会为每次访问都从头遍历页表。TLB 缓存近期使用的地址转换。工作集覆盖的页很多、访问又较分散时,有限的转换缓存可能不够用,需要更频繁地查询页表。TLB miss 与缺页不同:页已经驻留且映射有效时,仍可能发生 TLB miss;普通 TLB miss 也不会自动成为操作系统报告的缺页。

大页用更大的页面覆盖同样的地址范围,能够减少所需映射项。假设比较 4 KiB 与 2 MiB 的页面,连续覆盖 256 MiB 理论上分别需要 65536 个和 128 个页范围。这个例子只说明覆盖规模,不代表机器的 TLB 容量,也不保证速度按相同比例提高。CPU 型号、访问局部性以及是否真正使用大页都会影响结果。

大页也会提高分配和回收的粒度。若应用只访问稀疏位置,较大的驻留单位可能带来额外内存开销;建立大页可能需要更多准备工作,压力下还可能涉及整理和等待。Linux 上透明大页与显式申请的大页不是同套使用接口。前者可能按系统策略自动参与,后者通常需要专门配置和预留,不能仅凭机器“支持大页”就认定进程正在使用。

调查时先用 getconf PAGESIZE 了解基础页大小,再根据系统提供的接口查看大页策略。在可读的情况下,smaps 或 smaps_rollup 中的 AnonHugePages 可以提供匿名大页使用线索;更细的映射信息还需结合实际字段解释。单个字段并不能证明请求延迟来自 TLB 压力,也不能替代性能对照。不要把 major fault 很少误判成地址转换完全没有成本。

做选择时使用相同输入和并发,比较吞吐、尾延迟、峰值内存以及建立工作集的耗时。若开启某种策略后吞吐略好,却导致首次访问停顿和配额余量减少,就需要按服务目标权衡。先掌握“覆盖范围更大,管理粒度也更大”即可;页表详细结构、硬件计数器解释和大页整理算法留到后续课程。

6 常见坑:泄漏当「正常增长」、忽略 cgroup 限制

“缓存总会涨”不能作为增长没有上限的理由。合理的缓存需要容量、淘汰条件和压力下的行为。把相同任务连续执行若干轮,记录每轮开始、结束和空闲后的用量。如果稳定输入下的保留对象和内存底线持续抬升,就应查找增长来源;如果底线稳定而峰值随并发增加,则更像工作集和并发预算问题。两种情况可能同时存在,不能只选个好听的名称。

另个误区是看到主机 free -h 仍有余量,就排除内存不足。容器和服务可受到 cgroup 上限约束,进程可能先在自己的资源组里触顶。反过来,组内尚有余量也不保证主机没有压力。应分别记录主机可用内存、目标组当前用量、目标组和祖先组的限制,再核对事件计数。不同层次的限制会产生不同的故障范围。

在 cgroup v2 中,memory.high 常用于触发回收和节流相关行为,memory.max 是硬限制接口;它们不是提前保证申请成功的额度。memory.events 中的 high、max、oom、oom_kill 等字段要按各自含义解释,并比较故障窗口前后的增量。max 增加不等于发生了杀进程,oom 与 oom_kill 也不是同个事件。若使用 memory.events.local,还要说明它与层级统计的范围区别。

退出码也需要证据支撑。shell 中看到 137 常提示进程因信号 9 结束,但管理员执行 kill -9 也能造成类似结果。只有把退出时间、资源组事件、服务管理器记录,以及有权限读取的内核日志对齐,才能较可靠地判断是否为 OOM 终止。程序可能来不及写日志或清理文件,所以“应用没有报内存错误”并不构成反证。

内存故障的分支排查
内存故障的分支排查

网络服务还要把应用缓冲区与网络相关内存分开考虑。慢客户端可能使应用待发送队列增长,大量连接也会带来 socket 缓冲等开销。ss -m 可提供 socket 内存线索,cgroup 的 memory.stat 在相应系统上也有 sock 等统计项;两者范围和单位需要核对,不能直接逐项相加。先限制队列和并发,再谈协议栈内部实现。

不要用定时重启代替判断。重启能暂时恢复容量,却会清除增长曲线和部分现场,也可能让预热峰值反复出现。可以把重启作为明确记录的恢复动作,同时保留终止前的映射摘要、限制、事件变化与负载指标。若要临时提高配额,还必须确认主机余量;否则只是把故障从单个服务转移到更多进程。

7 案例:匿名页风暴触发的抖动

设想个接收压缩诊断文件的服务:每个任务把文件读入缓冲区,解压后构建索引,再把摘要写回文件。日常并发较低,驻留曲线看起来稳定。某次 shell 批处理同时启动了大量任务,服务吞吐先增加,随后请求等待时间变长,部分连接超时。此时 CPU 没有持续满载,主机又还有文件缓存,值班人员容易把问题误判为网络波动。

真正变化的是同时存活的匿名工作集。每个任务在解压阶段同时持有输入、输出和索引,首次写入会触碰大量页面。假设空闲基线为 180 MiB,测得每个活跃任务在重叠阶段额外需要约 60 MiB;并发从 4 增至 12 时,仅这部分估算就从 240 MiB 增至 720 MiB。若目标组上限为 1 GiB,加上队列、文件缓存和其他开销,余量会迅速缩小。

这些数字只是现场测量的示例,不能用于其他程序。更关键的是时间关系:并发上升后,RssAnon 和组内匿名内存增长,minor fault 速率出现尖峰;随后组内压力和事件计数变化,请求尾延迟上升。如果启用交换且存在换入换出,存储等待还可能加重;若没有交换,系统仍可能因回收、节流和后续 OOM 而变慢,不能要求必须看到 swap 活动才承认内存压力。

调查时先暂停新的批量提交,保留当前 PID、并发数和时间,连续读取组内用量与事件。再比较匿名页、文件页和 socket 相关统计,确认主要增长来源。若平台暴露 memory.pressure,可以辅助判断任务因内存压力停顿的情况,但它不直接指出是哪段代码分配了内存。对照应用阶段日志,发现增长集中在解压与索引重叠区间,才能把系统现象落回程序行为。

短期恢复是降低正在工作的任务数,并为等待任务设置容量限制。只限制工作线程却允许入口队列持有全部文件内容,仍然可能耗尽内存。等待队列应尽量保存小型任务描述,文件内容采用有界分块或受控临时文件;是否采用这些方式,要同时考虑磁盘容量、清理和失败恢复。每种转移都要记录资源成本,不能把内存问题藏进无限增长的临时目录。

随后修改流水线,尽早释放不再需要的中间副本,并给解压后大小设定上限。压缩文件很小并不保证解压结果很小,必须按实际输出累计检查,而非只检查输入文件大小。网络入口需要相应的超时和背压,避免任务变慢后客户端重试继续抬高并发。这里的背压就是让提交速率受处理能力约束,可以表现为有限排队或明确拒绝。

最后用原来的批量输入重放,比较相同时间窗口下的峰值、尾延迟和任务完成率。若并发降低后故障消失,只能说明压力与负载相关;还需验证长时间重复任务后底线是否稳定,才能排除同时存在的泄漏。修复记录要区分已测得的改善与仍需验证的猜测,尤其不要把“没有再次被杀”当作全部性能目标已经达成。

8 把内存预算写进服务配额

预算应该从服务行为出发。可以用“空闲基线 + 活跃任务工作集 + 有界队列 + 缓存及其他开销 + 余量”组织估算。各项必须说明统计范围,避免把已经包含在测得工作集中的缓冲区重复相加。共享文件页和组内其他进程使估算更复杂,所以最终仍要用整个资源组的峰值校验,而不是只看主进程 RSS。

延续案例,基线取 180 MiB,并发上限设为 6,测得每任务增量约 60 MiB,得到任务部分约 360 MiB。再给等待队列和有界缓存合计预留 120 MiB,其他开销估为 80 MiB,此时约为 740 MiB。若测试支持在 1 GiB 限制下保留剩余余量,可以将它作为候选配置;这不是通用的安全比例,输入分布或程序版本变化后必须重新测量。

预算应覆盖最容易重叠的阶段。部署期间旧进程和新进程可能同时驻留;进程创建后的写入可能产生私有副本;文件扫描会引入页缓存;网络拥塞会拉长缓冲区存活时间。把这些情况列为测试条件,比给日常平均值简单乘个系数更有解释力。还要明确受控拒绝发生在何处,确保服务在触及硬限制前就能减少新工作。

由并发与工作集形成可验证配额
由并发与工作集形成可验证配额

在配置文件或运行手册中,同时写下应用并发上限、队列容量、单任务大小限制、资源组硬上限和告警依据。告警不能只盯住某个百分比:持续增长、压力停顿、限制事件和尾延迟都提供信息。短暂接近上限与反复发生回收等待需要不同响应,阈值应由负载测试和业务目标共同确定。

验收可以分成正常峰值、超额输入和持续运行。正常峰值验证预算覆盖预期工作量;超额输入验证排队、拒绝和恢复是否有界;持续运行验证底线是否抬升。至少记录软件版本、输入规模、并发、组路径、限制和观测窗口。这样下次扩大配额时,团队能够判断是在支持更多工作,还是在延后尚未解决的增长问题。

9 从 OS 内存回到内存技术(H26)

到这里,内存已经不再只是程序里的变量。应用对象经过分配器组成地址区域,操作系统按页建立映射,物理内存承载驻留数据,缓存和交换提供不同的保留与恢复路径。资源组又为这些使用行为施加记账和边界。观察某条曲线时,先问它处在哪层,再问变化发生于哪个阶段,才能避免把虚拟地址增长等同于物理容量耗尽。

这些概念也为 H26 的内存技术建立问题清单。基础页和大页讨论管理粒度,TLB 讨论地址转换缓存;后续硬件层次会继续讨论数据如何被访问、容量与延迟为什么不同。不要把 TLB 与数据缓存混为同种缓存,也不要把更换内存介质当作消除泄漏的办法。硬件改善访问成本,程序仍需控制工作集和资源生命周期。

带着实验记录进入后续课程最有价值:为什么同样是访问 32 MiB,首次触碰与重复遍历表现不同;为什么随机访问和顺序访问可能有差异;为什么内存容量充足仍可能出现等待。当前只需提出这些问题,并确认已有证据能区分缺页、驻留变化和配额压力。具体硬件时序与更深入的测量方法留到 H26 逐步展开。

放进方案里

为接收诊断文件的服务编写方案时,先明确可承受的输入范围:限制压缩文件大小、解压后累计大小、活跃任务数和等待队列容量。等待任务只保留必要描述,文件内容按受控方式读取。把入口拒绝、客户端退避和超时行为写清楚,避免处理变慢后重试继续增加内存占用。临时文件方案还要写明空间限额、清理时机和进程异常退出后的恢复方式。

预算以实测为依据,记录空闲基线、单任务重叠阶段增量、缓存上限和其他开销,并说明它们是否重复计入。使用与生产相近的资源组限制运行峰值测试,同时采集组内总量、匿名页、文件页、压力与限制事件。主进程 RSS 用来定位变化,资源组统计用来验收配额;共享页和子进程使两者出现差异时,保留解释,不能挑选较小的数值作为结论。

上线条件包括正常峰值下完成率和尾延迟达标,超额输入能够有界排队或明确拒绝,持续重复工作后内存底线趋于稳定。测试还应覆盖慢客户端、部署重叠和异常大输出,确认缓冲区与旧进程不会突破预算。大页策略只作为经过对照实验的可选配置,记录吞吐收益与峰值、首次访问延迟的变化,不预设开启就更快。

运行手册写出实际资源组路径、只读采样命令、告警依据和恢复动作。出现抖动时先限制新任务并保留现场,再核对并发、匿名增长和事件增量;发生终止时对齐退出信号、服务记录与 OOM 证据。临时扩容须确认主机余量,并设定回看时间。负责人在输入规模、程序版本或并发策略改变后重新验收,使配额成为能够复测的服务约束。

10 小结:下一步内存技术(H26)

本课建立了从虚拟地址、缺页处理到驻留与配额的观察路径。分配成功不代表已经驻留,缺页不必涉及换页,对象释放不保证 RSS 立即下降,主机余量也不能替代资源组余量。大页可能减少转换压力,同时改变管理粒度,必须通过服务指标验证取舍。

检查理解时,试着解释 3 个现场:映射变大而 RSS 尚未增长;首次写入伴随大量 minor fault;主机仍有可用内存而服务因组内压力退出。每个解释都应指出要读取的接口,以及什么证据会推翻当前判断。带着这些问题进入 H26,再研究硬件内存层次如何影响访问成本。

同阶段相关课

AI 答疑

默认关闭。启用后,只发送本课标题、最近阅读的屏幕内正文摘录(最多 6000 字)与本次问题。

配置自己的服务