Linux进程第五讲:PPID与bash的关联、fork系统调用的原理与实践操作(上)
Linux进程第五讲:PPID与bash的关联、fork系统调用的原理与实践操作(上)
上一期我们深入探讨了进程的核心标识PID,以及如何通过getpid
系统调用获取PID、通过kill
命令管理进程。但进程并非孤立存在——每个进程都有其“来源”,这就引出了父进程与PPID的概念;而要理解进程的创建机制,就必须掌握Linux中最核心的进程创建接口fork
。本文将从PPID的实战现象切入,揭示bash与进程的父子关系,再通过fork
的代码实战与原理剖析,解答“函数为何能返回两次”“同一变量为何有不同值”等反直觉问题,帮你构建完整的进程创建认知。
一、PPID:父进程标识
在Linux系统中,每个进程(除了PID为1的init
或systemd
进程)都有一个“父进程”——即创建它的进程。而标识父进程的唯一ID,就是PPID(Parent Process ID,父进程ID) 。PPID与PID一样,存储在进程的PCB(struct task_struct
)中,对应的字段是pid_t ppid
;要获取当前进程的PPID,只需调用系统调用getppid
。
1.1 实战:获取进程的PPID
我们延续上一篇的process.c
代码,新增getppid
调用,让进程同时输出自身PID与父进程PPID。代码如下:
#include <stdio.h>
#include <unistd.h> // 包含sleep、getpid、getppid的头文件
#include <sys/types.h> // 包含pid_t类型的头文件int main() {while (1) {// 同时输出当前进程的PID(getpid())和父进程PPID(getppid())printf("I am a process | PID: %d | PPID: %d\n", getpid(), getppid());sleep(1); // 每秒输出一次,避免信息刷屏}return 0;
}
编译并运行该程序(Makefile与上一篇一致,执行make
编译,./proc
运行):
# 编译
make
# 运行进程
./proc
终端会每秒输出类似内容:
I am a process | PID: 10615 | PPID: 21823
I am a process | PID: 10615 | PPID: 21823
I am a process | PID: 10615 | PPID: 21823
此时我们打开另一个终端,用ps
命令验证进程信息:
# 查看proc进程的PID、PPID及启动命令,排除grep自身
ps axj | head -1 && ps axj | grep proc | grep -v grep
输出结果如下,可见ps
查到的PPID(21823)与进程自身输出的PPID完全一致:
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND21823 10615 10615 21823 pts/0 10615 S+ 1000 0:03 ./proc
1.2 关键现象:同一终端下PPID不变,不同终端下PPID变化
我们做一个简单实验:
- 第一次运行:在当前终端(如
pts/0
)运行./proc
,记录PPID为21823;按Ctrl+C
终止进程后,再次运行./proc
,发现PID变为11717,但PPID仍为21823。 - 第二次运行:打开一个新的终端(如
pts/1
),进入相同目录运行./proc
,此时输出的PPID变为16815(而非21823)。
为什么会出现这种现象?答案就在“命令行解释器bash”的作用里。
二、bash:所有命令行进程的“父进程”
在Linux中,我们通过“终端”与系统交互时,终端背后运行的核心程序是bash(Bourne-Again Shell) ——它是最常用的命令行解释器,负责接收用户输入的命令、解析命令并执行。而我们在命令行中执行的每一条指令(包括./proc
、ls
、pwd
等),最终都会被bash创建为自己的“子进程”。
2.1 bash与子进程的关系:“王婆与实习生”
上一篇提到过一个形象的比喻:bash就像“王婆”,用户是“客户”,而命令进程是“实习生”。具体来说:
- 王婆(bash)的核心工作是“接待客户(接收用户命令)”和“派单(解析命令)”,但不会亲自去“干活(执行命令)”;
- 每当用户输入一条命令(如
./proc
),bash会创建一个“实习生(子进程)”,让实习生去执行命令; - 若实习生(子进程)执行出错(如程序崩溃),只会影响实习生自己,不会影响王婆(bash)——这就是为什么命令执行失败后,bash仍能正常接收下一条命令的原因。
这个设计的核心是“隔离风险”:将命令的执行与命令行解释器的核心逻辑分离,避免单个命令的异常导致整个终端交互崩溃。
2.2 验证:PPID对应的进程就是bash
回到之前的实验,我们查看PPID为21823的进程究竟是什么:
# 查看PID为21823的进程信息
ps axj | head -1 && ps axj | grep 21823 | grep -v grep
输出结果如下,可见该进程的COMMAND
字段为bash
,且TTY
字段为pts/0
(与我们运行./proc
的终端一致):
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND21822 21823 21823 21823 pts/0 10615 Ss 1000 0:01 bash
同样,新终端中PPID为16815的进程,其COMMAND
也是bash
,TTY
为pts/1
。这验证了一个关键结论:
在命令行中执行的任何进程,其PPID都等于当前终端对应的bash进程的PID——bash是所有命令进程的父进程。
进一步思考:当我们登录终端(如通过xshell连接Linux)时,系统会为我们创建一个专属的bash进程;当我们退出终端时,这个bash进程及其所有未终止的子进程会被一并回收。这就是为什么“关闭终端后,终端中运行的程序会停止”的原因。
2.3 需要明确几个关于进程父子关系的细节
- 没有“母进程”:计算机系统中只定义“父进程”,因为进程的创建是“单源”的——一个进程由且仅由一个父进程创建,不存在“多个进程共同创建一个子进程”的情况;
- 无需维护“爷爷进程”:进程只需要记录父进程(PPID),无需记录祖父进程或更上层的血缘关系。若需获取祖父进程的PID,需先通过
getppid
获取父进程PID,再通过父进程PID查询其PPID(需借助/proc
或ps
工具),但这种需求在实际开发中极少; - init进程是“根”:系统中所有进程的最终父进程都是PID为1的
init
或systemd
进程(取决于Linux发行版)。bash进程的父进程就是init
,而命令进程的父进程是bash,形成“init → bash → 命令进程”的血缘链。
三、fork系统调用:创建进子程
通过./proc
运行程序是“指令级”创建进程,而在代码中创建进程,就需要用到Linux中最核心的系统调用——fork
。fork
的作用是“以当前进程为模板,创建一个新的子进程”,但它的行为非常特殊:调用一次,返回两次;父进程与子进程会从fork
之后的代码开始并发执行。
3.1 初探fork:“代码执行两次”的奇怪现象
我们先编写一个简单的fork
demo,观察其执行现象。新建fork_demo1.c
:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>int main() {printf("Before fork | PID: %d | PPID: %d\n", getpid(), getppid());// 调用fork创建子进程fork();printf("After fork | PID: %d | PPID: %d\n", getpid(), getppid());// 让进程休眠5秒,避免立即退出,方便观察sleep(5);return 0;
}
编译并运行:
# 编译
gcc -o fork_demo1 fork_demo1.c
# 运行
./fork_demo1
预期中,代码应该输出“Before fork”一次、“After fork”一次,但实际输出如下:
Before fork | PID: 12345 | PPID: 21823
After fork | PID: 12345 | PPID: 21823
After fork | PID: 12346 | PPID: 12345
这就是fork
的第一个反直觉现象:fork
之后的代码被执行了两次。第一次执行的进程PID为12345(原进程),第二次执行的进程PID为12346(新创建的子进程);且子进程的PPID等于原进程的PID,证明两者是父子关系。
3.2 深入理解fork:利用返回值区分父进程与子进程
为什么fork
之后会有两个进程?这要从fork
的返回值说起。通过man 2 fork
查看手册,核心信息如下:
- 函数原型:
pid_t fork(void);
- 返回值:
- 若成功:给父进程返回子进程的PID(一个大于0的整数);给子进程返回0;
- 若失败:返回**-1**(无新进程创建,通常因系统资源不足)。
也就是说,fork
调用一次,会产生两个返回值——这是它与普通函数的本质区别。我们可以利用这个返回值,让父进程与子进程执行不同的代码逻辑。
修改代码为fork_demo2.c
:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>int main() {printf("Before fork | PID: %d | PPID: %d\n", getpid(), getppid());// 保存fork的返回值,用于区分父子进程pid_t id = fork();// 检查fork是否失败if (id == -1) {perror("fork failed"); // 打印错误信息return 1; // 非0退出表示程序出错}// 父进程:fork返回子进程的PID(id > 0)else if (id > 0) {while (1) { // 父进程进入死循环,持续输出信息printf("I am parent | PID: %d | PPID: %d | Child PID: %d\n", getpid(), getppid(), id); // id是子进程PIDsleep(1);}}// 子进程:fork返回0(id == 0)else { // id == 0while (1) { // 子进程进入死循环,持续输出信息printf("I am child | PID: %d | PPID: %d\n", getpid(), getppid()); // 子进程的PPID是父进程PIDsleep(1);}}return 0;
}
编译并运行:
gcc -o fork_demo2 fork_demo2.c
./fork_demo2
输出结果如下(父子进程并发执行,输出顺序可能交替):
Before fork | PID: 15678 | PPID: 21823
I am parent | PID: 15678 | PPID: 21823 | Child PID: 15679
I am child | PID: 15679 | PPID: 15678
I am parent | PID: 15678 | PPID: 21823 | Child PID: 15679
I am child | PID: 15679 | PPID: 15678
同时,我们用ps
命令监控进程:
# 每秒查看一次包含15678的进程
while :; do ps axj | grep 15678 | grep -v grep; sleep 1; done
输出显示系统中确实存在两个进程:PID 15678(父进程)和PID 15679(子进程),且子进程的PPID为15678,完全符合预期。
3.3 关键结论:fork的核心特性
从上述实战中,我们可以总结fork
的三个核心特性:
- 调用一次,返回两次:
fork
在父进程中返回子进程PID,在子进程中返回0;只有失败时才返回一次(-1); - 父子进程并发执行:
fork
成功后,父进程与子进程会从fork
之后的代码开始“同时”执行(实际由CPU调度决定执行顺序,可能交替); - 子进程以父进程为模板:子进程会复制父进程的PCB、代码段、数据段、堆、栈等资源(后续会讲“写时复制”优化,并非完全复制),但父子进程拥有独立的地址空间——即父进程修改变量不会影响子进程,反之亦然。
四、解答fork的四个反直觉问题
fork
的行为与普通函数差异极大,初学者常被四个问题困扰。下面我们结合Linux内核的工作机制,逐一解答这些问题。
4.1 问题1:为什么fork给父进程返回子进程PID,给子进程返回0?
这个设计的核心是“满足父子进程的不同需求”:
- 父进程需要管理子进程:父进程可能需要通过
kill
、wait
等系统调用操作子进程(如终止子进程、回收子进程资源),而这些操作都需要子进程的PID作为参数。因此,fork
必须给父进程返回子进程PID,让父进程能定位子进程; - 子进程无需定位父进程:子进程若需与父进程交互(如获取父进程PID),只需调用
getppid
系统调用,无需通过fork
的返回值传递。同时,子进程的PID是系统分配的唯一值,无法用固定值(如0)表示,因此用0作为子进程的返回值,既不会与任何PID冲突(PID从1开始),又能清晰区分父子进程; - 失败返回-1:-1是Linux系统中“函数执行失败”的通用标识(如
open
、read
等系统调用均如此),fork
也遵循这一约定,方便开发者通过返回值判断是否创建成功。
4.2 问题2:一个函数是如何做到返回两次的?
要理解“返回两次”,必须先明确fork
的本质是“创建新进程”,而非普通的函数调用。fork
的执行过程可分为三个步骤:
步骤1:父进程执行fork系统调用
当父进程执行到fork()
时,会触发系统调用——即从用户态切换到内核态,由Linux内核执行fork
的核心逻辑。
步骤2:内核创建子进程
内核会完成以下工作:
- 分配新的PCB(struct task_struct):为子进程分配一个未使用的PID,设置子进程的PPID为父进程的PID,初始化子进程的状态(如就绪态)、优先级等属性;
- 复制父进程资源:
- 代码段:父子进程共享同一代码段(代码只读,无需复制);
- 数据段、堆、栈:默认情况下,内核会为子进程复制父进程的数据段、堆、栈(但为了优化性能,实际采用“写时复制(Copy-On-Write, COW)”机制——只有当父子进程修改数据时,才会真正复制数据,避免不必要的开销);
- 打开的文件:子进程会复制父进程的文件描述符表,即父子进程共享同一文件的偏移量;
- 将子进程加入进程链表:内核将子进程的PCB插入到进程双向链表中,使其能被CPU调度;
- 设置调度状态:父进程和子进程均处于就绪态,等待CPU调度。
步骤3:父子进程分别返回用户态
内核完成子进程创建后,会恢复父进程和子进程的执行:
- 父进程:从内核态返回用户态,继续执行
fork
之后的代码,此时fork
的返回值被设置为子进程的PID; - 子进程:从内核态返回用户态,从
fork
之后的代码开始执行(相当于“复制”了父进程的执行流),此时fork
的返回值被设置为0。
简言之,“返回两次”并非fork
函数本身返回两次,而是fork
成功后创建了两个独立的进程,每个进程都从fork
的返回点继续执行,因此产生了两个返回值。
4.3 问题3:同一个变量id,为什么会有不同的值(父进程中>0,子进程中=0)?
这个问题的核心是“父子进程拥有独立的地址空间”——变量id
并非“同一个变量”,而是“两个变量”,分别存在于父进程和子进程的地址空间中。
具体来说:
- fork前:父进程的地址空间中存在一个变量
id
,此时尚未赋值; - fork时:内核为子进程创建独立的地址空间,并复制父进程的数据段——包括变量
id
。此时,父子进程的id
是两个完全独立的变量,只是初始值相同(均未赋值); - fork返回时:内核分别修改父子进程的
id
变量:- 父进程的
id
被赋值为子进程的PID(>0); - 子进程的
id
被赋值为0;
- 父进程的
- 后续执行:父子进程修改
id
变量不会相互影响——例如父进程将id
改为100,子进程的id
仍为0。
我们可以通过代码验证这一点,修改fork_demo3.c
:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>int main() {int var = 10; // 定义一个全局变量pid_t id = fork();if (id > 0) {var = 20; // 父进程修改varprintf("Parent | var: %d | &var: %p\n", var, &var);sleep(2); // 等待子进程输出} else if (id == 0) {sleep(1); // 等待父进程修改varprintf("Child | var: %d | &var: %p\n", var, &var);}return 0;
}
运行结果如下:
Parent | var: 20 | &var: 0x55f8b8c7a04c
Child | var: 10 | &var: 0x55f8b8c7a04c
可见:
- 父子进程中
var
的地址(&var
)看起来相同——这是因为地址是“虚拟地址”(Linux采用虚拟内存机制),父子进程的虚拟地址空间独立,相同的虚拟地址对应不同的物理地址; - 父进程将
var
改为20后,子进程的var
仍为10——证明父子进程的变量完全独立,修改互不影响。
4.4 问题4:fork究竟在干什么?内核的具体操作是什么?
fork
的本质是“Linux内核为父进程创建一个几乎完全相同的子进程”,内核的操作可细分为五个步骤,每个步骤都服务于“让子进程能独立运行,且不影响父进程”:
步骤1:分配子进程的PCB
内核首先为子进程分配一个新的struct task_struct
对象(PCB),并完成以下初始化:
- PID分配:从系统的PID池中选取一个未使用的整数作为子进程的PID(如15679);
- PPID设置:将子进程的
ppid
字段设为父进程的PID(如15678); - 状态初始化:将子进程的状态设为
TASK_RUNNING
(就绪态),表示子进程已准备好等待CPU调度; - 优先级继承:子进程的优先级默认与父进程相同,可后续通过
nice
系统调用修改; - 资源限制继承:子进程继承父进程的资源限制(如最大内存、最大文件句柄数等)。
步骤2:复制父进程的地址空间(写时复制优化)
早期Linux中,fork
会完全复制父进程的地址空间(包括数据段、堆、栈),但这种方式效率极低——若子进程立即执行exec
(加载新程序),则复制的资源会被立即丢弃,造成浪费。为解决这个问题,Linux引入了写时复制(COW) 机制:
- 初始状态:父子进程共享同一物理内存页(代码段、数据段、堆、栈),但这些内存页被标记为“只读”;
- 写操作触发复制:当父进程或子进程试图修改某块内存(如修改变量)时,CPU会触发“页错误(Page Fault)”,内核会为修改方复制一块新的物理内存页,将原页的数据复制到新页,再修改新页的内容;
- 只读资源不复制:代码段是只读的,父子进程始终共享同一代码段,无需复制。
写时复制机制既保证了父子进程地址空间的独立性,又避免了不必要的内存复制,极大提升了fork
的效率。
步骤3:复制父进程的文件描述符表
父进程在运行过程中可能打开了多个文件(如日志文件、配置文件),子进程需要继承这些文件的访问权限。内核会为子进程复制父进程的“文件描述符表”:
- 每个文件描述符对应一个“文件表项”,记录文件的偏移量、打开模式等信息;
- 父子进程的文件描述符表指向同一组文件表项——即父子进程共享同一文件的偏移量。例如,父进程向文件写入10字节后,子进程继续写入时,会从文件的第11字节开始。
若子进程不需要继承某个文件,可通过close
系统调用关闭对应的文件描述符,不影响父进程。
步骤4:将子进程加入进程调度队列
内核会将子进程的PCB插入到“就绪队列”中,使其成为CPU调度的候选对象。此时,父进程和子进程都处于就绪态,CPU会根据调度算法(如CFS完全公平调度)决定先执行哪个进程。
步骤5:返回用户态,恢复执行
内核完成上述操作后,会将父进程和子进程的执行状态从“内核态”切换回“用户态”,并让它们从fork
之后的代码开始执行——父进程返回子进程PID,子进程返回0,完成“调用一次,返回两次”的过程。
五、本期总结
通过本期的学习,我们从PPID的现象切入,揭示了bash与命令进程的父子关系,再通过fork
的实战与原理剖析,理解了代码层面创建进程的核心机制。最终,我们可以梳理出Linux中进程创建的完整逻辑:
-
指令级创建(如./proc):
- 用户在bash中输入
./proc
,bash解析命令后,会调用fork
创建一个子进程; - 子进程调用
exec
系统调用,加载proc
可执行文件的代码和数据,替换自身的代码段和数据段; - 子进程执行
proc
的代码,成为一个独立的进程,其PPID为bash的PID。
- 用户在bash中输入
-
代码级创建(如fork):
- 父进程调用
fork
,内核通过写时复制机制创建子进程,子进程复制父进程的PCB、地址空间(共享只读页)、文件描述符表; - 父子进程从
fork
之后的代码开始并发执行,通过fork
的返回值区分角色(父进程>0,子进程=0); - 子进程可通过
exec
加载新程序,成为与父进程完全不同的进程;也可继续执行父进程的代码逻辑,实现“多任务并发”。
- 父进程调用
这些机制共同构成了Linux进程创建的基础,是理解后续“进程等待与回收”“进程间通信”“线程”等概念的关键。掌握fork
的原理,不仅能帮助你写出更高效的多进程程序,更能让你深入理解Linux内核的资源管理与调度逻辑,为后续系统级开发打下坚实基础。
感谢大家的关注,我们下期再见!