漫谈fork

fork有两个意思,

  • 一个是软件工程中的fork,例如github中的fork,而fork的含义随着开源社区的发展,含义有了悄悄的变化
  • 另一个Linux系统中创建进程的fork

 

1. 软件工程中的fork

1.1 传统开源语境下

对于很早很早以前就开始做开源的人,那个时候如果有人说,You are forked ,其实是一个不好消息。fork 就是 Knife and Fork 中的 fork ,就是吃饭用的叉子。但是开源代码跟叉子是怎么挂上勾的呢?

开源项目中一般很多很多人向同一个项目贡献代码,提交commit,基本用 Git 做版本控制,此时就有了fork。

那项目什么时候会被 fork 呢?项目不断开发,总有人会对项目的方向有不同意见,所以这些人会把项目代码自己拷贝一份。然后按照不同的思路继续开发。这样形成的结果是,两拨人的项目的最初的一些版本是相同的,也就是开发都在一条线上,但是后来大家分道扬镳了,项目变成了两条线来进行。这样整体上看起来就像叉子一样,顶部分叉了。这就是 fork 这个词在开源世界的最初含义了,国内对 fork 的翻译是”分叉“,翻译的非常好。

所以说,以前开源世界的fork,最本质的意思就是,有些人跟我有不同意见,自己另起炉灶了。所以,一旦被 fork ,就意味着团结破裂,竞争出现,所以是不太好的感觉

1.2 Github 语境下

2008年 Github 诞生了,Fork 的含义开始变得越来越正面。Github 甚至还出过一个文化衫,上面赫然印着 Fork me 。这种含义的转变,跟 Git 的工作模式有关系。

Linux 之父 Linus 在2005年创造了 Git 版本控制工具。Git 是去中心化的版本控制,原则上是没有中央服务器的,每个人都是各自开发,即使机器不联网也一样可以制作新版本,所以如果使用 Git 来开发项目,任意一个时间点,出现无数的分支时很正常的事情了,也就是说即使大家共识没有破裂,项目也一直处于一种临时的 fork 状态。区别于传统的 CVS/SVN 这些版本控制工具,Git 最大的强项就是合并分支,术语就叫 Merge 。

 上一个项目被 fork 的次数越多,就意味着这个项目越活跃。Github 流行起来之后,fork 的含义非常积极了。打开一个开源项目, 页面的右上角就可以看到一个 fork 按钮。如果我想要为这个项目贡献代码,就点一下 Fork 。这样,Github 就会把 项目拷贝到我的名下。这样,我就可以在这个拷贝上不断做 commit 。当我想要提交这些代码给 ckb 官方的时候,就会发一个 Pull Request ,也就是“拉取请求”,官方收到请求通知后,可以审核一下我的代码,没有问题就可以把代码 merge 到官方仓库中了。

所以现代Github 语境下,fork 的意思更接近于”拷贝“,是整个开源贡献过程的第一步,没有任何共识破裂的意思,所以意义是完全积极的。

 

2. Linux系统中创建进程的fork()函数

我看了很多关于fork函数的讨论,以及经常和fork一起搭配使用的exec,收获很多,总结如下:

什么是fork():

fork() causes creation of a new process. The new process (child process) is an exact copy of the calling process (parent process) except for ...

fork()是一个系统调用,用于创建进程。创建的这个进程与原来进程几乎完全相同。这个新产生的进程称为子进程。一个进程调用fork()函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中。fork()完以后,父进程和子进程由于有着同样的数据段和代码段,栈,PCB也大部分相同,所以两个进程就会干着同样的事情。

值得注意的是,但凡是进程,都有自己的虚拟地址空间。虚拟地址空间是从0到4G的大小,其中3-4G是属于内核的。创建完子进程后,父进程继续运行app(即原来的进程)的代码,刚创建出来的子进程拥有和父进程完全一样的代码段,数据段,也就是说完完全全拷贝了一份父进程,和父进程完全一样。即clone父进程0-3G的内容,而3-4G的kernel只需要重新映射一下到物理地址的kernel即可。但是操作系统要如何区分这两个进程呢?答案就是进程ID,即pid。

什么是exec():

The exec() family of functions replaces the current process image with a new process image.

exec()简单而言就是“启动参数指定的程序,代替自身进程”。

 

fork用途:

这个方法可以用来创建子进程,同时也是唯一的方法。子进程的内存空间是父进程的拷贝,但是实际上子进程要做什么仍然取决于程序员!

程序员可以

  1. fork without exec* 创建一个子进程成为一个父进程的拷贝
  2. fork with exec* 让子进程成为一个不同的进程
  3. just exec* without fork 执行而不拷贝进程

程序员只需要记住fork和exec*方法!

可能你会问如果每次fork都复制一遍内存的内容,岂不是很浪费资源?

No!其实在fork之后,所谓的拷贝只是拷贝了virtual memory map!如果接下来执行exec,告诉Unix系统我要让它变成一个新进程,那么实际上并没有真正拷贝父进程的内存内容


fork()带来的优势

这个fork在Unix系的地位是非常高的,高到一大票系统严重依赖于它——绝对不是Windows那个自我割了蛋蛋的createProcess所能比的。

典型如apache+php、apache+CGI搭建网站;在Linux上(著名的LAMP平台),你可以先读入一大票配置文档、连接数据库、做各种初始化操作——有些大型工程可能需要若干分钟才能搞定——完了来一组fork调用,于是你就得到了一大堆已经正确载入了配置文档、做好了初始化、随时可以提供服务的进程。

付出的代价却仅仅是一个fork调用和一个悬而未决的COW;一旦用户请求到来,也就一个memcpy的代价,一个单独为他提供服务的进程就出现了。

如果用户提交了错误信息甚至病态、恶意内容,导致进程内部出现了bug,那么只要记录下这个调用并立即杀掉这个进程就好了。

反正进程池里还有一大票进程等着呢;再拉起一个也不过是个memcpy的代价。

因此,借助于fork,Linux下这类服务就极其稳固;你甚至可以通过一个监控程序监控它们的一举一动,比如一旦获取计划外的权限、执行计划外的动作,那就杀它没商量——反正一秒钟重新拉起成千上万个进程都没问题——那么想要攻击渗透它就非常的难。

 
fork()是一个非常有Linux特色又非常有历史的函数,如果不关心历史和设计思维,上面介绍的内容就够用了,下面的内容很长且是我直接复制别人的回答,可以不看.
 
接口设计的一个指导原则是“完整且最小”

“完整”的意思是,对外提供的接口必须能够满足任何使用要求。

“最小”的意思是,接口功能无重复(无重叠),以能达到“正交化”水平为最佳。

“完整且最小”的接口未必好用。有时候,为了使用方便或者性能或者其它种种原因,接口可以出现冗余;但冗余必然带来维护/学习等方面的代价。

linux的fork/exec就是一组典型的“完整且最小”的接口。

 

从历史上来说,

fork()的使用,很大因素是由于exec的工作方式, exec()具体起源于早期 Unix 中 Shell 的运行方式:Shell 启动用户程序的时候会直接把用户程序的代码覆盖 Shell 的代码并清空内存,等执行完了再用 exit() 把 Shell 代码重新初始化一遍。于是,在运行用户进程前后 Shell 几乎没法保留任何信息……(这其实和 80 年代家用电脑挺像的……DOS 的 INT 21/4B 在处理 COM 的时候也差不多。)

为了解决这个问题,最简单的办法就是把 Shell 整个内存空间给复制一遍再覆盖,Unix 于是借鉴了 GENIE 分时系统里面的 Fork 来做这个复制的活,这就是 fork-exec 二件套的来历了。

 

“fork”用来产生一个新进程,这个进程默认会复制自身——于是类似apache这样用到“进程池”的场景得到支持。

“fork”的“复制自身”操作又是“悬挂”的,如果紧接着调用exec,复制就会取消——于是启动另外一个进程的场景得到支持。

 

“exec”则是“启动参数指定的程序,代替自身进程”。

如果不配合fork使用,它是“当前进程结束,执行指定进程”;配合fork使用,就成了“当前进程启动另一个进程”。

 

这样一来,各种使用场景就都得到了支持;再加上内部优化,写出“性能绝佳”的“多进程协作”程序就成了可能——于是linux甚至有相当一段时间都不支持线程,因为“fork的效率实在太好了,没必要支持线程”(另一个后遗症是,虽然现在linux内核有了线程支持,但线程和fork之间的关系极为复杂,以至于几乎只能在多进程/多线程两个方案中间选择其一)。

 

当然,为了便于使用,linux也提供了一个用起来简单一些的system调用。它和createProcess有点像,但内部仍然由fork+exec实现;此外,它执行时是阻塞的,同时还可能有很多信号之类的技术问题需要处理。


简单说,如果你没有在10年或者更早前做过桌面/服务器软件开发,那么在你的脑子里,很可能就会觉得“createProcess和thread就是亘古长存的、就是傻子都看得出的、从一开始就伟大光荣正确到结束的完美方案;至于fork-exec,那是老糊涂们偶然犯下的、短暂的错误”。

 

但是“短暂的错误”持续了差不多40年,“长久的辉煌”最近十年才占了上风;甚至于,哪怕线程被炒的火热之时,Linus也坚持不在Linux内核中引入线程,而是“抱残守缺”于那个“持续了40年的、短暂的错误”方案,也就是fork+exec。

 

当然,最终,Linus败了,举了白旗;线程也终于进了Linux内核——以某种和fork风格格格不入的形式。

但是,哪怕只是看到我这寥寥几句,你都不太可能毫无障碍的接受“fork是个不假思索的错误”这一套一套了吧。

其他人的答案提到“exec是为了实现Linux shell的古怪行为”——嗯,这个说法似乎符合事实,但却是倒果为因。

 

比如,DOS也是这么干的。

最初大家都要挤占实模式下的640k运行内存,这块内存在DOS启动后会先被占据,就是它提供了dos的shell;当用户敲了一个“外部命令”(也就是其它程序)时,里面的一小段代码就会把这个“外部命令”对应的目标应用加载进来、覆盖掉自己(exe和com还各有不同执行方式),只保留加载器所在的那一丁点内存;等用户程序执行结束、控制器返回加载器代码,这段加载代码就把重新加载回内存。

有时候用户程序可能特别大,640k都不够用(其实刨去其它零碎也就600k不到能用);那么用户还要自己搞个ovl文件,自己加载进来(并把自己之前占用的空间覆盖掉,所以叫“覆盖文件”;当然也会留下执行加载的那点代码不覆盖,不然就没得恢复了),然后跳转到ovl入口继续执行代码逻辑——有的程序可能需要载入N个不同的ovl才能完成自己的工作(有的大型软件一套几十张软盘,运行时需要依照提示在不同时刻插入不同的软盘)。

再后来内存/磁盘越来越大,计算机运行起来就不再需要这么捉襟见肘了。但由于这个历史,从那个时代走来的OS上的exec类系统调用往往都有一个“干掉发起调用的进程的副作用”。

 

没办法,当时内存太金贵了,像现在这样同时加载1024个进程到内存占着茅坑不拉屎是不可想象的。甚至同一个进程都还要用ovl文件分段加载执行呢,你敢想象一个进程启动了另一个进程、结果自己还赖着不走、不肯积极给人家腾地方?

所以exec的含义必然只能是“这活我得找人干,我自己暂时先退下了;等它干完再拉我起来”——单进程多进程你都得这么干。只要你得等下家出结果才能继续,你就得主动让贤。可不仅仅是shell。

也因为“主动让贤”策略深入人心,很多OS设计时就会默认“程序员自己会安排好一切、确保多个进程负责的逻辑相互错开,绝不会搞出‘两个进程同时执行’的幺蛾子”——最基础的“高内聚低耦合”原则而已,搞不定就别来捣乱。

如此一来,exec等于“起一个新进程的同时干掉旧进程”就顺理成章了。

 

哪怕到了后来的Windows3.1/Windows95/98/me时代,内存仍然是捉襟见肘的。除了音乐播放器和杀毒软件之类小打小闹的东西随便你玩;但稍微像点样子的任务,当你的程序需要多进程协作时,自己主动退出内存、腾地方给别人仍然是尽快完成任务的不二法门——256M内存或许1小时就搞定了,你占住128M内存不放或许三小时都搞不定。

甚至,哪怕现在台式机动辄插32G128G内存条的时代,全特效玩赛博朋克2077你敢双开三开吗?玩在线游戏时steam为什么会停止下载更新?

没有那么多资源挥霍,对吧。

 

当然,Windows系的exec倒是没有这个毛病。但你很难说这是“高瞻远瞩”——说成是为了Windows的一贯战略、为了用户易用性而牺牲性能还差不多。

当时互联网服务器Unix系是绝对主流,因为它有一个“独门绝技”就是fork。这是因为互联网服务从一开始就设计成了“无状态”的,应用只做内容分发(提供计算服务的小型机乃至中大型机是另一回事,当然它们也是unix系的优势领域);既然应用无状态,那么自然无需在其中保存什么数据,需要的时候拉起来即可。

于是就有了进程池这个概念,比如apache配置好了就可能同时起若干个apache进程,网络请求来了就分配给其中一个apache进程服务;但服务久了、apache跑的脚本内部可能就会出各种各样的问题,那么就应该杀掉这个apache进程然后重新拉起——所以如果你配过apache,就知道里面有个参数可以指定每个apache进程最多响应多少次请求,到了数量就会主动杀掉然后再拉起一个新的(默认值一般是50~200,当然也可能有其它值)。

可想而知,如果每次拉起apache都必须重新读取配置文件完成各种初始化那得多麻烦;有了fork,这一切就全都免了,创建进程就仅仅是一次比memcpy重不了多少的内存操作而已——这种超高性能甚至造就了fork bomb

至于Windows……就它那可怜巴巴的进程创建速度,玩蛋去吧……

 

你看,这个时候,谁敢说Linux的fork-exec是个错误?明明Windows的createProcess才是个愚蠢、缓慢的错误有木有。家庭娱乐你找它;想干正事?你看着办。

 

总之,最初,fork的表现是如此的惊艳,以至于线程出现很久了,Linus都不愿提供支持。因为Linux的fork-exec套装表现的实在太好了,根本就不是Windows所能撼动的。

线程最初是为了解决图形界面刷新的问题而搞出来的。

过去的程序都是单进程的;那么刷新UI时势必无法计算;忙于计算时就无法刷新UI——你当然可以安排个定时器,时间到了就刷新UI,Windows开发者最初就是这么做的;但这样也有很多困难,第一是如果刷新太频繁了就很容易影响计算性能;第二是这样的程序很难写,尤其如果你在循环中不停检查是否需要刷新屏幕…那性能实在太美;第三是究竟刷新哪些部分呢?全屏刷新对当时CPU来说是个太大的负担;刷新部分?那怎么触发呢?丢消息循环?那什么时候做计算?

 

于是,我们就见到了许许多多一忙起来窗口一片惨白的程序,鼠标一拖满屏都是窗口拖影;没人知道这个应用是忙完了还能过来、还是就这么卡死了,一着急强行关机的比比皆是。

当然,也有一些表现的好一些——它们在忙碌于计算时,虽然窗口同样一片惨白,但还有个蓝蓝的进度条不时动一动……

 

那么,有个线程专门负责刷新UI的话,这一切是不是就不成问题了?

 

Linux:谢谢。但我们不需要GUI。我劝你多搞搞严肃的、提高服务性能的正事,少耍点花活。

你看,当时只有服务器上才有多CPU系统,而且是SMP(对称多处理器)架构;进程、轻量级进程能从中得到更多的好处;至于线程……同一个进程的两个线程,分别处于不同的处理器缓存甚至内存,却时时刻刻要共享同一个变量?你在瞎胡闹知道吗?太不专业了。

 

但是,硬件环境在悄悄起变化……

具体时间不太清楚了,我记得是奔三或者奔四时代,intel直接在CPU里提供了多线程支持,单核多线程CPU普及;再往后AMD干脆直接推双核CPU,我们现在使用的这种看似一颗、实际上可能是10核20线程的处理器这才登上历史舞台……

有了硬件支持,线程这才慢慢取得了性能等其它方面的好处。

然后,Linus不得不顺应潮流,Linux内核这才开始支持线程。

 

但起码直到08年,我在工作中使用Linux线程仍然遇到不少问题,仍然有很多Linux发行版无法支持内核线程

哪怕能用内核线程了,线程也和fork先天不合。因为它们是匆匆捏到一起的,压根不能一起用。

没错,重复一遍:选择fork-exec方案不是匆匆忙忙缺乏考虑;选择在一个以fork-exec为基础的系统上支持内核线程,这才是匆匆忙忙缺乏考虑——或者说,就好像C++一样,此时的Linux是“多范式”的,你可以自由选择多进程还是多线程方案;但如果要在一个方案里糅合多进程和多线程……你在自讨苦吃。

 

总之,fork-exec绝对不是什么错误的选择更不是什么权宜之计。它是深思熟虑且经过实践检验的、效果绝佳的金点子;只是后来CPU设计思路变了,这个方案才变得不合时宜——不然你猜Linus怎么就那么蠢,线程摆在面前他都坚决不碰、绝不考虑fork之外的其它方案?

这个态度最起码可以证明:当时的Linus绝对不会认为fork是个缺乏考虑的、错误的设计;而且,他起码也说服了参与Linux kernel开发的绝大多数人,不然这个政策不会坚持这么久。

 

当然了,现在的fork和线程配合稀烂是事实。因为越是精巧的设计,在遇到预料之外的变化时就越是拙劣,远不如“没有丝毫内涵”的简单设计更能“不变应万变”——你看,fork-exec方案本来对标的是SMP;结果呢?突然大家都在一个CPU壳子里玩命的多塞起核心来,把研究了多年的、成熟的SMP扔到了一边。

就好像你做了十年做题家,却突然被大字不识几个的、“未曾被应试教育毒害的纯真眼神”扫进了垃圾堆一样。这你能找谁说理去。

但话又说回来了:当真的需要时,人家有作业控制有高性能的fork可以用;不需要时就fork+exec二连照样等于一个createProcess,没有付出任何代价——你那“未曾被应试教育毒害的纯真眼神”,又能比人家高贵到哪?就凭你不会解方程?

 

更可笑的是吹嘘什么“createProcess才真正深入思考了什么是进程”的……嘿嘿,你知道linux是什么抽象吗?

linux的抽象是:我们面对的任务是一组“作业”,也就是jobs;一个jobs由一组worker组合起来完成;我们可以启动一个“领班”的worker搭建舞台;舞台搭建结束了,worker携带着关于舞台的记忆开始分叉(fork);fork出的一大堆worker按照“领班”给自己的安排分头行动,比如内存数据处理这个worker就可能先把内存安排好、然后fork出一堆相同/不同的worker围绕着内存开始忙活;负责文件处理的worker把文件分成若干组,然后也fork出一堆worker各自领一组文件开始处理……万一哪个worker遇到什么奇葩情况误入岐途了(比如apache的worker被网上病态报文搞混乱了),没关系,杀掉它,再分裂一个补上就好。

注意这里的worker类似私有继承的类对象,继承过来就和原对象脱离了关系,从而避免互相干扰。因此才能做到“哪个出问题了就杀掉再拉起一个”——有fork,这就是个比memcpy重不了多少的任务而已。

等worker们任务都搞定了,纷纷返回,然后由父进程收集报告、汇总,最后你就可以通过jobs查询执行结果了。

你看,多完善的一套体系。

 

名词解释

系统调用:一句应用级汇编指令,触发中断让操作系统执行内核操作,期间不可被应用代码打断。

COW:copy on write,两个进程共享一块内存,意味着这两个进程的逻辑地址映射到相同的物理地址(必须要mmu硬件支持),读没有问题,只有当其中一个进程需要改写这片逻辑地址时,才会触发一个中断,让操作系统修改内存映射表,把同一逻辑地址映射到不同的物理地址,并把内容复制过去。

fd:一个整数,linux用来代表文件对象(IO对象)的序号

shm:共享内存对象,如果进程不清理即退出,则即使没有进程继续使用该对象还是继续存在。

tid, pid:线程id,进程id,执行main函数的线程id等于进程id。

mmap:创建内存映射表中的一项,可以指定映射文件,也可以不指定,相当于分配一块内存。

 

 

参考链接:

fork是什么意思?https://zhuanlan.zhihu.com/p/51386600

知乎invalid s的回答 https://www.zhihu.com/question/66902460/answer/247277668

知乎Pickle Pee的回答 https://www.zhihu.com/question/49445399/answer/296621105

知乎Belleve的回答 https://www.zhihu.com/question/66902460/answer/1642679613

posted @ 2021-12-28 15:19  青山牧云人  阅读(812)  评论(0编辑  收藏  举报