抖动与工作集:虚拟内存的性能问题
前面两篇我们看到,虚拟内存靠局部性让程序用上超过物理内存的空间,页面置换算法则在内存满时聪明地决定换谁出去。一切看起来都很优雅。但这份优雅有一个前提条件,而当这个前提被打破时,系统会掉进一个所有 SRE 都心有余悸的深坑:
CPU 利用率明明不高,磁盘却忙得冒烟,每个请求都慢得离谱,加机器、加并发反而更糟——系统像陷进了泥潭,越挣扎陷得越深。
这种"越忙越慢、几乎无法推进任何有效计算"的恶性循环,就叫抖动(thrashing)。它是虚拟内存最危险的失效模式。
这一篇我们先把抖动的发生机制讲透——它是怎么一步步陷入恶性循环的、为什么会自我强化、为什么加并发反而火上浇油——看清这个机制后,再讲工作集模型如何从根上解释和缓解它。这是虚拟内存系列的"故障篇",理解它,你才算真正掌握了虚拟内存的边界。
一、先厘清三个集合:虚拟空间、工作集、驻留集
要讲清抖动,先得把三个容易混的概念分开。它们是理解后面一切的基础。
- 虚拟地址空间:进程逻辑上拥有的全部地址范围。可能非常大(几十 GB 甚至更多),但大部分页平时根本不碰;
- 工作集(working set):进程在当前这一小段时间窗口内,真正频繁访问的那批页的集合。它是"程序此刻实际需要的页",通常远小于虚拟空间;
- 驻留集(resident set):操作系统当前实际分配给该进程、放在物理内存里的页框集合。它受物理内存大小和系统调度的限制。
三者的关系是理解抖动的钥匙。正常情况下我们希望:驻留集 ⊇ 工作集——也就是说,进程当前真正要用的那些页,都被装在了物理内存里。这样绝大多数访问都命中内存,缺页稀疏,性能良好。
而抖动的本质,一句话就能点破:
抖动 = 驻留集装不下工作集。进程当前真正需要的页比分到的物理内存还多,于是它需要的页总有一部分不在内存里。
二、抖动的机制:一个自我强化的恶性循环
为什么"驻留集装不下工作集"会演变成灾难性的恶性循环,而不只是"慢一点"?关键在于它会自我强化。我们一步步看这个循环是怎么转起来的:
跟着这个环走一遍,你就明白抖动为什么可怕:
- 起点:驻留集小于工作集,进程需要的页有一部分不在内存;
- 频繁缺页:每访问几步就撞到一个不在内存的页,触发缺页;
- 换出的页转头又要用:因为工作集整体装不下,换出去的往往正是马上还要用的页——这和上一篇 FIFO 踢热页的窘境如出一辙,但这里是结构性的,再好的置换算法也救不了,因为根本就没有"该留的页"能全留下;
- CPU 空转等 I/O:进程绝大部分时间卡在等磁盘调页上(磁盘比内存慢几个数量级),真正用于计算的时间极少,CPU 利用率反而很低;
- 致命的误判:这是循环的关键一环——调度器看到 CPU 空闲,按常规逻辑会认为"系统还有余力,多调入几个进程来提高 CPU 利用率";
- 火上浇油:新进程进来也要瓜分物理内存,导致每个进程的驻留集被挤得更小,工作集装不下的程度更严重——回到第 1 步,循环加剧。
这就是抖动最阴险的地方:它有一个正反馈。系统试图通过"多跑进程"来提升看起来很低的 CPU 利用率,结果恰恰是这个善意的举动把所有进程的内存挤得更紧,让抖动雪上加霜。系统不是平滑变慢,而是断崖式崩塌。
抖动不是"偶尔缺一次页"的小毛病,而是一个会自我强化的系统级失控状态。它的恐怖在于正反馈——你以为 CPU 闲着是好事,加负载却让它彻底瘫痪。
三、为什么 CPU 利用率低,反而是危险信号
第二节里那个"CPU 利用率低 → 调度器加进程"的误判,值得单独拎出来讲,因为它直接颠覆了一个常见直觉。
我们通常认为:CPU 利用率低 = 系统空闲 = 还能多干活。在计算密集的正常场景下这没错。但在抖动场景下,CPU 利用率低的真正原因是——所有进程都在排队等磁盘 I/O,没人能拿到要算的数据。CPU 不是"闲得没活干",而是"有活干但数据还在磁盘上、取不过来"。
这两种"CPU 利用率低",表象一样,本质天差地别:
- 健康的低利用率:任务少,加任务能提升吞吐;
- 抖动的低利用率:内存瓶颈,加任务会让吞吐崩溃。
所以排查线上问题时,绝不能只看 CPU 利用率。必须结合缺页率(尤其是 major fault)、磁盘 I/O 繁忙度、swap 换入换出速率一起看。如果 CPU 低、但缺页和 swap I/O 高企,那几乎可以断定是抖动,而不是"系统有余力"。
看到 CPU 空闲就加并发,是把系统推下悬崖的最常见操作。在内存吃紧时,"CPU 闲"恰恰是抖动的症状,不是余力的证明。
四、工作集模型:从机制层面给出解药
既然抖动的根源是"驻留集装不下工作集",那解决思路就清晰了——想办法让每个进程的驻留集,至少能装下它的工作集。这正是 工作集模型(Working Set Model) 的核心思想,由 Peter Denning 提出。
它的机制是这样的:
- 定义一个时间窗口 Δ(比如最近访问的 10000 次内存引用,或最近一段时间);
- 一个进程的工作集 W(t, Δ),就是在
[t-Δ, t]这个窗口内被它访问过的页的集合; - 这个集合的大小,叫工作集大小,它近似反映了"进程此刻真正需要多少物理内存才能跑得顺"。
工作集模型给操作系统提供了一个可操作的决策依据:
- 估算每个进程的工作集大小(通过统计窗口内访问的页);
- 只在物理内存装得下所有活跃进程的工作集之和时,才允许它们同时运行;
- 如果所有进程的工作集之和超过了物理内存,说明系统注定要抖动——这时正确的做法不是"多跑进程",而是反过来:挂起(换出)一部分进程,把它们的内存整个让出来,保证留下的进程驻留集都能装下各自的工作集。
注意第 3 步和抖动循环里那个致命误判是完全相反的方向:抖动时调度器想"加进程",工作集模型说"减进程"。这正是工作集模型的价值——它把"该不该再放进程进来"这个决策,从看 CPU 利用率(会被骗)改成了看工作集与物理内存的关系(看到了本质)。
这种"宁可少跑几个、也要让在跑的都跑顺"的策略,就是操作系统里的负载控制(load control)。它情愿牺牲一点并发度,换取系统整体不崩塌。
工作集模型把抖动从"事后救火"变成"事前预防":先估出每个进程要多少内存,量入为出地决定放多少进程进来,从源头上不让工作集之和超过物理内存。
五、工作集思想的延伸:局部性是这一切的根
退一步看,工作集之所以是个"小集合"、之所以这套模型能成立,靠的还是上一篇反复强调的局部性原理。
正因为程序在一段时间内只集中访问少数页(局部性),工作集才会远小于整个虚拟空间,物理内存才装得下它,虚拟内存才显得优雅。而抖动,本质上就是局部性假设的崩塌:
- 当程序的访问模式突然变得分散(比如在远大于内存的数据上随机访问),活跃页的数量暴涨,工作集急剧膨胀;
- 一旦工作集膨胀到超过物理内存,局部性带来的"少数页命中"红利消失,缺页变得密集,抖动随之而来。
所以"工作集大小"和"局部性强弱"是一枚硬币的两面:局部性越好,工作集越小、越稳定,越不容易抖动;局部性越差,工作集越大、越飘忽,越容易击穿物理内存。理解到这一层,你就把虚拟内存(靠局部性)、置换算法(赌局部性)、抖动(局部性崩塌)这三篇彻底串成了一条因果链。
六、和工程实践 / SRE 的联系
抖动是后端和 SRE 几乎一定会遇到的真实故障,这一篇的知识能直接救场。
- 延迟飙升的真凶常是抖动,不是"内存小":线上服务内存吃紧时,P99 延迟突然爆炸式上涨、且加机器/加并发反而更糟,典型就是进入了抖动。此时该做的是降并发、减小驻留数据、扩内存,而不是继续加压。
- swap 是抖动的放大器:服务器开了 swap,内存不足时系统把页换到磁盘,频繁的 swap in/out 就是抖动的物理表现。这正是很多生产环境关闭 swap 或调低 swappiness 的原因——宁可早点 OOM Kill 一个进程,也不愿全系统陷入抖动泥潭。
- 缓存/批处理规模不能无限放大:把缓存设得过大、批处理一次拉取过多数据,都会让工作集超过可用内存,把命中变成缺页。工作集模型告诉你:容量评估要看活跃集大小,不是数据总量。
- 容量规划要按工作集,不按总数据量:评估一个服务要多少内存,关键是它的热数据/活跃工作集有多大,而不是它管理的数据总量。很多 OOM 和抖动事故,源于按总量而非工作集来估容量。
- 监控要看组合信号:单看 CPU 会被骗。盯
major page fault速率、si/so(swap in/out)、磁盘%util,三者齐高 + CPU 低,就是抖动的指纹。
七、动手做一个小实验:亲手制造一次局部性崩塌
理解抖动最直观的方式,是写两个访问模式不同的程序,亲眼看局部性如何决定性能。
- 好局部性版本:开一个不大的数组(远小于内存),在它上面反复循环做累加,跑很多轮。工作集很小,全程命中内存;
- 坏局部性版本:开一个远大于物理内存的数组,对它做完全随机的访问(每次跳到随机下标)。工作集 ≈ 整个数组,远超物理内存;
- 用
/usr/bin/time -v或vmstat 1同时观察两者:对比耗时、Major page faults数量、以及vmstat里的si/so(swap)和bi/bo(块设备 I/O)。坏局部性版本会让缺页和磁盘 I/O 飙升、CPU 利用率反而下降——你会亲眼看到一次小规模抖动; - 进阶:在内存受限的容器里(设个较小的 memory limit)跑坏局部性版本,观察它如何触发剧烈 swap 甚至 OOM。
跑完这个实验,第二节那张恶性循环图、第五节"局部性崩塌"就从文字变成了你监控面板上跳动的真实曲线。
学习这一部分最容易踩的坑
1. 把抖动当成"偶尔缺页慢一下"
偶尔缺页是虚拟内存的正常代价;抖动是系统级、持续性、自我强化的失控状态。二者量变到质变,差别是根本性的——抖动会让系统几乎无法推进任何有效计算。
2. 看到 CPU 利用率低就加并发
抖动时 CPU 低是因为大家都在等磁盘 I/O,不是有余力。此时加进程会瓜分内存、加剧抖动。CPU 低 + 缺页/swap 高,是危险信号而非加压信号。
3. 混淆工作集和驻留集
工作集是进程"当前真正需要"的页集合(由程序行为决定);驻留集是"实际分到物理内存"的页集合(由系统分配决定)。抖动 = 驻留集装不下工作集。搞混这两者,就说不清抖动的根因。
4. 按数据总量而非工作集估容量
服务要多少内存,取决于活跃工作集,不是它管理的数据总量。按总量估往往过松或过紧,按工作集估才贴近真实需求,也才能预防抖动。
总结
虚拟内存并不保证一切都好用,只有当活跃工作集能被合理承载时,它才会表现出优雅的一面。把机制和结论一起带走:
- 三个集合:虚拟空间(逻辑全部)⊃ 工作集(当前真正需要);驻留集(实际分到的物理页框)。正常态要求驻留集 ⊇ 工作集;
- 抖动 = 驻留集装不下工作集,进而陷入"频繁缺页→等 I/O→CPU 空转→误加进程→内存更紧"的自我强化恶性循环;
- 抖动时 CPU 利用率低是危险信号(在等 I/O),绝不能据此加并发,否则把系统推下悬崖;
- 工作集模型用时间窗口估算进程真正需要的页集合,据此做负载控制——工作集之和超过物理内存时,宁可挂起部分进程,也不让所有进程一起抖动;
- 抖动的根是局部性崩塌,它把虚拟内存、置换、抖动三篇串成一条因果链;
- 工程上:延迟飙升多因抖动而非"内存小",监控要看 缺页率 + swap + 磁盘 I/O 的组合,容量要按工作集而非数据总量来估。
至此,内存管理的主线(地址映射 → 虚拟内存 → 性能边界)就完整了。当你能在监控图上一眼认出抖动的指纹、并知道该减并发而不是加并发,这部分就真正属于你了。下一篇,我们离开内存,进入持久化存储的世界——文件系统。
参考资源:
- 《现代操作系统》(Andrew S. Tanenbaum)虚拟内存与抖动章节
- 《操作系统导论》(Operating Systems: Three Easy Pieces)相关章节
- Denning, P. - The Working Set Model for Program Behavior
- 《深入理解计算机系统》虚拟内存章节