【问题标题】:Why fork() works the way it does为什么 fork() 以它的方式工作
【发布时间】:2012-01-07 16:45:15
【问题描述】:

所以,我使用了fork(),我知道它的作用。作为一个初学者,我很害怕它(我仍然不完全理解它)。你可以在网上找到fork()的一般描述是,它复制当前进程并分配不同的PID,父PID并且进程将具有不同的地址空间。一切都很好,但是,鉴于此功能描述,初学者会想知道“为什么这个功能如此重要......为什么我要复制我的流程?”。所以我确实想知道,最终我发现这就是您可以通过execve() 系列从当前进程中调用其他进程的方式。

我仍然不明白为什么你必须这样做?最合乎逻辑的事情是拥有一个可以像

一样调用的函数
create_process("executable_path+name",params..., more params); 

这将产生一个新进程并在 main() 的开头开始运行它并返回新的 PID。

让我感到困扰的是 fork/execve 解决方案正在做可能不需要的工作。如果我的进程正在使用大量内存怎么办?内核是否复制我的页表等。我确信它并没有真正分配真正的内存,除非我接触过它。另外,如果我有线程会发生什么?在我看来,它太乱了。

几乎所有关于 fork 的描述,说它只是复制进程,新进程在fork() 调用之后开始运行。这确实是发生的事情,但为什么会这样,为什么 fork/execve 是生成新进程的唯一方法,从当前进程创建新进程的最通用的 unix 方法是什么?还有其他更有效的方法来生成进程吗?** 不需要复制更多内存。

This 讨论了同样的问题,但我觉得不太满意:

谢谢。

【问题讨论】:

标签: linux process fork


【解决方案1】:

这是由于历史原因。正如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() 没有在写入时进行复制。由于这使得fork() 变得昂贵,并且fork() 经常被用于生成新进程(因此经常紧随其后的是exec*()),因此出现了fork() 的优化版本:vfork(),它在父进程和父进程之间共享内存孩子。在vfork() 的那些实现中,父级将被挂起,直到子级exec*()'ed 或_exit()'ed,从而放弃父级的内存。后来,fork() 被优化为在写入时复制,仅当内存页开始在父子节点之间出现差异时才复制内存页。 vfork() 后来看到对 !MMU 系统的端口重新产生兴趣(例如:如果你有一个 ADSL 路由器,它可能在 !MMU MIPS CPU 上运行 Linux),它不能做 COW 优化,而且不能支持 @ 987654339@'ed 高效处理。

fork() 中效率低下的其他原因是它最初复制了父级的地址空间(和页表),这可能会使从大型程序运行短程序相对较慢,或者可能使操作系统拒绝 fork()认为可能没有足够的内存(要解决这个问题,您可以增加交换空间,或更改操作系统的内存过度使用设置)。作为一个轶事,Java 7 使用vfork()/posix_spawn() 来避免这些问题。

另一方面,fork() 使得创建同一进程的多个实例非常有效:例如:Web 服务器可能有多个相同的进程为不同的客户端服务。其他平台偏爱线程,因为生成不同进程的成本远大于复制当前进程的成本,而复制当前进程的成本可能仅比生成新线程的成本高一点。这是不幸的,因为共享所有线程是错误的磁铁。

【讨论】:

  • 在所有答案中,这看起来应该是唯一的一个:^)
  • 链接已失效。致任何寻找论文的人:标题:“Unix 分时系统的演变”作者:“Dennis M. Ritchie”
【解决方案2】:

请记住,fork 是在 Unix 的早期(可能更早)在今天看起来非常小(例如 64K 字节的内存)的机器上发明的。

而且它更符合通过最基本的可能行动提供基本机制而非政策的整体(原始)理念。

fork只是新建一个进程,最简单的思路就是克隆当前进程。所以fork 语义非常自然,是最简单的机制。

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

将它们分开(并提供pipedup2 系统调用)提供了很大的灵活性。

在当前系统上,fork 的实现非常高效(通过写分页时的惰性复制技术)。众所周知,fork 机制使 Unix 进程创建速度非常快(例如,比在 Windows 或 VAX/VMS 上更快,它们的系统调用创建的进程与您建议的更相似)。

还有vfork 系统调用,我懒得使用。

并且posix_spawn API 比单独的forkexecve 复杂得多,因此说明fork 更简单...

【讨论】:

  • 所以,我听说过 spawn,但我想知道,例如,大型受人尊敬的 linux 应用程序(如 Gimp、openoffice、gnome、等等。)。我认为至少他们中的一些人需要这样做。
  • GTK 在fork 系统调用之上提供(在Glib 库中)调用,例如developer.gnome.org/glib/unstable/glib-Spawning-Processes.html
  • 我认为这最终只是一个明确的答案,它只是说“记住 fork 是在 Unix 很早就发明的”。尽管没有人证实这一点,但我相信可以实现更有效的新功能,它将完全按照fork() 所做的那样,除了额外的内存/属性克隆,其唯一目的是启动一个新的独立进程,它几乎没有任何共享父母。
  • 好吧,只有一种方法可以证明这一点:深入 *nix 内核,找出 fork 需要改进的地方,然后实际改进它们。顺便说一句,你可以自己做。
  • Windows 也通过分叉创建一个新进程。该函数在 ntdll.dll 中称为 ZwCreateProcess。一旦设置了克隆,克隆必须清空其地址空间,加载新的二进制代码,连接 Win32 子系统并执行 main()。这使得流程创建变得重量级。
【解决方案3】:

“fork()”是一项出色的创新,它使用单个 API 解决了所有问题。它是在多处理不常见的时候发明的(并且比你我今天使用的那种多处理早了大约 20 年)。

【讨论】:

  • 呃,多处理实际上从 1950 年代就开始了。
  • 出色?我会说一个愚蠢的(可以说)解决产生新进程的特定小子集 - 克隆现有进程。在大多数情况下,您只需要启动一个小的帮助进程来为您做一些小工作,而您所得到的只是'fork'?哎哟!太烂了,一直不喜欢。克隆在许多情况下确实有意义,但在这里不是,相信我。
【解决方案4】:

看看spawn和朋友们。

【讨论】:

  • 请记住,spawn 是 POSIX,而 fork 是纯 Unix。不是说它不能用,但是对于纯粹的 Unix 体验,你卡在fork-execve :)
  • 另外,请注意spawn 在内部使用fork(或clone)。内核中没有其他东西可以提供所需的功能。这意味着它更加用户友好和明显,但是无论开销是多少(复制页表和描述符),开销都是一样的。
【解决方案5】:

fork 通过复制当前进程创建新进程时,它会执行写时复制。这意味着新进程的内存与父进程共享,直到它被更改。当内存改变时,内存被复制以确保每个进程都有自己的有效内存副本。在forking 之后执行execve 时,没有内存副本,因为新进程只是加载一个新的可执行文件,因此是一个新的内存空间。

至于为什么要这样做,我不确定,但它似乎是 Unix 方式的一部分——做好一件事。不是创建一个创建新进程并加载新可执行文件的函数,而是将操作拆分为两个函数。这为开发人员提供了最大的灵活性。虽然我还没有单独使用过这两个函数...

【讨论】:

  • 它是由 MMU 通过标记页面 COW 在硬件中完成的。 Windows 使用相同的机制来启动一个新进程。底层系统调用fork(clone)与底层系统调用CreateProcess(ZwCreateProcess)非常相似,其实可以在ZwCreateProcess之上实现fork。
【解决方案6】:

正如其他人所说,fork 的实现速度非常快,所以这不是问题。但是为什么不使用像create_process() 这样的函数呢?答案是:为了灵活而简单。 所有 unix 中的系统调用都被编程为只做一件事。像 create_process 这样的函数会做两件事:创建一个进程并将二进制文件加载到其中。

当您尝试并行化事物时,您可以使用线程 - 或使用fork() 打开的进程。在大多数情况下,您通过fork() 打开n 进程,然后使用IPC 机制在这些进程之间进行通信和同步。一些 IPC 坚持在全局空间中有变量。

管道示例:

  • 创建管道
  • fork 一个继承管道句柄的子节点
  • 孩子关闭输入端
  • 父关闭输出端

不可能没有fork()...

另一个重要的事实是整个 Unix API 只有几个函数。每个程序员都可以很容易地记住使用过的函数。但是请参阅 Windows API:数以千计的函数,没人能记住。

总结起来再说一遍:简单就是灵活

【讨论】:

  • 虽然我同意你的观点,fork() 可以做“create_process()”不能做的事情,但我强烈不同意,即使 fork() 被实现得非常快,也可以让它更快除了内存复制之外,它的功能与 fork() 的功能完全相同。这总是会节省一堆 CPU 指令,因此会更快。
  • @Petr:加载一个新进程主要使fork()的开销相比之下微不足道。
  • 克隆是由 MMU 通过标记写时复制的页面来完成的。它不会占用任何 CPU 周期。事实上,在 Unix 和 Linux 上,派生线程是使用用于实现 fork 的相同系统调用完成的,并且派生不会比派生线程有更高的开销。鲜为人知的是,Windows 也通过 fork 启动一个新进程,尽管它被称为 ZwCreateProcess 并且隐藏在 ntdll.dll 中。 CreateProcess 与 fork 的开销来自必须清空并重新初始化克隆以启动一个空进程。
【解决方案7】:

这是一个很好的问题。我不得不深入研究一下源代码,以了解到底发生了什么。

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 用于挖掘 fork() 的工作原理。然而,真的没有比克隆现有更好的方法来启动一个新进程吗?我只是看不出这有任何意义。如果您想启动新的、独立的流程,为什么要先克隆现有流程?
  • 我已根据您的评论更改了我的答案。
  • 如果你生成一个新进程,你必须从 main() 启动它并设置所有内容。线程也经常出现这种情况,线程从它们自己的 threadproc 开始,随后必须解码 void 指针(它的唯一参数)提供的数据。使用 fork 不需要初始化任何东西。
【解决方案8】:

其他答案很好地解释了为什么fork 比看起来更快,以及它最初是如何存在的。但也有充分的理由保留 fork+exec 组合,这就是它提供的灵活性。

通常,在生成子进程时,在执行子进程之前需要采取一些准备步骤。例如:您可以使用pipe(一个读取器和一个写入器)创建一对管道,然后将子进程的stdoutstderr 重定向到写入器,或者将读取器用作进程的stdin — 或就此而言,任何其他文件描述符。或者,您可能想要设置环境变量(但仅限于子项)。或使用setrlimit 设置资源限制,以限制子级可以使用的资源量(不限制父级)。或使用setuid/seteuid 更改用户(不更改父级)。等等等等。

当然,您可以使用假设的 create_process 函数来完成所有这些操作。但是要覆盖的东西很多!为什么不提供运行fork 的灵活性,为孩子做任何你想做的事情,然后运行exec

此外,有时您实际上根本不需要子进程。如果您当前的程序(或脚本)只是为了执行其中一些设置步骤而存在,并且它要做的最后一件事就是运行新进程,那么为什么有两个进程呢?你可以使用exec来替换当前进程,释放你自己的内存和PID。

分叉还允许一些关于只读数据集的有用行为。例如,您可以有一个父进程收集和索引大量数据,然后派生子工作人员以基于该数据执行遍历和计算。父母不需要把它保存在任何地方,孩子不需要阅读它,你也不需要对共享内存做任何复杂的工作。 (例如:某些数据库使用此方法让子进程将内存中的数据库转储到磁盘,而不会阻塞父进程。)

上述还包括读取配置、数据库和/或一组代码文件,然后继续派生子进程以处理请求并更好地利用多核 CPU 的任何程序。这包括网络服务器,也包括网络(或其他)应用程序本身,特别是如果这些应用程序花费大量启动时间来阅读和/或编译更高级别的代码。

分叉也是管理内存和避免碎片的有用方法,尤其是对于使用自动内存管理(垃圾收集)且无法直接控制其内存布局的高级语言。如果您的进程短暂地需要大量内存用于特定操作,您可以分叉并执行该操作,然后退出,释放您刚刚分配的所有内存。相比之下,如果您在父进程中执行该操作,则可能会在进程期间持续存在大量内存碎片——这对于长时间运行的进程来说不是很好。

最后:一旦你接受forkexec 都有自己的用途,彼此独立,问题就变成了——为什么要创建一个单独的函数来结合两者?据说 Unix 的哲学是让它的工具“做一件事,把它做好”。通过将 forkexec 作为单独的构建块提供给您 - 并使每个构建块尽可能快速和高效 - 它们提供了比单个 create_process 函数更大的灵活性。

【讨论】:

    【解决方案9】:

    假设底层实现使用写时复制寻址系统,fork() 可以用很少的内存分配来实现。使用该优化实现 create_process 函数是不可能的。

    【讨论】:

      【解决方案10】:

      使用 fork 的主要原因是执行速度。

      如果您按照您的建议使用一组参数启动流程的新副本,则新流程将需要解析这些参数并重复父流程已完成的大部分处理。使用“fork()”,父进程堆栈的完整副本可立即提供给子进程,所有内容均按应有的方式解析和格式化。

      此外,在大多数情况下,程序将是“.so”或“.dll”,因此不会复制可执行指令,只会复制堆栈和堆存储。

      【讨论】:

        【解决方案11】:

        所以,您主要担心的是:fork() 会导致不必要的内存复制。

        答案是:不,没有内存浪费。简而言之,fork() 是在内存资源非常有限的时候诞生的,所以没有人会考虑这样浪费它。

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

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

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

        【讨论】:

        • CPU 浪费怎么办?当进程的某些属性被复制到新进程时,这个操作是否只是一堆不需要执行的额外指令,因为我知道我无论如何都会丢弃它们?我的意思是 fork() 复制进程。它确实复制了许多后来被覆盖的属性,并且消耗了一些不需要消耗的 CPU?
        • 不会有太多属性被覆盖。这样的开销是可以接受的
        【解决方案12】:

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

        人们习惯于在调用exec 之前设置一个新的工作环境(打开文件描述符、管道和其他东西),所以fork 一直待在那儿。

        【讨论】:

          【解决方案13】:

          就分页/虚拟内存而言,有些技术 fork() 并不总是复制进程的整个地址空间。存在写入时复制,其中分叉的进程获得与其父进程相同的地址空间,然后仅复制更改的空间的一部分(由任一进程)。

          【讨论】:

            【解决方案14】:

            您可以认为这有点像在 Windows 中生成一个线程,只是进程不共享资源,除了文件句柄、共享内存和其他显式可继承的东西。因此,如果您有一项新任务要做,您可以分叉,一个进程继续其原始工作,而克隆负责新任务。

            如果您想进行并行计算,您的进程可以在循环上方将自己拆分为多个克隆。每个克隆都执行计算的子集,而父等待它们完成。操作系统确保它们可以并行运行。在 Windows 中,你会例如需要使用 OpenMP 来获得相同的可表达性。

            如果您需要读取或写入文件但不能等待,您可以只 fork 并且您的克隆在继续执行原始任务的同时执行 i/o。在 Windows 上,您可能会考虑在 Unix 中使用简单 fork 的许多情况下生成线程或使用重叠 i/o。特别是,进程不存在与线程相同的可伸缩性问题。在 32 位系统上尤其如此。只分叉比处理重叠 i/o 的复杂性要方便得多。虽然进程有自己的内存空间,但线程存在于同一个内存空间中,因此您应该考虑将多少线程放入 32 位进程中是有限制的。使用 fork 制作 32 位服务器应用程序非常简单,而使用线程制作 32 位服务器应用程序可能是一场噩梦。因此,如果您在 32 位 Windows 上编程,您将不得不求助于其他解决方案,例如重叠 i/o,这是一个可以使用的 PITA。

            因为进程不会像线程那样共享全局资源(例如 malloc 中的全局锁),所以这更具可扩展性。虽然线程通常会相互阻塞,但进程独立运行。

            在 Unix 上,因为 fork 对您的进程进行了写时复制克隆,所以它并不比在 Windows 中产生新线程更重要。

            如果您处理解释语言,通常有一个全局解释器锁(Python、Ruby、PHP...),那么一个能够让您分叉的操作系统是必不可少的。否则,您利用多个处理器的能力会受到很大限制。

            另一件事是这里存在安全问题。进程不共享内存空间,也不能弄乱彼此的内部细节。这导致更高的稳定性。如果您有一台使用线程的服务器,则一个线程中的崩溃将导致整个服务器应用程序瘫痪。分叉崩溃只会删除分叉的克隆。这也使错误处理更加简化。让您的分叉克隆中止通常就足够了,因为它对原始应用没有任何影响。

            还有一个安全问题。如果一个分叉的进程被注入恶意代码,它就不会进一步影响父进程。现代网络浏览器利用了这一点,例如保护一个标签不受另一个标签的影响。如果你有一个 fork 系统调用,所有这些都更方便编程。

            【讨论】:

              猜你喜欢
              • 2019-04-05
              • 1970-01-01
              • 1970-01-01
              • 2021-03-29
              • 2012-01-13
              • 1970-01-01
              • 1970-01-01
              • 2018-01-01
              • 1970-01-01
              相关资源
              最近更新 更多