TLPI 第24 章 读书笔记:Process Creation

📅 2026/7/15 6:42:59
TLPI 第24 章 读书笔记:Process Creation
笔记和练习博客总目录见开始读TLPI。在本章和接下来的三章中我们将探讨进程是如何创建和终止的以及进程如何执行一个新程序。本章介绍进程创建。然而在深入该主题之前我们先对这四章中涉及的主要系统调用做一个简短的概述。24.1 Overview of fork(), exit(), wait(), and execve()本章及接下来的几章的主要主题是系统调用 fork()、exit()、wait() 和 execve()。每个系统调用都有变体我们也将进行探讨。目前我们先概述这四个系统调用以及它们通常如何一起使用。fork()系统调用允许一个进程父进程创建出新进程子进程。其实现方式为将新创建的子进程制作为父进程几乎完全一致的副本。子进程会获得父进程栈段、数据段、堆段与代码段的副本详见 6.3 节。“fork”分叉这一命名来源于这样一种直观理解我们可以把父进程想象成一分为二生成两份自身副本。exit(status) 库函数终止一个进程使该进程使用的所有资源内存、打开的文件描述符等可以由内核重新分配。status 参数是一个整数用于确定进程的终止状态。通过使用 wait() 系统调用父进程可以获取这个状态。exit() 库函数是建立在 _exit() 系统调用之上的。在第 25 章中我们将解释这两个接口之间的区别。在此期间我们先指出在 fork() 之后通常只有父进程和子进程中的一个通过调用 exit() 终止另一个进程应使用 _exit() 终止。wait(status) 系统调用有两个目的。首先如果此进程的子进程尚未通过调用 exit() 终止则 wait() 会挂起进程的执行直到其某个子进程终止为止。其次子进程的终止状态会通过 wait() 的 status 参数返回。execve(pathname, argv, envp) 系统调用将一个新程序pathname、带有参数列表 argv 和环境列表 envp加载到进程的内存中。现有的程序文本会被丢弃并为新程序新建堆栈、数据和堆段。这个操作通常被称为执行exec一个新程序。稍后我们将看到几个库函数是建立在 execve() 之上的每个函数在编程接口上提供了有用的变体。当我们不关心这些接口变体时我们通常按照通用惯例将这些调用统称为 exec()但要注意这个名称下没有对应的系统调用或库函数。 execve exec argv envp其他一些操作系统将 fork() 和 exec() 的功能合并为一个操作——所谓的 spawn——它创建一个新进程然后执行指定的程序。相比之下UNIX 的方法通常更简单、更优雅。将这两个步骤分开使 API 更简单fork() 系统调用不接受任何参数并且允许程序在两个步骤之间执行的操作具有很大的灵活性。此外执行 fork() 而不接着 exec() 往往也是有用的。SUSv3 指定了可选的 posix_spawn() 函数它结合了 fork() 和 exec() 的效果。该函数以及 SUSv3 指定的几个相关 API 在 Linux 的 glibc 中得以实现。SUSv3 指定 posix_spawn() 以允许为不提供交换功能或内存管理单元的硬件架构编写可移植应用程序这在许多嵌入式系统中很常见。在这种架构上传统的 fork() 很难实现甚至不可能实现。图24-1概述了fork()、exit()、wait()和execve()通常如何一起使用。该图说明了shell在执行命令时所采取的步骤shell持续执行一个循环读取命令对其进行各种处理然后fork一个子进程来执行该命令。该图中显示的execve()的使用是可选的。有时让子进程继续执行与父进程相同的程序反而更有用。无论哪种情况子进程的执行最终都会通过调用exit()或通过信号的传递而终止从而产生父进程可以通过wait()获取的终止状态。wait()的调用同样是可选的。父进程可以完全忽略它的子进程并继续执行。然而我们稍后会看到使用wait()通常是可取的而且经常在SIGCHLD信号的处理程序中使用当子进程之一终止时内核会为父进程生成该信号。默认情况下SIGCHLD被忽略这就是为什么我们在图中标注为可选择发送。Figure 24-1: Overview of the use of fork(), exit(), wait(), and execve()24.2 Creating a New Process: fork()在许多应用中创建多个进程可以成为划分任务的一个有用方法。例如一个网络服务器进程可以监听传入的客户端请求并为每个请求创建一个新的子进程来处理同时服务器进程继续监听进一步的客户端连接。以这种方式划分任务通常使应用程序的设计更简单。它还允许更高的并发即可以同时处理更多任务或请求。fork() 系统调用创建一个新进程即子进程它几乎是调用进程父进程的完全副本。#includeunistd.hpid_tfork(void);理解 fork() 的关键点在于要意识到在它完成工作后会存在两个进程并且在每个进程中执行会从 fork() 返回的点继续进行。这两个进程执行相同的程序文本但它们拥有独立的堆栈、数据和堆段的副本。子进程的堆栈、数据和堆段最初是父进程对应内存部分的完全复制。fork() 之后每个进程都可以修改自己堆栈、数据和堆段中的变量而不会影响另一个进程。在程序代码中我们可以通过 fork() 返回的值区分两个进程。对于父进程fork() 返回新创建子进程的进程 ID。这很有用因为父进程可能会创建并需要跟踪多个子进程通过 wait() 或其相关函数。对于子进程fork() 返回 0。如果需要子进程可以使用 getpid() 获取自己的进程 ID并使用 getppid() 获取父进程的进程 ID。如果无法创建新进程fork() 返回 -1。可能失败的原因是允许此真实用户 ID 创建的进程数量的资源限制RLIMIT_NPROC如第 36.3 节所述已被超过或者已达到系统范围内可创建的进程数量限制。在调用 fork() 时有时会采用以下惯用法$ cat1pid_tchildPid;/* Used in parent after successful fork() to record PID of child */switch(childPidfork()){case-1:/* fork() failed *//* Handle error */case0:/* Child of successful fork() comes here *//* Perform actions specific to child */default:/* Parent comes here after successful fork() *//* Perform actions specific to parent */}需要注意的是在调用 fork() 之后无法确定两个进程中哪一个会被下一个调度使用 CPU。在编写不当的程序中这种不确定性可能导致称为竞态条件的错误我们将在第 24.4 节中进一步描述这一点。清单 24-1 展示了 fork() 的使用。该程序创建了一个子进程子进程会修改在 fork() 期间继承的全局变量和自动变量的副本。在该程序中父进程执行的代码中使用 sleep()允许子进程在父进程之前被 CPU 调度从而使子进程能够完成其工作并在父进程继续执行之前终止。以这种方式使用 sleep() 并不能保证绝对实现这一结果我们将在第 24.5 节中介绍一种更好的方法。当我们运行清单 24-1 中的程序时我们会看到以下输出$ ./t_forkPID16250(child)idata333istack666PID16249(parent)idata111istack222上述输出表明子进程在 fork() 时会得到自己的一份栈和数据段的副本并且能够修改这些段中的变量而不影响父进程。Listing 24-1: Using fork()// procexec/t_fork.c// 代码略 父进程和fork后的子进程共享内核资源文件描述符、偏移量、信号处理… 不共享所有内存变量全局、static、堆、栈。24.2.1 File Sharing Between Parent and Child当执行 fork() 时子进程会收到父进程所有文件描述符的副本。这些副本是以 dup() 的方式创建的这意味着父进程和子进程中对应的描述符引用同一个打开的文件描述。正如我们在第 5.4 节中看到的打开的文件描述包含当前的文件偏移量由 read()、write() 和 lseek() 修改以及打开文件状态标志由 open() 设置并可通过 fcntl() 的 F_SETFL 操作更改。因此这些打开文件的属性在父进程和子进程之间是共享的。例如如果子进程更新了文件偏移量这一变化通过父进程中的对应描述符是可见的。这些属性在 fork() 之后被父进程和子进程共享这一点在示例 24-2 中的程序得到了说明。该程序使用 mkstemp() 打开一个临时文件然后调用 fork() 创建一个子进程。子进程更改临时文件的文件偏移量和打开文件的状态标志然后退出。父进程随后获取文件偏移量和标志以验证它是否能看到子进程所做的更改。当我们运行该程序时会看到如下内容$ ./fork_file_sharing File offset before fork():0O_APPEND flag before fork()is: off Child has exited File offsetinparent:1000O_APPEND flaginparent is: on有关为什么我们在清单 24-2 中将 lseek() 的返回值转换为 long long 的解释请参见第 5.10 节。Listing 24-2: Sharing of file offset and open file status flags between parent and child// procexec/fork_file_sharing.c// 代码略在父进程和子进程之间共享打开的文件属性通常是很有用的。例如如果父进程和子进程都在向一个文件写入数据共享文件偏移量可以确保两个进程不会覆盖彼此的输出。然而这并不能防止两个进程的输出被随机交错。如果不希望出现这种情况那么就需要某种形式的进程同步。例如父进程可以使用 wait() 系统调用暂停直到子进程退出。Shell 就是这样做的这样它只有在执行命令的子进程终止后才打印提示符除非用户明确在命令末尾加上 符号将命令放在后台运行。若程序不需要以这种方式共享文件描述符应用程序的设计应当遵循如下规范在调用fork()之后父进程与子进程使用各自独立的文件描述符并且两个进程在完成分叉操作后立刻关闭自身不会用到的文件描述符也就是仅对方进程需要使用的描述符。如果其中某个进程会执行exec()系统调用27.4 节介绍的执行时关闭标志也能起到相应作用。上述处理流程如图 24-2 所示。。图略Figure 24-2: Duplication of file descriptors during fork(), and closing of unused descriptors24.2.2 Memory Semantics of fork()从概念上讲我们可以将 fork() 视为创建父进程的文本、数据、堆和栈段的副本。实际上在一些早期的 UNIX 实现中这种复制确实是字面上执行的通过将父进程的内存复制到交换空间来创建一个新的进程映像并将该交换出的映像作为子进程而父进程则保留自己的内存。然而实际上将父进程的虚拟内存页简单复制到新子进程会因多种原因而浪费——其中一个原因是 fork() 通常会紧接着执行 exec()该操作用新程序替换进程的文本并重新初始化进程的数据、堆和栈段。包括 Linux 在内的大多数现代 UNIX 实现使用两种技术来避免这种浪费的复制内核将每个进程的文本段标记为只读以便进程无法修改自己的代码。这意味着父进程和子进程可以共享相同的文本段。fork() 系统调用通过为子进程构建一组每个进程的页表条目来创建子进程的文本段这些条目引用父进程已经使用的相同虚拟内存页框。对于父进程的数据、堆和栈段中的页面内核采用一种称为写时复制的技术。写时复制的实现可参考 [Bach, 1986] 和 [Bovet Cesati, 2005]。最初内核设置这些段的页表条目使其引用与父进程中对应页表条目相同的物理内存页面并将这些页面标记为只读。在 fork() 之后内核会捕捉父进程或子进程试图修改这些页面的任何尝试并复制即将被修改的页面。这个新的页面副本会分配给发生错误的进程并相应地调整子进程的页表条目。从此之后父进程和子进程可以分别修改它们的私有页面副本而这些更改不会对另一进程可见。图 24-3 说明了写时复制技术。 为避免复制造成的浪费共享只读的部分对于可读写部分则采用快照技术。图略Figure 24-3: Page tables before and after modification of a shared copy-on-write pageControlling a process’s memory footprint我们可以结合使用 fork() 和 wait() 来控制进程的内存占用。进程的内存占用是进程使用的虚拟内存页的范围会受到诸如函数调用和返回时堆栈调整、调用 exec()以及在本讨论中特别关注的由调用 malloc() 和 free() 导致的堆修改等因素的影响。假设我们像清单 24-3 中所示的那样使用 fork() 和 wait() 来括住对某个函数 func() 的调用。执行这段代码后我们知道父进程的内存占用与调用 func() 之前相比没有变化因为所有可能的更改都将发生在子进程中。这在以下几个方面可能是有用的如果我们知道 func() 会导致内存泄漏或堆的过度碎片化这种技术可以消除这个问题。如果我们无法访问 func() 的源代码我们可能无法处理这些问题。假设我们有某个算法在进行树分析的同时执行内存分配例如一个分析可能的棋步及其应对措施的游戏程序。我们可以编写这样的程序来调用 free() 以释放所有分配的内存。然而在某些情况下采用我们这里描述的技术会更简单从而允许我们回溯同时保持调用者父级的原始内存占用不变。在清单 24-3 中显示的实现中func() 的结果必须用 exit() 从终止的子进程传递给调用 wait() 的父进程的 8 位表示。然而我们可以使用文件、管道或其他一些进程间通信技术来允许 func() 返回更大的结果。Listing 24-3: Calling a function without changing the process’s memory footprint// procexec/footprint.c// 代码略24.3 The vfork() System Call早期的 BSD 实现中fork() 会对父进程的数据、堆和栈进行字面上的复制。如前所述这非常浪费尤其是当 fork() 紧接着被立即 exec() 调用时。出于这个原因后来版本的 BSD 引入了 vfork() 系统调用它比 BSD 的 fork() 高效得多尽管它的操作语义略有不同实际上有些奇怪。采用写时复制copy-on-write来实现 fork() 的现代 UNIX 实现比老的 fork() 实现高效得多因此在很大程度上消除了对 vfork() 的需求。然而Linux以及许多其他 UNIX 实现提供了具有 BSD 语义的 vfork() 系统调用以满足需要尽可能快速 fork 的程序。然而由于 vfork() 的特殊语义可能导致一些细微的程序错误因此通常应避免使用除非在极少数情况下它能带来显著的性能提升。与 fork() 一样vfork() 也用于由调用进程创建新的子进程。然而vfork() 明确设计用于子进程立即执行 exec() 调用的程序中。#includeunistd.hpid_tvfork(void);有两个特征使 vfork() 系统调用有别于 fork() 并使其更高效不为子进程复制虚拟内存页或页表。相反子进程共享父进程的内存直到它成功执行 exec() 或调用 _exit() 终止。父进程的执行会暂停直到子进程执行了 exec() 或 _exit()。这些要点具有一些重要的意义。由于子进程使用的是父进程的内存因此子进程对数据段、堆或栈段所做的任何更改在父进程恢复执行后都会可见。此外如果子进程在 vfork() 和稍后的 exec() 或 _exit() 之间执行了函数返回这也会影响父进程。这类似于第 6.8 节中描述的尝试 longjmp() 到一个已经返回的函数的例子。类似的混乱——通常是段错误SIGSEGV——很可能会发生。在 vfork() 和 exec() 之间子进程可以执行一些不会影响父进程的操作。其中包括对已打开文件描述符的操作但不包括 stdio 文件流。由于每个进程的文件描述符表是在内核空间维护的第 5.4 节并在 vfork() 期间被复制因此子进程可以执行文件描述符操作而不影响父进程。SUSv3 规定如果程序存在以下行为其行为是未定义的a) 修改除用于存储 vfork() 返回值的 pid_t 类型变量以外的任何数据b) 从调用 vfork() 的函数中返回或 c) 在成功调用 _exit() 或执行 exec() 之前调用任何其他函数。当我们在第 28.2 节查看 clone() 系统调用时我们会看到使用 fork() 或 vfork() 创建的子进程还会获得一些其他进程属性的副本。vfork() 的语义意味着在调用之后子进程保证会在父进程之前被调度到 CPU。在第 24.2 节中我们指出这并不是 fork() 的保证fork() 调用之后父进程或子进程都可能先被调度。清单 24-4 展示了 vfork() 的使用并演示了两个区别于 fork() 的语义特性子进程共享父进程的内存并且父进程会被挂起直到子进程终止或调用 exec()。当我们运行这个程序时会看到如下输出$ ./t_vfork Child executing Parent executingistack666从最后一行输出可以看出子进程对变量 istack 所做的修改实际上是在父进程的变量上进行的。Listing 24-4: Using vfork()// procexec/t_vfork.c// 代码略**除非速度绝对关键否则新程序应该避免使用 vfork()而应使用 fork()。**这是因为当 fork() 使用写时复制语义实现如大多数现代 UNIX 实现的方式时它的速度接近 vfork()并且可以避免如上所述与 vfork() 相关的怪异行为。我们在第 28.3 节中展示了 fork() 和 vfork() 之间的一些速度比较。SUSv3 将 vfork() 标记为过时而 SUSv4 更进一步删除了 vfork() 的规范。SUSv3 对 vfork() 的许多操作细节没有明确规定允许其作为对 fork() 的调用来实现。当以这种方式实现时vfork() 的 BSD 语义并未得到保留。一些 UNIX 系统确实将 vfork() 实现为对 fork() 的调用Linux 在 2.0 内核及以前的版本中也采用了这种方式。在使用 vfork() 的地方通常应该紧跟着调用 exec()。如果 exec() 调用失败子进程应该使用 _exit() 终止。vfork() 的子进程不应该通过调用 exit() 来终止因为那会导致父进程的 stdio 缓冲区被刷新并关闭。我们在第 25.4 节对此点进行了更详细的说明。vfork() 的其他用途——特别是那些依赖其不寻常的内存共享和进程调度语义的用途——可能会使程序变得不可移植尤其是在 vfork() 仅仅被实现为 fork() 调用的环境中。24.4 Race Conditions After fork()在调用 fork() 之后无法确定哪个进程——父进程还是子进程——将首先获得 CPU 的访问权限。在多处理器系统上它们可能会同时获得 CPU 访问权限。那些隐式或显式依赖特定执行顺序以获得正确结果的应用程序可能会由于竞争条件而失败我们在第 5.1 节中对此进行了描述。这类错误可能很难发现因为它们的发生取决于内核根据系统负载做出的调度决定。我们可以使用清单24-5中的程序来演示这种不确定性。这个程序使用循环通过fork()创建多个子进程。在每次fork()之后父进程和子进程都会打印一条包含循环计数值和指示进程是父进程还是子进程的字符串的消息。例如如果我们要求程序只产生一个子进程我们可能会看到如下内容$ ./fork_whos_on_first10parent0child我们可以使用这个程序创建大量的子进程然后分析输出以查看每次是父进程还是子进程先打印其消息。在 Linux/x86-32 2.2.19 系统上使用这个程序创建一百万个子进程并分析结果显示除了 332 个案例之外父进程在所有情况下都是先打印其消息的即在 99.97% 的情况下。$ ./fork_whos_on_first1000000|./fork_whos_on_first.count.awk Num children100000parent9963699.64% child3640.36% Num children200000parent19925199.63% child7490.37% Num children300000parent29880999.60% child11910.40% Num children400000parent39821199.55% child17890.45% Num children500000parent49766299.53% child23380.47% Num children600000parent59715299.53% child28480.47% Num children700000parent69669799.53% child33030.47% Num children800000parent79629299.54% child37080.46% Num children900000parent89581099.53% child41900.47% Num children1000000parent99536199.54% child46390.46% Alldoneparent99536199.54% child46390.46%使用本书源码发行版中提供的脚本 procexec/fork_whos_on_first.count.awk 对清单 24-5 中运行程序的结果进行了分析。根据这些结果我们可以推测在 Linux 2.2.19 上fork() 后执行总是从父进程继续。子进程偶尔先打印它的消息的原因是在 0.03% 的情况下父进程的 CPU 时间片在它有机会打印消息之前就用完了。换句话说如果这个例子表示我们依赖父进程在 fork() 后总是优先调度的情况那么通常会顺利但每 3000 次中可能会有一次出问题。当然如果应用程序期望父进程在子进程被调度之前能够完成更大的一部分工作那么出错的可能性会更大。在复杂程序中调试这种错误可能会很困难。Listing 24-5: Parent and child race to write a message after fork()// procexec/fork_whos_on_first.c// 代码略虽然在 Linux 2.2.19 中fork() 后总是继续执行父进程但我们不能指望在其他 UNIX 实现上甚至在不同版本的 Linux 内核上也会如此。在 2.4 稳定内核系列中曾短暂尝试过一个“fork() 后子进程先运行”的补丁这会完全颠倒在 2.2.19 中得到的结果。虽然这个修改后来从 2.4 内核系列中取消了但它随后在 Linux 2.6 中被采纳。因此假设 2.2.19 行为的程序在 2.6 内核上会出问题。一些最近的实验则颠覆了内核开发者关于在 fork() 后先运行子进程还是父进程更好的评估自 Linux 2.6.32 起默认情况下fork() 后再次优先运行的是父进程。这个默认行为可以通过给 Linux 特有的 /proc/sys/kernel/sched_child_runs_first 文件分配非零值来改变。$cat/proc/sys/kernel/sched_child_runs_first0为了理解“fork() 后子进程优先”行为的理由可以考虑在子进程执行 fork() 后立即执行 exec() 时复制写入copy-on-write语义的情况。在这种情况下由于父进程在 fork() 后继续修改数据页和栈页内核会为子进程复制将要被修改的页面。由于子进程在被调度运行后立即执行 exec()这种复制就是浪费。根据这一论点最好先调度子进程这样当父进程下一次被调度时就不需要进行页面复制。使用清单 24-5 中的程序在一台运行内核 2.6.30 的繁忙 Linux/x86-32 系统上创建 100 万个子进程显示在 99.98% 的情况下子进程先显示它的消息。确切百分比取决于系统负载等因素。在其他 UNIX 实现上测试该程序显示决定 fork() 后先运行哪个进程的规则差异很大。在 Linux 2.6.32 中改回“fork() 后父进程优先”的理由是基于以下观察在 fork() 之后父进程的状态已经在 CPU 上处于活跃状态并且其内存管理信息已经缓存到硬件内存管理单元的转换后备缓冲TLB中。因此先运行父进程应该能获得更好的性能。通过测量在这两种行为下内核构建所需的时间可以对这一点进行非正式的验证。总之值得注意的是这两种行为之间的性能差异相当小对大多数应用程序不会产生影响。从前面的讨论可以清楚地看出**我们不能假设在 fork() 之后父进程和子进程会按照特定顺序执行。如果我们需要保证特定的执行顺序就必须使用某种同步技术。**我们将在后面的章节中描述几种同步技术包括信号量、文件锁以及使用管道在进程之间发送消息。另一种方法我们将在接下来描述是使用信号。24.5 Avoiding Race Conditions by Synchronizing with Signals在 fork() 之后如果任一进程需要等待另一个进程完成某个操作那么活动进程在完成操作后可以发送一个信号另一个进程则等待该信号。清单 24-6 演示了这种技术。在该程序中我们假设父进程必须等待子进程执行某个操作。如果子进程必须等待父进程则父进程和子进程中的与信号相关的调用顺序可以互换。实际上父子进程也可以多次相互发送信号以协调它们的操作尽管在实践中这种协调更可能通过信号量、文件锁或消息传递来完成。[Stevens Rago, 2005] 建议将这种同步步骤屏蔽信号、发送信号、接收信号封装到一组标准的进程同步函数中。这样封装的优势在于如果需要我们可以以后用另一种 IPC 机制替换信号的使用。请注意我们在 Listing 24-6 中的 fork() 调用之前阻塞了同步信号 (SIGUSR1)。如果父进程在 fork() 之后尝试阻塞该信号它仍然会容易受到我们试图避免的竞争条件的影响。在此程序中我们假设子进程中信号掩码的状态无关紧要如果有必要我们可以在 fork() 之后在子进程中取消阻塞 SIGUSR1。以下 shell 会话日志显示了运行 Listing 24-6 中程序时发生的情况$ ./fork_sig_sync[08:19:541016359]Parent about towaitforsignal[08:19:541016360]Child started - doing some work[08:19:561016360]Child about to signal parent[08:19:561016359]Parent got signalListing 24-6: Using signals to synchronize process actions// procexec/fork_sig_sync.c// 代码略24.6 Summaryfork() 系统调用通过几乎完全复制调用进程父进程来创建一个新进程子进程。vfork() 系统调用是 fork() 的一个更高效的版本但通常最好避免使用因为它具有不寻常的语义即子进程使用父进程的内存直到它执行 exec() 或终止在此期间父进程的执行会被挂起。在调用 fork() 之后我们不能依赖父进程和子进程下次被调度使用 CPU 的顺序。对执行顺序做出假设的程序容易出现称为竞态条件的错误。由于此类错误的发生取决于系统负载等外部因素因此可能很难发现和调试。Further information[Bach, 1986] 和 [Goodheart Cox, 1994] 提供了 UNIX 系统上 fork()、execve()、wait() 和 exit() 的实现细节。[Bovet Cesati, 2005] 和 [Love, 2010] 提供了 Linux 上进程创建和终止的特定实现细节。