1. 第三章的难点不在概念在地址这两个字翻到计算机操作系统第三章的时候多数人第一反应是概念我都认识题就是不会做。页面、页表、段、块、缺页中断这些词读起来毫无障碍课本上的图也画得清清楚楚可一到给定逻辑地址求物理地址、或者算有效访问时间手就僵住了。我自己第一次学这一章时也栽过同样的跟头后来带过几届同学做课程设计发现问题高度集中大家把第三章当成一堆名词来背却没有在脑子里建立一条完整的地址变换链路。这一章在不同教材版本里落点略有差异主流写法基本都在存储管理/内存管理上从地址空间、装入与链接讲起到连续分配、离散分配分页、分段、段页式再到虚拟内存、请求调页、页面置换、抖动与工作集。也有一部分教材把处理机调度与死锁放在第三章但内存管理这条线的概念密度和计算量最大考场上最能拉开差距的也在这里。这篇内容就以内存管理这条主线来写其他版本的第三章相关问题后面会单独说明。适合谁看正在学这门课、准备考试的人做课程设计需要自己实现一个页面置换模拟器的人工作几年后回头补底层知识、想搞明白为什么进程内存看起来那么大、物理内存却只有那么点的人。三类人关注点不同但共同的基础都是同一个——你得先能手动把一次地址转换走通。1.1 一道地址换算题暴露的三个认知断层拿一道最朴素的题开场。某系统页面大小为 4KB逻辑地址空间 32 位某进程的页表中页号 3 对应的物理块号是 0x1C。求逻辑地址0x00003A2F对应的物理地址。这道题的完整流程应该是先把逻辑地址拆成页号和页内偏移再查页表把页号换成物理块号最后拼回物理地址。具体算页面大小 4KB 2^12所以页内偏移占低 12 位逻辑地址0x00003A2F低 12 位0xA2F就是偏移剩下高位0x3就是页号页号 3 → 物理块号 0x1C物理地址 0x1C 12 | 0xA2F0x1CA2F三行完事但真正卡住人的地方不在这三行而在三个隐性的认知断层上。第一个断层不知道页号占几位是怎么推出来的。很多人是死记4KB 对应 12 位换个页面大小比如 1KB 或 8KB 就懵。正确的心智模型是页内偏移的位数 log2(页面大小)因为一个页面里要能寻址到每一个字节。32 位地址减去偏移位数剩下的就是页号位数。页面大小 1KB → 偏移 10 位 → 页号 22 位页面大小 8KB → 偏移 13 位 → 页号 19 位。这个换算必须变成肌肉记忆。第二个断层分不清逻辑地址和物理地址到底谁长谁短。在单级分页里两者的位数其实是一样的都是 32 位只是高位那段从页号变成了块号。很多人以为虚拟地址空间比物理地址空间大这个印象来自虚拟内存的容量概念而不是地址的位宽。要区分清楚地址位宽决定寻址范围页表项里存的是块号块号的位数由物理内存块总数决定。第三个断层不知道页表项里除了块号还有什么。课本图里常常只画一个块号格实际页表项还包含有效位在不在内存、修改位脏位、访问位置换算法要用、保护位读/写/执行权限。这些位在后面的请求调页和置换算法里全都是主角如果一开始就忽略后面讲到为什么置换算法需要一个访问位时就会觉得突兀。提示拿到任何一道地址转换题第一步永远是画一条地址位图把哪几位是页号、哪几位是偏移标出来再动手算。跳过这一步直接心算错位是最常见的失分点。1.2 局部性原理后面所有设计的底层依据第三章后半段那一堆机制——请求调页、工作集、置换算法、TLB——看起来各说各的其实全部挂在同一个前提上程序访问内存具有局部性。如果程序是真随机访问内存的虚拟内存这套东西一天都活不下去因为缺页会密集到系统无法前进。局部性分两层。时间局部性指刚被访问过的数据很可能马上又被访问循环变量、循环体指令、热点配置对象都是典型。空间局部性指刚被访问的位置附近的地址很可能马上被访问数组遍历、结构体连续访问、指令顺序执行都是典型。这两条性质在硬件层面被 TLB 和 Cache 放大利用在操作系统层面被页面置换算法利用。理解了局部性很多看起来不讲道理的设计就讲得通了。为什么页面大小不能太小页面太小页面数量暴涨页表本身就吃掉大量内存而且 TLB 能覆盖的地址范围变小命中率下降。为什么页面大小不能太大页面太大一个页面里可能装了很多其实用不到的数据内部碎片变大一次缺页要读入的数据量变大换入换出开销上升。所以页面大小是在页表开销、TLB 覆盖率、内部碎片、I/O 效率四者之间折中主流系统停在 4KB 附近大页机制在特定场景下用 2MB 甚至 1GB 的页面来提升 TLB 覆盖范围。再比如为什么置换算法里最近最少使用比先进先出更合理因为时间局部性告诉我们最近被频繁访问的页面在接下来一段时间很可能还会被访问而最早进来的页面大概率已经是冷数据。这个推理链条如果在脑子里建立起来你就不需要死记LRU 比 FIFO 好而是能自己推出来。注意局部性原理是统计意义上的不是绝对规律。那些故意按大步长随机访问大块内存的程序比如某些图算法、随机哈希探测局部性极差实测吞吐会掉得很难看。这也是为什么高性能计算里特别强调数据布局——本质上是在给操作系统和硬件制造局部性。2. 一条访存指令走过的地址变换链路要把第三章学扎实最好的办法是把自己塞进CPU 里跟着一条mov eax, [0x40001000]指令走完整个流程。这一节把链路一段一段拆开每一段都补充课本上一笔带过的细节。起点是 CPU 发出的逻辑地址也叫虚拟地址。这个地址先被送进MMU内存管理单元MMU 做的第一件事不是查页表而是先查 TLB。TLB 是一小块高速相联存储器里面缓存的是页号 → 块号的映射容量通常几十到几百项。命中就直接拿到块号拼出物理地址不命中才真正走多级页表一路读内存。这个顺序至关重要因为TLB 命中与否直接决定了平均访问时间而平均访问时间正是考试里最常考的计算。如果 TLB 不命中MMU 根据页表基址寄存器找到页表用页号去索引。如果页表项的有效位为 0说明这个页面不在内存里硬件触发缺页异常控制权交给操作系统。操作系统此时要做的事情相当重在空闲块链里找一个物理块没有就触发置换、把页面从外存读进来、更新页表项、如果被换出的页面被修改过还要写回外存、最后重新执行那条被中断的指令。整个过程里进程是被挂起的但系统还在跑别的进程——这就是虚拟内存能做到用少量物理内存支撑大地址空间的原因。2.1 分页地址的拆分与手算全过程继续用具体数字把流程钉死。设 32 位地址、页面大小 4KB、页表项大小 4B、采用两级页表页目录号 10 位、页表索引 10 位、页内偏移 12 位。逻辑地址0x00403ABC的二进制拆解部分位范围取值十六进制页目录号31–220x001页表索引21–120x003页内偏移11–00xABC计算过程0x00403ABC 22 1所以页目录号是 1(0x00403ABC 12) 0x3FF 0x003页表索引是 30x00403ABC 0xFFF 0xABC偏移是 0xABC。接着查页目录第 1 项得到二级页表的物理基址假设是0x2A000再查该二级页表第 3 项得到物理块号假设是0x1F5最终物理地址 0x1F5 12 | 0xABC0x1F5ABC。整个过程的关键点在于每一级查表都要访问一次内存。两级页表意味着 TLB 不命中时要访问 3 次内存页目录、二级页表、实际数据这就是多级页表提升空间效率但牺牲时间效率的具体体现也是 TLB 存在的全部理由。提示手算时建议把每个中间结果都写下来尤其是页目录基址这种题目给的和算出来的容易混淆的量。我见过太多人把页目录号当成页目录项的物理地址直接拿去加偏移。2.2 多级页表为什么能把 4MB 压到几KB单级页表的问题很直观32 位地址、4KB 页面共有 2^20 个页表项每项 4B页表本身就要4MB。如果系统里有 100 个进程光页表就占 400MB这还没算上真正给进程用的内存。更荒唐的是绝大多数进程根本没用到完整的 4GB 地址空间页表里大量表项是空的这些空表项全是浪费。多级页表的核心思想是按需分配。把页号拆成两段一级页目录永远在内存里10 位 → 1024 项 → 4KB二级页表按需创建。一个进程如果只用了 4MB 数据1024 个页面它只需要 1 个二级页表就能覆盖一个二级页表管理 1024 个页面总开销是 4KB页目录 4KB二级页表 8KB相比 4MB 压缩了 500 倍。这个压缩比要在理解上到位不是说多级页表一定比单级省内存而是多级页表把必须常驻的页表内存和实际使用的地址范围关联起来了。地址空间用得越稀疏多级页表的优势越明显。反过来如果进程把 4GB 地址空间全用满多级页表的总开销反而比单级页表多一点点多了一层页目录。实际系统里还有更省的办法倒排页表以物理块为索引每个物理块一项和多级页表的组合以及 64 位系统下常见的四级甚至五级页表。这些扩展不是本章重点但知道多级页表不是唯一解有助于理解设计空间。2.3 TLB命中率如何折算成有效访问时间有效访问时间EAT的计算是第三章最标准的计算题型也是错误率最高的一类。核心思路只有一句话按概率加权求期望。假设 TLB 访问时间 10ns内存访问时间 100nsTLB 命中率 90%采用单级页表。那么命中时访问 TLB10ns 访问内存取数据100ns 110ns概率 0.9不命中时访问 TLB10ns 访问内存查页表100ns 访问内存取数据100ns 210ns概率 0.1EAT 0.9 × 110 0.1 × 210 99 21 120ns如果没有 TLB单级页表下每次访存都要 200ns查页表 100 取数据 100有了 TLB 后降到 120ns加速比约 1.67。换成两级页表、命中率还是 90%不命中的代价变成 10 100 100 100 310nsEAT 0.9 × 110 0.1 × 310 99 31 130ns。再考虑缺页。设缺页率为 p缺页处理时间 8ms含磁盘 I/O约 8,000,000ns命中 TLB 时的数据访问按 110ns 算。这里要注意缺页的路径是先在 TLB 和页表里都找不到然后才触发所以严格写法是EAT ≈ (1 - p) × 110 p × 8,000,000当 p 0.0001万分之一时EAT ≈ 110 800 910ns比不缺页时慢了 8 倍多。这个数字很能说明问题缺页率的容忍度极低。想让系统性能不明显下降缺页率通常要控制在十万分之一量级。这也是后面讲抖动时缺页率一旦起来就刹不住的量化依据。常见错误有三个一是把 TLB 访问时间漏掉不命中时只算两次内存二是在多级页表下少算一层内存访问三是把缺页处理时间当成加上去而不是替换掉。做题时建议先写清楚命中路径和不命中路径各自的完整步骤再套概率。3. 分页、分段、段页式三种切法的取舍逻辑课本上这三种方式常常并列给出表格一摆就过去了导致很多人只记住分页有内部碎片、分段有外部碎片这两句话却说不清为什么要有两种。这个问题的答案要从切分的目的往回找。分页的目的是消除外部碎片、简化内存分配。把地址空间机械地切成固定大小的页物理内存切成同样大小的块任何一页都能装进任何一块分配就退化成找空闲块这一件事不需要连续空间也不会有明明有 100KB 空闲但最大连续块只有 30KB的尴尬。代价是最后一页装不满产生内部碎片平均浪费半页4KB 页面对应平均 2KB 浪费可以接受。分段的目的是贴合程序的逻辑结构。一个程序天然分成代码段、数据段、堆栈段每段长度不同段内连续、段间不要求连续。这样做的收益是保护和共享可以按段粒度做代码段设成只读可执行多个进程共享同一个库的代码段数据段各自私有。代价是段长不固定物理内存里会出现大小不一的空洞产生外部碎片需要紧凑compaction来回收而紧凑的代价是大量数据搬移。段页式是把两者拼起来先按逻辑分段每段内部再分页。这样既保留了分段在保护和共享上的优势又用分页消除了外部碎片。代价是地址转换要做两次先查段表拿到该段的页表基址再查页表拿到块号一次访存要访问三次内存段表、页表、数据TLB 的重要性进一步提升。3.1 碎片问题决定了方案边界碎片这件事值得单独拎出来说因为它是判断该选哪种方案的第一依据。内部碎片是分配给进程但用不上的空间。它只在分配单位大于需求时产生。分页的最后一页没填满就是内部碎片。段页式里段的最后一页没填满也是内部碎片。内部碎片的量可以估算平均每页浪费半页总浪费 ≈ 进程数 × 页面大小 / 2。外部碎片是没分配给任何进程、但因为太小或太散而无法使用的空闲空间。它只在需要连续空间时产生。连续分配和纯分段都会产生外部碎片。外部碎片的量难以精确估算取决于分配释放的历史最坏情况下可用内存总量足够但任何一块都不够大。这两种碎片不会同时消失。你可以用分页消掉外部碎片代价是接受内部碎片也可以接受外部碎片换取按需分配和逻辑结构。段页式的思路是把外部碎片压缩到段这一级再把段内部用分页消化实际上是把外部碎片的规模从整个内存缩小到段表能覆盖的范围但并没有完全消除——段本身仍然要求连续的虚拟地址空间。注意很多资料上说段页式没有外部碎片这个说法不严谨。段页式在物理内存层面确实没有外部碎片因为物理分配单位是固定大小的页但在虚拟地址空间层面段与段之间的空洞依然存在。面试或答题时如果被追问务必区分清楚是哪一层。3.2 三张维度对照把差异钉死光靠文字描述容易糊直接上表。下面这张表建议默写一遍比背十遍定义都管用。维度分页分段段页式划分依据物理固定大小逻辑长度可变先逻辑后物理地址空间一维一个地址二维段号段内偏移二维段号段内偏移碎片类型内部碎片外部碎片段内有内部碎片物理层无外部碎片对用户可见不可见可见程序员划分段可见页不可见共享与保护以页为单位粒度细以段为单位语义清晰段级保护页级分配地址转换次数1 次查页表1 次查段表2 次段表页表典型用途通用内存管理逻辑结构清晰的场景兼顾两者的系统关于对用户可见这一行需要解释一下。分页是操作系统为了管理内存自己做的切分程序员写代码时完全感知不到页的存在所以是一维地址。分段是把程序的逻辑结构暴露出来程序员或编译器需要显式指定段号所以是二维地址。这个差异在考试里经常以下列哪种方式用户可见的形式出现。还有一点容易被忽略段的长度是有限的因为段号通常只有十几位段内偏移也有限单段最大长度受限而分页的地址空间由地址位宽决定可以很大。这个差异在 64 位系统里体现得不明显但在早期 16 位、32 位系统里影响很大。4. 页面置换算法手算、代码验证与Belady异常置换算法这一块我建议的学习顺序是先手算一遍再写代码跑一遍最后对比两者的结果。手算能让你理解每一步为什么这样选代码能验证你有没有漏掉边界情况。很多同学手算全对但写不出代码或者代码能跑但考场上算错都是因为只做了一半。4.1 同一串引用、三种算法、三种缺页数统一用页面引用串1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5物理块数 3逐个走一遍。FIFO先进先出访问内存状态是否缺页11缺21,2缺31,2,3缺42,3,4缺换出 113,4,1缺换出 224,1,2缺换出 351,2,5缺换出 411,2,5命中21,2,5命中32,5,3缺换出 145,3,4缺换出 255,3,4命中缺页9 次缺页率 9/12 75%。LRU最近最少使用前三次同样缺页。第 4 次访问 4 时1 是最久未用的位置 1换出 1访问 1 时最久未用的是 2位置 2换出 2访问 2 时换出 3访问 5 时换出 4。之后再访问 1、2 命中访问 3 时最久未用的是 1换出 1访问 4 时最久未用的是 2换出 2访问 5 命中。缺页同样9 次。这个例子恰好 FIFO 和 LRU 打平用来做对比其实不太典型但它能说明一件事不是所有情况下 LRU 都优于 FIFO优势是在长序列的统计意义上体现的。OPT最佳置换访问 4 时未来引用中 1 在位置 5、2 在位置 6、3 在位置 103 最远换出 3访问 5 时1 在位置 8、2 在位置 9、4 在位置 114 最远换出 4访问 3 时1 和 2 之后都不再出现视为无限远任选其一换出访问 4 时同理。缺页7 次缺页率 7/12 ≈ 58.3%。OPT 是理论下界实际无法实现需要预知未来只用来做性能上界的参照。4.2 用Python把算法跑一遍再对照手算手算完之后强烈建议立刻写代码验证一遍。Python 几十行就能搞定而且能把边界情况暴露出来。from collections import deque, OrderedDict def fifo(refs, n): frames, q, faults set(), deque(), 0 for r in refs: if r in frames: # 命中队列不动这是 FIFO 的关键 continue faults 1 if len(frames) n: frames.add(r); q.append(r) else: frames.remove(q.popleft()) frames.add(r); q.append(r) return faults def lru(refs, n): frames, faults OrderedDict(), 0 for r in refs: if r in frames: frames.move_to_end(r) # 命中即刷新最近使用 continue faults 1 if len(frames) n: frames.popitem(lastFalse) # 弹出最久未用 frames[r] True return faults def opt(refs, n): frames, faults set(), 0 for i, r in enumerate(refs): if r in frames: continue faults 1 if len(frames) n: frames.add(r); continue far, victim -1, None for f in frames: # 找未来最远才会被用到的页 try: nxt refs.index(f, i 1) except ValueError: nxt float(inf) if nxt far: far, victim nxt, f frames.remove(victim); frames.add(r) return faults refs [1,2,3,4,1,2,5,1,2,3,4,5] for n in (3, 4): print(n, 帧:, FIFO, fifo(refs, n), LRU, lru(refs, n), OPT, opt(refs, n))跑出来的结果是3 帧: FIFO 9 LRU 9 OPT 7和4 帧: FIFO 10 LRU 9 OPT 8。和手算完全对得上说明逻辑没写错。写这段代码时有几个坑值得说一下。第一FIFO 命中时队列绝对不能动很多人的实现里一命中就把它移到队尾那就变成 LRU 了。第二LRU 的命中刷新必须用move_to_end普通字典在 Python 3.7 虽然保持插入顺序但不会自动刷新语义就错了。第三OPT 里未来不再出现要当成正无穷处理如果直接当成一个很大的常数遇到同样不再出现多个页时会因为相等而选错。提示把代码扩展成可视化版本把每一步的内存状态打印出来配合手算表格逐行对照。这个练习做一遍置换算法基本就不会再出错了。4.3 Belady异常给更多内存反而更差把上面代码里帧数从 3 改成 4FIFO 的缺页次数从 9 变成10。多了内存反而更慢这就是Belady 异常。这个现象的根源在于 FIFO 的决策依据进入时间和局部性原理没有关系。增加内存块后队列里保留的历史页变多某些本来会被及时换出的页反而赖着不走把即将被访问的页挤了出去。LRU 和 OPT 不会出现这个异常因为它们都属于栈式算法n 个帧时的驻留集合永远是 n1 个帧时驻留集合的子集。这个性质保证了帧数增加缺页次数不增。用代码验证 Belady 异常很直观for n in range(2, 7): print(n, fifo(refs, n), lru(refs, n))跑一下就能看到 FIFO 的缺页数偶尔出现台阶式上升而 LRU 单调不增。实际系统里几乎不用纯 FIFO道理也在这里。Clock 算法也叫二次机会算法是 FIFO 的改良版把页面排成环形链表每页带一个访问位置换时从指针位置扫描访问位为 1 就清 0 并跳过给一次机会为 0 就换出。它实现简单不需要维护复杂的链表、开销小是很多真实系统的选择。改进型 Clock还会考虑修改位优先换出既没被访问也没被修改的页因为这类页换出时不需要写回磁盘代价最低。这个访问位 修改位的两比特设计本身就是一道经典考点四种组合的换出优先级从高到低是0,0、0,1、1,0、1,1。理解逻辑也很自然——没访问过说明是冷数据没修改过说明不用写盘两者都满足的页换出成本最低。5. 抖动与工作集系统变慢时的连锁反应第三章最后一块内容是抖动thrashing。这个概念很多人只停留在一句话定义上进程频繁缺页导致系统吞吐量急剧下降。但要真正理解得把因果链条拆开看清楚。链条的起点通常是多道程序度过高。系统为了提升 CPU 利用率不断往里塞新进程每个进程分到的物理块越来越少。当某个进程的可用块数低于它当前的工作集活跃页面集合时它的每一次指令执行都可能缺页。缺页意味着要等磁盘 I/O进程被阻塞CPU 空闲出来系统又调入新进程新进程进一步挤占别人的块缺页率继续上升。这是一个正反馈一旦启动就很难自己停下来。链条的终点是CPU 利用率断崖式下跌。这个现象很有迷惑性监控上看到 CPU 空闲率很高运维的第一反应往往是负载不够加任务结果越加越糟。正确的判断依据是缺页率和换页 I/O 量只要这两个指标在飙升就说明问题不是 CPU 不够而是内存不够。5.1 缺页率失控的传导路径把这条路径拆成几个可观测的环节排查时就有抓手了。第一步缺页率上升。从/proc/统计里能看到进程的次缺页minor fault和主缺页major fault计数主缺页才涉及磁盘 I/O是真正致命的。如果主缺页次数在持续增长基本可以确定是内存压力。第二步页换入换出量上升。系统级的换页统计会明显抬头。这时候磁盘队列长度通常也在涨因为换页和正常文件 I/O 在抢同一块盘。第三步有效访存时间变长。用第 2.3 节的公式估算一下就明白了缺页率从万分之一涨到百分之一EAT 从几百纳秒直接到几十微秒慢了两个数量级。表现出来就是程序没死但每一步都像在泥里走。第四步CPU 利用率下降。注意是下降不是上升。因为进程都在等 I/O可运行的进程变少调度器找不到活干。第五步系统进入抖动状态。此时加进程只会更糟减进程才能缓解。这就是为什么操作系统需要负载控制机制——在抖动边缘主动挂起一部分进程把物理块让给剩下的进程。注意不要一看到 swap 用量高就判定抖动。swap 里存着大量冷页面是完全正常的只要换入换出速率低对性能没有实质影响。真正要看的是换页速率而不是换页总量。这个区分在实际排查中非常关键。5.2 工作集模型的近似与多道程序度控制工作集的概念是进程在最近 Δ 个时间单位内访问过的页面集合记作 WS(t, Δ)。这个集合的大小决定了进程当前需要多少物理块才能跑得顺。Δ 太小学不到完整的工作集Δ 太大则把冷页面也算进来浪费内存。实践中 Δ 通常取几千次访存的时间窗口。工作集模型的思想很优雅但精确实现几乎不可能因为需要记录每一次访存。实际系统用的是近似方案用访问位 定时采样。每隔一个时间片扫描所有页表项的访问位把访问位置 1 的页面记为本周期活跃然后清零。连续若干个周期都活跃的页面就属于工作集。用缺页率反馈。直接盯住每个进程的缺页率高于上界就给它加块低于下界就回收它的块。这个方案简单粗暴但有效被很多系统采用。用页面错误间隔。记录两次缺页之间的时间间隔很短说明块不够间隔很长说明块有余。这个思路在早期的分页系统里用得比较多。真正落地时操作系统一般不会精确计算工作集而是用缺页率作为间接指标。这个选择很务实缺页率是现成的、每次缺页都会更新的计数器不需要额外的采样开销。负载控制的具体做法是挂起部分进程把它们的页面全部换出物理块回收给活跃进程。挂起谁通常是优先级最低的、或者最近最不活跃的。恢复的时机是系统缺页率回落到阈值以下。这套机制在多用户系统里非常重要因为用户提交的任务数量是不可控的操作系统必须自己兜底。6. 把第三章搬到真实系统上验证课本上的模型是理想化的真实系统里有一堆工程细节页缓存、匿名页和文件页的区别、透明大页、内存回收的优先级、cgroup 限制。想把这些搞清楚最好的办法是动手做几个小实验。6.1 四个命令看清缺页与内存压力先给一套最小命令集日常排查够用了。# 1. 总体内存与 swap 概况关注 available 而不是 free free -m # 2. 内存明细重点看 MemAvailable、Dirty、Writeback grep -E MemTotal|MemFree|MemAvailable|SwapTotal|SwapFree|Dirty|Writeback /proc/meminfo # 3. 每秒采样重点看 si/so换入换出和 r/b运行队列与阻塞 vmstat 1 5 # 4. 单进程的缺页计数字段 10 是 minflt12 是 majflt awk {print minflt$10, majflt$12} /proc/PID/statfree -m里free那一列常年很小是正常的Linux 会把空闲内存拿去做页缓存真正要看的指标是available——它表示在不触发换页的前提下还能拿出多少内存给新程序用。这个用空闲内存做缓存的策略正是利用了空间局部性让文件读取尽可能命中缓存。vmstat的si/so是换入换出速率单位 KB/s。这两个值长期大于 0说明系统在持续换页内存确实紧张。但如果只是偶尔抖动一下不用紧张。/proc/PID/stat的第 10 和第 12 字段分别是次缺页和主缺页累计次数。次缺页是页面在内存里只是映射没建立代价很小主缺页是页面不在内存需要读盘代价很大。区分这两个数字是判断性能问题的第一步。还有一个组合很好用/usr/bin/time -v ./your_program它会输出Minor (reclaiming a frame) page faults和Major (requiring I/O) page faults两个数字跑一次就能知道程序的内存访问模式是否友好。6.2 手工制造一次major fault并观察光看数字不够直观自己造一次缺页印象最深。下面这段代码用 mmap 映射一个大文件逐页读一个字节然后立刻用madvise把这一页的映射丢掉强制下一次访问重新缺页。#include stdio.h #include stdlib.h #include fcntl.h #include unistd.h #include sys/mman.h #include sys/stat.h int main(int argc, char **argv) { int fd open(argv[1], O_RDONLY); if (fd 0) { perror(open); return 1; } struct stat st; fstat(fd, st); size_t len st.st_size; size_t pages len / 4096; char *p mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0); if (p MAP_FAILED) { perror(mmap); return 1; } volatile unsigned long sum 0; for (size_t i 0; i pages; i) { sum p[i * 4096]; /* 首次访问触发缺页 */ madvise(p i * 4096, 4096, MADV_DONTNEED); /* 丢弃映射制造下一次缺页 */ } printf(sum%lu pages%zu\n, sum, pages); munmap(p, len); close(fd); return 0; }编译运行gcc -O2 -o fault_demo fault_demo.c dd if/dev/zero of/tmp/bigfile bs1M count256 /usr/bin/time -v ./fault_demo /tmp/bigfile跑完看Major (requiring I/O) page faults这一项如果文件已经被读进页缓存这一项可能接近 0而次缺页数量会是几万。想看到真正的主缺页可以先用posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED)把文件从缓存里踢掉需要相应的权限或者换一个远大于物理内存的文件。这个实验的价值在于让你亲眼看到同一次读一个字节的操作代价可以从几十纳秒到几毫秒差五个数量级。理解了这一点你才会真正重视局部性也才会明白为什么数据库、搜索引擎这些系统在数据布局上要下那么大功夫。提示做这类实验一定要在虚拟机或容器里别在跑着重要服务的机器上折腾。内存压力测试很容易把系统拖进抖动恢复起来很麻烦。7. 复习第三章最容易翻车的几个点带过几轮之后我发现错误高度集中在几个固定位置。下面把它们列出来配纠正方式考前过一遍基本能避开大部分坑。序号高频错点正确理解1页号与页内偏移的位数搞反偏移位数 log2(页面大小)页号位数 地址位数 − 偏移位数2有效访问时间漏算 TLB 访问命中路径 TLB 一次内存不命中 TLB 每级页表一次内存 数据一次3认为分页有外部碎片分页只有内部碎片外部碎片出现在连续分配和纯分段4FIFO 命中时刷新队列命中不改变 FIFO 顺序改了就是 LRU5认为 Belady 异常所有算法都有只有非栈式算法如 FIFO才有LRU、OPT 不会6把缺页中断当成普通中断缺页是内部异常在指令执行期间发生一条指令可能触发多次第 6 点值得单独展开。缺页中断和普通中断的区别有三个产生时机不同普通中断在指令执行完之后检查缺页在指令执行过程中产生返回位置不同缺页处理完之后要重新执行被中断的那条指令而不是执行下一条次数不同一条指令可能跨越两个页面从而触发两次缺页。这三点在选择题里经常出现也经常有人记混。第 2 点再补一个细节如果题目没说 TLB只说了页表在内存里那就按每级页表一次内存访问来算。单级页表是 2 次页表 数据两级是 3 次三级是 4 次。这个加一的规律记牢了无论题目给几级都不会错。还有一类题容易被忽略给了缺页率和缺页处理时间求平均访问时间。这类题的关键是理解缺页处理时间是替换掉正常访问时间而不是额外叠加。正确的写法是(1-p) × 正常时间 p × 缺页时间其中缺页时间通常已经包含了磁盘读入的完整耗时。7.1 一道综合题的完整推演最后走一道覆盖面比较全的综合题把前面几节的知识串起来。题目某系统采用两级页表地址 32 位页面大小 4KB页表项 4B。TLB 访问时间 10ns内存访问时间 100nsTLB 命中率 95%缺页率 0.0001缺页处理时间 5ms。求平均访存时间。第一步确定地址结构。页面大小 4KB 2^12偏移 12 位两级页表各 10 位共 32 位正好。第二步算命中路径。TLB 命中10nsTLB 100ns取数据 110ns。注意命中时不需要查页表。第三步算不命中路径。TLB 不命中时10nsTLB 100ns页目录 100ns二级页表 100ns取数据 310ns。第四步算不缺页时的期望。0.95 × 110 0.05 × 310 104.5 15.5 120ns。第五步叠加缺页。缺页率 0.0001缺页时间 5ms 5,000,000ns。EAT (1 − 0.0001) × 120 0.0001 × 5,000,000 119.988 500 619.988ns ≈ 620ns对照一下如果不缺页EAT 只有 120ns加上万分之一的缺页率之后变成 620ns慢了 5 倍多。而如果缺页率涨到千分之一EAT 会变成约 5120ns慢 42 倍。这组数字是我认为第三章最值得记住的东西。它解释了为什么操作系统在内存管理上如此保守为什么宁可频繁回收页缓存也不愿意让系统进入换页状态也解释了为什么内存不够就加 swap这种做法在性能敏感场景下基本无效。我在实际带同学做课程设计时的体会是第三章的每一个机制都不是孤立的知识点它们是一条因果链上的不同环节。局部性原理决定了虚拟内存可行虚拟内存的代价是缺页缺页的代价催生了置换算法置换算法的失误导致抖动抖动的应对是工作集和负载控制。把这条链在脑子里串起来比背十张表格都管用。至于那些公式和手算说白了就是把这条链上的每一步量化——你理解了链条公式自然就写得出来反过来只背公式题目稍微变个形就全乱了。