8.4.3 回收子进程

P516

image
第一个ps就显示了子进程是一个僵尸进程(注意僵尸进程的父进程不一定要终止),第二个ps没有显示子进程的原因是子进程已经被init进程回收了
image

P518

image

P519

注意一共只会创建N个子进程,子进程不会继续创建子进程。这是因为创建子进程的时候,子进程也会立马执行Fork(),而子进程返回0,于是就会进入分支执行exit,所以子进程立马就会终止

P523

如果execve函数覆盖了当前进程的地址空间,那么如果execve出现了错误,如果返回到当前进程呢?
这个问题非常好,它触及了 execve 系统调用实现机制的核心。

简单直接的回答是:如果 execve 失败了,它根本不会去覆盖当前进程的地址空间,因此可以安全地返回到调用它的程序中。

下面是更详细的解释:

操作系统如何实现 execve

execve 的工作流程可以分解为几个关键的、有序的步骤。操作系统必须确保这些步骤是原子性的——要么全部成功,要么在失败时完全回滚,就像什么都没发生过一样。

  1. 验证与准备(在旧地址空间中)

    • 首先,内核会验证传入的参数是否有效(例如,文件路径是否存在、是否可执行)。
    • 它读取新程序的头部信息,了解其内存布局要求(代码段、数据段等)。
    • 所有这些准备工作都是在当前进程的原有上下文中完成的,原有的代码、数据和堆栈都还完好无损。
  2. 分配与加载(在成功验证之后)

    • 如果前面的步骤都成功了,内核才会开始“破旧”:
      • 它释放当前进程旧的代码段、数据段、堆和栈等内存区域。
      • 它根据新程序的要求,为新的代码段、数据段等分配新的内存页。
      • 它将新程序的内容从磁盘加载到新分配的内存页中。
      • 它设置新的堆栈,并将命令行参数(argv)和环境变量(envp)放入其中。
      • 它重新设置程序的计数器(PC 寄存器),使其指向新程序的 main 函数入口。
  3. “交换”上下文(成功的那一刻)

    • 只有当所有新资源的分配和加载都成功后,内核才会执行一个最终的、不可逆的“开关”,将进程的内存映射(页表)切换到新的布局。
    • 从这一刻起,旧的地址空间就彻底消失了,进程开始执行新的指令。

错误处理

现在,关键点来了:错误可能发生在第1步或第2步。

  • 在第1步出错(例如,文件未找到、权限不足):这非常简单。内核只是设置错误码(如 ENOENT, EACCES),然后直接返回-1给调用者。由于地址空间还没有任何改变,调用 execve 的程序可以像处理普通函数调用错误一样,通过检查返回值来执行错误处理逻辑(例如,打印错误信息、尝试另一个程序等)。

  • 在第2步出错(这是一个非常罕见但理论上可能发生的情况,例如,在加载过程中系统内存突然耗尽):内核的加载过程必须设计成能够处理这种故障。如果在新地址空间的构建过程中失败,内核会清理掉所有已经为这次 execve 尝试分配的新资源(释放刚分配的内存页等),然后返回错误。同样,旧地址空间保持不变,进程可以安全地恢复执行。

总结

阶段 状态 结果
调用 execve 旧地址空间完好无损 内核开始验证和准备
验证或加载失败 旧地址空间仍然完好无损 execve 返回 -1,进程继续运行旧代码
验证和加载成功 内核执行最终的“上下文切换” 旧地址空间被丢弃execve 永不返回,开始执行新程序

所以,execve 的“覆盖地址空间”行为不是一个鲁莽的、一步完成的操作。它是一个精心设计的、具有完整事务性保证的过程。只有在确保新程序100%可以成功运行之后,它才会销毁旧的程序。 这保证了在失败时,总有一条清晰的退路可以返回到调用者。
第2步出错为什么旧地址空间保持不变?
问得非常好!这个问题直击了操作系统内核设计的关键:原子性和事务性

第2步出错后,旧地址空间能保持不变,这绝非偶然,而是操作系统内核刻意设计和精心维护的结果。其核心原理可以概括为:“先在别处把新家盖好,再拆旧家”

下面是详细的解释:

1. 核心思想:写时复制(Copy-on-Write, COW)与惰性计算

现代操作系统不会直接在当前使用的地址空间上“动手术”。相反,它利用进程的内核数据结构(如内存描述符 mm_struct 和页表)来玩一个“魔术”。

  • 旧地址空间:对应着一套现有的内存映射关系(页表),这些映射指向实际的物理内存页,其中存放着当前进程的代码和数据。
  • 新地址空间:内核需要为新的程序创建一套全新的、独立的内存映射关系。

2. 实际操作:如何构建新地址空间

当内核执行 execve 的第2步时,它并不是直接清除旧映射,而是:

a) 创建一个新的、空的 mm_struct(内存描述符结构体)。你可以把它想象成一个空白的新页表框架。

b) 在这个新的、空的框架上搭建新程序
* 根据新程序的头部信息,在新页表中创建代码段、数据段等的虚拟内存区域(VMAs)
* 为这些区域分配新的物理内存页
* 将磁盘上的程序内容加载到这些全新的物理页中
* 建立新的堆栈,并将 argvenvp 放入新的堆栈所使用的物理页中

最关键的一点是:所有这些操作(分配新物理页、写入新数据)都是在为那个“新的、空的”内存描述符进行的。旧的页表和它指向的物理内存页在此期间完全没有被触碰。

这个过程就像一个建筑师先在旁边的空地上按照新图纸把房子完全盖好、装修好、家具也搬进去,而你的旧房子还原封不动地在那里,你依然住在里面。

3. 出错怎么办:优雅地清理

如果在搭建这个“新家”的过程中任何一步失败(比如,在分配第100个内存页时系统内存耗尽了),内核的错误处理流程非常简单:

  1. 释放所有为这次 execve 尝试而新分配的资源:释放所有刚刚申请来的新物理内存页、销毁那个刚刚创建了一半的新 mm_struct 和页表。
  2. 返回错误码:像 ENOMEM(内存不足)。
  3. 进程恢复:由于旧的 mm_struct 和页表从头到尾都没有被修改过,进程的代码、数据、堆栈完好如初。CPU继续沿着调用 execve 之后的指令执行,发现返回值是 -1,于是进入错误处理分支。

因为新旧环境是完全隔离的,所以破坏新环境不会影响到旧环境。

4. 成功怎么办:原子性的切换

只有当“新家”完全盖好,一切准备就绪后,内核才会执行那个最终的、不可逆的原子操作:

  1. 将进程的 mm_struct 指针从指向“旧结构”改为指向“新结构”。
  2. 通知CPU刷新其 Translation Lookaside Buffer (TLB),使用新的页表。
  3. 设置程序计数器(PC),开始执行新程序的代码。

在这个切换动作之后,旧的 mm_struct 就失去了引用。如果没有其他进程共享它(通常不会),它就会被内核的垃圾回收机制自动释放,其对应的物理内存页也会被回收。 这就是“拆旧家”的过程,但它发生在确保“新家”已经完全可用之后。

比喻总结

想象你要换电脑操作系统:

  • 旧地址空间:你当前正在运行的Windows系统。
  • execve():你想要换成一个Linux系统。
  • 第1步(验证):你先检查Linux的安装镜像是否下载完整、能否打开。
  • 第2步(加载):你不是直接在C盘里删Windows文件然后塞Linux文件,而是另拿一块新硬盘,把Linux系统完整地安装到这块新硬盘上。Windows硬盘 untouched。
  • 出错:如果在安装新硬盘上的Linux时失败(比如新硬盘坏了),你 simply 把这块坏硬盘拔掉扔掉。你的Windows硬盘和系统完全没受影响,开机照样能用。
  • 成功:只有当新硬盘上的Linux100%安装并测试成功,你才会拔掉Windows硬盘,换上新硬盘,然后开机。这是一个瞬间完成的切换动作。

正是这种“先构建,后切换”的设计,保证了 execve 在错误情况下能够安全失败,从而让旧地址空间保持不变。

posted @ 2025-08-11 00:23  最爱丁珰  阅读(2)  评论(0)    收藏  举报