为什么fork()函数能够按照它的方式工作

42

所以,我使用了fork(),并且知道它的作用。作为一个初学者,我对它感到很害怕(而且我仍然没有完全理解它)。在网上可以找到的fork()的一般描述是,它会复制当前进程并分配不同的PID、父PID,并且该进程将具有不同的地址空间。这都很好,然而,根据这个功能描述,一个初学者会想知道“为什么这个函数如此重要……为什么我要复制我的进程呢?”于是我开始思考,最终我发现这就是通过execve()家族在当前进程中调用其他进程的方法。

我仍然不明白为什么你必须用这种方式来做?最合乎逻辑的事情应该是有一个你可以像调用其他函数一样调用的函数

create_process("executable_path+name",params..., more params); 

该方法将产生一个新进程并在main()函数的开始运行它,并返回新的PID。

让我担心的是fork/execve方法可能会做一些不必要的工作。如果我的进程正在使用大量内存,内核会复制我的页表等信息吗?除非我已经使用了它,否则我敢肯定它实际上没有分配真正的内存。此外,如果我有线程会怎么样?对我来说,这似乎太混乱了。

几乎所有关于fork的描述都说它只是复制进程,并且在fork()调用之后启动新进程运行。这确实是发生的事情,但为什么会这样发生,为什么fork/execve是生成新进程的唯一方法,以及从当前进程创建新进程的最常见Unix方式是什么?是否有其他更有效的生成进程的方法?**这些方法不需要复制更多的内存。

这个主题讨论了同样的问题,但我觉得不太令人满意:

谢谢。


请发布在 http://unix.stackexchange.com/ 或 http://superuser.com/ 上。 - rlemon
10
为什么选择Unix?这是一个编程问题,应该发布到Stack Overflow上。 - Petr
请参考 http://cm.bell-labs.com/who/dmr/hist.html 进行解释。 - ninjalj
2
阅读了vfork的man页面后,我认为你是正确的。fork确实有一些不必要的行为。因此,在普通情况下,vfork更好,只有在非常特殊的情况下需要使用fork,例如子进程需要父进程的某些内存结构,并且不执行任何其他ELF文件。 - lovespring
链接已失效,但我怀疑http://www.landley.net/history/mirror/unix/dmr/hist.html是同一份文档。 - Douglas Leeder
14个回答

30
这是由于历史原因。如https://www.bell-labs.com/usr/dmr/www/hist.html所述,早期的Unix既没有fork()也没有exec*(),shell执行命令的方式是:
  • 进行必要的初始化(打开stdin/stdout)。
  • 读取一个命令行。
  • 打开命令,加载一些引导代码并跳转到它。
  • 引导代码读取打开的命令(覆盖了shell的内存),并跳转到它。
  • 一旦命令结束,它将调用exit(),然后通过重新加载shell(覆盖命令的内存)并跳转到它来工作,返回到步骤1。
从那时起,fork()成为了一个简单的添加(27行汇编代码),重复使用其余代码。
在Unix发展的这个阶段,执行命令变成了:
  • 读取一个命令行。
  • fork()出一个子进程,并等待它(通过向其发送消息)。
  • 子进程加载命令(覆盖了子进程的内存),并跳转到它。
  • 一旦命令结束,它将调用exit(),现在更简单了。它只需清除自己的进程条目,并放弃控制。

fork()最初没有使用写时复制(Copy On Write)。由于这会使fork()变得很昂贵,而且fork()通常用于生成新进程(因此通常紧随其后的是exec*()),于是出现了一个优化版本的fork(): vfork(),它在父进程和子进程之间共享内存。在那些实现了vfork()的系统中,父进程将被挂起,直到子进程执行了exec*()_exit(),从而让出父进程的内存。后来,fork()被优化为进行写时复制,仅在父子进程之间开始差异时才复制内存页。vfork()后来又在移植到!MMU系统(例如:如果您有一个ADSL路由器,则可能在!MMU MIPS CPU上运行Linux)时再次引起关注,这些系统无法进行写时复制优化,而且不能有效地支持fork()进程。

fork()的另一种低效性来源在于它最初会复制父进程的地址空间(和页表),这可能会使从大型程序中运行短程序相对较慢,或者可能会使操作系统拒绝fork(),认为可能没有足够的内存用于它(要解决这个问题,您可以增加交换空间或更改操作系统的内存超配设置)。有趣的是,Java 7使用vfork()/posix_spawn()来避免这些问题。

另一方面,fork()使得创建多个相同进程实例非常高效:例如:Web服务器可能有多个相同的进程为不同的客户端提供服务。其他平台喜欢使用线程,因为生成不同进程的代价比复制当前进程的代价大得多,而复制当前进程的代价可能只比生成新线程的代价稍微大一点。不幸的是,共享所有的线程很容易出现错误。


在所有的答案中,这个看起来是唯一应该在这里的:^) - ivan_pozdeev
链接已失效。对于寻找该论文的任何人:标题:“Unix 分时系统的演变”作者:“丹尼斯·里奇” - Sidervs

12

记住,在Unix系统中(也许在之前),fork是很早就发明的,当时机器内存非常小(例如只有64K字节)。

它更符合提供基本机制而不是策略的原始哲学,通过最基本的操作来实现。

fork仅仅创建一个新进程,最简单的思考方式就是克隆当前进程。所以fork的语义非常自然,是可能的最简单的机制。

其他系统调用(比如execve)负责加载新的可执行文件等等。

将它们分开(并提供pipedup2系统调用)可以提供很多灵活性。

在当前系统上,fork被实现得非常高效(通过惰性写时复制页表技术)。众所周知,fork机制使得Unix进程创建相当快速(例如比Windows或VAX/VMS更快,它们的系统调用创建进程更类似于您提出的那种方法)。

还有vfork系统调用,但我不打算使用它。

posix_spawn API比forkexecve单独使用更加复杂,因此说明了fork更简单...


我听说过spawn,但是我想知道像Gimp、openoffice、gnome等大型知名Linux应用程序使用哪种创建新进程的方法。我认为它们中至少有一些需要这样做。 - Swetko
GTK提供(在Glib库中)超过fork系统调用的调用,例如http://developer.gnome.org/glib/unstable/glib-Spawning-Processes.html。 - Basile Starynkevitch
我认为这最终只是一个明确的答案,它只是说“记住fork是在Unix早期发明的”。尽管没有人证实过,但我相信可以实现一种更有效的新函数,它可以做到与fork()完全相同的事情,除了额外的内存/属性克隆,其唯一目的是启动一个几乎与其父进程无关的新独立进程。 - Petr
1
好的,证明这一点只有一种方法:深入研究*nix内核,找出fork需要改进的地方,并实际加以改进。顺便说一句,你可以自己做到这一点。 - Kirill Gamazkov
Windows也可以通过forking来创建新进程。该函数称为ZwCreateProcess,位于ntdll.dll中。一旦克隆设置完成,克隆必须清空其地址空间,加载新的二进制代码,与Win32子系统连接并执行main()。这使得进程创建变得繁重。 - Sturla Molden

4
"

“fork()”是一项非常出色的创新,通过一个API解决了整个问题类别。它是在多处理并不普遍的时候发明的(比你和我今天使用的多处理技术早大约20年)。

"

错误,实际上自20世纪50年代以来就一直存在多进程处理。 - user207421
1
优秀?我会说这是一个愚蠢的方法来(有争议地)解决生成新进程的特定微小子集 - 克隆现有进程。在大多数情况下,您只需要启动一个小型辅助进程来为您执行一些小工作,而您所拥有的全部就是“fork”?哎呀!太糟糕了,从来不喜欢它。克隆在许多情况下确实有意义,但在这里不适用,相信我。 - Sergey

3

从历史上看,Unix运行在相当小的系统上,不允许在RAM中运行多个进程(它们都在同一地址空间中运行,没有MMU)。fork简单地将当前进程交换到磁盘(或其他辅助存储)而不费心地交换到另一个进程。你可以继续运行内存中的副本,或使用exec加载并继续不同的可执行文件。

人们习惯于在调用exec之前设置新的工作环境(打开文件描述符、管道等),因此fork仍然存在。


2
正如其他人所说,fork被实现得非常快,因此这不是问题。但为什么不使用像create_process()这样的函数呢?答案是:简单灵活。Unix中的所有系统调用都被编程为只执行一件事。像create_process这样的函数将执行两个操作:创建进程和将二进制文件加载到其中。

每当您尝试并行处理任务时,可以使用线程或使用fork()打开的进程。在大多数情况下,您通过fork()打开n个进程,然后使用IPC机制在这些进程之间进行通信和同步。有些IPC要求在全局空间中具有变量。

使用管道的示例:

  • 创建管道
  • 分叉一个子进程,该子进程继承管道句柄
  • 子进程关闭输入端
  • 父进程关闭输出端

没有fork()就无法完成...

另一个重要的事实是,整个Unix API只有很少的几个函数。每个程序员都可以轻松记住使用的函数。但看看Windows API:超过数千个函数,没有人能够记住。

因此,总结并再次说一遍:简单灵活。


2
虽然我同意你的观点,即fork()可以做一些“create_process()”无法做到的事情,但我强烈反对即使实现了非常快速的fork()也永远无法比一个执行与fork()完全相同的功能(除了内存复制)的函数更快。这将始终节省大量CPU指令,因此会更快。 - Petr
@Petr:加载新进程通常使fork()的开销相比微不足道。 - ninjalj
克隆是由MMU通过标记页面的写时复制完成的。它不会占用任何CPU周期。事实上,生成线程使用的系统调用与在Unix和Linux上实现fork的相同,而fork的开销并不比生成线程更高。较少人知道Windows也通过fork启动新进程,尽管它被称为ZwCreateProcess,并隐藏在ntdll.dll中。在CreateProcess与fork之间的开销来自于必须清空和重新初始化克隆以启动一个空进程。 - Sturla Molden
create_process 这样的函数会做两件事情:创建一个进程并将二进制文件加载到其中。但不一定如此。它可以创建一个空的非运行进程,由父进程进行操作,然后在设置完成后,实际的可执行代码可以通过类似于 exec 的东西被加载,而这个东西是由父进程调用的。 - Pharap

2
这是一个很好的问题。我不得不在源代码中进行一些调查,才能确定实际发生了什么。
fork() 通过复制调用进程来创建一个新进程。
在Linux下,fork() 采用写时复制页面实现,因此它产生的唯一负面影响就是需要时间和内存来复制父进程的页表,并为子进程创建一个独特的任务结构。
新进程称为子进程,是调用进程(称为父进程)的精确副本。除了以下几个方面:
  • 孩子有自己独特的进程ID,而这个PID与任何现有进程组的ID不匹配。
  • 孩子的父进程ID与父进程的进程ID相同。
  • 孩子不继承其父进程的内存锁。
  • 在孩子中,进程资源利用率和CPU时间计数器被重置为零。
  • 孩子的待处理信号集最初为空。
  • 孩子不从其父进程继承信号量调整。
  • 孩子不从其父进程继承记录锁定。
  • 孩子不从其父进程继承计时器。
  • 孩子不从其父进程继承未完成的异步I/O操作,也不从其父进程继承任何异步I/O上下文。

结论:

fork的主要目标是将父进程的任务分成较小的子任务,而不影响父进程的独特任务结构。这就是为什么fork会克隆现有进程的原因。

来源:

http://www.quora.com/Linux-Kernel/After-a-fork-where-exactly-does-the-childs-execution-start http://learnlinuxconcepts.blogspot.in/2014/03/process-management.html


1
+1 为深入了解 fork() 的工作原理。但是,难道没有比克隆现有进程更好的启动新进程的方法吗?我只是不明白这一点。如果您想要启动一个新的、独立的进程,为什么要先克隆现有的进程呢? - Petr
我已根据您的评论对我的答案进行了更改。 - Anudeep Samaiya
如果您要生成一个新进程,您必须从main()函数中启动它并设置一切。这通常也适用于线程,它们从自己的线程过程(threadproc)启动,随后需要解码由void指针(其唯一参数) 提供的数据。使用fork,则无需初始化任何内容。 - Sturla Molden

2
其他答案已经很好地解释了为什么“fork”比看起来快,以及它最初的存在方式。但是,保持“fork+exec”组合也有充分的理由,那就是它提供的灵活性。
通常,在生成子进程时,需要在执行子进程之前进行准备工作。例如:您可能会使用“pipe”(一个读取器和一个写入器)创建一对管道,然后将子进程的“stdout”或“stderr”重定向到写入器,或者使用读取器作为进程的“stdin”——或者任何其他文件描述符。或者,您可能想要设置环境变量(但仅在子进程中)。或使用“setrlimit”设置资源限制,以限制子进程可以使用的资源数量(而不限制父进程)。或使用“setuid”/“seteuid”更改用户(而不更改父进程)。等等。
当然,您可以使用假设的“create_process”函数完成所有这些工作。但是这是很多东西要涵盖!为什么不提供运行“fork”的灵活性,然后做任何您想要设置子进程的事情,然后再运行“exec”呢?
此外,有时您实际上根本不需要子进程。如果您当前的程序(或脚本)仅存在于执行某些设置步骤,并且它要做的最后一件事就是运行新进程,那么为什么要有两个进程呢?您可以使用“exec”来替换当前进程,释放自己的内存和PID。
分叉还允许在只读数据集方面具有一些有用的行为。例如,您可以有一个父进程,该进程收集并索引大量数据,然后分叉出子工作进程以根据该数据执行遍历和计算。父进程不需要将其保存在任何地方,子进程也不需要读取它,您也不需要使用共享内存进行任何复杂的工作。(例如:某些数据库使用这种方式将子进程转储到磁盘中的内存数据库,而不会阻塞父进程。)
上述内容还包括任何读取配置、数据库和/或一组代码文件的程序,然后继续分叉子进程以处理请求并更好地利用多核CPU。这包括Web服务器,但也包括Web(或其他)应用程序本身,特别是如果这些应用程序花费了大量启动时间仅读取和/或编译高级代码。
分叉(Forking)也可以是管理内存和避免碎片化的一种有用方式,特别是对于使用自动内存管理(垃圾回收)并且没有直接控制其内存布局的高级语言。如果您的进程需要暂时为某个操作分配大量内存,则可以分叉并执行该操作,然后退出,释放所有刚刚分配的内存。相比之下,如果您在父进程中执行该操作,可能会导致持续时间内存在重大的内存碎片化,这对于长时间运行的进程来说不太好。
最后,一旦您接受了fork和exec各自独立的用途,问题就变成了:为什么要费力地创建一个将两者合二为一的单独函数呢? Unix哲学是让工具“专一而精通”。通过提供fork和exec作为单独的构建块,并尽可能使每个构建块快速有效,它们比单个create_process函数更灵活。

2

看一下spawn以及相关的内容。


2
请记住,spawn是POSIX标准,而fork是纯Unix的。并不是说不能使用spawn,但如果想要纯Unix的体验,你只能使用fork-execve :) - Eli Iser
2
另外,请注意spawn在内部使用fork(或clone)。 内核中没有其他东西可以提供所需的功能。 这意味着它更加用户友好和明显,但无论开销是什么(复制页面表和描述符),开销都是相同的。 - Damon

2
fork 通过复制当前进程创建一个新的进程时,它执行了写时复制(copy-on-write)。这意味着新进程的内存与父进程共享,直到被修改。当内存被修改时,为确保每个进程都有自己的有效内存副本,内存会被复制。在 fork 后立即执行 execve 时,没有内存的副本,因为新进程只加载了一个新可执行文件,因此具有新的内存空间。
至于为什么要这样做,我不确定,但似乎这是 Unix 的方式之一 - 做好一件事情。而不是创建一个同时创建新进程和加载新可执行文件的函数,该操作被拆分成两个函数。这给开发人员带来了最大的灵活性。虽然我还没有单独使用过任何一个函数...

这是通过MMU在硬件上标记页面COW来完成的。Windows使用相同的机制启动新进程。系统调用基础的fork(clone)与基础的CreateProcess(ZwCreateProcess)非常相似,实际上,你可以通过ZwCreateProcess实现fork。 - Sturla Molden

1

所以,你的主要关注点是:fork()导致不必要的内存复制。

答案是:不,没有内存浪费。简而言之,当内存非常有限时,fork()诞生了,因此没有人会想到这样浪费它。

虽然每个进程都有自己的地址空间,但物理内存页面和进程的虚拟内存页面之间没有一对一的映射关系。相反,一个物理内存页面可以映射到多个虚拟页面(搜索CPU TLB以获取更多详细信息)。

因此,当您使用fork()创建新进程时,它们的虚拟地址空间被映射到同一物理内存页面上。不需要进行内存复制。这也意味着没有使用的库的副本,因为它们的代码部分被标记为只读。

实际的内存复制仅在父进程或子进程修改某些内存页面时发生。在这种情况下,将分配新的物理内存页面,并将其映射到修改页面的进程的虚拟地址空间。


1
CPU浪费怎么办?当将进程的某些属性复制到新进程时,这个操作不就是一堆多余的指令吗?因为我知道最终会抛弃它们,所以这些指令其实并不需要执行,对吧?我的意思是fork()会复制进程,它会复制许多属性,但后来这些属性会被覆盖,这样会消耗一些本来不必要的CPU资源,对吗? - Petr
不会覆盖太多属性,这样的开销是可以接受的。 - Kirill Gamazkov

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接