当前位置: 首页 > news >正文

Linux进程第五讲:PPID与bash的关联、fork系统调用的原理与实践操作(上)

Linux进程第五讲:PPID与bash的关联、fork系统调用的原理与实践操作(上)

上一期我们深入探讨了进程的核心标识PID,以及如何通过getpid系统调用获取PID、通过kill命令管理进程。但进程并非孤立存在——每个进程都有其“来源”,这就引出了父进程与PPID的概念;而要理解进程的创建机制,就必须掌握Linux中最核心的进程创建接口fork。本文将从PPID的实战现象切入,揭示bash与进程的父子关系,再通过fork的代码实战与原理剖析,解答“函数为何能返回两次”“同一变量为何有不同值”等反直觉问题,帮你构建完整的进程创建认知。

一、PPID:父进程标识

在Linux系统中,每个进程(除了PID为1的initsystemd进程)都有一个“父进程”——即创建它的进程。而标识父进程的唯一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变化

我们做一个简单实验:

  1. 第一次运行:在当前终端(如pts/0)运行./proc,记录PPID为21823;按Ctrl+C终止进程后,再次运行./proc,发现PID变为11717,但PPID仍为21823。
  2. 第二次运行:打开一个新的终端(如pts/1),进入相同目录运行./proc,此时输出的PPID变为16815(而非21823)。

为什么会出现这种现象?答案就在“命令行解释器bash”的作用里。

二、bash:所有命令行进程的“父进程”

在Linux中,我们通过“终端”与系统交互时,终端背后运行的核心程序是bash(Bourne-Again Shell) ——它是最常用的命令行解释器,负责接收用户输入的命令、解析命令并执行。而我们在命令行中执行的每一条指令(包括./proclspwd等),最终都会被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也是bashTTYpts/1。这验证了一个关键结论:
在命令行中执行的任何进程,其PPID都等于当前终端对应的bash进程的PID——bash是所有命令进程的父进程

进一步思考:当我们登录终端(如通过xshell连接Linux)时,系统会为我们创建一个专属的bash进程;当我们退出终端时,这个bash进程及其所有未终止的子进程会被一并回收。这就是为什么“关闭终端后,终端中运行的程序会停止”的原因。

2.3 需要明确几个关于进程父子关系的细节

  1. 没有“母进程”:计算机系统中只定义“父进程”,因为进程的创建是“单源”的——一个进程由且仅由一个父进程创建,不存在“多个进程共同创建一个子进程”的情况;
  2. 无需维护“爷爷进程”:进程只需要记录父进程(PPID),无需记录祖父进程或更上层的血缘关系。若需获取祖父进程的PID,需先通过getppid获取父进程PID,再通过父进程PID查询其PPID(需借助/procps工具),但这种需求在实际开发中极少;
  3. init进程是“根”:系统中所有进程的最终父进程都是PID为1的initsystemd进程(取决于Linux发行版)。bash进程的父进程就是init,而命令进程的父进程是bash,形成“init → bash → 命令进程”的血缘链。

三、fork系统调用:创建进子程

通过./proc运行程序是“指令级”创建进程,而在代码中创建进程,就需要用到Linux中最核心的系统调用——forkfork的作用是“以当前进程为模板,创建一个新的子进程”,但它的行为非常特殊:调用一次,返回两次;父进程与子进程会从fork之后的代码开始并发执行。

3.1 初探fork:“代码执行两次”的奇怪现象

我们先编写一个简单的forkdemo,观察其执行现象。新建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的三个核心特性:

  1. 调用一次,返回两次fork在父进程中返回子进程PID,在子进程中返回0;只有失败时才返回一次(-1);
  2. 父子进程并发执行fork成功后,父进程与子进程会从fork之后的代码开始“同时”执行(实际由CPU调度决定执行顺序,可能交替);
  3. 子进程以父进程为模板:子进程会复制父进程的PCB、代码段、数据段、堆、栈等资源(后续会讲“写时复制”优化,并非完全复制),但父子进程拥有独立的地址空间——即父进程修改变量不会影响子进程,反之亦然。

四、解答fork的四个反直觉问题

fork的行为与普通函数差异极大,初学者常被四个问题困扰。下面我们结合Linux内核的工作机制,逐一解答这些问题。

4.1 问题1:为什么fork给父进程返回子进程PID,给子进程返回0?

这个设计的核心是“满足父子进程的不同需求”:

  • 父进程需要管理子进程:父进程可能需要通过killwait等系统调用操作子进程(如终止子进程、回收子进程资源),而这些操作都需要子进程的PID作为参数。因此,fork必须给父进程返回子进程PID,让父进程能定位子进程;
  • 子进程无需定位父进程:子进程若需与父进程交互(如获取父进程PID),只需调用getppid系统调用,无需通过fork的返回值传递。同时,子进程的PID是系统分配的唯一值,无法用固定值(如0)表示,因此用0作为子进程的返回值,既不会与任何PID冲突(PID从1开始),又能清晰区分父子进程;
  • 失败返回-1:-1是Linux系统中“函数执行失败”的通用标识(如openread等系统调用均如此),fork也遵循这一约定,方便开发者通过返回值判断是否创建成功。

4.2 问题2:一个函数是如何做到返回两次的?

要理解“返回两次”,必须先明确fork的本质是“创建新进程”,而非普通的函数调用。fork的执行过程可分为三个步骤:

步骤1:父进程执行fork系统调用

当父进程执行到fork()时,会触发系统调用——即从用户态切换到内核态,由Linux内核执行fork的核心逻辑。

步骤2:内核创建子进程

内核会完成以下工作:

  1. 分配新的PCB(struct task_struct):为子进程分配一个未使用的PID,设置子进程的PPID为父进程的PID,初始化子进程的状态(如就绪态)、优先级等属性;
  2. 复制父进程资源
    • 代码段:父子进程共享同一代码段(代码只读,无需复制);
    • 数据段、堆、栈:默认情况下,内核会为子进程复制父进程的数据段、堆、栈(但为了优化性能,实际采用“写时复制(Copy-On-Write, COW)”机制——只有当父子进程修改数据时,才会真正复制数据,避免不必要的开销);
    • 打开的文件:子进程会复制父进程的文件描述符表,即父子进程共享同一文件的偏移量;
  3. 将子进程加入进程链表:内核将子进程的PCB插入到进程双向链表中,使其能被CPU调度;
  4. 设置调度状态:父进程和子进程均处于就绪态,等待CPU调度。
步骤3:父子进程分别返回用户态

内核完成子进程创建后,会恢复父进程和子进程的执行:

  • 父进程:从内核态返回用户态,继续执行fork之后的代码,此时fork的返回值被设置为子进程的PID;
  • 子进程:从内核态返回用户态,从fork之后的代码开始执行(相当于“复制”了父进程的执行流),此时fork的返回值被设置为0。

简言之,“返回两次”并非fork函数本身返回两次,而是fork成功后创建了两个独立的进程,每个进程都从fork的返回点继续执行,因此产生了两个返回值。

4.3 问题3:同一个变量id,为什么会有不同的值(父进程中>0,子进程中=0)?

这个问题的核心是“父子进程拥有独立的地址空间”——变量id并非“同一个变量”,而是“两个变量”,分别存在于父进程和子进程的地址空间中。

具体来说:

  1. fork前:父进程的地址空间中存在一个变量id,此时尚未赋值;
  2. fork时:内核为子进程创建独立的地址空间,并复制父进程的数据段——包括变量id。此时,父子进程的id是两个完全独立的变量,只是初始值相同(均未赋值);
  3. fork返回时:内核分别修改父子进程的id变量:
    • 父进程的id被赋值为子进程的PID(>0);
    • 子进程的id被赋值为0;
  4. 后续执行:父子进程修改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中进程创建的完整逻辑:

  1. 指令级创建(如./proc)

    • 用户在bash中输入./proc,bash解析命令后,会调用fork创建一个子进程;
    • 子进程调用exec系统调用,加载proc可执行文件的代码和数据,替换自身的代码段和数据段;
    • 子进程执行proc的代码,成为一个独立的进程,其PPID为bash的PID。
  2. 代码级创建(如fork)

    • 父进程调用fork,内核通过写时复制机制创建子进程,子进程复制父进程的PCB、地址空间(共享只读页)、文件描述符表;
    • 父子进程从fork之后的代码开始并发执行,通过fork的返回值区分角色(父进程>0,子进程=0);
    • 子进程可通过exec加载新程序,成为与父进程完全不同的进程;也可继续执行父进程的代码逻辑,实现“多任务并发”。

这些机制共同构成了Linux进程创建的基础,是理解后续“进程等待与回收”“进程间通信”“线程”等概念的关键。掌握fork的原理,不仅能帮助你写出更高效的多进程程序,更能让你深入理解Linux内核的资源管理与调度逻辑,为后续系统级开发打下坚实基础。

感谢大家的关注,我们下期再见!
丰收的田野

http://www.dtcms.com/a/456911.html

相关文章:

  • 精品购物网站如何创建个人主页
  • 怎样建设电子商务网站wordpress 4.9 中文
  • AI赋能锂电:机器学习加速电池技术革新
  • await
  • 机器学习-常用库
  • 前端网络与优化
  • (二) 机器学习之卷积神经网络
  • GAN入门:生成器与判别器原理(附Python代码)
  • 企业网站seo报价校园门户网站开发需求
  • RabbitMQ核心机制
  • 四、代码风格规范
  • 做网站采集青岛做教育的网站建设
  • Ethernaut Level 8: Vault - 私有变量读取
  • 去水印擦除大师 3.7.6 | 专门用于去除视频和图片水印的工具,支持多个热门平台无水印下载
  • 关键词排名优化网站东营交通信息网官网
  • 【URP】Unity[内置Shader]复杂光照ComplexLit
  • 【Linux】vim的操作大全
  • Web Worker:释放前端性能的“后台线程”技术
  • 机械行业网站建设方案电商公司有哪些?
  • 赋能智能制造领域:全星QMS质量管理软件系统深度解析
  • java返回参数报错
  • cesium126,230217,Pixel Streaming in Unreal Engine 像素流 - 1 基本概念:
  • JavaScript基础知识
  • 以太网数据报文各协议字段深度解析(第一、二章)
  • microsoft做网站浙江建设培训中心网站
  • 从LLM角度学习和了解MoE架构
  • 【学习笔记06】内存管理与智能指针学习总结
  • 0、FreeRTOS编码和命名规则
  • 无锡专业制作网站wordpress 手风琴
  • 通过camel AI创建多agent进行写作