本篇位置 全课的地基。三大部分(虚拟化 / 并发 / 持久化)都从这里分出去
运行环境 容器里的 Linux(见总览页的一次性准备),Python 3

一、你已经在依赖三个谎言

你现在这台机器上,大概同时开着浏览器、编辑器、聊天软件、一堆后台进程。你写代码的时候,从来没考虑过它们。

这件事本身很奇怪。

你写 x = x + 1 的时候,没有担心过隔壁的浏览器会不会正好也在改这块内存。你写 for 循环的时候,没有担心过 CPU 会不会被别人抢走。你写 f.write(data) 的时候,默认它就存下来了。

这三件事你一件都没做过,但它们都成立了。

有人在替你做。而它替你做的方式,是对你撒了三个谎

谎一:这个 CPU 是你的。 谎二:这块内存是你的。 谎三:你已经写进硬盘了。

三个都是假的。这一篇就把它们一个一个戳破——不是靠我说,是靠让真实的内核自己说。

打个比方

你租了一间合租公寓,跟二房东签的约。

别嫌"二房东"这个词难听——他做的事,恰恰是你最需要的。他跟你说:这是你的房间,这是你的储物柜 3 号,你的快递我收着。 于是你搬进来那天,不需要认识另外五个租客,不需要跟他们开会协商谁几点用厨房,不需要知道热水器在哪。

代价是这三句话里每一句都掺了水:

  • “厨房这会儿是你的”——其实早高峰要排队。
  • “储物柜 3 号是你的”——每个人的柜子都叫 3 号,实际在不同的格子里。
  • “你的快递我收着”——它还在前台桌上,没进你屋。

操作系统就是那个二房东。 后面三节,把这三句掺水的话各自拆开看。

二、第一个谎:这个 CPU 是你的

谎言长什么样

写一段纯计算的程序。注意它从头到尾没有提到过"还有别人在跑"这回事

# spin.py
import sys, time
me = sys.argv[1]
t0 = time.time()
x = 0
for i in range(20_000_000):
    x += i
print(f"{me}: 算完了,用了 {time.time() - t0:.2f} 秒")

先一个人跑:

python3 spin.py 进程01
进程01: 算完了,用了 0.56 秒

现在让 20 个同时跑。这台机器有 10 个核(nproc 报的):

for i in 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20; do
    python3 spin.py "进程$i" &
done
wait
进程11: 算完了,用了 1.82 秒
进程06: 算完了,用了 1.84 秒
进程15: 算完了,用了 2.04 秒
进程03: 算完了,用了 2.05 秒
进程02: 算完了,用了 2.07 秒
...
进程12: 算完了,用了 2.34 秒
进程04: 算完了,用了 2.33 秒
进程20: 算完了,用了 2.34 秒

二十个都跑起来了,而且它们的代码里没有一个字提到彼此。 谎撒得很成功——每个进程都是照着"CPU 是我的"这个前提写的,而且都跑通了。

谎在哪里漏了

漏在时间上。

一个人跑 0.56 秒,二十个人跑要 2.2 秒左右。慢了差不多 4 倍。

⚠️ 这里有个细节值得停一下:20 个进程分 10 个核,你可能期望正好慢 2 倍。实际慢了 4 倍。多出来的那一倍不全是调度器的锅——这台机器的 10 个核并不一样快(Apple Silicon 有性能核和能效核两种)。一个人跑的时候你独占最快的那个核;二十个人跑的时候,大部分人分到的是慢核。

这类"你以为均分,其实不均"的问题,是第 7 篇的全部内容。

还有一个更隐蔽的漏点:它们不是同时结束的,也不是按启动顺序结束的。进程 11 比进程 20 早了半秒。你的程序里没有任何一行决定了这个顺序——决定它的是内核,而且它不欠你一个解释

⭐ 这就是虚拟化 CPU:每个程序都以为核是自己的,代价是它对"要花多久"失去了控制权。

三、第二个谎:这块内存是你的

这个谎比上一个大得多,也精巧得多。

谎言长什么样

os.fork() 会把当前进程复制一份。复制之后,两个进程各自跑自己的,我们让它们都去看同一个变量:

# same_addr.py
import os, sys, time

box = [0]
print(f"fork 之前:box 在地址 {hex(id(box))},值 {box[0]}", flush=True)

if os.fork() == 0:
    for _ in range(3):
        box[0] += 1
        print(f"  子进程 pid={os.getpid()}  地址 {hex(id(box))}{box[0]}", flush=True)
        time.sleep(0.4)
    sys.exit(0)
else:
    for _ in range(3):
        box[0] += 100
        print(f"  父进程 pid={os.getpid()}  地址 {hex(id(box))}{box[0]}", flush=True)
        time.sleep(0.4)
    os.wait()

id() 在 CPython 里返回的就是这个对象所在的内存地址,正好拿来用。)

fork 之前:box 在地址 0xffffa4479d40,值 0
  父进程 pid=1  地址 0xffffa4479d40  值 100
  子进程 pid=6  地址 0xffffa4479d40  值 1
  父进程 pid=1  地址 0xffffa4479d40  值 200
  子进程 pid=6  地址 0xffffa4479d40  值 2
  父进程 pid=1  地址 0xffffa4479d40  值 300
  子进程 pid=6  地址 0xffffa4479d40  值 3

把这段输出多看两眼。

两个进程,地址完全一样——都是 0xffffa4479d40,一位不差。但值完全不一样:一个是 100、200、300,另一个是 1、2、3。它们谁也没有覆盖谁。

如果地址是真的物理地址,这不可能发生。同一个地址不可能同时存着 300 和 3。

所以它不是物理地址。它是一个只在这个进程内部有效的编号。 两个进程都在用 0xffffa4479d40 这个编号,但内核把它们指到了两块不同的真实内存上。

这就是储物柜 3 号:每个租客都有一个 3 号柜,柜门上的号码是一样的,柜子是不同的。谁也打不开谁的。

顺便:那个 pid=1

注意父进程的 pid 是 1。在你自己的机器上,pid 1 是开机第一个进程,绝不可能是你刚敲的这个脚本。

因为我们在容器里跑。容器又把这一招原样用了一遍——这次骗的不是内存地址,是进程号。第 29 篇会拆开讲这件事。

⭐ 这就是虚拟化内存:每个进程都以为自己独占整个地址空间,代价是每一次内存访问都要先翻译一道。 那道翻译怎么做到不慢死,是第 12 到 14 篇。

四、第三个谎:你已经写进硬盘了

前两个谎是操作系统骗你。这一个是你自己愿意被骗——因为不骗你的话,你的程序会慢到没法用。

谎言长什么样

Linux 会在 /proc/meminfo 里如实报告一个数字:Dirty——已经写进了内存、但还没落到盘上的数据有多少。这个数字是内核自己算的,不是我编的。

# dirty.py
import os, time

def dirty_kb():
    for line in open("/proc/meminfo"):
        if line.startswith("Dirty:"):
            return int(line.split()[1])

MB = 300
print(f"写之前,内核报告的脏数据:{dirty_kb()} KB")

# 注意:必须写在容器自己的文件系统里(/tmp),不能写在挂进来的 /lab
fd = os.open("/tmp/bigfile", os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
chunk = b"x" * (1 << 20)
t0 = time.time()
for _ in range(MB):
    os.write(fd, chunk)
dt = time.time() - t0
print(f"write() 写完 {MB} MB 并返回,用了 {dt:.2f} 秒({MB/dt:.0f} MB/s)")
print(f"此刻内核报告的脏数据:{dirty_kb()} KB   <-- 这些还没落盘")

t0 = time.time()
os.fsync(fd)
print(f"fsync() 用了 {time.time()-t0:.2f} 秒")
print(f"fsync 之后的脏数据:{dirty_kb()} KB")

os.close(fd)
os.unlink("/tmp/bigfile")
写之前,内核报告的脏数据:136 KB
write() 写完 300 MB 并返回,用了 0.06 秒(4751 MB/s)
此刻内核报告的脏数据:307216 KB   <-- 这些还没落盘
fsync() 用了 0.06 秒
fsync 之后的脏数据:156 KB

⚠️ 如果你把文件路径改到挂载进来的 /lab,这个演示测不出任何东西——Dirty 一动不动。因为那不是容器里 Linux 的文件系统,是从宿主机透传进来的一层转发。这一点我第一次跑的时候就踩到了,正好说明一件事:代码跑通了不等于它证明了你想证明的东西。 这门课后面每一个演示,都会写清楚"到底是谁在裁判"。

谎在哪里漏了

三处证据,指向同一个结论:

  1. write() 返回时,Dirty 涨到了 307216 KB——正好是那 300 MB。内核自己在说:这些数据我收下了,但它们还在内存里。
  2. 写 300 MB 只花了 0.06 秒,折合 4751 MB/s。这个速度对一块盘来说不合理。它其实是内存的速度。
  3. fsync() 之后 Dirty 掉回 156 KB。 到这一刻,数据才真的下去了。

所以 write() 返回的含义不是“写进硬盘了”,而是"我记下了,回头会写"。这就是二房东那句"你的快递我收着"——签收了,但还在前台桌上。你要是这会儿断电,那 300 MB 就没了,而你的程序从头到尾没收到过任何错误

⭐ 这就是持久化的核心矛盾:为了速度,系统必须推迟真正的写入;而一推迟,“断电之后还是对的"就得另想办法保证。 那个办法叫日志,是第 27 篇。

五、三个谎是同一招

现在把三节并排放在一起看:

系统告诉你 实际情况 谎从哪漏
CPU 这个核是你的 每几毫秒被抢走一次 变慢、顺序不由你定
内存 这个地址是你的 只是个编号,随时可能被换出去 变慢、能被别人间接观测
硬盘 你已经写进去了 还在内存里 断电就没,且不报错

三行,一个模式:

在你和真实资源之间,加一层中间人。中间人对每个人都说"这份是你的”。

这一招有名字,叫虚拟化。而它之所以值得,是因为它把"协调"这件事从你身上拿走了

回到那间合租公寓:二房东最大的价值,不是他省了你多少房租,而是你搬进去那天不用认识另外五个人。你不需要跟他们开会决定谁几点用厨房——你只跟二房东打交道,他去协调。

⭐ 你写代码时从不考虑隔壁浏览器在干什么,是同一回事:中间人把 n 个人两两协调的活儿,变成了 n 个人各自和一个人打交道。

一个跟着出现的分工

再看一眼上面三行,每一行其实都能拆成两个问题:

  • 怎么做到——怎么把 CPU 从一个进程手里拿回来?(第 4 篇)
  • 该怎么选——拿回来以后,下一个给谁?(第 5、6、7 篇)

前者叫机制(mechanism),后者叫策略(policy)。操作系统里几乎每个话题都是这么切的:分页是机制,换出谁是策略;日志是机制,多久刷一次是策略。

看到一个新话题时,先分清它在讲机制还是策略。 机制通常只有一两种活下来了,策略往往至今没有标准答案——工业界还在换。这门课里凡是"至今没定论"的地方,我会直说。

六、这一招要付两笔钱

天下没有免费的中间人。二房东要抽成,而且他不是全能的。

第一笔:中间人本身要花时间。

每次访问内存都要把虚拟地址翻译成物理地址。如果每次都老老实实查一遍页表,程序会慢到不能用——第 13 篇的 TLB 就是专门为了把这笔钱省下来。 你会发现这门课里有惊人比例的篇幅,是在给某个中间层想办法降低开销。

第二笔:谎总会在某个地方漏出来。

这一篇已经漏了三次:程序莫名其妙变慢(漏在时间上)、断电丢数据且不报错(漏在故障上)。后面还会漏得更狠——第 30 篇的侧信道攻击,就是有人专门盯着漏点,从"你不该看到的东西"里把信息读出来。

所以这门课真正教的,是识别每一层的谎言,以及知道它在哪里漏。 记住机制的名字是最容易的部分。

七、小结

  • 操作系统只做三件事:把一个 CPU 变成很多个(虚拟化)、让很多人同时动同一份东西而不打架(并发)、把断电就消失的东西留下来(持久化)。
  • 三件事用的是同一招:加一层中间人,对每个人说"这份是你的"。中间人的价值在于把两两协调变成各自对接一个人
  • 每一招都有两笔账:中间层自己的开销,和谎言泄漏的地方。这门课一半篇幅在讲第一笔怎么省,另一半在讲第二笔漏在哪。
  • 遇到新话题先分清机制还是策略。机制通常收敛了,策略往往还在吵。
  • 别信"代码跑通了"。第四节那个演示第一次跑的时候一切正常,但什么都没证明——因为写错了地方。后面每个演示我都会写清楚谁在裁判。

思考题

  1. 第二节里,20 个进程分 10 个核,慢了约 4 倍而不是 2 倍。除了"核不一样快",还有什么会让它比 2 倍更慢?(提示:切换本身不是免费的。)
  2. 第三节证明了两个进程可以用同一个地址存不同的值。那么,两个进程有没有可能用同一个地址存同一份数据,而且改一个另一个能看见?如果可以,操作系统凭什么还敢说"这块内存是你的"?
  3. write() 返回不代表落盘,fsync() 才代表。那为什么系统不干脆让每个 write() 都直接落盘?(先估一下:如果第四节那 300 MB 每写 1 MB 就等一次盘,总共要多久?)
  4. 二房东这个比方在哪里会失效?想一个操作系统能做到、而二房东做不到的事。