| 本篇位置 | 全课的地基。三大部分(虚拟化 / 并发 / 持久化)都从这里分出去 |
| 运行环境 | 容器里的 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 的文件系统,是从宿主机透传进来的一层转发。这一点我第一次跑的时候就踩到了,正好说明一件事:代码跑通了不等于它证明了你想证明的东西。 这门课后面每一个演示,都会写清楚"到底是谁在裁判"。
谎在哪里漏了
三处证据,指向同一个结论:
write()返回时,Dirty涨到了 307216 KB——正好是那 300 MB。内核自己在说:这些数据我收下了,但它们还在内存里。- 写 300 MB 只花了 0.06 秒,折合 4751 MB/s。这个速度对一块盘来说不合理。它其实是内存的速度。
fsync()之后Dirty掉回 156 KB。 到这一刻,数据才真的下去了。
所以 write() 返回的含义不是“写进硬盘了”,而是"我记下了,回头会写"。这就是二房东那句"你的快递我收着"——签收了,但还在前台桌上。你要是这会儿断电,那 300 MB 就没了,而你的程序从头到尾没收到过任何错误。
⭐ 这就是持久化的核心矛盾:为了速度,系统必须推迟真正的写入;而一推迟,“断电之后还是对的"就得另想办法保证。 那个办法叫日志,是第 27 篇。
五、三个谎是同一招
现在把三节并排放在一起看:
| 系统告诉你 | 实际情况 | 谎从哪漏 | |
|---|---|---|---|
| CPU | 这个核是你的 | 每几毫秒被抢走一次 | 变慢、顺序不由你定 |
| 内存 | 这个地址是你的 | 只是个编号,随时可能被换出去 | 变慢、能被别人间接观测 |
| 硬盘 | 你已经写进去了 | 还在内存里 | 断电就没,且不报错 |
三行,一个模式:
在你和真实资源之间,加一层中间人。中间人对每个人都说"这份是你的”。
这一招有名字,叫虚拟化。而它之所以值得,是因为它把"协调"这件事从你身上拿走了。
回到那间合租公寓:二房东最大的价值,不是他省了你多少房租,而是你搬进去那天不用认识另外五个人。你不需要跟他们开会决定谁几点用厨房——你只跟二房东打交道,他去协调。
⭐ 你写代码时从不考虑隔壁浏览器在干什么,是同一回事:中间人把 n 个人两两协调的活儿,变成了 n 个人各自和一个人打交道。
一个跟着出现的分工
再看一眼上面三行,每一行其实都能拆成两个问题:
- 怎么做到——怎么把 CPU 从一个进程手里拿回来?(第 4 篇)
- 该怎么选——拿回来以后,下一个给谁?(第 5、6、7 篇)
前者叫机制(mechanism),后者叫策略(policy)。操作系统里几乎每个话题都是这么切的:分页是机制,换出谁是策略;日志是机制,多久刷一次是策略。
⭐ 看到一个新话题时,先分清它在讲机制还是策略。 机制通常只有一两种活下来了,策略往往至今没有标准答案——工业界还在换。这门课里凡是"至今没定论"的地方,我会直说。
六、这一招要付两笔钱
天下没有免费的中间人。二房东要抽成,而且他不是全能的。
第一笔:中间人本身要花时间。
每次访问内存都要把虚拟地址翻译成物理地址。如果每次都老老实实查一遍页表,程序会慢到不能用——第 13 篇的 TLB 就是专门为了把这笔钱省下来。 你会发现这门课里有惊人比例的篇幅,是在给某个中间层想办法降低开销。
第二笔:谎总会在某个地方漏出来。
这一篇已经漏了三次:程序莫名其妙变慢(漏在时间上)、断电丢数据且不报错(漏在故障上)。后面还会漏得更狠——第 30 篇的侧信道攻击,就是有人专门盯着漏点,从"你不该看到的东西"里把信息读出来。
⭐ 所以这门课真正教的,是识别每一层的谎言,以及知道它在哪里漏。 记住机制的名字是最容易的部分。
七、小结
- 操作系统只做三件事:把一个 CPU 变成很多个(虚拟化)、让很多人同时动同一份东西而不打架(并发)、把断电就消失的东西留下来(持久化)。
- 三件事用的是同一招:加一层中间人,对每个人说"这份是你的"。中间人的价值在于把两两协调变成各自对接一个人。
- 每一招都有两笔账:中间层自己的开销,和谎言泄漏的地方。这门课一半篇幅在讲第一笔怎么省,另一半在讲第二笔漏在哪。
- 遇到新话题先分清机制还是策略。机制通常收敛了,策略往往还在吵。
- 别信"代码跑通了"。第四节那个演示第一次跑的时候一切正常,但什么都没证明——因为写错了地方。后面每个演示我都会写清楚谁在裁判。
思考题
- 第二节里,20 个进程分 10 个核,慢了约 4 倍而不是 2 倍。除了"核不一样快",还有什么会让它比 2 倍更慢?(提示:切换本身不是免费的。)
- 第三节证明了两个进程可以用同一个地址存不同的值。那么,两个进程有没有可能用同一个地址存同一份数据,而且改一个另一个能看见?如果可以,操作系统凭什么还敢说"这块内存是你的"?
write()返回不代表落盘,fsync()才代表。那为什么系统不干脆让每个write()都直接落盘?(先估一下:如果第四节那 300 MB 每写 1 MB 就等一次盘,总共要多久?)- 二房东这个比方在哪里会失效?想一个操作系统能做到、而二房东做不到的事。