漫谈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 。
http://Github.com 上一个项目被 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用途:
这个方法可以用来创建子进程,同时也是唯一的方法。子进程的内存空间是父进程的拷贝,但是实际上子进程要做什么仍然取决于程序员!
程序员可以
- fork without exec* 创建一个子进程成为一个父进程的拷贝
- fork with exec* 让子进程成为一个不同的进程
- 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下这类服务就极其稳固;你甚至可以通过一个监控程序监控它们的一举一动,比如一旦获取计划外的权限、执行计划外的动作,那就杀它没商量——反正一秒钟重新拉起成千上万个进程都没问题——那么想要攻击渗透它就非常的难。
“完整”的意思是,对外提供的接口必须能够满足任何使用要求。
“最小”的意思是,接口功能无重复(无重叠),以能达到“正交化”水平为最佳。
“完整且最小”的接口未必好用。有时候,为了使用方便或者性能或者其它种种原因,接口可以出现冗余;但冗余必然带来维护/学习等方面的代价。
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启动后会先被http://command.com占据,就是它提供了dos的shell;当用户敲了一个“外部命令”(也就是其它程序)时,http://command.com里面的一小段代码就会把这个“外部命令”对应的目标应用加载进来、覆盖掉自己(exe和com还各有不同执行方式),只保留加载器所在的那一丁点内存;等用户程序执行结束、控制器返回加载器代码,这段加载代码就把http://command.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