专题 · 争议与对照Topic

与 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 版。

主题OSTEPAPOSD一句话的联系
文件 I/O 接口39、404.3、4.4五个调用背后是整个文件系统;fsync 是不能省的"重要细节"
分层36.7、407 章引言文件 → 块 → 设备命令,每层抽象都不同
设备驱动36.76.6、7.2、21.3特化被推到最底层;通用接口也有代价
异常屏蔽48.3、49.710.6、10.9重传和重试把错误挡在低层;端到端论证是它的边界
定义掉错误39.14、49.410.3、10.4删除打开的文件、无状态协议:换个定义,错误就不存在
配置参数8.3、8.5、48.38.2Ousterhout 定律 ↔ 把复杂性往下拉
崩溃恢复42、43.1210.7、10.8让"恢复"成为一条常走的路径
日志结构43(本站项目)追加写 + 间接索引 + 清理,minikv 的原型
内存 API142.2、4.4、10.8谁负责释放,就是接口的一部分

Unix 文件 I/O:教科书级的深接口

OSTEP 39 文件和目录OSTEP 40 文件系统实现APOSD 第 4 章

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 章说的"复杂性在模块内部只需理解一次"。

文件系统分层:每层换一种抽象

OSTEP 36 I/O 设备(36.7)APOSD 第 7 章

APOSD 第 7 章开篇用文件系统作为"好的分层"的例子:最上层把文件呈现为可变长的字节数组;下面一层是内存中定长磁盘块的缓存;最底层是设备驱动,在存储设备和内存之间搬运块。作者的判断标准是:沿着一次调用往下走,每跨一层,抽象都应该变一次;相邻两层抽象差不多,就是红旗(透传方法 往往由此而来)。

OSTEP 36.7 节的图 36.4 画的是 Linux 的同一条栈(POSIX API → 文件系统 → 通用块层 → 设备驱动),出发点是另一个问题:怎样让操作系统的大部分代码对设备一无所知。下面这张图把两本书的视角合在一起:左边是层,右边是每层向上提供的抽象。

层 向上提供的抽象 应用程序 文件系统 inode、目录、块分配、日志 有名字、可变长的字节数组 open / read / write / fsync 通用块层 + 块缓存 调度、合并请求、缓存 编号的定长块,常用块留在内存 read_block(n) / write_block(n) SATA 驱动 NVMe 驱动 USB 驱动 同一个块接口的多个实现 特化全部收在这一层(APOSD 6.6、7.2) 设备 各自的命令集、寄存器、DMA 厂商协议:命令、扇区、错误码 每 层 都 变
沿一次 read 往下走,抽象从"字节范围"变成"块号"再变成"设备命令"。本站自绘示意,层次划分做了简化。

反过来想:如果某个项目里出现了一层 FileManager,它的方法和下面 FileStore 的方法一一对应、参数也一样,只是转手调用,那它就违反了这张图背后的原则:它没有提供新的抽象,只增加了一层要学的接口。

设备驱动:把特化往下推

OSTEP 36.7APOSD 6.6APOSD 7.2APOSD 21.3

设备驱动在 APOSD 里出现了三次,每次说明不同的东西:

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 重试

OSTEP 48 分布式系统OSTEP 49 NFSAPOSD 10.6、10.9

APOSD 10.6 节讲"屏蔽异常"(exception masking):在系统的低层检测并处理异常,上层根本感觉不到。两个例子都是你在 OSTEP 里学过机制的:

两本书在这里正好互补:OSTEP 讲机制(幂等让重试安全),APOSD 为它的用户可见后果辩护。NFS 用户经常抱怨应用"卡死",很多人主张应该返回错误;作者认为那样更糟:应用对"文件服务器没了"几乎无能为力,只能层层放弃,最后整个工作环境崩掉,而在 NFS 层统一重试,应用不需要任何处理代码,服务器回来后就能无缝继续,用户等不及还可以手动终止程序。

应用(编辑器、编译器……) NFS 客户端文件系统 RPC 可靠传输(确认 + 超时重试 + 序号) 不可靠网络 丢包、乱序 在传输层被吸收,上层看不见 服务器崩溃、重启、无响应 NFS 客户端重试吸收;应用表现为挂起 数据在内存或磁盘里被写坏 低层都"成功"了,只有两端能发现
一个错误应该被哪一层吸收,取决于哪一层既能发现它、又能处理它。本站自绘示意。

OSTEP 48.5 节里的旁注介绍了端到端论证(end-to-end argument):要确认文件从 A 机器完整传到 B 机器,只能在两端做校验;可靠传输保证的只是"网络上没丢",挡不住数据在发送方内存里或接收方写盘时被损坏。这就是上图最上面那一格。

APOSD 10.9 节从另一个方向画了同一条线:屏蔽只适用于模块外部不需要这个异常信息的情况。作者举的反例是一个学生团队写的网络模块,把所有网络异常都吞掉了,结果上层应用根本无法知道消息丢了或对端挂了。OSTEP 48.5 节还给出了另一种"在错误的层屏蔽"的代价:RPC 如果建在可靠传输之上,会多出两条确认消息,所以很多 RPC 系统选择建在 UDP 上、自己做超时重试。屏蔽不是越低越好,而是要放在"既能发现又能正确处理"的那一层。

把错误定义掉:删除打开的文件与无状态协议

OSTEP 39.14OSTEP 49.4APOSD 10.3、10.4

屏蔽是"错误发生了,但在低层处理掉";APOSD 10.3 节更推崇的是另一招:重新定义语义,让错误根本不存在。OSTEP 里至少有两处现成的例子:

两个例子的共同点是:设计者没有在"怎么处理这个错误"上花力气,而是问"能不能换个定义,让这个错误不会发生"。代价也都很明确:Unix 允许进程继续写一个已经"被删除"的文件(Ousterhout 说从没见过这造成严重问题);无状态意味着服务器不知道哪些客户端缓存了哪些块(OSTEP 49.4 节明确这样说),也就没法主动通知它们缓存失效,读 49.9~49.10 节的缓存一致性问题时可以带着这一点去看。

巫毒常数与配置参数

OSTEP 8 MLFQOSTEP 48.3APOSD 第 8 章

你可能还记得 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 崩溃一致性OSTEP 43.12APOSD 10.7、10.8

OSTEP 第 42 章的核心问题是:更新磁盘上几个相关结构的过程中断电了,怎么办?早期答案是 fsck(事后扫描全盘修复),OSTEP 吐槽它像在卧室掉了钥匙却从地下室开始搜整栋房子;后来的答案是日志(journaling,即预写日志):先把要做的修改写进日志,再写到原位置,崩溃后只需重放日志。LFS 的恢复(43.12 节)用的是检查点加前滚(roll forward)。

APOSD 第 10 章站在应用设计者的位置,提出了两个和"崩溃"有关的手法:

两本书连起来看,会得到一个很实用的推论:"直接崩溃"之所以可以接受,前提是重启之后能回到一致状态,而这恰恰是 OSTEP 第 42、43 章所讲机制的价值。日志、原子 rename、检查点让"进程随时可能死掉"变成一条被设计过、被经常执行的正常路径,于是上层代码才敢对罕见错误一律"崩溃了事",而不是在每个调用点写一段大概率没被测过的恢复代码。反过来,如果你的存储格式没有崩溃一致性保证,"直接崩溃"就会把一个小错误变成数据损坏。

日志结构设计与本站的 minikv

OSTEP 43 LFS本站项目: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 内存 APIAPOSD 4.4APOSD 10.8

OSTEP 14.4 节列了一串常见的内存错误:忘了分配、分配不够、忘了初始化、忘了释放、用完之前就释放、重复释放、free 了不该 free 的指针。每一条,从 APOSD 的角度看,都是接口的非正式部分没被遵守:谁分配、谁释放、什么时候释放,这些约束不在签名里,却是每个调用者必须知道的。

/* 返回 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:把复杂性往下拉,还是接口凝固了

OSTEP 第 5 章APOSD 第 8 章

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 的思路:把"布置子进程"做成一组各自简单、只在需要时才调用的操作。

小结:从机制读出设计

练习

你在写一个分布式日志收集客户端,把本地日志通过网络发到收集服务器。服务器暂时不可达时,客户端 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 说的那种"塞满默认值、没人敢动"的配置文件。

OSTEP 第 49 章和 APOSD 10.6 节都讨论了 NFS 客户端的重试。两者的侧重点分别是?
OSTEP 49.7 节的重点是:多数 NFS 请求幂等,所以三种故障可以用同一个"重发"统一处理。APOSD 10.6 节承认用户常抱怨挂起,但认为在 NFS 层屏蔽比让每个应用自己处理更好。
OSTEP 36.7 节提到 SCSI 的丰富错误信息到了上层只剩一个 EIO。用 APOSD 的语言,这说明了什么?
这是抽象"省略细节"的代价:对大多数调用者,"读失败"就够了;对诊断工具,细节很重要。APOSD 4.3 节(只省略不重要的细节)和 10.9 节(外部需要的异常信息不能被屏蔽)给出了判断方向。
"Ousterhout 定律"这个名字出自哪里,说的是什么?
这个名字是 OSTEP 作者起的(他们说 Ousterhout 是他们当年的操作系统老师)。APOSD 书中没有用这个名字,但 8.2 节对配置参数的批评是同一个思想。