《是时候淘汰对操作系统的 fork() 调用了 - InfoQ》
是时候淘汰对操作系统的 fork() 调用了 - InfoQ
概述
一般观点认为针对线程创建 Unix 的 fork() 与 exec() 的组合堪称绝配,但微软研究院与波士顿大学联合发表的一篇论文则提出了相反的观点。他们认为 fork 在当下早已过时,对操作系统和应用程序的设计弊大于利,并给出了一些替代 fork 的方案和未来的发展路线建议。
1 引言
当初人们在开发 Unix 的时候需要一种创建线程的机制,于是他们发明了一个特殊的系统调用:fork()。Fork 会创建一个与其父进程(fork 的调用者)相同的新进程,但系统调用的返回值除外。现在的开发者都习惯了 Unix 中用 fork() 加上 exec() 执行子进程中不同程序的用法,但相比非 Unix 系的操作系统来说,这种用法还是比较特立独行的 [例如,1,30,33,54]。
50 年过去了,fork 仍然是 POSIX 上默认的线程创建 API:Atlidakis 等 [8] 发现有 1304 个 Ubuntu 包(占总数的 7.2%)会调用 fork,相比之下更现代化的 posix_spawn() 只有 41 个包在用。几乎所有的 Unix 内核系统、主流 Web 和数据库服务器(例如 Apache、PostgreSQL 和 Oracle)、Google Chrome、Redis 键值存储甚至 Node.js 都在使用 fork。大家似乎认为 fork 是很好的设计。我们审查的几本操作系统教科书 [4,7,9,35,75,78] 都对其持中立态度,甚至赞誉有加,而且常常会强调它相比其它方法的 “简单性” 优势。今天的专业课会教学生“fork 系统调用是 Unix 的伟大思想之一”[46],并且“设计创建线程的 API 有很多条路可选,而 fork() 和 exec() 的组合是其中既简单又强大的一条路……Unix 的设计者选对了路”[7]。
如今我们就要纠正这种错误。Fork 是一种机制:它是上个时代遗留的产物,在现代操作系统中已经过时,甚至有很多害处。我们开发社区对 fork 很熟悉,但也会因此无视它的问题(§4,第 4 部分,下同)。公认 fork 存在的问题包括它没有线程安全、低效且不可扩展,且带来了安全问题。此外,fork 已经不再像当年一样简洁了;如今它会影响自己曾经正交过的其它所有操作系统抽象。此外,fork 面临的一项根本挑战在于,由于它将进程与其运行的地址空间混为一谈,因此 fork 会阻碍操作系统功能的用户模式实现,搞乱从缓冲 IO 到内核旁路网络的所有内容。也许最大的问题在于 fork 不支持 compose——但系统的每一层,从内核到最小的用户模式库都必须支持它。
我们使用在先前研究系统中获得的经验来说明 fork 对操作系统实现带来的坏处(§5)。Fork 限制了操作系统研究者和开发者的创新能力,因为新的抽象都必须专门定做。有效支持 fork 和 exec 的系统被迫懒惰地复制每个进程状态。这还促进了状态的中心化,这是不用单内核构建的系统面临的主要问题。另一方面,不支持 fork 的创新系统原型也无法运行大量需要 fork 支持的软件。
我们最后讨论了备选方案(§6)并发出了号召(§7):fork 应移除出我们系统的第一类原语,并用良好的模拟方法替换,为旧式应用程序提供兼容性。仅向操作系统添加新原语是不够的,fork 必须从内核中删掉。
2 历史起源:fork 最初是一种取巧
一般认为,最早实现 fork 操作的项目是 Project Genie 分时系统 [61]。Ritchie 和 Thompson [70] 声称 Unix fork“基本上和我们在 Genie 中实现的是一样的”。但是,Genie 监视器的 fork 调用比 Unix 更灵活:它允许父进程为新的子进程指定地址空间和机器上下文 [49,71]。默认情况下,子进程共享其父进程的地址空间(有点像现代线程);根据需要也可以给子进程一个完全不同的内存块的地址空间供用户访问;后者可能用来运行不同的程序。最重要的是,这里没有工具来复制地址空间,而是由 Unix 无条件完成的。
Ritchie [69] 后来指出 “Unix 引入 fork 的主要原因可能是它比较容易实现,不用改变太多东西。” 他接着讲到了当年的 PDP-7 计算机如何用 27 行代码第一次实现了 fork,包括将当前进程复制到虚拟内存,并将子进程保留在内存中。Ritchie 还指出,Unix 的 fork-exec 组合“当其中的 exec 并不存在时,这个组合就会变得非常复杂;它的功能已经由 shell 使用显式 IO 执行了。“
TENEX 操作系统 [18] 为 Unix 的路子提供了一个值得注意的反例。它也受到了 Project Genie 的影响,但它的发展和 Unix 互相独立。它的设计者也为进程创建引入了 fork 调用,但与 Genie 更相似的是,TENEX fork 要么共享了父进程之间的地址空间,要么创建了具有空地址空间的子进程 [19]。它没有 Unix 风格的地址空间复制,可能是因为它能用到虚拟内存硬件了。
Unix fork 不是一种 “必然性”[61] 的产物。它只是一种权宜之计,照搬 PDP-7 中的实现而已;结果 50 年过去了,它却已遍布现代操作系统和应用程序了。
3 FORK API 的优点
当 Unix 为 PDP-11 计算机(其带有内存转换硬件,允许多个进程保留驻留)重写时,只为了在 exec 中丢弃一个进程就复制进程的全部内存就已经很没效率了。我们怀疑在 Unix 的早期发展阶段,fork 之所以能幸存下来,主要是因为程序和内存都很小(PDP-11 上有只 8 个 8 KiB 页面),内存访问速度相对于指令执行速度较快,而且它提供了一个合理的抽象。这里有两点很重要:
Fork 很简单。除了易于实现之外,fork 还简化了 Unix API。最明显的是 fork 不需要参数,因为它为新进程的所有状态简单地提供了一个默认值:从父进程继承过来。与之形成鲜明对比的是,Windows CreateProcess()API 采用显式参数来指定子项内核状态的所有细节——包括 10 个参数和许多可选 flag。
更重要的是,使用 fork 创建进程和启动一个新程序是正交的,且 fork 和 exec 之间的空间有自己的用途。由于 fork 复制了父进程,因此允许进程修改其内核状态的系统在发起调用后,可以在 exec 之前在子进程中复用:shell 在命令执行之前就可以打开、关闭和重新映射文??件描述符,并且程序可以减少权限或更改子项的命名空间以在受限上下文中运行它。
Fork 简化了并发。在多线程或异步 IO 流行之前的年代,不用 exec 的 fork 提供了有效的并发形式。在共享库流行之前,它带来了一种简单的代码复用形式。程序可以初始化,解析其配置文件,然后 fork 自身的多个副本,这些副本从相同的二进制文件中运行不同的函数,或处理不同的输入。这种设计延续到了预 fork 服务器中,我们会在 §6 中再讲。
4 现代的 fork
乍一看,fork 现在好像还是很简洁。我们认为这是一个美丽的谎言,而且这种 fork 效应对现代应用来说弊大于利。
Fork 已经不再简洁了。Fork 的语义已经影响了所有新的创建进程状态的 API 设计。POSIX 规范列出了 25 个关于如何将父状态复制到子进程 [63] 的具体情况:文件锁、定时器、异步 IO 操作、跟踪等等。此外,许多系统调用 flag 会控制 fork 的行为,如内存映射(Linux madvise()flag,MADV_DONTFORK/DOFORK/WIPEONFORK 等)、文件描述符(O_CLOEXEC,FD_CLOEXEC)和多线程(pthread_atfork())。所有新式操作系统工具都必须用 fork 记录其行为,并且必须准备好用户模式库,以便随时 fork 它们的状态。Fork 的简洁性与正交性如今已荡然无存。
Fork 不会 compose。因为 fork 复制了整个地址空间,所以它不适合在用户模式下实现的操作系统抽象。缓冲 IO 就是一个典型的例子:用户必须在 fork 之前显式刷新 IO,以免重复输出 [73]。
Fork 是非线程安全的。今天的 Unix 进程支持多线程,但 fork 创建的子进程只有一个线程(调用线程的副本)。除非父进程对其他线程也逐个 fork,否则子地址空间最后可能会与父进程不一致。一个简单但常见的情况是一个线程进行内存分配并持有堆锁,而另一个线程 fork。任何在子进程中分配内存的尝试(从而获得相同的锁)都将立即死锁,等待永远不会发生的解锁操作。
编程指南建议不要在多线程进程中使用 fork,或者 fork 之后立即调用 exec [64,76,77]。POSIX 仅保证在 fork 和 exec 之间可以使用一小部分 “异步信号安全” 函数,特别是排除 malloc() 以及标准库中其它可能分配内存或获取锁的标准库中的内容。真正的多线程程序如果 fork,可能会在实践中出现各种错误并为之困扰 [24-26,66]。
很难想象有哪位理智的内核维护者会在内核中加入一个有这么多限制属性的系统调用。
Fork 是不安全的。默认情况下,fork 出的子进程从其父进程继承所有内容,并且程序员要负责显式删除子进程不需要的状态:他要关闭文件描述符(或将其标记为 close-on-exec)、从内存中清除机密 、使用 unshare()[52] 等隔离命名空间。从安全角度来看,fork 的默认继承行为违反了最小特权原则。此外,fork 但不执行的程序使地址空间布局随机化无效,因为每个进程都具有相同的内存布局 [17]。
Fork 很慢。自 Thompson 首次应用 fork 以来的几十年中,内存大小和相对访问成本不断增长。即使在 1979 年(当时第三个 BSD Unix 引入了 vfork()[15]),fork 已经有了性能问题,多亏了写入时复制技术 [3,72] 才让它的性能表现可以被接受。今天,就连建立写时复制映射的时间也成了一个问题:Chrome 在 fork [28] 中的延迟长达 100 毫秒,并且在 exec 之前 fork 时,Node.js 应用会被阻塞几秒钟 [56] 之久。
Fork 现在太拖累性能了,所以 C 语言库特意避免在 posix_spawn()[34,38] 中使用它,而 Solaris 将 spawn 用作了原生系统调用 [32]。但是,只要应用程序还是直接调用 fork,它们就会付出高昂的代价。图 1 对比了在 3.6 GHz 的 Intel i7-6850K CPU 上,Ubuntu 16.04.3 下不同大小的进程 fork 和 exec 的时间。脏线显示使用脏页 fork 进程的开销,必须将其降级为只读来做写入时复制映射。在碎片化的情况下,父对象只会污染它的堆栈,但会通过交替分配只读和读写页面来模拟复杂应用的内存布局,后者的复杂性体现在共享库、随机化地址空间和实时编译等。相比之下,无论父进程的大小或内存布局如何,posix_spawn() 需要相同的时间都一样(大约 0.5 ms)。
Fork 无法扩展。在 Linux 中,设置 fork 的写时复制映射所需的内存管理操作会损害可扩展性 [22,82],但真正的问题在更深的层次:正如 Clements 等人 [29] 所观察到的,fork API 的规范本身就引入了一个瓶颈,因为(与 spawn 不同)它无法与进程上的其他操作通信。其他因素进一步阻碍了 fork 的可扩展实现。直观地说,扩展系统规模就要避免不必要的共享。Fork 进程启动时就与其父进程共享所有内容。由于 fork 复制了进程操作系统状态的所有方面,因此它鼓励将该状态集中在单体内核中,这样复制和 / 或引用计数开销较少。这样就难以实现诸如用于安全性或可靠性的内核划分了。
Fork 鼓励内存过度使用。在考虑写时复制页面映射所使用的内存时,fork 的实现者面临着一个艰难的选择。这样的页面都代表了一个潜在的分配——如果页面的任何副本被修改,将需要一个新的物理内存页面来解决页面错误。因此,保守的实现会让 fork 调用失败,除非有足够的后备存储来应对所有潜在的写时复制错误 [55]。但是,当一个大进程执行 fork 和 exec 时,会创建许多写时复制页面映射但从不去修改,尤其是 exec 过的子进程很小时更是如此;并且因为最坏的分配情况(进程的虚拟空间加倍)无法实现就导致 fork 失败是不可理喻的。
另一种方法,也就是 Linux 上的默认方法是过度使用虚拟内存:建立虚拟地址映射的操作(包括 fork 的地址空间的写时复制克隆)无论是否存在足够的后备存储,都会立即成功。后续页面错误(例如,对分 fork 页面的写入)可能无法分配所需的内存,而调用基于启发式的 “内存外杀手” 来终止进程并释放内存。
需要明确的是,Unix 并不需要过度使用,但我们认为写入时复制 fork(而不是类似于 spawn 的工具)的广泛应用让这种现象泛滥了。现实应用程序并没有准备好处理 fork [27,37,57] 中明显虚假的内存不足错误。Redis 使用 fork 进行持久化,明确建议不要禁用内存过量提交 [67];否则,Redis 必须限制在总虚拟内存的一半用量,以避免在内存不足的情况下被杀死的风险。
总结。今天的 Fork 是适合单线程进程的 API,具有较小的内存占用和简单的内存布局,需要对其子进程的执行环境进行细粒度控制,但不需要与它们完全隔离。换句话说,它是一个 shell。毫不奇怪,Unix shell 是第一个 fork [69] 的程序,fork 的支持者也会拿 shell 举例作为其优雅的证明 [4,7]。但是,大多数现代程序都不是 shell。为了方便 shell 而去优化操作系统 API 现在还是个好主意吗?
5 实现 fork
虽然很难量化在现有系统上实现 fork 的成本,但有明显证据表明支持 fork 限制了操作系统体系结构的变化,并限制了操作系统适应硬件演进的能力。
Fork 与单个地址空间不兼容。许多现代上下文将执行限制在单个地址空间,包括 picoprocess [42]、unikernels [53] 和 en- claves [14]。尽管有数量庞大操作系统研究者在使用并改进 Unix 系统,但如果研究者使用的是不基于 fork 的系统,那么就更容易适应这些环境。
例如,Drawbridge libOS[65] 在隔离的用户模式地址空间内实现二进制兼容的 Windows 运行时环境,称为 picoprocess。Drawbridge 支持同一共享地址空间内的多个 “虚拟进程”; CreateProcess() 是通过在地址空间的不同部分加载新的二进制文件和库,然后创建一个单独的线程来开始执行子进程,同时确保跨进程系统调用是按预期运行实现的。不用说,这些进程之间没有安全隔离——主 picoprocess 负责提供安全边界。然而,该模型已被用于在 SGX Enclave 内支持完整的多进程 Windows 环境 [14],使包含多进程和程序的复杂应用程序能够部署在 enclave 中。
相比之下,fork 在单地址空间 [23] 中需要复杂的编译器和链接调整 [81] 才能实现。因此,从 Unix 系统派生的 Unikernels 不支持内部多进程环境 [44,45],并且在 enclave 中运行多进程 Linux 应用程序要复杂得多。SCONE 和 SGX-LKL 仅支持单进程应用程序 [6,50]。Graphene-SGX [79] 通过在新的主机进程中创建一个新的接口来实现 fork,然后通过加密的 RPC 流复制父进程的内存;这套操作可能要花几秒钟的时间。
Fork 与异构硬件不兼容。Fork 将进程的抽象与包含它的硬件地址空间混合在一起。实际上,fork 将进程的定义限制为单个地址空间,并且(如前所述)是在某个核心上运行的单个线程。
现代硬件和在其上运行的程序并不是这样的机制。硬件越来越异构化,并且使用诸如带内核旁路 NIC[12] 的 DPDK,或使用 GPU 的 OpenCL 的进程无法安全地 fork,因为操作系统无法复制 NIC/GPU 上的进程状态。这种困境似乎已经困扰了 GPU 程序员至少十年 [58-60,74]。随着未来的片上系统包含越来越多的有状态加速器,这种情况只会变得更糟。
Fork 会感染整个系统。仅支持 fork 对系统的设计和运行时环境造成了很大的限制。任何层的高效 fork 都需要在其下的所有层上都有基于 fork 的实现。例如,Cygwin 是 Windows 的一个 POSIX 兼容环境;它实现了 fork 以运行 Linux 应用程序。由于 Win32 API 缺少 fork,Cygwin 在 CreateProcess()[31,47] 之上模拟它:它创建一个新的进程,在恢复子进程之前运行与父进程相同的程序并复制所有可写页面(数据部分、堆、堆栈等)。这既不快也不可靠,并且可能由于多种原因而失败,最常见的失败是当父和子进程中的存储器地址因地址空间布局随机化而不同时出现的。
讽刺的是,NT 内核本身支持 fork;只有 Cygwin 所依赖的 Win32 API 才没有(用户模式库和系统服务不支持 fork,因此 fork 的 Win32 进程会崩溃)。作为一个抽象,fork 无法 compose:除非每个层都支持 fork,否则无法使用它。
在研究用操作系统中 fork:K42 的经验
许多研究用操作系统都面临着是否(以及如何)实现 fork 的困境,本文作者就亲身经历了 6 个这种案例 [13,36,41,48,51,80]。这种选择至关重要。实现 fork 打开了支持大量 Unix 派生应用程序的大门,其中最先用到的是 shell 和构建工具,它们可以简化整个系统的创建过程。然而 fork 也让研究者束手束脚:但凡一个系统实现了 fork,尤其是想要高效实现 fork 或者在开发初期就引入 fork 的系统都会无可救药地变成类 Unix 的设计。
K42 [48] 是基于我们在 Tornado [36] 的经验上开发的系统,展示了对多处理器友好的面向对象方法、基于各个应用程序的可定制对象和微内核架构 [5] 的好处,以实现普遍的局部性和并发优化。我们的目标是构建一个成熟的通用操作系统,在(可能)非常大规模的多处理器平台上支持使用多操作系统特性的大量应用程序。最后,K42 与 POSIX 兼容并且兼容 Linux ABI,但是为 Linux 特性执行 fork 操作的设计导致 fork 语义颠覆了整个操作系统设计,对其它特性都带来了负面影响。
我们开始以为我们能够像 Cygwin 一样实现 fork:作为用户级库函数,通过适当地构造必要的新对象实例来创建现有进程的子副本。这本身并不是个问题。相比之下,为了允许任何进程在任何时候都可 fork,并且在追求高性能表现的同时有效地做到这一点的努力最终失败了——随之而来的复杂性让我们放弃了几乎所有特性,只剩下对 Unix 的支持和对我们原生特性的支持了。
尤其严重的是,以下问题几乎渗透到了系统的每个方面:
反模块化:只要是可能支持正在运行进程的对象实现就需要在进程 fork 时定义其行为。这让实现专用组件变得非常复杂,这些组件的目的可能仅仅是为长期运行的并行科学计算或服务器引入局部优化而已,根本用不着 fork。
内在的懒惰需求:鉴于每个核心的资源,从内存区域和文件到特定特性的抽象,诸如文件描述符和信号处理器都需要 fork 支持,我们只能实现懒惰写时复制行为来缓解性能压力。这不仅增加了单个对象中的复杂度,还需要对象交互来维护 fork 创建的层次关系。这与我们限制共享和同步的目标背道而驰,结果损害了局部性。
中心化:操作系统的可扩展性是通过避免中心化的策略和避免确切全局知识的机制来实现的 [11]。因此,跨对象实例和服务器的分解状态和功能成为了我们的核心理念。但是,尽管 fork 是在库代码中协调的,它还是需要与进程可能连接的所有服务器和对象通信。
可扩展性较差:除了违反我们的核心可扩展性原则之外,在 NUMA 系统中 fork 必须要么访问父进程位置的存储器,要么将子进程安排在系统的受控部分中;这些都是我们花费大量精力去解决的固有问题。
事后看来,我们犯了一个错误,没有仔细评估 fork 的实际用例。如果我们将 K42 的 fork 局限到单线程进程(例如 shell)上,我们就可以避免让它的复杂性影响到核心对象了。
6 取代 fork
既然 fork 有这么多问题,那么该用什么来取代它? 创建新进程会往往会引出混乱的 API 设计问题,因为任何选项都必须隐式或显式地指定属于新进程的所有操作系统资源的初始状态。Fork 的应对很简单:复制一切,结果如我们所见最后成为了 fork 的软肋。为了取代 fork,我们提出了一个上层 spawn API 和一个底层微内核 API 的组合,以便在执行之前设置一个新进程。然后我们讨论了无需 exec 的 fork 的替代方案。
上层:Spawn。在我们看来,fork 和 exec 的大多数功能最好改由 spawn API 提供。这种改动所需的重构工作可能会很棘手,尤其是当 fork 和 exec 在代码中的位置并不好找的时候;但正如我们在 §4 中所示,这种方案的性能和可靠性有着显著优势,更不用说可移植性了。值得注意的是,使用 fork 的主流应用程序(例如,Apache,Chrome,PostgreSQL)的 Windows 端口并不支持 fork,因此 fork 显然不是必需的。
posix_spawn()API 可以简化这种重构。spawn 属性不要求在单个调用站点上提供影响新进程的所有参数(如 CreateProcess() 的情况),而是由可扩展定义的辅助函数设置。例如,fork 之前的 close() 可以用预生成的调用替换,该调用记录了在子进程中发生的 “关闭动作”。不幸的是,这意味着 API 被指定为由 fork 和 exec 实现,尽管这实际上没必要 [32]。
posix_spawn() 的主要缺点在于,它不是 fork 和 exec 的完整替代。它尚不支持一些不太常见的操作,例如设置终端属性或切换到隔离的命名空间;它还缺少有效的错误报告机制:在子进程开始执行之前发生的故障(例如无效的文件描述符参数)是异步报告的,并且与子进程的终止无法区分。这些缺点可以而且应该得到纠正。
替代方案:vfork()。这种流行的 fork 变体由 BSD 引入作为优化措施 [15];它创建了一个直到子进程调用 exec 前共享父地址空间的新进程,更像是原始的 Genie fork [71]。为了让子进程能使用父进程的堆栈,它会阻止父进程执行,直到 exec 为止。这种编程风格类似于 fork,其中新进程在 exec 之前调整其内核状态。但由于地址空间共享,vfork() 很难安全使用 [34]。虽然 vfork() 避免了克隆地址空间的成本,并且在难以重构代码使用 spawn 的场合可以用来替换 fork,但在大多数情况下最好别用它。
底层:跨进程操作。虽然大多数启动新程序的实例都喜欢类似于 spawn 的 API,但为了完全通用,它需要一个 flag、参数或者辅助函数控制过程状态的所有可能方面。单个操作系统 API 无法完全控制新进程的初始状态。在今天的 Unix 中,高级用例的唯一后备仍然是在 fork 之后执行的代码,但是整洁状态设计 [例如,40,43] 已经演示了一种替代模型,其中修改每个进程状态的系统调用不仅限于当前进程,而可以操纵调用者能访问的任何进程。这就带来了 fork/exec 模型所有的灵活性和正交性,但避开了后者的大多数缺点:一个新的进程从一个空的地址空间开始,一个高级用户可能以零碎的方式操作它,填充它的地址空间和内核执行前的上下文,无需克隆父项,也不需要在子项的上下文中运行代码。ExOS[43] 使用这种原语的顶层用户模式实现了 fork。将跨进程 API 纳入 Unix 看起来很有挑战性,但也会对未来的研究有所帮助。
替代方案:clone()。这个系统调用是 Linux 上所有进程和线程创建的基础。就像它之前的 Plan 9 的 rfork() 一样,它需要单独的 flag 来控制子进程的内核状态:地址空间、文件描述符表、命名空间等。这避免了 fork 的一个问题:它的行为对于许多抽象是隐式的或未定义的。但是,对于每个资源都有两个选项:在父项和子项之间共享资源,或者复制它。因此,clone 遇到了 fork 所面临的大多数问题。
只使用 fork 的用例。一些特殊情况下,fork 后面并不会跟着需要复制父进程的 exec。
多进程服务器。传统上,构建并发服务器的标准方法是 fork 进程。然而,推动多进程服务器的动力早已不复存在:操作系统库是线程安全的,并且困扰的多线程或事件驱动服务器的可扩展性瓶颈已经消失 [10]。虽然从故障隔离的角度来看进程边界可能还有其价值,但我们认为使用 spawn API 启动这些进程更有意义。当大多数并发由多线程处理,且现代操作系统会对内存进行重复数据删除的情况下,fork 带来的共享初始状态的性能优势就没那么明显了。最后,使用 fork 时所有进程要共享相同的地址空间布局,并且容易受到盲目 ROP 攻击 [17]。
写时复制内存。fork 的现代实现使用写时复制来减少复制很快会被丢弃的内存的开销 [72]。由此以来许多应用程序只依赖 fork 来获得对写时复制内存的访问权限。一种常见模式是从预先初始化的进程中分离,以减少工作进程的启动开销和内存占用,如 Linux 上 [4] 的 Android Zygote [39,62] 和 Chrome 站点隔离。另一种模式使用 fork 来捕获正在运行的进程的地址空间的一致快照,允许父进程继续执行;这包括 Redis [68] 中的持久性支持,以及一些反向调试器 [21]。
POSIX 将受益于一个 API,它可以无需 fork 新进程就使用写时复制内存功能。Bittau [16] 建议使用 checkpoint() 和 resume() 调用来获取地址空间的写时复制快照,从而减少安全隔离的开销。最近,Xu 等人 [82] 观察到 fork 花费的时间是影响 fuzzing 工具性能的主要因素,并提出了类似的 snapshot()API。这些设计尚不足以涵盖上述所有用例,但也许可以作为新的起点。我们注意到,任何新的写时复制内存 API 都必须解决 §4 中描述的内存过度使用问题,但是将此问题与 fork 解耦应该处理起来会简单些。
7 让我的操作系统摆脱 fork!
我们已经解释了为什么 fork 已经是旧时代的老古董了,它会损害应用程序和操作系统的设计。我们必须做三件事来纠正这种情况。
弃用 fork。由于 Unix 的广泛流行,未来的操作系统在很长时期内都需要支持 fork;但不管怎样,50 年前的一种偏门技巧不应该决定未来操作系统的设计。因此,我们强烈建议不要在新代码中使用 fork,并尝试将其从现有应用程序中删除。一旦 fork 离开了性能关键路径,它就可以从操作系统的核心中删除,并根据需要重新实现。如果未来的系统仅在有限的情况下支持 fork,例如单线程进程 [2],那么仍然可以在无需非必要的复杂性的情况下运行传统软件。
改进替代方案。很长一段时间里,fork 已经成为类 Unix 系统上的通用进程创建机制,其他抽象层则叠在最顶层。值得庆幸的是这种情况已经开始改变 [32,38],但是还有更多工作要做(§6)。
修正我们的课本。显然,学生需要学习 fork,但是目前大多数教科书(和教师)都使用 fork [7,35,78] 做例子来讲解进程创建过程。这不仅会延长 fork 的生命期,而且是在灌输过时的知识——这种 API 根本就不直观。正如现代编程课程不会以 goto 开头一样,我们建议大家教授 posix_spawn() 或 CreateProcess(),然后将 fork 作为其历史背景的特殊情况讲一讲就够了(§2)。
本文的备注可在原始论文附注中查看。
查看英文原文: https://www.microsoft.com/en-us/research/publication/a-fork-in-the-road
概述1 引言2 历史起源:fork 最初是一种取巧3 FORK API 的优点4 现代的 fork5 实现 fork在研究用操作系统中 fork:K42 的经验6 取代 fork7 让我的操作系统摆脱 fork!