一个你从没注意过的谎言
你写过成千上万行代码。它们全都建立在一个你从未质疑过的前提上:这台机器是我的。这一章要说的是——这个前提是假的,而且是被精心伪造的。伪造它的那个程序,叫操作系统。
先看一段最无聊的代码
没有比这更朴素的程序了:
#include <stdio.h>
int main(void) {
printf("hello\n");
return 0;
}
你觉得它做了什么?把六个字节送到屏幕上。一件事。
实际上,从你按下回车到屏幕上出现那六个字母,机器做了十四件事,而其中八件发生在一个你完全无法进入的地方。
下面这台机器把那十四步拆开摆给你看。上半部分是你的进程能做的事,下半部分是只有内核能做的事,中间那道双线就是本书的主角。一步一步点下去,注意两件事:分界线被跨过了几次,以及每一步各花了多少时间。
点完你会发现一件有点扎心的事:真正把字节搬到屏幕上那一步,只占全程的一小部分。大头花在别的地方——把参数摆成内核认识的样子、跨过那道线、让内核逐项检查「你到底有没有资格干这件事」,然后再跨回来。
这本书就是关于那道线的。
三个你信了很多年的说法
「我的程序从头跑到尾」
你写了一个循环,它从第一次迭代跑到最后一次,中间没有断过——至少看起来是这样。
真相是:在你的循环执行期间,它每秒被强行打断几百到几千次。每一次打断,CPU 都跑去执行别人的代码——你的浏览器、系统的日志服务、某个后台索引进程——然后再回来。
你察觉不到,是因为每次打断时,你的全部状态(所有寄存器、程序计数器、栈指针)都被完整地保存下来,回来时又被完整地恢复。就像有人把书签精确地夹在你正在读的那个字上,把书拿走给别人读,再放回你手里,翻到那一页。你唯一能察觉的证据,是时间过去了。
负责这件事的,是调度器(卷 II)。
「我的变量在某个内存地址上」
你打印一个指针,得到 0x7ffd4a2b1c30。这个地址看起来无比具体。
但如果你的同事在他的机器上跑同一个程序,他会得到一个非常相似甚至完全相同的地址。你们俩的变量当然不在同一条内存上——那么这个地址到底是什么?
它是编的。你的程序里出现的每一个地址,都是「虚拟地址」——一个只在你这个进程内部有意义的编号。真正的物理内存地址长什么样,你这辈子都没见过,也没有任何办法见到。每次你访问内存,都有一个叫 MMU 的硬件在中间做翻译,把你编的那个地址翻成真的。
负责这件事的,是虚拟内存与页表(卷 III)。
「我打开了一个文件」
你写 open("data.txt"),拿到一个 3。你觉得这个 3 代表那个文件。
它不代表。它是一个数组下标——内核给你的进程维护了一张表,3 是这张表里的第 3 格。同一时刻,我的进程里的 3 可能指向一个网络连接,另一个进程里的 3 可能指向你的摄像头。这个数字本身什么信息都不携带。
你从来没有「拿到过文件」。你拿到的是一个号码,凭这个号码去求内核替你做事。
负责这件事的,是文件描述符与 VFS(卷 IV)。
操作系统的全部工作,是维持一个骗局:让每个进程都以为自己独占整台机器。
这个骗局有三重:时间上骗你独占 CPU(其实被切成片)、空间上骗你独占内存(其实一个真地址都没碰过)、接口上骗你独占设备(其实只拿到一个号码)。
三重假象的背后是同一个东西:一道线。线的上面是你,只能做「不会影响别人」的事;线的下面是内核,全机器只有一份,它知道所有真相。你每次想干点真事,都得跨过去——而每一次跨越都要付钱。
这本书就是把这三重假象逐一拆开,最后交给你一张价目表。
为什么非得骗你?
值得停下来想一想:这一切能不能不要?让每个程序直接用真的物理地址、直接跟硬盘控制器说话、自己决定什么时候让出 CPU——不是更简单更快吗?
这个方案真的存在过。DOS 就是这么干的,早期的嵌入式系统也是。而它带来的后果非常具体:
| 如果没有这个骗局 | 会发生什么 | 这本书在哪章解决它 |
|---|---|---|
| 大家用真的物理地址 | 你的程序写错一个指针,改掉的是别人的变量。而且每个程序都得知道自己被装在内存的哪一段——换台机器就得重新编译 | 卷 III |
| 程序自己决定何时让出 CPU | 一个死循环就能让整台机器躺下。你无法 kill 它,因为 kill 也是个程序,而它拿不到 CPU | 卷 II |
| 程序直接操作硬件 | 两个程序同时往硬盘写,扇区交错,文件系统结构当场损坏。而且每个程序都得自带一套硬盘驱动 | 卷 IV |
| 没有权限边界 | 任何一个程序都能读走你的密码、改掉系统文件。「安全」这个词失去意义 | 卷 I |
所以这个骗局不是为了骗你,是为了保护你——保护你不被别人搞垮,也保护别人不被你搞垮。它让你可以放心地写 int x = 5; 而不必操心这块内存会不会正巧是别人的,让你可以写一个死循环而不必担心整台机器陪你一起死。
你享受的每一份「简单」,都是这个骗局替你挡下来的复杂度。
「操作系统」(operating system)这个名字,来自它最早的职责:在批处理时代,程序员把打孔卡交给机房,由操作员(operator)一批批装载运行。后来这套流程被自动化成一个常驻程序,人们就管它叫「自动操作员的系统」。
换句话说,它一开始只是个排队员。「保护」和「假象」这两个后来的核心职责,是随着多任务和多用户的出现,被现实一点点逼出来的——每一层保护,背后都躺着一次真实的事故。
那道线,和它的价目表
这本书会反复回到一个动作:跨过那道线。它有三种发生方式,你会在卷 I 里逐一见到:
- 你主动下去——系统调用(第 3 章)。你需要内核帮忙:读文件、开进程、发网络包。
- 你被迫下去——异常(第 4 章)。你访问了一个还没准备好的地址、你除以了零。CPU 当场把控制权交给内核。
- 你被打断——中断(第 4 章)。跟你毫无关系:网卡收到了包、时钟走了一格。你的执行被强行暂停。
三种方式,同一个结果:CPU 从「执行你的代码」切换到「执行内核的代码」。而这件事有成本——保存状态、切换特权级、换栈、让缓存变冷。
一次什么也不做的系统调用,往返一趟大约 100 ns。
参照物:一次 L1 缓存命中约 1 ns,一次主存访问约 80 ns。也就是说——光是「下去再上来」这个动作本身,就值一次内存访问还多。它什么都还没帮你做。
这个数字会跟着你走完全书。每次你看到一段代码在循环里调系统调用,心里应该立刻响一声:这里每转一圈,就有一百纳秒扔在门口了。
全书每一章都会往这张价目表上添一行。到第 24 章,你会拿到完整的一张——从 1 纳秒到 8 毫秒,横跨七个数量级。
这本书怎么走
卷 I · 边界(你在这儿):把那道线本身说清楚。为什么必须有它、它长什么样、有哪三种方式跨过去。
卷 II · 分身:拆第一重假象。进程是什么、fork 为什么会返回两次、调度器凭什么决定谁跑、切换一次到底多贵。
卷 III · 幻境:拆第二重假象,也是全书最硬的一卷。地址是怎么被翻译的、缺页时发生了什么、malloc 底下藏着什么、以及「为什么 malloc 从不失败但你还是被 OOM 杀了」。
卷 IV · 万物:拆第三重假象。fd 的三层表、inode 与「删了文件磁盘没变空」、IO 一路要经过哪些层、一个线程怎么守住一万条连接。
卷 V · 邻里:进程之间怎么说话,以及——怎么把这个骗局再演一遍(那就是容器)。
卷 VI · 落地:把前面所有东西接回你的日常。一章排障对照表,一张完整价目表。
每一章都会以这样一节结尾,把当章的机制接回一个你可能真的遇到过的现象。这一章的是:
「为什么我的程序在容器里比在物理机上慢?」——这个问题至少有五个完全不同的答案,分别藏在第 8 章(CPU 配额怎么影响调度)、第 12 章(内存限制怎么变成颠簸)、第 18 章(存储驱动多加了一层)、第 20 章(网络多走了一跳)和第 22 章(cgroup 与 namespace 的区别)里。
在读完这本书之前,你只能靠试。读完之后,你会知道该先量哪一个。
这一章的一句话
你写的每一行代码,都活在一个被精心维护的假象里:你以为你独占整台机器。这个假象由一道硬件边界撑着,而每一次跨越边界都要付钱——这本书就是把假象拆开、把价目表交给你。
下一章,我们去看那道线本身:它在硬件上到底是什么,以及划线的标准为什么朴素得让人意外。