第 1 篇:操作系统在做什么——三件事,和一个反复出现的手法
「管理硬件资源」是一句听不出内容的话。这一篇换个说法:操作系统在对你撒三个谎——CPU 是你的、内存是你的、你已经写进硬盘了。三个谎各配一段能跑的代码,让真实的 Linux 内核自己把谎戳破:20 个进程分 10 个核,每个都慢了 4 倍;两个进程在同一个内存地址上存着不同的值;write() 说写完了 300 MB,而内核同时报告这 300 MB 一个字节都没落盘。最后指出三个谎其实是同一招,以及这一招要付的两笔钱。
「管理硬件资源」是一句听不出内容的话。这一篇换个说法:操作系统在对你撒三个谎——CPU 是你的、内存是你的、你已经写进硬盘了。三个谎各配一段能跑的代码,让真实的 Linux 内核自己把谎戳破:20 个进程分 10 个核,每个都慢了 4 倍;两个进程在同一个内存地址上存着不同的值;write() 说写完了 300 MB,而内核同时报告这 300 MB 一个字节都没落盘。最后指出三个谎其实是同一招,以及这一招要付的两笔钱。
程序是磁盘上一坨死的字节,进程是这坨字节正在被执行的一次。这一篇讲清一个进程到底由什么构成(地址空间、寄存器、打开的文件),以及它为什么必须有「状态」这个概念——用 /proc 让内核自己报告:一个在算的进程是 R,一个在睡的进程是 S。最后指出「阻塞」才是整个虚拟化能成立的原因:如果没有人会停下来等,多任务根本不划算。
创建一个进程,最直觉的设计是「给我一个程序名,我给你一个新进程」。UNIX 偏偏拆成了两步:fork 复制一份自己,exec 把自己换成别的程序。这一篇先让你看 fork 一次调用两次返回,再证明这个看起来别扭的设计好在哪——shell 的重定向和管道,全都发生在 fork 和 exec 之间那道缝里。最后用 /proc 抓一个真正的僵尸进程,说明 wait 不是礼貌,是义务;并补上这套接口唯一一条从外面进来的路——信号,包括那个几乎人人都不知道的性质:连发十个信号,处理函数只会跑一次。
让程序直接在 CPU 上跑,速度最快,但会带来两个问题:它想干什么就干什么,而且它可以永远不还回来。这一篇给出两个答案——特权级和时钟中断,并且都让硬件自己作证:一段 C 程序执行特权指令,被 CPU 直接判 SIGILL;/proc/stat 显示你什么都没做的两秒里,硬件打断了 CPU 476 次、内核换了 738 次进程。最后算一笔上下文切换的账。
有了抢占的能力,接下来的问题是:下一个给谁?这一篇用真实测量证明三件事——同样三个作业,只换执行顺序,平均周转时间从 0.89 秒降到 0.45 秒;最优的那个策略需要预知未来,所以没法用;而让所有人轮流跑能把响应时间从 1.04 秒压到 0.00 秒,代价是平均周转反而变差。周转和响应是一对不可兼得的指标,这就是调度这件事永远没有标准答案的原因。
上一篇卡在一个死结上:最优策略要求预知作业要跑多久,而你不可能知道。MLFQ 的破法是不问未来问过去——用完整个时间片的降级,没用完的留在原地。这一篇给出它的五条规则,说明每一条都是被一个具体的失败逼出来的,然后用真实的 Linux 作证:同一个核上 20 个进程死命抢,交互型进程的唤醒延迟中位数是 0.05 毫秒,计算型进程一停就是 60 毫秒——差了一千二百倍,而调度器从没问过它们是谁。
MLFQ 只管排序,不管分配比例。当一台机器要同时跑三个客户的服务时,你需要的是「这个容器拿 20%」这种硬承诺。这一篇讲 Linux 的答案:CFS 不排队,它记账——每个进程一个虚拟时间,谁的最小谁先跑。我们把 /proc//sched 里的 vruntime 直接读出来,看到两个进程真实 CPU 用量差了 67 倍,虚拟时间却几乎一样,比值精确等于 1024/15 = 68.3。然后拆多核为什么必须每核一个队列,以及 cpu.max 这种硬上限为什么会制造延迟尖刺。
第 1 篇证明了两个进程能用同一个地址存不同的值。这一篇把那个地址空间整个摊开看:读 /proc/self/maps,看到代码段、堆、栈各在哪;用 C 证明堆往上长、栈往下长;关掉地址随机化让同一个程序三次跑出一模一样的地址,再打开它看三次全变。最后指出一件反直觉的事——申请 64 MB 之后,[heap] 一个字节都没涨。
这一篇落到 C,因为只有在这里才看得见内存管理的真相。五个经典错法逐个跑一遍,由 glibc 自己当裁判:漏掉两万次分配,RSS 从 1 MB 涨到 80 MB;用完之后接着用,读到的是后来别人写进去的数据;释放两次和释放一个栈地址,glibc 当场 abort。最危险的是第四个——往 16 字节的地里写 64 字节,程序一声不吭地跑完了,退出码 0,而 AddressSanitizer 一开就精确指出了那一行。
前面一直说「地址会被翻译」,这一篇讲第一代翻译机制怎么做的。从最简单的基址加界限讲起:一个加法一个比较,硬件就实现了重定位和保护。然后是它的问题——栈和堆之间那片空地也被算成了你的内存。分段把一对寄存器变成三对,问题解决了,但引来一个更麻烦的:外部碎片。中间用一个真实的 SIGSEGV 演示,让硬件报出精确到字节的越权地址。