fork 的思想比 UNIX 早出现几年,大约是 1963 年,比 UNIX 在 PDP-7 上的第一个版本早了 6 年。1963 年,计算机科学家 Melvin Conway 写下论文 A Multiprocessor System Design,正式提出了 fork 思想——最初是作为多处理器并行的方案提出来的。
fork 来源于流程图
简而言之,fork 思想来源于流程图:如果我们承认一个计算机程序可以表示为一个流程,那么就不能否认这种并行方案的正确性。流程图的分支处,fork(叉子)多么形象——一个分支点分裂出来的分支显然是逻辑独立的,这就是可并行的前提,它们便可以表现为不同的处理进程(当时的 process 还不是现代操作系统意义上的进程)。
join 同步点则表现为多个并行进程因某种原因必须同步、汇合的点。直到现在,多线程编程中这个点依然叫 join,比如 Java Thread 的 join 方法、pthread 的 pthread_join 函数。广义上讲,join 也表示临界区等必须串行通过的点,减少 join 点的数量会提高并行效率。
Conway 论文的另一个创举,是把处理进程与执行它的处理器分离,抽象出了 schedule 层——大意是”只要满足系统中的活动处理器数量是总处理器数量和并行处理进程的最小值即可”。这意味着调度程序可以把所有处理器和所有处理进程分别看作统一的资源池和消费者,执行统一调度。这种多处理器并行的设计思想,最终影响了 UNIX 以及后来的 Linux。
Conway 本人还有一句名言:
Any organization that designs a system (defined broadly) will produce a design whose structure is a copy of the organization's communication structure.
早期 UNIX 的覆盖技术
接下来看 UNIX fork 的另一个脉络——早期 UNIX 的覆盖(overlaying)技术。
1969 年最初的 UNIX 运行方式在现在看来非常奇怪。一般资料都从 UNIX v6 版本讲起,那个版本已经是比较”现代”的了,所以很少有人能看到最初的 UNIX。1969 年的汤普森版 UNIX 极其简陋,这可以在 Dennis M. Ritchie 的论文 The Evolution of the Unix Time-sharing System 中见一斑。
最初的 UNIX 是分时系统,只有两个 shell 进程,分属两个终端。这里插叙一段分时系统的背景:分时系统最初并不是基于进程分时的(那时还没有完整的进程概念),而是针对终端分时的——操作员坐在终端前,为了让每个人感觉独占机器,每个终端享受一段时间的时间片(十几秒到分钟级,与现在的进程调度时间片完全是两个概念)。所以最初的 UNIX 为了体现分时特性,只实现了最少的两个终端。
注意,最初的 UNIX 没有 fork、没有 exec、甚至没有多进程概念,系统中仅有两个朴素的 shell 进程,用只有两个元素的表容纳所有进程(当时的系统用 PDP-7 汇编编写,还没有 C 语言的数据结构)。
那么问题来了:一个终端只有一个 shell 进程,当它执行其它命令程序时,自己怎么办?答案很直接——根本不产生新进程,直接把命令程序的代码载入内存覆盖掉 shell 进程的代码。命令执行完后,再用 shell 代码覆盖掉命令程序的代码。针对单个终端,系统一直在执行这样的覆盖循环:
- shell 执行命令 A:命令程序 A 覆盖内存中的 shell 代码;
- 命令 A 执行结束:shell 覆盖掉命令 A 的内存代码。
这个覆盖逻辑的第 3 步,其实就是 exec 的逻辑——如果你熟悉 Linux 内核 execve 系统调用加载 ELF 文件的逻辑,会发现对 ELF 文件而言,这里的 bootstrap 就是 load_elf_binary 函数。但当时还没有把该逻辑封装成 exec 系统调用,而是每个进程显式完成的:shell 自己执行磁盘 I/O 载入命令程序覆盖自身;命令结束时,exit 内部执行磁盘 I/O 载入 shell 程序。exec 逻辑是 shell 程序的一部分,也被封装进了 exit 调用。
fork 的诞生
到这里我们看完了两条线索:
- 1963 年 Conway 提出 fork 思想,作为多处理器并行执行进程的手段;
- 1969 年汤普森版 UNIX 仅有两个 shell 进程,用覆盖技术执行命令。
覆盖技术存在固有缺陷。要解决它,思路很简单——保持 shell 进程驻留而不是销毁:命令执行时,把 shell 交换到磁盘。这样命令程序就不能覆盖 shell 进程了,这就用到了”交换”技术。
覆盖与交换都是解决有限内存下多进程使用问题的,方向相反:
- 覆盖:用不同的进程磁盘映像覆盖当前进程的内存映像;
- 交换:把进程内存映像交换到磁盘,再载入另一个进程磁盘映像。
使用交换技术,意味着要创建新进程,两个配额的进程表不够用了。剩下唯一的问题:如何创建新进程?最直接的办法就是 copy 当前 shell 进程,在新进程中执行覆盖——命令程序覆盖 copy 出来的新进程,终端 shell 进程则被交换到磁盘。覆盖与交换结合,UNIX 离现代化更近了一步。
确定了 copy 方案后,就是如何 copy。Conway 提出 fork 思想后,很快有了实现原型——Project Genie 算是实现 fork 比较完善的系统之一,它对 fork 过程有精细的控制(分配多大内存、copy 哪些资源),是冲着 Conway 的多处理器并行去的。但对 UNIX 而言,Project Genie 的 fork 太复杂、太精细化了,UNIX 只是想 fork 出一个被覆盖的新进程,并不需要多处理器并行。
于是 UNIX 非常”粗暴”地实现了 fork——完全 copy 父进程,没有参数,非常简洁,这就是直到现在我们仍在使用的 fork 系统调用。严格说,fork 本不是让你来覆盖新进程的,而是让你分解程序流程以并行处理;UNIX 借用了 fork 的 copy 逻辑来完成另一件事。
fork 之后
有了 fork,UNIX 进程就能组合出无限可能,正式成为一个名副其实的多用户多进程操作系统(Linux 上可用 pstree 观测)。关于 exec 没什么好讲的,它就是上述覆盖逻辑的封装,此后程序员直接调用 exec 系统调用即可,经典的 UNIX fork-exec 序列就此形成。
值得一提的是,fork 引入后,exit 的语义发生了巨大改变:原本每个终端只有一个进程,覆盖永远在 shell 与命令程序之间进行;而 fork 引入后,shell 作为父进程只是被交换到磁盘(后来内存大了连交换都不需要),命令结束后 exit 不能再让 shell 覆盖当前进程,只需结束自己即可——本着自己的资源自己管理的原则,exit 清理掉自己的内存空间等资源。对于子进程而言,它是父进程生成的,因此由父进程来管理释放。