Unix/Linux fork/exec的前世今生

本文是《Linux fork那些隐藏的开销》的前传《Unix/Linix fork前传》。转载注明来自公众号“Linux阅码场”。


昨天(好像是上周的事了,暴雨天?),我发了个朋友圈,承诺给大家扒拉扒拉fork和exec的历史,顺便说一下fork/exec/exit/wait家族的历史。

了解历史才能预测未来。

写本文就是来兑现这个承诺的。


一开始的Unix没有fork,一开始Unix也不需要创建新进程,一开始Unix只有exec。

fork的由来

fork的思想在UNIX出现几年前就出现了,时间大概是1963年,这比UNIX在PDP-7上的第一个版本早了6年。

1963年,计算机科学家Melvin Conway(以Conway’s Law闻名于世)写下一篇论文,正式提出了fork思想,该论文链接:
A Multiprocessor System Design: https://archive.org/details/AMultiprocessorSystemDesignConway1963/page/n7

fork的思想最初是Conway作为一种 多处理器并行 的方案提出来的,这个想法非常有意思。简而言之,fork思想来源于流程图。

我们看一个普通的流程图:
Unix/Linux fork/exec的前世今生_第1张图片
如果我们承认一个计算机程序可以表示为一个流程,那么我们就不能否认这种并行方案是正确的。

你看,流程图的分枝处,fork-叉子,多么形象!

一个流程图上的分支点分裂出来的分支显然是逻辑独立的,这便是可并行的前提,于是它们便可以表现为不同的 处理进程(process) 的形式,当时的表达还只是“process”这个术语,它还不是现代操作系统意义上的“进程”的概念。

join同步点表现为多个并行处理的进程由于某种原因不得不同步的点,也就是多个并行流程汇合的点,直到现在,在多线程编程中,这个点依然叫join。比如Java Thread的join方法以及pthread库的pthread_join函数。

广义来讲,join也表示诸如临界区等必须串行通过的点, 减少join点的数量将会提高并行的效率。

我们来看看Conway论文中关于fork的原始图示:
Unix/Linux fork/exec的前世今生_第2张图片

Conway在论文中的另一个创举是,他将处理进程(也就是后来操作系统中的process的概念)以及执行该进程的处理器(即CPU核)分离了开来,抽象出了schedule层。

大意是说, “只要满足系统中的活动处理器数量是总处理器数量和并行处理进程的最小值即可。” 这意味着调度程序可以将多处理器系统的所有处理器和系统所有处理进程分别看作是统一的资源池和消费者,执行统一调度:
Unix/Linux fork/exec的前世今生_第3张图片在UNIX引入fork之后,这种多处理器并行的设计思想就深入到了UNIX的核心。这个思想最终也影响了UNIX以及后来的Linux,直到现在。

关于这个设计思想为什么可以影响UNIX这么久,我想和Conway本人的“Conway’s law”不无关系,在这个law中,他提到:
Any organization that designs a system (defined broadly) will produce a design whose structure is a copy of the organization’s communication structure.
真正的大牛一枚!

好了,fork本身的由来我们已经了解,就像做菜一样,现在我们把它放在一边备用。

花开两朵,各表一枝。接下来看UNIX fork的另一个脉络。

早期UNIX的覆盖(overlaying)技术

1969年最初的UNIX用一种在现在看来非常奇怪的方式运行。

一般的资料都是从UNIX v6版本开始讲起,那个版本已经是比较 “现代” 的版本了,所以很少有人能看到最初的UNIX是什么样子的。即便是能查阅到的1970年的PDP-7上运行的UNIX源码,也是引入fork之后的版本,在那之前的最原始版本几乎找不到了(你可能会说,那时的UNIX不叫UNIX,but who cares…)。

1969年的汤普森版UNIX超级简陋,这可以在Dennis M. Ritchie的一篇论文中见一斑:
The Evolution of the Unix Time-sharing System: http://www.read.seas.harvard.edu/~kohler/class/aosref/ritchie84evolution.pdf
建议细读。

最初的UNIX是一个分时系统,它只有两个shell进程,分别属于两个终端:
在这里插入图片描述
可见其简陋。这里插叙一段关于分时系统的文字:

分时系统最初并不是基于进程分时的,那时根本还没有完整的进程的概念,分时系统是针对终端分时的,而操作员坐在终端前,为了让每个操作员在操作过程中感觉上是在独占机器资源,每个终端享受一段时间的时间片,在该时间片内,该终端前的操作员完全享受机器,但是为了公平,超过了时间片,时间片就要给另一个终端。

时间片的大小一般是十几秒到分钟级别。这个和现如今的进程调度时间片完全是两个概念。

就是这样,最初的UNIX为了体现分时特性,实现了最少的两个终端。注意,最初的UNIX没有fork,没有exec,甚至没有多进程的概念,为了实现分时,系统中仅有两个朴素的shell进程。

事实上,最初的UNIX用只有两个元素的表来容纳所有进程(显然,这看起来好笑…),当然,这里的 “表” 的概念也是抽象的朴素概念,因为当时的系统是用PDP-7的汇编写的,还没有后来C语言数据结构。

我们现在考虑其中一个终端的shell进程如何工作。马上问题就来了, 这个shell进程如何执行别的命令程序??

如果说系统中最多只能容纳两个进程,一个终端只有一个shell进程的话,当该终端的shell进程执行其它命令程序时,它自己怎么办?这个问题得思考一会儿…

注意,不要用现代的眼光去评价1969年的初版UNIX,按照现代的眼光,执行一个程序必然要生成一个新的进程,显然这在初版UNIX中并不正确。

答案是根本不用产生新的进程,直接将命令程序的代码载入内存并 覆盖 掉shell进程的代码即可!当命令执行完后,再用shell的代码覆盖掉命令程序的代码,针对单独的终端,系统其实一直在执行下面的覆盖循环(摘自论文的Process control 章节):
Unix/Linux fork/exec的前世今生_第4张图片
这会让很多人大跌眼镜的吧。

然而,在fork被引入UNIX之前,事实就是这样。一个终端上一直都是那一个进程,一会儿它执行shell的代码,一会儿它执行具体命令程序的代码,以下是一个覆盖程序的结构(图片来自《FreeBSD操作系统设计与实现》一书):
Unix/Linux fork/exec的前世今生_第5张图片
结合上图和无聊的覆盖循环的第3)步,你会发现,这个第3)步其实就是exec的逻辑。如果你熟悉Linux内核execve系统调用加载ELF可执行文件的逻辑,你会发现,对于ELF文件而言,这里所谓的bootstrap其实就是load_elf_binary函数。

然而,当时毕竟还没有将这个逻辑封装成exec系统调用,这些都是每一个进程显式完成的:

  • 对于shell执行命令程序而言,shell自己执行disk IO来载入命令程序覆盖掉自身;
  • 对于命令程序执行结束时,exit调用内部执行disk IO载入shell程序。

exec逻辑是shell程序的一部分,由于它会被所有的命令程序所使用,该逻辑也被封装到了exit调用中。

fork引入UNIX前的表象

好了,目前为止,我们看完了两条线索:

  1. 1963年Melvin Conway提出了fork思想,作为在多处理器中并行执行进程的一个手段。
  2. 1969年汤普森版UNIX仅有两个shell进程,使用覆盖(overlaying)技术执行命令。

截止目前,我们看到的表象是:

  • 汤普森版UNIX没有fork,没有exec,没有wait,仅有的库函数般的exit也和现在的exit系统调用大相径庭,显然汤普森版UNIX并非一个多进程系统,而只是一个可以跑的简陋的两终端分时系统!

UNIX fork的诞生

fork是如何引入UNIX的呢?

这还要从采用覆盖技术的汤普森版UNIX所固有的问题说起,还是看论文原文:
Unix/Linux fork/exec的前世今生_第6张图片

若要解决这些问题,很简单的方案汤普森都想到了:

  • 保持shell进程的驻留而不是销毁。命令执行时,将其交换到磁盘便是了

很显然,命令程序是不能覆盖掉shell进程了。解决方案是使用 “交换” 技术。

交换技术和覆盖技术其实都是解决有限内存的多进程使用问题的,不同点在于方向不同:

  • 覆盖技术指的是用不同的进程磁盘映像覆盖当前的进程内存映像。
  • 交换技术指的是用将进程的内存映像交换到磁盘,载入一个别的进程磁盘映像。

使用交换技术解决覆盖的问题,意味着要创建新的进程:

  • 在新的进程中执行命令程序。

UNIX需要进行改动,两个配额的进程表显然不够用了。当然,解决方案也并不麻烦:
在这里插入图片描述
现在,剩下唯一的问题就是如何创建新进程了!谁来临门一脚呢?

要讲效率,创造不如抄袭,创建新进程的最直接的就是copy当前shell进程,在copy的新进程中执行覆盖,命令程序覆盖copy的新进程,而当前的终端shell进程则被交换到磁盘保得全身。

覆盖和交换相结合了,UNIX离现代化更近了一步!

确定了copy当前进程的方案后,进一步的问题是如何来copy进程。

现在要说回fork了。

Conway提出fork思想后,马上就有了fork的实现原型(正如Conway自己所说,他只是提出了一个可能造就存在的想法,并没有实现它),Project Genie算是实现fork比较完善的系统之一了。

Project Genie系统的fork不仅仅是盲目地copy进程,它对fork的过程拥有精细的控制权,比如分配多大的内存空间,copy哪些必要的资源等等。显然,Project Genie的fork是冲着Conway的多处理器并行逻辑去的。

还是那句话,创造不如抄袭,UNIX若想实现进程copy,有一个现成的模版就是Project Genie,但是Project Genie的fork对于UNIX太过复杂,太过精细化了,UNIX显然用不到这些精细的控制, UNIX仅仅是想让fork出来的新进程被覆盖,而不是让它去执行什么多处理器上的并行逻辑。

换句话说,UNIX只是借用了fork的copy逻辑的实现,来完成一件别的事。

于是,UNIX非常粗暴的实现了fork!即完全copy父进程,这就是直到现在我们依然在使用的fork系统调用:
Unix/Linux fork/exec的前世今生_第7张图片
没有参数,非常简洁,显得这很优雅。

取了个巧,奇技淫巧:

  • fork本来就不是让你用来覆盖新进程的,不然为何多此一举。fork是让你来分解程序流程得以并行处理的。

UNIX fork就此诞生!

我们再次回顾一下UNIX fork诞生之前的景象:
Unix/Linux fork/exec的前世今生_第8张图片
每个终端单一的进程兜兜转转循环覆盖,再没有别的可能性。

再来看看fork诞生之后的景象:
Unix/Linux fork/exec的前世今生_第9张图片
有了fork之后,UNIX进程便可以组合出无限的可能,正式成为一个名副其实的多用户多进程现代操作系统了。fork孕育了无限的可能性(Linux上可用pstree命令观测):
Unix/Linux fork/exec的前世今生_第10张图片

于是UNIX正式迈开了现代化建设的步伐,一直走到了今天。

UNIX fork-exec

关于exec,故事没什么好讲的,它事实上就是关于上述覆盖逻辑的封装,此后程序员不必自己写覆盖逻辑了,直接调用exec系统调用即可。

于是经典的UNIX fork-exec序列便形成了。

UNIX fork/exec/exit/wait

值得一提的是,fork被引入UNIX后,exit的语义发生了巨大的改变。

在原始的1969年汤普森版UNIX中,由于每一个终端有且仅有一个进程,这意味着覆盖永远是在shell程序和某个命令程序之间进行的:

  • shell执行命令A:命令程序A覆盖内存中的shell代码。
  • 命令A执行结束:shell覆盖结束的命令A的内存代码。

然而,在fork被引入后,虽然shell执行某个命令依然是特定的命令程序覆盖fork出来的shell子进程,但是当命令执行完毕后,exit逻辑却不能再让shell覆盖当前命令程序了,因为shell从来就没有结束过,它作为父进程只是被交换到了磁盘而已(后来内存到了,可以容纳多个进程时,连交换都不需要了)。

那么exit将让谁来覆盖当前进程呢?

答案是不用覆盖,按照exit的字面意思,它只要结束自己就可以了。

本着 自己的资源自己管理的责任原则 exit只需要清理掉自己分配的资源即可。比如清理掉自己的内存空间以及一些其它的数据结构。

对于子进程本身而言,由于它是父进程生成的,所以它便由父进程来管理释放。于是经典的UNIX进程管理四件套正式形成:
Unix/Linux fork/exec的前世今生_第11张图片
故事基本上讲完了。

fork为何成为了神话?因为UNIX系统的设计思想从1970年代开始垄断了操作系统的设计,当时没有任何足以抗衡的操作系统用不同的方式来实现进程管理。要问为什么fork几十年都不变,答案就是 积重难返!

fork的后续

我试着给fork过时的观点寻找一些权威些的支撑,推荐另一篇论文的ppt,来自微软:
A fork() in the road: https://www.microsoft.com/en-us/research/uploads/prod/2019/04/fork-hotos19-slides.pdf
当然,最好还是去阅读该论文的原文。

PDP-7机器的内存映射非常简单,当时的程序也非常小,fork调用中直接copy整个父进程是最简单有效的方案。这一点并没有随着时间的发展而发生什么比较大的变化,一直到1980年代中期,fork的开销依然可以接受。

虽然变化并不在瞬间,事情却一直在悄悄地起变化。

随着计算机体系结构的发展,计算机的能力越来越强,业务逻辑的越发繁复也促进了编程技术的发展。程序越来越大,内存映射机制越来越复杂,fork的开销开始不容忽视,此时COW来救场,然而,最终还是需要更大的变化。

计算机体系结构发展带来了收益,但代价却是管理开销的增加。仍然以页表为例,如果你觉得32位系统一个稀疏地址空间的进程页表开销还可以接受,算一下64位系统一个稀疏地址空间的进程页表开销。在这样的背景下,fork机制即便辅助以COW,系统也将不堪重负(页表的COW以及COW的缺页中断处理)。

  • 是时候让fork回归它1963年的本源了,让它去处理多处理器的并行化吧,然后用更直接的方式来创建新进程。

对了,本文中我没有提到丹尼斯.里奇,而只是提到了肯.汤普森,原因是有的。1969年最初写UNIX就是汤普森,办公室主任里奇是后来加入的,他主要贡献了设备文件的思想。
在这里插入图片描述
显然,本文所说的fork被引入之前的最初的UNIX版本,就是汤普森所作,所以叫做汤普森版UNIX。

此外,关于早期UNIX的源码和文献,可以在以下的站点找到:
https://minnie.tuhs.org/

浙江温州皮鞋湿,下雨进水不会胖!

你可能感兴趣的:(Unix/Linux fork/exec的前世今生)