建议先全部做完再看答案。 算错不要紧,重要的是看清楚错在哪一步。


一、进程与 API(第 2–3 篇)

1. 下面这段代码会打印几行 hi

fork(); fork(); printf("hi\n");

如果改成 fork() && fork(); 呢?

第一段:4 行。 第一个 fork 之后有 2 个进程,两个都执行第二个 fork,变成 4 个,每个打印一次。n 个连续的 fork 产生 2ⁿ 个进程。

第二段:3 行。 && 会短路:

  • 父进程:第一个 fork 返回子 pid(非 0,为真)→ 执行第二个 fork → 又分出一个。
  • 第一个子进程:fork 返回 0(假)→ 短路,不执行第二个 fork

所以:父 + 父的第二个子 + 第一个子 = 3 个

⚠️ 顺带:如果 stdout 被重定向到文件(块缓冲),第一段可能打印超过 4 行——fork 会把缓冲区里没刷出去的内容也复制一份。这就是第 1 篇踩过的坑。

2. 为什么 cd 必须是 shell 的内置命令,不能做成一个独立的程序?

因为工作目录是进程的属性。如果 cd 是独立程序,shell 会 fork + exec 它,它改的是那个子进程的工作目录,而子进程随即退出——shell 自己的工作目录一点没变。

⭐ 同理,exportulimitumask 也都必须内置。判据是:它改的是"进程自身的状态"还是"外部世界"。

3. 一个服务每秒 fork 100 次,从不 wait/proc/sys/kernel/pid_max 是 32768。多久之后它 fork 不动了?如果它改成每次 forkwait,但子进程要跑 10 秒呢?

第一问:约 328 秒(5 分半)。 每个僵尸占一个 pid,32768 ÷ 100 ≈ 328 秒。

第二问:不会耗尽,但会稳定占用约 1000 个 pid。 用一点排队论:到达率 100/秒,每个存活 10 秒,稳态在场数 = 100 × 10 = 1000。离 32768 还很远。

⭐ 区别在于僵尸永不消失(除非被 wait),而正常运行的进程会自己退出——一个是无界积累,一个是有界稳态


二、调度(第 5–7 篇)

4. 三个作业同时在 0 时刻到达:A 需要 10、B 需要 3、C 需要 3。分别算 FIFO(按 A、B、C 顺序)、SJF、RR(时间片 1,忽略切换开销)的平均周转时间平均响应时间

FIFO(A→B→C): 完成时刻 A=10, B=13, C=16 → 平均周转 = (10+13+16)/3 = 13.0 首次运行 A=0, B=10, C=13 → 平均响应 = (0+10+13)/3 = 7.67

SJF(B→C→A): 完成 B=3, C=6, A=16 → 平均周转 = (3+6+16)/3 = 8.33 首次运行 B=0, C=3, A=6 → 平均响应 = 3.0

RR(时间片 1): 轮转顺序 A B C A B C A B C A A A A A A A B 在第 8 个时间片结束时完成 → B=8;C=9;A 跑完全部 10 个片,最后一片在 t=16 → A=16 平均周转 = (16+8+9)/3 = 11.0 首次运行 A=0, B=1, C=2 → 平均响应 = 1.0

平均周转 平均响应
FIFO 13.00 7.67
SJF 8.33 3.00
RR 11.00 1.00

SJF 周转最优,RR 响应最优,两者不可兼得——第 5 篇的核心结论,这里是它的数字版。

5. 上题的 RR,如果每次切换要花 0.1 个单位,平均周转变成多少?切换开销占总时间的百分之几?

总工作量 16 个单位,共 16 个时间片,切换 15 次(最后一片跑完不用切),额外开销 1.5。

总时间从 16 变成 17.5。开销占 1.5/17.5 ≈ 8.6%。

各作业完成时刻大致按比例放大:A ≈ 17.5,B ≈ 8 + 0.7 = 8.7,C ≈ 9 + 0.8 = 9.8 平均周转 ≈ 12.0(原来 11.0)。

时间片 1、切换 0.1,你就有 8.6% 的 CPU 花在换人上——第 5 篇第五节那句话的数字版。时间片改成 10,开销降到不足 1%,但响应时间变成 10 倍。

6. 两个 CPU 密集进程,nice 分别是 0 和 10。在一个核上跑,各自能拿到多少比例的 CPU?如果是 0 和 5 呢?

权重表:nice 0 → 1024,nice 5 → 335,nice 10 → 110。

0 vs 10: 1024/(1024+110) = 90.3%9.7%,比值约 9.3:1。 0 vs 5: 1024/(1024+335) = 75.3%24.7%,比值约 3.1:1。

⭐ 记忆法:nice 每差 1,比例差约 1.25 倍;差 5 约 3 倍,差 10 约 9.3 倍,差 19 约 68 倍。

7. 一个 cgroup 设了 cpu.max = "10000 100000"(每 100 毫秒最多 10 毫秒)。一个请求需要 30 毫秒 CPU,机器上没有别的负载。这个请求的响应时间是多少?

约 210 毫秒。

  • 第 1 个周期:跑 10 毫秒(用完配额),被冻 90 毫秒
  • 第 2 个周期:跑 10 毫秒,冻 90 毫秒。
  • 第 3 个周期:跑 10 毫秒,完成。

总时间 = 10 + 90 + 10 + 90 + 10 = 210 毫秒。而它实际只用了 30 毫秒 CPU。

⚠️ ⭐ 这就是第 7 篇讲的 Kubernetes 延迟尖刺:平均 CPU 使用率只有 30/210 ≈ 14%,看起来"资源很空闲",而延迟是纯计算时间的 7 倍。


三、地址空间与分页(第 8–14 篇)

8. 32 位地址空间、4 KB 页、每个页表项 4 字节。朴素的单级页表要多大?如果是 48 位地址空间、4 KB 页、8 字节一项呢?

32 位: 页数 = 2³²/2¹² = 2²⁰ = 1,048,576,× 4 字节 = 4 MB。每个进程一份,勉强能接受——这就是早期系统真用单级页表的原因。

48 位: 页数 = 2⁴⁸/2¹² = 2³⁶,× 8 字节 = 2³⁹ = 512 GB。荒谬。

从 32 位到 48 位,地址空间大了 65536 倍,页表也大了 131072 倍(项还变宽了一倍)。这就是多级页表必须出现的原因。

9. 一个进程只用了三段内存:代码 8 MB(从 0 开始)、堆 16 MB、栈 1 MB(在地址空间顶端)。四级页表、每级 9 位、4 KB 页。它的页表实际占多少内存?

每级 512 项、每张表 4 KB。末级一张覆盖 512 × 4 KB = 2 MB。

末级表:

  • 代码 8 MB → 4 张
  • 堆 16 MB → 8 张
  • 栈 1 MB → 1 张 合计 13 张 = 52 KB

三级表: 一张覆盖 512 × 2 MB = 1 GB。三段各在不同的 GB 区间(代码堆可能同一个),算 2 张 = 8 KB

二级表: 一张覆盖 512 GB。代码堆在低端、栈在高端 → 2 张 = 8 KB

一级(顶级)表: 1 张 = 4 KB

合计约 72 KB。

⭐ 对比朴素方案的 512 GB——省了七百万倍。第 12 篇实测的 VmPTE = 196 kB 就是这个量级。

10. 一台机器的 TLB 有 1536 项,页大小 4 KB。它的"覆盖范围"是多少?如果程序的工作集是 32 MB,命中率大概是多少?换成 2 MB 的大页呢?

4 KB 页: 1536 × 4 KB = 6 MB

工作集 32 MB,如果访问是均匀随机的,命中率约 6/32 = 18.75%——非常糟。

2 MB 大页: 1536 × 2 MB = 3 GB。32 MB 的工作集只需要 16 个 TLB 项,命中率接近 100%

⭐ 这就是第 13 篇说的"大页让同样条数的 TLB 覆盖 512 倍内存"。⚠️ 但注意实际的 TLB 通常给大页留的项数远少于普通页的项数,所以别直接用 1536 去算。

11. 假设 TLB 命中要 1 纳秒(和访存并行,不额外计时),TLB 未命中要走四级页表(每级一次内存访问,100 纳秒),最后取数据 100 纳秒。TLB 命中率 99% 时,平均访存时间是多少?99.9% 呢?

命中: 100 纳秒(只取数据)。 未命中: 4 × 100(走表)+ 100(取数据)= 500 纳秒。

99%: 0.99 × 100 + 0.01 × 500 = 99 + 5 = 104 纳秒(比理想慢 4%) 99.9%: 0.999 × 100 + 0.001 × 500 = 99.9 + 0.5 = 100.4 纳秒(慢 0.4%)

命中率从 99% 到 99.9%,开销从 4% 降到 0.4%——十倍。这就是为什么 TLB 命中率是性能分析里的关键指标:它在 99% 附近极其敏感。

12. 一个程序按行遍历一个 4096 × 4096 的 int 二维数组(每个 4 字节),和按列遍历,性能能差多少?给出数量级的估算。

数组共 64 MB,每一行 4096 × 4 = 16 KB = 4 个页

按行遍历:顺序访问。每读一个缓存行(64 字节)能用 16 个 int;每 4096 字节换一次页。缓存和 TLB 都友好。

按列遍历:每访问一个元素就跳 16 KB(一整行)。于是:

  • 每一次访问都是一个新的缓存行(浪费 15/16 的带宽)。
  • 每一次访问都是一个新的页——列遍历一遍要碰 4096 个不同的页 = 16 MB,⭐ 超过第 10 题算的 6 MB TLB 覆盖范围,所以几乎每次都是 TLB 未命中。

两个因素叠加,实测通常差 5–20 倍。

⭐ 这道题把第 13 篇那个实测(工作集超过 TLB 覆盖范围就掉一个台阶)搬进了一段最普通的业务代码里。你的代码一个字没改,只是循环顺序换了。


四、置换(第 15 篇)

13. 三个物理帧,访问序列 1 2 3 4 1 2 5 1 2 3 4 5。分别算 FIFO、LRU、最优的缺页次数。

FIFO:

1→缺 2→缺 3→缺 4→缺(逐1) 1→缺(逐2) 2→缺(逐3) 5→缺(逐4)
1→中  2→中  3→缺(逐5) 4→缺(逐1) 5→缺(逐2)

9 次缺页。

LRU:

1→缺 2→缺 3→缺 4→缺(逐1) 1→缺(逐2) 2→缺(逐3) 5→缺(逐4)
1→中  2→中  3→缺(逐5) 4→缺(逐1) 5→缺(逐2)

9 次缺页。(这个序列上两者恰好相同。)

最优:

1→缺 2→缺 3→缺 4→缺(逐3,因为3最晚才用) 1→中 2→中
5→缺(逐4) 1→中 2→中 3→缺(逐1或2) 4→缺 5→中

7 次缺页。

⭐ 最优永远不劣于其它——它是第 15 篇说的那把"尺子"。

14. 用四个帧重算上题的 FIFO。缺页变多了还是变少了?

四帧 FIFO:

1缺 2缺 3缺 4缺 1中 2中 5缺(逐1) 1缺(逐2) 2缺(逐3) 3缺(逐4) 4缺(逐5) 5缺(逐1)

10 次缺页。

三帧是 9 次,四帧是 10 次——给了更多内存,缺页反而更多。

⭐ 这就是 Belady 异常。⚠️ LRU 不会有这个毛病,因为 LRU 有一个性质(栈算法):n 个帧时缓存里的内容,一定是 n+1 个帧时内容的子集。所以帧多了不可能命中变少。FIFO 不满足这条。


五、综合

15. 一个进程 malloc(1 GB) 但一个字节都不碰。它的 VmSizeVmRSSVmPTE 分别大约是多少?如果它顺序碰满这 1 GB 呢?

不碰:

  • VmSize 增加约 1 GB(地址空间登记了)。
  • VmRSS 几乎不变(没有物理页)。
  • VmPTE 几乎不变——⭐ 关键点:连页表项都还没建,只在 mm_struct 里登记了一个 vma。

碰满 1 GB:

  • VmSize 不变,还是 1 GB。
  • VmRSS 增加约 1 GB
  • VmPTE:1 GB / 2 MB = 512 张末级页表 × 4 KB = 2 MB,加上上层几张。

VmPTE 大约是 VmRSS 的 1/512(每 2 MB 数据配一张 4 KB 的表)。第 14 篇那个"散着摸"的实验之所以吓人,正是因为它把这个比例从 1/512 变成了 1:1。

16. 你的服务 RSS 一直涨,但 valgrind 报告零泄漏。列出至少三种可能的原因。

  1. 碎片(第 11 篇)。内存全 free 了,但空洞凑不出可以整页归还的连续空间。实测过:释放一半,RSS 一个字节没降。 换分配器(jemalloc/tcmalloc)或改分配模式(arena、对象池)。
  2. glibc 的缓存free 只把内存还给分配器,不还给内核。malloc_trim() 可以主动归还,但对付不了碎片。
  3. 页缓存/映射文件mmap 的文件页也算进 RSS。这不是"泄漏",是缓存。
  4. 线程栈。每个线程一份,线程池只增不减的话,RSS 会阶梯式上涨(第 22 篇实测:5 万线程 414 MB)。
  5. 不是 C 层的泄漏。Java 的堆外内存、Python 的循环引用、Go 的 goroutine 泄漏——valgrind 都看不见,因为从 C 的角度那些内存确实还被引用着

⭐ 通用思路:先分清"分配器手里"还是"内核手里"——/proc/<pid>/smapsmalloc_stats() 能把这条线画出来。