ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

【Linux】进程父子关系 进程状态

【Linux】进程父子关系  进程状态

一、进程父子关系

1.1 进程树

我们的进程是一个单亲繁殖系统,类似于 B 树(我们还没有学),一棵多叉树从根结点出发延伸出多个子进程。譬如我们一切的命令都有一个共同的父进程 bash。

1.2 子进程的创建

那么进程应该如何创建呢?我们先来介绍一个创建子进程的系统调用:

  • fork

头文件:#include <unistd.h>

快速了解一下这个系统调用:一句话核心 ——fork()就是进程的"分身术",调用一次,原地复制出一个几乎一模一样的子进程,让程序实现真正的"分头行动"。

作用:

  • 创建分身:产生子进程,继承父进程的代码和数据;
  • 并发处理:让父子进程同时运行,互不干扰地处理不同任务;
  • 环境隔离:子进程在独立空间运行,崩溃不影响父进程。

它在 2 号手册中属于系统调用。

我们先写一个代码,然后慢慢讲:

voidFunc_Test_Process(){pid_t_id=fork();if(_id<0){printf("创建进程失败\n");}//子进程elseif(_id==0){while(1){printf("我是一个子进程,我的pid是: %d ,父进程的ppid是: %d .\n",getpid(),getppid());sleep(1);}}elseif(_id>0){while(1){printf("我是一个父进程,我的pid是: %d ,父进程的ppid是: %d .\n",getpid(),getppid());sleep(1);}}}

如上,父子进程都拿着各自的_id去执行对应的代码。

我们的父进程创建一个子进程,默认是将父进程的 PCB 中的数据拷贝一份给子进程,并修改一部分内容,比如 pid 和 ppid。于是子进程自然而然地就会指向父进程的那段数据与代码,于是子进程也会去执行父进程后面的那一段代码,相当于有两个进程同时执行代码。

这里父进程获取到子进程的 pid,并且子进程获取的 id 结果是 0,为什么?

先举一个例子:一个父亲可以有很多孩子,但是一个孩子只有一个父亲。
父亲叫孩子过来必须要叫全名,不能只说"孩子过来",否则就分不清要谁过来了;
而孩子叫父亲只需要叫"父亲过来"就好了。

  • 父进程必须获得每一个子进程的 pid,方便做管理;
  • 子进程不必获得父进程的 pid,因为子进程可以用 ppid。

看到这里,一定有很多问题,没错,它确实颠覆了你从 C 到 C++ 到数据结构以来的代码经验。

于是我们有三个问题需要解决,这些问题看似都违反了 C++ 的语言规则。

  1. if 和 else 为什么可以同时执行?
  2. 一个局部变量为什么可以同时等于 0 和大于 0?
  3. 一个函数为什么可以有两个返回值?

1.3 一个函数为什么可以有两个返回值

我们首先来解决第三个问题:一个函数为什么可以有两个返回值?为什么 fork 可以返回两个值?

首先我想要问你一个问题:一个函数到达了 return,它的核心功能做完了没有?答案是做完了,fork 内部也是如此,也就是说,fork 在 return 之前就已经完成了:

  1. 申请新的 PCB;
  2. 拷贝父 PCB 给子进程;
  3. 子 PCB 放入进程 list;
  4. 甚至放入调度队列中。

那么精确到语句层面,子进程被创建的时间结点一定在 return 之前,于是 fork 内部必然会有两个进程返回两个值。

1.4 if 和 else 为什么可以同时执行

在之前的认知里,if 和 else 永远互斥。但在 Linux 系统编程中,这个规则被"进程"这个维度打破了。

我们要明确一个概念:代码是共享的,但执行流是独立的。

正如前面所说,当 fork 函数核心逻辑执行完毕时,系统里已经存在两个独立的执行流(父进程和子进程)。

  • 父进程:拿着 fork 返回的子进程 PID,继续向下执行。它遇到了if(_id > 0),逻辑判断为真,于是进入该分支;
  • 子进程:拿着 fork 返回的 0,也继续向下执行。它遇到了if(_id == 0),逻辑判断为真,于是进入该分支。

所以,并不是"在一个进程里同时执行了两个分支",而是两个进程各自执行了属于自己的那个分支。由于它们几乎同时在屏幕上打印,才给了你一种"代码逻辑被违背"的错觉。

1.5 一个局部变量为什么可以同时等于 0 和大于 0

这可能是最令你崩溃的地方:同一个变量_id,怎么可能既是 0 又是进程号?前面说了代码数据是共享的,为什么一个值既等于 0 又大于 0?

这里涉及到了 Linux 内存管理的核心技术 —— 写时拷贝。

  1. 初始状态:fork 刚完成时,子进程的页表指向的物理内存和父进程是同一块。也就是说,它们最初确实共享同一个_id的内存空间;
  2. 发生写入:当 fork 准备返回结果并写入_id变量时,操作系统发现有两个进程尝试操作这块内存;
  3. 触发拷贝:为了保证进程的独立性(互不干扰),操作系统会为子进程重新开辟一块物理空间,把父进程的内容拷贝过去,然后修改子进程页表的映射关系。

你在父进程里看_id,它映射的是一块存着子进程 PID 的物理内存;你在子进程里看_id,它映射的是另一块存着 0 的物理内存。

由上我们其实还能得出一个结论:进程具有独立性。两个进程的 PCB 数据结构是独立的,代码不可修改,数据又有写时拷贝护着。

二、操作系统的进程状态

如下图,精辟地总结了课本上提到的进程状态,确实有些复杂。不过这张图讲解的是操作系统的进程状态的总理论,而我们今天要讲的 Linux 进程状态,是一款具体的操作系统。

三、Linux 中的进程状态

3.1 进程的状态有哪些

为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状态(在 Linux 内核里,进程有时候也叫做任务)。

下面的状态在 kernel 源代码里定义:

/* * The task state array is a strange "bitmap" of reasons to sleep. * Thus "running" is zero, and you can test for combinations of * others with simple bit tests. */staticconstchar*consttask_state_array[]={"R (running)",/*0 */"S (sleeping)",/*1 */"D (disk sleep)",/*2 */"T (stopped)",/*4 */"t (tracing stop)",/*8 */"X (dead)",/*16 */"Z (zombie)",/*32 */};
  • R 运行状态(running):并不意味着进程一定在运行中,它表明进程要么是在运行中,要么在运行队列里;
  • S 睡眠状态(sleeping):意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠,interruptible sleep);
  • D 磁盘休眠状态(Disk sleep):有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待 IO 的结束;
  • T 停止状态(stopped):可以通过发送 SIGSTOP 信号给进程来停止(T)进程,这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行;
  • X 死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态;
  • Z 僵尸状态(Zombie):当子进程退出时,内核会释放该进程所有的代码和数据内存,但允许它的进程控制块(PCB,即task_struct)还保留在系统内核中。

我们具体一个一个讲。

3.2 调度队列 FIFO 算法 && 进程运行状态(R)

在谈论进程的运行状态之前,我需要先讲一讲进程的调度队列:我们上一篇中讲到了,进程是由 PCB + 代码与数据构成的,进程的 PCB 里面有一个指针指向下一个 PCB,于是就形成了"全局进程链表"。

我们这里讲的队列和全局链表非常相似,为什么它既是链表又是队列?我们后面会讲。

先抛出一个结论:“进程在调度队列里面 == 进程处于运行状态”。

通过进程的调度队列,我们也得到一个名词:“进程的 FIFO 算法”——进程按照队列的规则先进先出,依次被 CPU 调用执行。

3.3 硬件的设备建模 && 进程的阻塞状态

1. 硬件的设备建模

我们的计算机,它的摄像头、麦克风、键盘鼠标、显示器以及磁盘网卡等等一系列硬件,都是操作系统通过硬件驱动调用的。

然而硬件驱动调用硬件不是直接访问硬件本身,实际上其内部也经历了先描述再组织的过程,操作系统将它们的数据封装成为一个结构体,然后将它们以某种数据结构组织在一起。

2. 阻塞状态

每一个结点都有一个等待队列。

输出一个结论:进程在等待队列中的时候,就是阻塞状态。

  • 具体来说

当一个进程正在运行,但它发起的某个请求(通常是 I/O 请求)暂时无法得到满足,或者必须等待某个事件(如磁盘数据读取、网卡数据到达)发生时,操作系统会剥夺该进程的 CPU 使用权,将其从运行队列(Run Queue)中移出。

然后该进程就会被链入对应设备的等待队列当中,等待设备就绪。

什么是就绪?这很简单,我们的键盘(scanf的时候你不输入就是在等待)、磁盘读入(文件还没准备好给你读)这些都是设备就绪。

3.4 交换分区 && 进程挂起状态(Linux 中没有描述)

挂起首先你要知道,这是一种非常极端的情况,一般不会发生。什么时候会发生呢?你的操作系统认识到内存资源严重不足的时候。

1. 交换分区

交换分区,我们首先需要了解的概念。它是磁盘中的一个概念,在磁盘中有这么一块临时区域,用来临时存放从内存中交换过来的代码。

2. 挂起状态

那么当程序挂起,就是操作系统将程序的代码和数据从内存中提取出来,挂在这个交换分区当中,以此来腾出空间给其他进程使用,缓解内存压力,这个过程中 PCB 不会动。

那么谁会被优先带走呢?答案是正在阻塞的进程。它们不着急使用 CPU 资源,所以先挂起。在极端中的极端情况,有可能运行队列中靠后的一些进程的代码和数据也会被挂起来,当然这很少见。

3.5 进程的状态变化

从运行到阻塞,从阻塞到挂起,从运行到挂起,以及它们所有的反方向,在之前我们全部讲完了。现在给一张总结图:

四、深入理解内核链表

4.1 内核链表不是传统链表

我们 Linux 中的一个个 PCB,它们被链接起来形成一个链表,这没有错,但是操作系统比我们想的复杂多了。

Linux 内核使用的链表不是传统的链表,传统的链表我们可以画一个潦草的示意图:

一个带头循环双向链表,每一个结点里面都会有两个struct Node*指针,指向上一个/下一个结点。

那么 Linux 的内核链表呢?试想一下:把前后指针独立封装一下,让它和你的数据解耦,会发生什么?这就是 Linux 内核链表的想法。

structlist_head{structlist_head*next,*prev;};structtask_struct{intx,y,z;structlist_headlinks;...};

也即是说,现在这个链表本身依赖的不是指针,而是这样一个连接器结构体,它来帮助前后 PCB 建立联系。

4.2 为什么内核链表要这么设计

1. 更方便的偏移量计算 —— offsetof 宏

现在,如果你手里只有一个指向links成员的指针,想要拿到整个task_struct的首地址,你就必须知道links在这个结构体里"往后挪了多少距离"。

结构体首地址 = 当前地址 − 偏移量

有一种非常通用的计算方式:

offset = &(((struct task_struct *)0)->links)

  1. 首先人为设定起点:将 0 强制转换为结构体指针,相当于在内存地址 0 的位置"虚拟"出一个结构体;
  2. 算位置:访问成员links并取地址,编译器会根据内存对齐规则,算出它相对于起点(即地址 0)的距离;
  3. 空位即偏移:因为起点是 0,所以得到的成员地址在数值上就精准等于它在结构体里的偏移量。

C 自带一个宏可以直接获取这个偏移量,上面的讲解就是它的底层。

  • 首先包含头文件:#include <stddef.h>;
  • 第一个参数填结构体类型,第二个填成员名。
size_toffset=offsetof(structtask_struct,links);

2. 物理上

在物理内存中,一个进程有且仅有一个task_struct(进程控制块 PCB)实例。这个结构体非常庞大,包含了一个进程的所有核心信息(PID、状态、优先级等)。它就像一个巨大的"多功能插线板",身上长满了各种不同的接口 —— 这些接口就是内嵌在其中的struct list_head成员变量。

3. 逻辑上

虽然所有的插口都长在同一个 PCB 上,但每个插口连接的都是不同的、相互独立的、没有交集的独立链表。这就是著名的"用绳子穿过实体"的设计:

  1. 全局链表(Tasks List):像一根红色的绳子,横向穿过每个 PCB 的tasks插口。只要进程存活,这条线就永远不解绑,内核通过它来遍历系统中的所有进程;
  2. 运行队列(Runqueue):像一根橙色的绳子,只穿过那些状态为"就绪(Ready)"或"运行(Running)"的 PCB 的run_list插口。调度器(Scheduler)只在这条线上挑选进程去 CPU 上执行;
  3. 某一条等待队列(Wait Queue):设备千千万,等待队列的蓝线也有千千万。每个硬件设备(网卡、键盘、磁盘等)都有自己专属的一条等待队列线。

4.3 再谈状态变化

当一个进程从"运行态"变成"阻塞态"(例如等待读取磁盘文件)时,内核绝对不会去搬动或复制这个巨大的 PCB,它在幕后只干了两件事:

  • 解绑(断开旧线):调用list_del,把该 PCB 负责"运行队列"的run_list钩子从橙线上取下来;
  • 重连(挂上新线):调用list_add,把该 PCB 负责"等待队列"的wait_list钩子,挂到该磁盘设备特有的那条蓝色等待队列线上。

4.4 总结

  1. 实体唯一:内存中只有task_struct一个实体静静地呆着;
  2. 状态改变 = 关系解绑与重组:进程所谓的"状态流动",物理本质上只是不同颜色的绳子(链表指针)在 PCB 的不同插口上进行解绑和重新勾连的过程;
  3. 高效率:这种设计让进程状态切换变成了极其高效的指针操作(O(1) 复杂度),不需要任何内存拷贝,完美体现了 Linux 内核"扁平化且高效"的管理哲学。

五、运行 R 与睡眠 S 状态

5.1 具体看 R 和 S 状态

Linux 的 Sleep 就相当于操作系统的阻塞状态。

voidFunc_Test_Getpid(){// chdir("/home/ljm"); // 将进程的工作目录切换到 /home/ljm// fopen("hello.txt", "a"); // 此时文件将创建在 /home/ljm 目录while(1){sleep(1);// 获取当前进程的pidprintf("我是一个进程!我的pid: %d,我的ppid: %d\n",getpid(),getppid());}}
while:;dopsaxj|head-1&&psaxj|grepmyprocess|grep-vgrep;sleep1;done

为什么我们查到的进程状态都是 S?因为我们的进程是一个循环打印,假设执行一次是 1 毫秒,因为 IO 很慢,所以在这毫秒中 99% 的时间是在等待显示屏就绪,1% 的时间是拿来打印,于是运气不好我们查不出来。

voidFunc_Test_Getpid(){while(1){// 不进行任何 IO 操作}}

不让他 IO,查出来全部都是 R。

5.2 前后台进程

如果进程在前台,那么状态前面就有一个+号,否则没有。此时输入什么东西都没有用。

加上&表示当前进程在后台启动,可以输入指令。

./myprocess&

那么如何杀死呢?只能通过发送信号。

kill-932456

六、停止 T 与追踪停止状态 t

t:表示进程处于"正在被追踪而停止"的状态。虽然它也属于停止状态,但它与大写 T 有细微的语义区别:

  • 大写 T(Stopped):通常是用户主动发送信号(如 Ctrl+Z)让进程停下的;
  • 小写 t(Tracing stop):专门用于 Debugger(调试器)环境。当进程被系统追踪(比如你在用 gdb 调试时),进程在等待调试器发送下一个指令(c/s)期间,状态会显示为小写的 t。

关于 19 号信号稍微补充一下(信号还没有学):在 Linux 信号机制中,19 号信号是 SIGSTOP。

SIGSTOP 的唯一作用就是强制停止(挂起)一个进程。当进程接收到这个信号时,它的状态会立即转变为 T(Stopped)。你按下 Ctrl+Z 也是传递这个信号给进程。

七、深度睡眠 D

7.1 对比一般睡眠状态

浅睡眠 S,比如有一个scanf在等待阻塞,然后我们直接按 Ctrl+C 就可以 kill 它,或者执行指令kill -9 PID杀死(这个是信号的知识,后面会讲)。但是深度睡眠 D 状态,怎么都不会被杀死。如何杀掉 D 进程?1. 等它自己醒来;2. 物理方法(重启计算机/断电)。

7.2 深度睡眠的意义

场景:一个进程正在向磁盘写入数据。

但是磁盘写入数据的速度超级慢(相对于进程而言),于是进程就要去等待磁盘(进入 S 状态)。等待过程中可能发生一种非常极端的内存不足情况,这个时候 OS 就要开始杀没有用的进程。

这个时候可能会误杀这个正在"摆烂"的进程。于是这个时候磁盘还没有读完数据,但是进程已经被干掉了,磁盘拿着数据不知道该怎么办,于是就只能把数据丢弃了。这个时候用户还不知道,于是就丢失了数据。(如果这个数据是医院里几千个病人的病历,或者是银行的转账信息,这是非常危险的。)

为了防止操作系统发生上述情况,一种新的状态被设计出来,就像一个免死金牌 —— D 状态,不可被杀死。

八、僵尸进程

8.1 僵尸进程概念

在 Linux 系统中,进程的生命周期并不是随着代码执行完毕就画上句号的。当一个子进程退出后,它会进入一个特殊的过渡状态 —— 僵尸状态(Z 状态,Zombie)。

8.2 引入

你正走在路上,一个人突然从你身边跑过,突然!摔地上猝死了 —— 此时他虽已失去生命体征,但由于死因不明,遗体和随身物品(对应子进程的task_struct、退出码和信号)必须原封不动地保留在原地,任何人不得擅自收尸,这个在原地等待验尸的阶段就是"僵尸状态"。

直到你报警后警察赶到,在现场拍照、翻看口袋、登记完死亡信息并查明死因(对应父进程调用waitpid获取退出信息),这才挥手让救护车把遗体拉走火化,此时死者在世间留下的最后痕迹被彻底抹去,这才真正进入了"死亡状态(X 状态)"。

8.3 僵尸进程的定义与产生原因

我们创建子进程的目的,本质上是为了让它帮我们完成某种任务。既然是完成任务,那么任务的结果如何(是成功了、失败了,还是异常崩溃了),父进程必须得知道。

因此,当子进程退出时,内核会释放该进程所有的代码和数据内存,但允许它的进程控制块(PCB,即task_struct)暂时保留在系统内核中。

核心结论:

你的数据和代码可以释放掉,但是你的 PCB 不能走!

保持 Z 状态,就是为了让父进程来读取子进程退出时的相关信息。

8.4 退出信息存在哪里

在 Linux 内核的task_struct结构体中,专门有几个字段用来维护进程的退出状态和信息:

structtask_struct{// ...longexit_state;// 进程的退出状态(如 EXIT_ZOMBIE, EXIT_DEAD)intexit_code;// 退出数字(如 exit(0) 中的 0)intexit_signal;// 退出信号(如果是被信号所杀,则记录该信号)// ...};

当子进程变为僵尸状态时,exit_state会被设置为相应的值,而父进程通过调用wait()或waitpid()系统调用(后面进程等待的时候讲),就能从这个task_struct中拿走exit_code和exit_signal。

8.5 僵尸进程的危害:内存泄漏

1. 为什么会引发内存泄漏?

如果父进程一直处于忙碌状态,或者压根不管、不回收、不获取子进程的退出信息,那么这个子进程的 Z 状态就会一直存在!

虽然子进程的用户空间内存被释放了,但它的task_struct依然常驻在内核空间中。task_struct本身也是一个结构体对象,是要占用物理内存的。如果一个父进程源源不断地创建子进程,退出后又从不回收,那么内核中的task_struct就会堆积得越来越多,从而引发严重的内核内存泄漏!你的程序会越用越卡。

2. 进程退出了,内存泄漏还在吗?

  • 如果是子进程自身在用户空间申请的内存(如malloc):随着子进程的退出,这部分内存会被操作系统强制回收,因此不需要担心;
  • 由于 Z 状态残留的内核task_struct:只要父进程不退出、不回收,这部分内核内存就永远存在;
  • 常驻内存的进程更危险:类似服务器后台服务(Nginx、Database 等)这类需要常年运行的进程,如果产生了僵尸子进程,会持续蚕食内核内存,最终可能导致系统因内存耗尽而崩溃。

8.6 观察与模拟验证 Z 状态

我们可以编写一段代码来模拟这种"子进程先退出,父进程一直不管"的场景。

voidFunc_Test_Process(){pid_t_id=fork();if(_id<0){printf("创建进程失败\n");}//子进程elseif(_id==0){// 子进程:只活 5 秒intcount=5;while(count--){printf("我是一个子进程,我的pid是: %d ,父进程的ppid是: %d .\n",getpid(),getppid());sleep(1);}printf("我是子进程我现在要退出了\n");exit(-1);}elseif(_id>0){while(1){printf("我是一个父进程,我的pid是: %d ,父进程的ppid是: %d .\n",getpid(),getppid());sleep(1);}}}

监控脚本:

while:;dopsaxj|head-1&&psaxj|grepmyprocess|grep-vgrep;sleep1;done

12056128171281712056pts/312817S+10000:00 ./myprocess12817128181281712056pts/312817Z+10000:00[myprocess]<defunct>

当子进程结束这 5 秒后,你会观察到如下的现象:

  1. 子进程的 STAT 栏从 S+(睡眠状态)变成了 Z+(僵尸状态);
  2. 子进程的 COMMAND 栏后面多出了一个特殊的标记:[myprocess] <defunct>(defunct 意为:死者、不存在的,即僵尸进程的标志)。

至于如何解决僵尸进程,我们后面通过waitpid()来解决。

九、扩展:内核结构体的申请与 SLAB 技术

当父进程终于调用waitpid()拿走了退出信息后,这个残留的task_struct是怎么被销毁的呢?这就涉及到 Linux 内核的高效内存管理机制了。

9.1 什么是 SLAB 技术

在 Linux 操作系统中,类似task_struct、mm_struct、file这样的内核结构体对象,在系统运行期间会被频繁地创建(如频繁 fork)和销毁。

如果每次创建都直接向操作系统申请新的物理页,销毁时又直接释放掉,这种底层的内存申请与释放操作会涉及到大量的内核页表映射和虚实地址转换,成本极高,极其耗时。

为了解决这个问题,Linux 内核引入了 SLAB 内存分配器(SLAB / SLUB / SLOB)技术。

9.2 数据结构对象的缓存机制

SLAB 技术的本质就是内核级的数据结构对象池(缓存):

  • 回收不等于彻底销毁:当一个进程的僵尸状态结束,内核要释放其task_struct时,并不会直接把这块内存还给物理内存管理系统,而是将其状态标记为"空闲(unused)",并送入一个 unused 队列(或名为 slab 的缓存池)当中;
  • 创建时的极致复用:当下次系统又有新的进程被创建,需要新的task_struct时,内核会直接去这个缓存池里捞一个现成的、被标记为 unused 的内存块出来,稍微修改一下内部的属性即可使用。

通过这种"对象缓存"的思路,内核规避了频繁申请释放物理内存的开销,极大地提高了系统创建和销毁进程的效率。

十、孤儿进程(Orphan Process)

在多进程的家族体系中,除了"子进程先走,父进程不管"导致的僵尸进程外,还存在一种截然相反的特殊场景:父进程先于子进程一步退出,留下子进程还在孤独地运行。

10.1 孤儿进程的定义与产生原因

在 Linux 系统中,如果一个父进程由于提前执行完毕、异常崩溃或被信号杀掉而退出,而它的一个或多个子进程仍在继续运行,那么这些失去父进程的子进程就沦为了孤儿进程。

10.2 怎么解决

**操作系统是一个极其注重安全与稳定的系统,绝不允许任何进程在没有"监护人"的情况下自由游荡。**因为如果这些子进程没人管,当它们运行结束退出并进入僵尸状态(Z 状态)时,将永远没有父进程来为它们"收尸",最终必然会引发内存泄漏。

为了解决这个问题,Linux 内核设计了一套领养(Adoption)机制:

只要父进程先退出,操作系统(OS)就会强制指派 1 号进程领养剩下的所有子进程!

1 号进程在老版本的 Linux 中是 init 进程,而在现代 Linux 发行版(如 CentOS 7 及以上、Ubuntu 等)中,它通常被称为 systemd。

10.3 1 号进程:systemd

1 号进程是 Linux 内核启动后创建的第一个用户空间进程。它是整个系统所有用户进程的"老祖宗",专门用来帮内核打理登录认证、系统服务管理(如处理各种设备和系统初始化问题)等。

当我们登录操作系统的时候,系统就会为我们自动创建一个 bash,这个 bash 的创建者就是 1 号进程。systemd 是它的名字,它就是操作系统的一部分。

正因为它的地位极其特殊,由它来接管孤儿进程最合适不过 —— 1 号进程会永远驻留内存,并且在孤儿进程退出时,会非常尽职尽责地自动调用wait()族函数释放它们占用的内核资源,绝不产生内存泄漏。

10.4 观察与模拟验证孤儿进程

我们可以通过编写一段 C 语言代码,来重现"父进程活 5 秒后主动退出,子进程进入死循环"的场景。

//孤儿进程代码voidFunc_Test_Process(){pid_t_id=fork();if(_id<0){printf("创建进程失败\n");}//子进程elseif(_id==0){while(1){printf("我是一个子进程,我的pid是: %d ,父进程的ppid是: %d .\n",getpid(),getppid());sleep(1);}}elseif(_id>0){// 父进程:只活 5 秒intcount=5;while(count--){printf("我是一个父进程,我的pid是: %d ,父进程的ppid是: %d .\n",getpid(),getppid());sleep(1);}printf("我是父进程我现在要退出了\n");exit(-1);}}

  1. 在前 5 秒:子进程的 ppid 为 13894(即它的亲生父亲);
  2. 在第 5 秒后:父进程准时退出,命令行回到了 Shell 提示符处。然而此时,子进程依然在疯狂刷屏打印,且其 ppid 瞬间变为了 1!这证明它已经被 1 号进程 systemd 成功领养。

10.5 为什么父进程退出不会变成孤儿

有人会问:既然"父进程退出时,子进程还在运行,子进程会变成孤儿",那父进程退出时,它的亲生父亲(也就是命令行终端 bash 进程)不也还在运行吗?父进程怎么没变成孤儿?

原因很简单:父进程退出时,它的子进程(代码中的 child)还没有退出,所以子进程成了孤儿。
而父进程在退出时,它的父亲 bash 一直存活在后台盯着它呢。父进程一旦退出,bash 会秒回收这个父进程的退出信息,因此父进程会直接被清理干净,根本没有机会触发孤儿机制。

10.6 前台进程与后台进程的转变

注意看你在测试孤儿进程时的终端输入:当父进程退出、控制台显示出提示符后,无论你疯狂按 Ctrl+C,子进程都完全无动于衷,依然在屏幕上刷屏输出!

这是因为子进程在被 1 号进程领养后,其状态发生了一个隐蔽的转变:

阶段状态标识归属终端控制权键盘 Ctrl+C屏幕输出能力
阶段 1S+ / R+父进程健在,前台进程占据终端可以被 Ctrl+C 终止可以向显示器输出
阶段 2S / R被 1 号进程领养,后台进程失去终端控制只能用信号强杀依然可以向显示器刷屏输出
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进