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。代码如下:

c 复制代码
#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运行):

bash 复制代码
# 编译
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命令验证进程信息:

bash 复制代码
# 查看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 COMMAND
 21823 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的进程究竟是什么:

bash 复制代码
# 查看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 COMMAND
 21822 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

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;
}

编译并运行:

bash 复制代码
# 编译
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

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是子进程PID
            sleep(1);
        }
    }
    // 子进程:fork返回0(id == 0)
    else {  // id == 0
        while (1) {  // 子进程进入死循环,持续输出信息
            printf("I am child  | PID: %d | PPID: %d\n", 
                   getpid(), getppid());  // 子进程的PPID是父进程PID
            sleep(1);
        }
    }

    return 0;
}

编译并运行:

bash 复制代码
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命令监控进程:

bash 复制代码
# 每秒查看一次包含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

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;  // 父进程修改var
        printf("Parent | var: %d | &var: %p\n", var, &var);
        sleep(2);  // 等待子进程输出
    } else if (id == 0) {
        sleep(1);  // 等待父进程修改var
        printf("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内核的资源管理与调度逻辑,为后续系统级开发打下坚实基础。

感谢大家的关注,我们下期再见!

相关推荐
呱呱巨基4 小时前
Linux 基础IO
linux·c++·笔记·学习
QFIUNE4 小时前
CD-HIT 详解:序列去冗余、安装使用与聚类结果解析
linux·服务器·机器学习·数据挖掘·conda·聚类
vortex54 小时前
XFCE 桌面环境组件详解:从面板到剪贴板管理
linux·xfce·桌面环境
marsh02064 小时前
43 openclaw熔断与降级:保障系统在异常情况下的可用性
java·运维·网络·ai·编程·技术
摇滚侠4 小时前
Docker 如何查询挂载的目录
运维·docker·容器
勇闯逆流河5 小时前
【Linux】linux进程控制(进程池的详解与实现)
linux·运维·服务器
zhangfeng11336 小时前
部署到服务器上 宝塔系统 使用宝塔在线编辑器 FTP 批量上传 Git 部署 打包上传 codebudyy 编程程序开发
服务器·git·编辑器
WJ.Polar6 小时前
Scapy基本应用
linux·运维·网络·python
lljss20206 小时前
1. NameServer 域名服务器---NS
linux·服务器·前端
萧行之7 小时前
Ubuntu+Windows双系统:解决GRUB不显示Windows启动项、一闪而过问题
linux·windows·ubuntu