跳转到内容

C 编程/POSIX 参考/unistd.h/fork

来自维基教科书,自由的教科书

计算 中,当一个 进程 分叉 时,它会创建一个自身的副本。更一般地说,在 多线程 环境中,分叉意味着一个 线程 的执行被复制,从父线程创建一个子线程。

Unix类 Unix 操作系统 中,父进程和子进程可以通过检查 fork() 系统调用 的返回值来区分彼此。在子进程中,fork() 的返回值为 0,而在父进程中,返回值为新创建的子进程的 PID

分叉操作为子进程创建了一个独立的 地址空间。子进程拥有父进程所有内存段的精确副本,尽管如果实现了 写时复制 语义,实际的物理内存可能不会被分配(即,这两个进程可以共享相同的物理内存段一段时间)。父进程和子进程都拥有相同的代码段,但它们独立执行。

Unix 中分叉的重要性

[编辑 | 编辑源代码]

分叉是 Unix 的重要组成部分,对于支持其设计理念至关重要,该理念鼓励 过滤器 的开发。在 Unix 中,过滤器是一个(通常很小)程序,它从 stdin 读取输入,并将输出写入 stdout。这些命令的 管道 可以通过 shell 连接在一起,以创建新的命令。例如,可以将 find(1) 命令的输出和 wc(1) 命令的输入连接在一起,创建一个新的命令,该命令将打印当前目录和任何子目录中以“.cpp”结尾的文件的计数,如下所示

$ find . -name "*.cpp" -print | wc -l

为了实现这一点,shell 会分叉自身,并使用 管道,一种 进程间通信 的形式,将 find 命令的输出与 wc 命令的输入连接起来。创建了两个子进程,每个命令一个(findwc)。这些子进程使用 exec(3) 系统调用族(在上面的示例中,find 将覆盖第一个子进程,wc 将覆盖第二个子进程,shell 将使用管道将 find 的输出与 wc 的输入连接起来)被 覆盖 了与它们要执行的程序相关的代码。

更一般地说,每次用户发出命令时,shell 也会执行分叉操作。通过分叉 shell 创建子进程,并使用 exec 再次覆盖与要执行的程序相关的代码。

进程地址空间

[编辑 | 编辑源代码]

每当执行可执行文件时,它就会成为一个进程。可执行文件包含二进制代码,这些代码被分组到称为段的若干块中。每个段用于存储特定类型的数据。下面列出了典型 ELF 可执行文件的一些段名称。

  • 文本 — 包含可执行代码的段
  • .bss — 包含初始化为零的数据的段
  • 数据 — 包含已初始化数据的段
  • symtab — 包含程序符号(例如,函数名、变量名等)的段
  • interp — 包含要使用的解释器名称的段

readelf 命令可以提供有关 ELF 文件的更多详细信息。当此类文件加载到内存中以执行时,这些段将加载到内存中。可执行文件不必全部加载到连续的内存位置。内存被分成大小相等的块,称为页面(通常为 4KB)。因此,当可执行文件加载到内存中时,可执行文件的不同部分被放置在不同的页面(这些页面可能不连续)。考虑一个大小为 10K 的 ELF 可执行文件。如果操作系统支持的页面大小为 4K,那么该文件将被分成三个大小分别为 4K、4K 和 2K 的部分(也称为 )。这三个帧将被容纳在内存中任何三个空闲页面中。

分叉和页面共享

[编辑 | 编辑源代码]

当发出 fork() 系统调用时,将创建父进程所有对应页面的副本,并由操作系统加载到子进程的独立内存位置。但在某些情况下,这并非必要。考虑子进程执行“exec”系统调用(用于在 C 程序中执行任何可执行文件)或在 fork() 之后很快退出。当子进程仅用于为父进程执行命令时,无需复制父进程的页面,因为 exec 将调用它的进程的地址空间替换为要执行的命令。

在这种情况下,会使用一种称为 写时复制 (COW) 的技术。使用这种技术,当发生分叉时,不会为子进程复制父进程的页面。相反,这些页面在子进程和父进程之间共享。每当进程(父进程或子进程)修改页面时,就会为执行修改的进程(父进程或子进程)创建该特定页面的单独副本。然后,该进程将在所有未来的引用中使用新复制的页面,而不是共享页面。另一个进程(未修改 共享页面 的进程)继续使用该页面的原始副本(现在不再共享)。该技术称为写时复制,因为当某个进程写入该页面时,该页面就会被复制。

Vfork 和页面共享

[编辑 | 编辑源代码]

vfork 是另一个用于创建新进程的 UNIX 系统调用。当发出 vfork() 系统调用时,父进程将被挂起,直到子进程完成执行或被通过 execve() 系统调用族中的一个系统调用替换为新的可执行映像。即使在 vfork 中,页面也是在父进程和子进程之间共享的。但 vfork 并不强制执行写时复制。因此,如果子进程在任何共享页面中进行修改,则不会创建新页面,并且父进程也可以看到修改后的页面。由于完全没有涉及页面复制(消耗额外的内存),因此当进程需要使用子进程执行阻塞命令时,这种技术非常高效。

在某些系统上,vfork()fork() 相同。

vfork() 函数与 fork() 函数的区别在于子进程可以与调用进程(父进程)共享代码和数据。这显着加快了克隆活动,但在滥用 vfork() 时会危及父进程的完整性。

不建议将 vfork() 用于除作为立即调用 exec 系列函数或 _exit() 的前奏之外的任何目的。特别是 Linux 的 vfork 手册页强烈反对使用它:[1]

Linux 从过去恢复这个幽灵真是不幸。BSD 手册页中指出:“当实现适当的系统共享机制时,此系统调用将被消除。用户不应依赖 vfork() 的内存共享语义,因为它在这种情况下将与 fork(2) 同义。”

vfork() 函数可用于创建新进程,而无需完全复制旧进程的地址空间。如果分叉的进程只是要调用 exec,那么由 fork() 从父进程复制到子进程的数据空间将不会使用。这在分页环境中特别低效,使得 vfork() 尤其有用。根据父进程数据空间的大小,vfork() 可以比 fork() 提供显着的性能提升。

vfork() 函数通常可以像 fork() 一样使用。但是,它不能在 vfork() 调用者的上下文中运行时在子进程中返回,因为最终从 vfork() 返回将返回到不再存在的堆栈帧。如果不能调用 exec,还必须注意调用 _exit() 而不是 exit(),因为 exit() 会刷新并关闭标准 I/O 通道,从而损坏父进程的标准 I/O 数据结构。(即使使用 fork(),调用 exit() 仍然不正确,因为缓冲的数据将被刷新两次。)

如果在 vfork() 后在子进程中调用信号处理程序,它们必须遵循与子进程中其他代码相同的规则。[2]

无 MMU 系统

[edit | edit source]

在几个嵌入式设备上,没有 内存管理单元,这是实现 fork() 指定的写时复制语义的要求。如果系统有一些其他机制用于每个进程的地址空间,例如 段寄存器,将整个进程内存复制到新进程会达到预期的效果,但是这是一个代价高昂的操作,并且在大多数情况下可能是不必要的,因为新进程几乎立即会替换大多数情况下的进程映像。

如果所有进程共享一个地址空间,那么 fork() 的唯一实现方式将是与任务上下文切换的其余部分一起交换内存页面。与其这样做,嵌入式操作系统(例如 uClinux)通常会省略 fork(),并且只实现 vfork();移植到此类平台的部分工作包括重写代码以使用后者。

在其他操作系统中的分叉

[edit | edit source]

Unix 和 Linux 中的分叉机制 (1969) 在底层硬件上保持着隐含的假设:线性内存和 分页 机制,它使对连续地址范围进行有效的内存复制操作成为可能。在 VMS(现在是 OpenVMS)操作系统 (1977) 的原始设计中,将复制操作与随后对新进程的几个特定地址的内容进行变异,就像分叉一样,被认为是有风险的。当前进程状态中的错误可能会复制到子进程。在这里,使用了进程产生的隐喻:新进程的内存布局的每个组件都是从头开始新建的。从软件工程的角度来看,后一种方法将被认为更干净、更安全,但分叉机制由于其效率而仍然占主导地位。 生成 (计算) 隐喻后来在 Microsoft 操作系统 (1993) 中采用。

应用程序使用

[edit | edit source]

fork() 系统调用 不接受任何参数,并返回一个 进程 ID,它通常是一个整数值。返回的进程 ID 的类型为 pid_t,它已在 头文件 sys/types.h 中定义。

fork() 系统调用的目的是创建一个新进程,该进程成为调用者的 子进程,之后父进程和子进程都将执行 fork() 系统调用之后的代码。因此,区分父进程和子进程非常重要。这可以通过测试 fork() 系统调用的返回值来完成。

  • 如果 fork() 返回负值,则表示进程创建不成功。
  • fork() 返回零到新创建的子进程。
  • fork() 返回一个正值(子进程的进程 ID)给父进程。[3]

C 中的示例

[edit | edit source]
#include <stdio.h>   /* printf, stderr, fprintf */
#include <sys/types.h> /* pid_t */
#include <unistd.h>  /* _exit, fork */
#include <stdlib.h>  /* exit */
#include <errno.h>   /* errno */

int main(void)
{
   pid_t  pid;

   /* Output from both the child and the parent process
    * will be written to the standard output,
    * as they both run at the same time.
    */
   pid = fork();
   if (pid == -1)
   {   
      /* Error:
       * When fork() returns -1, an error happened
       * (for example, number of processes reached the limit).
       */
      fprintf(stderr, "can't fork, error %d\n", errno);
      exit(EXIT_FAILURE);
   }

   if (pid == 0)
   {
      /* Child process:
       * When fork() returns 0, we are in
       * the child process.
       * Here we count up to ten, one each second.
       */
      int j;
      for (j = 0; j < 10; j++)
      {
         printf("child: %d\n", j);
         sleep(1);
      }
      _exit(0);  /* Note that we do not use exit() */
   }
   else
   { 
      /* Parent process:
       * When fork() returns a positive number, we are in the parent process
       * (the fork return value is the PID of the newly created child process).
       * Again we count up to ten.
       */
      int i;
      for (i = 0; i < 10; i++)
      {
         printf("parent: %d\n", i);
         sleep(1);
      }
      exit(0);
   }
   return 0;
}

参考

[edit | edit source]
  1. VFORK
  2. UNIX 规范版本 2,1997 http://www.opengroup.org/pubs/online/7908799/xsh/vfork.html
  3. "CSL 网站".
[edit | edit source]
华夏公益教科书