第 21 篇:死锁与并发 bug——不死锁的那些 bug 更常见
死锁很出名,但研究真实项目的并发 bug 会发现:死锁只占三分之一,另外三分之二是原子性违反和顺序违反——它们不挂死,只是偶尔算错,因此更难发现。这一篇先跑一个真死锁(一个线程 A→B,一个 B→A,两个都卡死;统一顺序后立刻正常),拆开它成立的四个必要条件,然后讲那三分之二,最后是 1997 年火星探路者号的优先级反转——它在火星上反复重启,原因是第 6 篇讲过的调度策略和这一篇的锁撞在了一起。
死锁很出名,但研究真实项目的并发 bug 会发现:死锁只占三分之一,另外三分之二是原子性违反和顺序违反——它们不挂死,只是偶尔算错,因此更难发现。这一篇先跑一个真死锁(一个线程 A→B,一个 B→A,两个都卡死;统一顺序后立刻正常),拆开它成立的四个必要条件,然后讲那三分之二,最后是 1997 年火星探路者号的优先级反转——它在火星上反复重启,原因是第 6 篇讲过的调度策略和这一篇的锁撞在了一起。
你在网络课上写的那个「一个连接一个线程」的服务器,为什么撑不住一万个连接?这一篇把账算清楚:五万个线程要 414 MB 内存和 412 GB 地址空间,而 epoll 盯着同样五万个连接只用 256 KB——差了一千六百倍。然后讲这条路的代价(回调地狱、一处阻塞全线卡死),协程怎么把它还原成看起来同步的代码,以及 io_uring 为什么要把系统调用本身也批量化。
CPU 比外设快几个数量级,所以「怎么等」这件事本身就是设计。这一篇讲三次减负:中断让 CPU 不用一直问、DMA 让 CPU 不用亲自搬数据、而当设备快到中断反而成为负担时,又转回了轮询。中间用一个实测把「每次操作的固定开销」拍出来——同一个 64 MB 的文件,一次读 1 字节要 17.23 秒,一次读 1 MB 只要 0.00 秒,而每次系统调用的成本都是 260 纳秒左右。
机械硬盘的随机读比顺序读慢几百倍,这个数字塑造了此后三十年的文件系统设计。SSD 拿掉了机械臂,却带来一条新的怪规则——能按页读写,但只能按块擦除,于是有了 FTL、垃圾回收和写放大。这一篇也如实交代一件事:这个容器跑在虚拟磁盘上,我测出来顺序 2.2 微秒、随机 2.7 微秒,这个数字不是磁盘的性质,是虚拟化的性质。所以物理部分用可验算的算术讲,不拿测不准的数字充数。
文件名不在文件里。这一篇用几个实验把这句话拆开:两个名字指向同一个 inode,删掉其中一个,内容完好无损;而 rm 一个正在被打开的 200 MB 文件,ls 里立刻消失、链接数变成 0,但它仍然占着 200 MB 磁盘、fd 还能照读——直到最后一个 fd 关闭。搞清楚「名字、inode、打开的文件」这三层,rm 为什么不释放空间、日志切割为什么要 reopen,这类问题就都不用背了。
一块盘就是一长串编号的块,文件系统要在上面摆出目录树。这一篇把最小的那套结构讲清楚——超级块、位图、inode 表、数据块——然后用实测证明两件事:一个 1 字节的文件占掉 4096 字节(分配以块为单位),而一个「大小 1 GB」的文件可以只占 4096 字节(它是一张从偏移到块的映射表,不是一段连续的地)。再加上目录本身随文件数增长的大小,你能直接看见目录就是一个装着表的文件。
往文件里追加一个块要改三个地方:位图、inode、数据块。中间断电,这三个的任意子集可能落盘了——有些结果只是浪费空间,有些是文件里出现别人的旧数据。这一篇讲两种解法:事后扫描修复(fsck,慢到不可用)和事前写日志(journaling,今天的答案),以及日志为什么必须先写日志再写数据、为什么要有提交记录。最后用实测给出你自己写代码时唯一该记住的那条:原地覆盖有 92% 的概率被读到半成品,写临时文件再 rename 是 0%。
第 27 篇的日志把「改到一半断电」堵上了,但还有两件事没解决:随机小写依然很贵,而且没有人在检查读回来的数据是不是当初写下去的。这一篇讲三个答案——日志结构文件系统把所有写变成顺序追加(这个 1991 年为机械硬盘设计的思想,今天活在每一个 SSD 和每一个 LSM-tree 里)、写时复制把提交收缩成一次指针替换、校验和让「静默损坏」不再静默。开头先用一个实验说明第三件事有多必要:改掉一个字节,read() 照常返回成功。
容器不是轻量级虚拟机,它根本不是虚拟机——它就是宿主机上一个普通进程,只是被内核换了几副眼镜。这一篇让内核自己证明:同一个进程,NSpid 那一行同时写着外面的 8 和里面的 1;同一个路径,两个进程 cat 出完全不同的内容;新的网络 namespace 里 eth0 直接消失。然后讲三根支柱各自管什么——namespace 管「你看得见什么」,cgroup 管「你能用多少」,联合文件系统管「你的根目录从哪来」。
这门课的最后一篇,回到第 1 篇那句话:谎言总会在某个地方漏出来。这里看的是有人专门盯着漏点的时候会发生什么。用一个不需要任何漏洞的实验收尾——攻击者只靠一个普通的 mincore 系统调用,就精确还原出受害者读了文件的哪 20 页,一页不差,而且全程没有任何权限被突破。顺便说清楚 Linux 后来给它打的那个补丁为什么没有真正修好任何东西。然后讲清楚三层边界各自靠什么撑着、Meltdown 为什么是「架构层没漏、时间上漏了」,以及为什么这一层的防御永远只能说「目前还没被打穿」。