与 OSTEP 对照阅读
Reading APOSD alongside Operating Systems: Three Easy Pieces
为什么要对照着读
你读 OSTEP 时学的是机制:inode 怎么组织、日志怎么保证一致、重传怎么做。APOSD 讲的是接口与取舍:哪些东西该让使用者知道、哪些该藏起来、错误该在哪一层处理。两本书讨论的常常是同一批系统,只是站在不同的高度。Ousterhout 本人也是操作系统和文件系统研究出身(LFS 论文的作者之一,OSTEP 第 43 章的参考文献里就有这篇论文),APOSD 里不少例子直接取自这些系统。
本页把两本书里讲同一件事的地方逐一配对。每一节开头有两个链接:橙色是 OSTEP 精读站的章节,蓝色是本站的 APOSD 章节页。OSTEP 的内容都对照过 OSTEP 原文(1.10 版)核对;APOSD 的小节号对应第 2 版。
| 主题 | OSTEP | APOSD | 一句话的联系 |
|---|---|---|---|
| 文件 I/O 接口 | 39、40 | 4.3、4.4 | 五个调用背后是整个文件系统;fsync 是不能省的"重要细节" |
| 分层 | 36.7、40 | 7 章引言 | 文件 → 块 → 设备命令,每层抽象都不同 |
| 设备驱动 | 36.7 | 6.6、7.2、21.3 | 特化被推到最底层;通用接口也有代价 |
| 异常屏蔽 | 48.3、49.7 | 10.6、10.9 | 重传和重试把错误挡在低层;端到端论证是它的边界 |
| 定义掉错误 | 39.14、49.4 | 10.3、10.4 | 删除打开的文件、无状态协议:换个定义,错误就不存在 |
| 配置参数 | 8.3、8.5、48.3 | 8.2 | Ousterhout 定律 ↔ 把复杂性往下拉 |
| 崩溃恢复 | 42、43.12 | 10.7、10.8 | 让"恢复"成为一条常走的路径 |
| 日志结构 | 43 | (本站项目) | 追加写 + 间接索引 + 清理,minikv 的原型 |
| 内存 API | 14 | 2.2、4.4、10.8 | 谁负责释放,就是接口的一部分 |
Unix 文件 I/O:教科书级的深接口
APOSD 4.4 节把 open/read/write/lseek/close 当作深模块的标准例子:五个签名简单的调用,背后是几十万行实现,而且实现几十年来大幅演进,这五个调用本身却没变。OSTEP 第 40 章用一个极简文件系统(vsfs)把"背后"摊开给你看:inode、位图、目录项、多级索引、路径解析时要读多少次磁盘。两章对着读,你会对"接口有多窄、实现有多深"有体感。
更有意思的是接口里没能藏住的部分。APOSD 4.3 节说,对数据库这类使用者来说,"数据什么时候真正落盘"是重要细节,必须出现在接口里,否则就是虚假的抽象。OSTEP 39.7 节讲的正是这个出口:fsync()。39.8 节进一步给出了"写临时文件 → fsync → rename"来原子地替换文件的套路,依赖的是 rename 在崩溃面前(通常)是原子的这一保证。
这个套路本身就是一个很好的"往下拉复杂性"的练习对象:每个需要原子更新文件的调用方都手写一遍,还是封装成一个深函数?
// 用 data 原子地替换 path 的内容:崩溃后,path 要么是旧内容,要么是完整的新内容,
// 不会出现半截文件。成功返回时新内容(包括目录项)已经落盘。
// 失败时抛 std::system_error,path 保持原样,临时文件会被删除。
// 要求:path 所在目录可写;同一 path 不能被多个进程并发替换。
void replaceFileAtomically(const std::filesystem::path& path, std::string_view data);
注释里的每一条都是调用者必须知道、签名却表达不了的:保证了什么(原子性、持久性)、失败时的状态、并发约束。实现里要处理的事:临时文件命名、写满所有字节(write 可能只写一部分)、fsync 文件、rename、再 fsync 所在目录让目录项也持久化、出错时清理。这些都被藏了起来。这就是第 4 章说的"复杂性在模块内部只需理解一次"。
文件系统分层:每层换一种抽象
APOSD 第 7 章开篇用文件系统作为"好的分层"的例子:最上层把文件呈现为可变长的字节数组;下面一层是内存中定长磁盘块的缓存;最底层是设备驱动,在存储设备和内存之间搬运块。作者的判断标准是:沿着一次调用往下走,每跨一层,抽象都应该变一次;相邻两层抽象差不多,就是红旗(透传方法 往往由此而来)。
OSTEP 36.7 节的图 36.4 画的是 Linux 的同一条栈(POSIX API → 文件系统 → 通用块层 → 设备驱动),出发点是另一个问题:怎样让操作系统的大部分代码对设备一无所知。下面这张图把两本书的视角合在一起:左边是层,右边是每层向上提供的抽象。
反过来想:如果某个项目里出现了一层 FileManager,它的方法和下面 FileStore 的方法一一对应、参数也一样,只是转手调用,那它就违反了这张图背后的原则:它没有提供新的抽象,只增加了一层要学的接口。
设备驱动:把特化往下推
设备驱动在 APOSD 里出现了三次,每次说明不同的东西:
- 6.6 节:通常我们把特化代码往上推(让底层保持通用),但驱动是把特化往下推的例子:内核定义一个只有"读块""写块"这类通用操作的接口,每种设备的怪癖都关在各自的驱动里,内核核心不需要知道任何设备细节,新设备只要能实现这个接口就能接入。
- 7.2 节:多个驱动签名完全相同,但这不是透传方法,因为它们是同一接口的不同实现,各自提供实质性的功能。
- 21.3 节:驱动接口是一个"中心性"的设计决定,成百上千个驱动依赖它,所以它是需要被强调、被认真设计的东西。
OSTEP 36.7 节从内核实现者的角度讲了同一件事("怎样构建与设备无关的操作系统"),还给出一个让人印象深刻的数字:对 Linux 内核的研究发现,超过 70% 的代码在设备驱动里。换句话说,"把特化往下推"的结果是,绝大部分代码都在最底层,而上面的核心保持小而通用。用 C 写的话,这种接口通常长这样(本站简化示例):
/* 块设备驱动必须提供的操作。内核其余部分只通过这张表访问设备。
* 所有操作以 512 字节扇区为单位;成功返回 0,失败返回负的 errno。 */
struct blockdev_ops {
int (*read)(void *dev, uint64_t sector, void *buf, uint32_t nsect);
int (*write)(void *dev, uint64_t sector, const void *buf, uint32_t nsect);
int (*flush)(void *dev); /* 把设备自身的写缓存刷到持久介质 */
uint64_t (*capacity)(void *dev); /* 以扇区计 */
};
OSTEP 36.7 节同时指出了封装的代价:SCSI 设备能报告非常丰富的错误信息,但因为通用块接口要照顾错误处理简单得多的 ATA/IDE 设备,上层最终只能收到一个笼统的 EIO,SCSI 提供的细节全丢了。
这正好对应 APOSD 的两处提醒:4.3 节说抽象只能省略不重要的细节;10.9 节说,如果模块外部确实需要某个异常信息,就不能把它屏蔽掉。对普通应用,"读失败了"就够了;对文件系统检查工具或存储运维来说,"是介质错误还是链路超时"决定了下一步怎么做。好的通用接口通常会为少数需要细节的调用者留一条旁路(OSTEP 同一节也提到了绕过文件抽象的"原始接口"),而不是让所有人都背上细节。
屏蔽异常:可靠传输与 NFS 重试
APOSD 10.6 节讲"屏蔽异常"(exception masking):在系统的低层检测并处理异常,上层根本感觉不到。两个例子都是你在 OSTEP 里学过机制的:
- TCP 重传:包可能因为损坏或拥塞被丢掉,TCP 在内部重发,调用者看到的是可靠的字节流。OSTEP 48.3 节从零搭了一个可靠通信层(确认、超时重试、序号去重),并指出最常用的可靠层就是 TCP。
- NFS 客户端重试:服务器崩溃或没有响应时,客户端反复重发请求,直到服务器恢复;应用程序不会收到错误,只会挂起。OSTEP 49.7 节解释了这为什么可行:大多数 NFS 请求是幂等的(WRITE 也带着确切的偏移量),所以"请求丢了""服务器宕了""回复丢了"三种情况都可以用同一个动作(重发)处理。
两本书在这里正好互补:OSTEP 讲机制(幂等让重试安全),APOSD 为它的用户可见后果辩护。NFS 用户经常抱怨应用"卡死",很多人主张应该返回错误;作者认为那样更糟:应用对"文件服务器没了"几乎无能为力,只能层层放弃,最后整个工作环境崩掉,而在 NFS 层统一重试,应用不需要任何处理代码,服务器回来后就能无缝继续,用户等不及还可以手动终止程序。
OSTEP 48.5 节里的旁注介绍了端到端论证(end-to-end argument):要确认文件从 A 机器完整传到 B 机器,只能在两端做校验;可靠传输保证的只是"网络上没丢",挡不住数据在发送方内存里或接收方写盘时被损坏。这就是上图最上面那一格。
APOSD 10.9 节从另一个方向画了同一条线:屏蔽只适用于模块外部不需要这个异常信息的情况。作者举的反例是一个学生团队写的网络模块,把所有网络异常都吞掉了,结果上层应用根本无法知道消息丢了或对端挂了。OSTEP 48.5 节还给出了另一种"在错误的层屏蔽"的代价:RPC 如果建在可靠传输之上,会多出两条确认消息,所以很多 RPC 系统选择建在 UDP 上、自己做超时重试。屏蔽不是越低越好,而是要放在"既能发现又能正确处理"的那一层。
把错误定义掉:删除打开的文件与无状态协议
屏蔽是"错误发生了,但在低层处理掉";APOSD 10.3 节更推崇的是另一招:重新定义语义,让错误根本不存在。OSTEP 里至少有两处现成的例子:
- 删除一个还打开着的文件。APOSD 10.4 节对比了两种系统:Windows 拒绝删除被打开的文件,用户只好去找、去杀占用它的进程,甚至重启;Unix 则让删除立即成功,只是把名字从目录里拿掉,已经打开它的进程照常读写,等所有人都关闭后数据才真正释放。作者指出这一个定义同时消灭了两类错误:删除方不会失败,使用方也不会突然读写出错。OSTEP 39.14 节讲
unlink为什么叫 unlink 时给出了机制的一半:删除只是去掉"名字 → inode"的链接并递减链接计数,计数归零才回收;另一半(打开的文件描述符也会让 inode 活着)就是 APOSD 描述的行为。 - NFSv2 的无状态协议。OSTEP 49.4 节解释:如果服务器记住"哪个客户端打开了哪个文件、读到哪里",那么服务器崩溃要恢复这些状态,客户端崩溃了服务器还得发现并清理。NFS 的设计者干脆让每个请求都自带完成它所需的全部信息,服务器不保存客户端状态,于是"服务器重启后状态丢失"这一类错误不复存在,崩溃恢复退化成"客户端重试一下"。OSTEP 在这里还引用了 Ousterhout 1991 年关于"分布式状态"(distributed state)的文章。
两个例子的共同点是:设计者没有在"怎么处理这个错误"上花力气,而是问"能不能换个定义,让这个错误不会发生"。代价也都很明确:Unix 允许进程继续写一个已经"被删除"的文件(Ousterhout 说从没见过这造成严重问题);无状态意味着服务器不知道哪些客户端缓存了哪些块(OSTEP 49.4 节明确这样说),也就没法主动通知它们缓存失效,读 49.9~49.10 节的缓存一致性问题时可以带着这一点去看。
巫毒常数与配置参数
你可能还记得 OSTEP 第 8 章 8.3 节的一段讨论(及随后的提示框):MLFQ 的优先级提升周期 S 该设成多少?设大了长任务会饿死,设小了交互任务分不到足够的 CPU。OSTEP 说 Ousterhout 把这类需要"黑魔法"才能调对的值叫作巫毒常数(voo-doo constants),并把"尽量避免巫毒常数"命名为 Ousterhout 定律。它也坦白了常见的结局:一个塞满默认值的配置文件,偶尔由经验丰富的管理员调一调,多数时候没人动,只能祈祷默认值够用。8.5 节还列了更多同类问题:队列数、每个队列的时间片多长、提升多频繁,都没有简单答案。
APOSD 8.2 节是这条定律在接口设计上的展开:配置参数是把复杂性往上推给使用者,很多时候用户也定不出正确的值,而模块自己多做一点工作就能算出来。作者的例子恰好又是网络:重传间隔与其做成参数,不如测量成功请求的响应时间、取它的倍数,这样还能随运行条件自动调整。OSTEP 48.3 节的提示框讲的是同一个旋钮:超时设小了白白重发,设大了反应迟钝;多客户端时可以用指数退避(exponential back-off)。
作者给出的检验问题值得背下来:导出一个参数之前,问自己"用户(或更高层的模块)能比我们在这里算得更好吗?"如果能(比如只有用户知道哪些请求更紧急),就导出,并给一个合理的默认值;如果不能,就自己算。
本站的 minikv 项目里有一个专门对应第 8 章的任务:找出被往上推给调用者的配置参数,判断哪些应该由模块自己决定。做那个任务时,可以把 OSTEP 里 MLFQ 那几个旋钮当作参照:哪些是真正需要用户知识的,哪些只是设计者没想清楚?
崩溃一致性与"直接崩溃"
OSTEP 第 42 章的核心问题是:更新磁盘上几个相关结构的过程中断电了,怎么办?早期答案是 fsck(事后扫描全盘修复),OSTEP 吐槽它像在卧室掉了钥匙却从地下室开始搜整栋房子;后来的答案是日志(journaling,即预写日志):先把要做的修改写进日志,再写到原位置,崩溃后只需重放日志。LFS 的恢复(43.12 节)用的是检查点加前滚(roll forward)。
APOSD 第 10 章站在应用设计者的位置,提出了两个和"崩溃"有关的手法:
- 错误升级(error promotion,10.7 节):作者的 RAMCloud 存储系统发现单个对象损坏时,不单独修这个对象,而是直接让整台服务器崩溃,复用已有的服务器崩溃恢复机制。好处是恢复机制只有一套,而且它被调用得更频繁,bug 更容易被发现;作者也说明了限制:只适用于罕见的错误,不能每丢一个包就崩一次。
- 直接崩溃(just crash,10.8 节):对多数程序来说,内存耗尽、打开的文件读写时出现硬件错误、内部数据结构不一致,最简单的处理就是打印诊断信息然后退出。但作者特意指出:对复制存储系统,遇到 I/O 错误就退出是不可接受的,用副本恢复数据正是它提供给用户的价值。
两本书连起来看,会得到一个很实用的推论:"直接崩溃"之所以可以接受,前提是重启之后能回到一致状态,而这恰恰是 OSTEP 第 42、43 章所讲机制的价值。日志、原子 rename、检查点让"进程随时可能死掉"变成一条被设计过、被经常执行的正常路径,于是上层代码才敢对罕见错误一律"崩溃了事",而不是在每个调用点写一段大概率没被测过的恢复代码。反过来,如果你的存储格式没有崩溃一致性保证,"直接崩溃"就会把一个小错误变成数据损坏。
日志结构设计与本站的 minikv
本站的渐进重构项目 minikv 是一个追加写日志加内存索引的小型键值存储,本质上就是 OSTEP 第 43 章 LFS 思想的缩小版。LFS 的几个关键设计在 minikv 里都有对应:
| LFS(OSTEP 43) | 追加写 KV 里的对应物 | 接口设计上该问的问题(APOSD) |
|---|---|---|
| 所有写入都追加到日志,从不原地覆盖(43.1) | put / del 都追加一条记录 | 调用者需要知道"删除其实是追加一条墓碑"吗?(第 5 章:信息隐藏) |
| inode map 记录每个 inode 的最新位置(43.5) | 内存索引:键 → 最新记录的偏移 | 索引的格式有没有泄漏到其他模块?(信息泄漏 ) |
| 检查点区域 + 前滚恢复(43.6、43.12) | 启动时重放日志、跳过撕裂的尾部 | 恢复逻辑是藏在 open 里,还是要调用者按顺序调用几个函数?(按时间顺序分解 ) |
| 清理器回收旧段;何时清、清哪些是策略问题(43.9~43.11) | 压缩(compaction) | 该暴露 compact() 和阈值参数,还是让模块自己决定?(第 8 章 + 上面的巫毒常数) |
OSTEP 43.13 节的小结还有一句和 APOSD 精神相通的话("把缺陷变成特性"的提示框):NetApp WAFL 把旧版本数据变成快照功能,同时缓解了清理压力。这和 APOSD 第 10 章"把错误定义为不存在"是同一种思路:不在原问题上硬磕,而是重新定义问题,让它消失或变成别的东西。
内存 API 与深模块
OSTEP 14.4 节列了一串常见的内存错误:忘了分配、分配不够、忘了初始化、忘了释放、用完之前就释放、重复释放、free 了不该 free 的指针。每一条,从 APOSD 的角度看,都是接口的非正式部分没被遵守:谁分配、谁释放、什么时候释放,这些约束不在签名里,却是每个调用者必须知道的。
- APOSD 2.2 节用的正是这个例子说明认知负担:函数分配内存、返回指针、要求调用者释放,每个调用者都多了一件要记住的事;如果分配和释放由同一个模块负责,负担就消失了。
- APOSD 4.4 节说垃圾回收器是"根本没有接口"(no interface at all)的深模块:引入它反而让系统的总接口变小,因为"释放对象"这项接口不存在了。OSTEP 14.4 节开头也提到,正因为内存管理太容易出错,很多新语言用垃圾回收免去了调用
free。 - APOSD 10.8 节的
ckalloc:malloc返回NULL要求每个调用点都检查;包一层"失败就打印信息并退出",就把这个错误从所有调用点定义掉了。
/* 返回 path 的全部内容,以 '\0' 结尾,长度写入 *len。
* 调用者必须用 free() 释放返回值。失败返回 NULL 并设置 errno。 */
char *read_whole_file(const char *path, size_t *len);
size_t n; char *cfg = read_whole_file("app.conf", &n);
if (!cfg) { perror("app.conf"); return -1; }
if (parse(cfg, n) < 0) return -1; /* 这里漏了 free(cfg) */
free(cfg);
接口注释写得很尽责,但"必须 free"这条约束要靠每个调用者在每条退出路径上记住。第 7 行就是 OSTEP 14.4 节"忘记释放"的典型现场:错误路径提前返回,内存泄漏了。
// 返回 path 的全部内容。失败抛 std::system_error(带路径和 errno)。
std::string readWholeFile(const std::filesystem::path& path);
std::string cfg = readWholeFile("app.conf");
parse(cfg); // 任何路径退出,cfg 都会被析构释放
C++ 的 RAII 让"谁释放"从注释里的约定变成了类型系统的一部分,调用者不再需要知道。这和 APOSD 对垃圾回收的评价是同一个方向:一项接口消失了。错误也从"返回 NULL + errno"变成了异常,调用者不想处理时可以让它向上传播(第 10 章的异常聚合)。
OSTEP 14.4 节特意提醒:垃圾回收并不能消灭内存泄漏。只要你还持有某块内存的引用,回收器就永远不会释放它。所以"不再需要的对象要及时放掉引用"仍然是使用者要知道的事,只是比"手动 free"轻得多。所以 APOSD 说垃圾回收器"根本没有接口",本站认为要打个折扣:它仍有一条使用者必须知道的非正式约束;C++ 里对应的问题是 shared_ptr 的循环引用。
fork 与 exec:把复杂性往下拉,还是接口凝固了
APOSD 没有讨论进程创建,OSTEP 第 5 章则专门解释过为什么 Unix 把创建进程拆成 fork 和 exec 两步:shell 可以在两步之间布置子进程的环境,重定向和管道因此很容易实现。书的读者讨论组里有一串正好把这个设计放到了 APOSD 的框架下。
以下不在书里。有读者问 fork 到底算不算简单。Ousterhout 说自己喜欢 fork 加 exec 的做法(2022-08):两个调用各自都简单,父进程可以在两者之间任意调整子进程的环境。主要缺点是最直接的实现很低效:fork 复制了父进程的全部状态,转眼又被 exec 丢掉;要让它实用,底层系统得承担不少额外的复杂性(写时复制),而且可能仍然慢一些。Ousterhout 表示接受这个取舍,并直接称之为"把复杂性往下拉":对使用者干净简单,对系统的实现者更费事。
相比之下,一步到位的创建进程接口容易高效实现,但对使用者别扭:子进程可能与父进程不同的每一个方面都要有参数;想新增一种定制方式,就得给它再加参数。而在 fork 加 exec 的方式下,可以新增一个独立的系统调用,只有需要的程序才会去调。Ousterhout 最后说,这大概是个会永远争下去的取舍。
同一串里一位读者给了相当扎实的反方,Ousterhout 没有再回。要点:fork 起初确实简单,但后来和线程、和几乎所有后加的机制都配合得不好,子进程继承来的状态常常需要清理;这位读者的说法是,这类接口在概念上是简单的,直到它不再简单。更重要的一点是"要么 fork、要么一个参数巨多的创建调用"是假二分,还有第三种设计:先创建一个尚不可运行的子进程,父进程用一组普通的调用去布置它(文件描述符、地址空间等),最后再让它开始运行。读者举的例子来自研究系统和学术论文,本站没有逐一核对。
对照着读的收获:Ousterhout 的论证和 OSTEP 的解释是同一件事的两面(接口小、可组合,代价压在内核里);读者的反方则提醒,"往下拉"的账要按接口的整个寿命来算。1970 年代进程里只有一个线程、状态很少时,这笔账很划算;后来进程里的东西越来越多,"复制一切"这个最简单的定义就成了负担。第三种设计其实也符合 APOSD 的思路:把"布置子进程"做成一组各自简单、只在需要时才调用的操作。
小结:从机制读出设计
- OSTEP 里每一个"让上层不用操心"的机制(文件抽象、块层、驱动、重传、日志),都可以用 APOSD 的问题重新审视一遍:它向上提供了什么新抽象?省略了哪些细节?省略得对不对?
- 接口没能藏住的东西(
fsync、EIO、"至少一次"语义),往往就是 APOSD 所说的"重要细节",必须出现在接口里。 - 处理错误有四种层次:定义掉(Unix 删除、无状态协议)、屏蔽(重传、NFS 重试)、聚合与升级(崩溃恢复复用)、直接崩溃。选哪一种,取决于哪一层既能发现它又能处理它,以及外部是否需要知道。
- 巫毒常数是"把复杂性往上推"的症状;能测量就自己算,确实需要用户知识才导出参数。
- "直接崩溃"的底气来自崩溃一致性:先让重启后的恢复成为一条可靠、常走的路径。
练习
你在写一个分布式日志收集客户端,把本地日志通过网络发到收集服务器。服务器暂时不可达时,客户端 API append(record) 应该怎么做:像 NFS 一样阻塞重试,立刻返回错误,还是先写本地缓冲、后台重试?
展开对照
先问 APOSD 10.9 节的问题:调用者需不需要知道这个异常?业务代码打一条日志,几乎从不关心服务器是否暂时不可达,更不应该因为日志服务挂了而卡住请求处理。所以 NFS 式的"挂起"在这里不合适(NFS 挂起是因为应用没有文件就什么也做不了,而业务没有日志服务照样能工作)。合理的设计通常是:在模块内部缓冲并后台重试(屏蔽),缓冲满了按明确的策略丢弃或降级,并通过一个单独的统计接口暴露"丢了多少",给少数关心的运维代码用。
再问 OSTEP 49.7 节的问题:重试安全吗?如果服务器可能已收到但回复丢了,重发会产生重复日志,所以要么让记录带唯一 ID 使写入幂等,要么在接口注释里写明"至少一次"语义。这条信息是重要的,必须出现在接口里。
下面哪些值应该做成配置参数?(1) 数据库连接池大小;(2) 本地缓存的 LRU 容量;(3) TCP 风格的重传超时;(4) 某类请求是否为"高优先级"。
展开对照
(4) 是 APOSD 8.2 节自己承认的正当用例:只有用户知道哪些请求更紧急。(3) 是作者举的反例,应该测量往返时间自动计算。(1) 和 (2) 介于两者之间:模块可以给出合理默认值(例如按 CPU 核数、可用内存推算),并允许在少见情况下覆盖。关键是默认值要好到大多数人永远不需要碰它,否则就成了 OSTEP 说的那种"塞满默认值、没人敢动"的配置文件。
EIO。用 APOSD 的语言,这说明了什么?