一、前言

        之前我们学习了静态库和动态库的相关知识,今天我们来学习Linux系统中的重点——进程。理解 Linux 进程是掌握系统管理的核心,它本质上是程序在 CPU 中的一次执行过程,是操作系统资源分配的基本单位。

二、进程

1、进程的定义与属性

        进程并非程序本身,而是程序加载到内存后,由内核创建的动态实体。每个进程都有唯一的标识和一套独立的资源集合。Linux 中进程是系统资源分配和调度的基本单位,所有程序运行后都会以进程形式存在。当一个进程被创建时,操作系统会为它分配内存、CPU时间、文件句柄、I/O设备等系统资源。

        定义:进程是程序在计算机中的一次运行实例,包含程序代码、数据、进程控制块(PCB)等核心组件。

        属性:并发执行(多个进程交替占用 CPU)、独立地址空间(每个进程有专属内存区域)、动态性(生命周期包含创建、运行、终止等状态)。

        一个最直观的比喻是:程序是菜谱,进程是照着菜谱做饭的过程

        程序:是存储在磁盘上的一个可执行文件,它包含了一系列的指令和数据。它是一个静态的实体。

        进程:是程序的一次执行过程。它是一个动态的实体,拥有自己的生命期。当程序被加载到内存并开始执行时,它就变成了一个进程。

2、并发与并行

2.1、并发

        定义:在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个CPU上运行。

        核心逻辑:CPU 通过快速切换任务(时间片轮转),让每个任务都得到执行,宏观上看似 “同时进行”。

        Linux 场景:单 CPU 服务器上运行多个进程(如同时开终端、浏览器、后台服务),内核调度器快速切换进程上下文,每个进程轮流占用 CPU,最终完成各自任务。

        如下图所示:

2.2、并行

        定义:当系统有一个以上CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程 互不抢占CPU资源,可以同时进行,这种方式我们称之为并行。

        核心逻辑:每个 CPU 核心独立执行一个任务,任务之间无需切换,微观上真正 “同时运行”。

        Linux 场景:多 CPU / 多核服务器上,多个进程分别在不同核心上运行(如核心 1 跑数据库进程,核心 2 跑 Web 服务进程),任务执行互不干扰,无需 CPU 切换开销。

        如图所示:

2.3、并发和并行的区别

        硬件依赖:并发无需多核心,单 CPU 即可实现;并行必须依赖多 CPU 或多核。

        执行本质:并发是 “交替执行”,靠切换实现;并行是 “同时执行”,靠多核心并行处理。

        资源开销:并发存在进程和线程切换开销;并行无切换开销,资源利用率更高。

3、PCB(进程控制块)

        PCB(进程控制块)是内核存储进程所有关键信息的结构体,Linux 中对应task_struct,是内核管理、调度、回收进程的唯一依据。

3.1、PCB 的定义与核心作用

        定义:PCB 是操作系统为每个进程分配的专属数据结构,集中存储进程运行所需的所有元信息。

        核心作用:作为内核与进程的交互接口内核通过 PCB 获取进程状态、分配资源、调度执行,无需直接操作进程的代码和数据。

3.2、PCB存储的关键信息

1、进程标识信息

        PID(进程 ID):系统唯一标识进程的编号,如1是 init 进程、2是 kthreadd 进程。

        PPID(父进程 ID):记录创建当前进程的父进程 PID,用于进程树管理。

        UID/GID:进程所属用户和组的 ID,控制进程的权限范围

2、进程状态信息

        状态字段(state):记录进程当前状态(运行 R、阻塞 D、停止 T、僵尸 Z 等)。

        退出码(exit_code):进程终止时的返回值,供父进程查询。

3、调度相关信息

        优先级(priority/nice 值):决定进程获取 CPU 的概率

        调度策略(policy):如 CFS(完全公平调度)、实时调度(SCHED_FIFO)等。

        时间片信息:记录进程已使用的 CPU 时间、剩余时间片。

4、资源关联信息

        内存地址空间:指向进程的页表,关联进程的代码段、数据段、堆、栈等内存区域。

        文件描述符表:记录进程打开的所有文件(如文件、socket、设备)的描述符。

        信号处理表:存储进程对各类信号(如 SIGKILL、SIGSTOP)的处理方式。

5、上下文信息

        存储进程切换时需要保存的 CPU 寄存器值(如程序计数器 PC、栈指针 SP)。

        下次进程恢复运行时,内核通过这些信息还原进程的执行现场。

3.3、PCB 的核心作用场景

        进程调度:调度器遍历进程链表,根据task_struct中的优先级、状态信息选择下一个要运行的进程。

        进程切换:切换时保存当前进程的上下文到其task_struct再加载目标进程的上下文。

        资源回收:进程终止时,内核先回收其关联的内存、文件等资源,最后释放task_struct(僵尸进程就是task_struct未被释放)。

        权限检查:进程访问文件、设备时,内核通过task_struct中的 UID/GID 验证权限。

        PCB 就是“进程档案袋”,调度、切换、回收都靠它。

4、进程的状态

        进程基本的状态有五种,分别为初始态,就绪态,运行态,挂起态和终止态。 其中初始态为进程准备阶段,常常与就绪态结合来看。如下图所示:

        其中初始态是进程刚被创建时的临时状态,完成初始化后进入就绪态;就绪态是进程有 “执行资格”,但无 “执行权”,已准备好运行,仅需等待 CPU 调度(即等待分配时间片);运行态是进程既有 “执行资格”(系统允许其运行),又有 “执行权”(实际占用 CPU 资源),正在 CPU 上执行指令。挂起态(阻塞态)是进程既无 “执行资格”,也无 “执行权”,因等待某条件满足(如 I/O 操作完成、信号触发)而暂时无法运行;终止态是进程执行完毕(正常退出)或被强制终止,生命周期结束的状态。

        补:进程的时间片用完(CPU 使用权到期),但仍有执行资格,运行态回到就绪态等待下一次调度;当进程需要等待某资源 / 条件(如发起 I/O 请求)时,会主动进入 “睡眠(sleep)” 状态,失去执行资格和执行权。当挂起态等待的条件满足(如 I/O 完成)时,进程会重新获得执行资格,进入就绪态等待 CPU。

三、进程控制

1、fork函数

1.1、fork函数的基本定义与作用

        定义:fork是一个系统调用,用于创建一个新的进程。

        作用:调用fork的进程称为父进程,新创建的进程称为子进程。子进程几乎是父进程的一个完整副本

        核心特点:fork函数只被调用一次,但会返回两次——一次在父进程中,一次在子进程中。

1.2、函数原型

        包含的头文件: 

#include <sys/types.h> 
#include <unistd.h> 

        函数原型:

pid_t fork(void); //返回值类型为pif_t,无参数

1.3、fork的返回值与父子进程区分

        fork 调用后会返回两次值,以此区分父子进程:

        父进程:返回子进程的 PID(进程 ID),用于标识和管理子进程。

        子进程:返回 0,表示自身是新创建的子进程。

        如下图所示:

1.4、父子进程的继承与区别

1.4.1、子进程继承父进程的资源

        进程标识符(但 PID、PPID 不同);

        打开的文件描述符(共享文件指针,即读写位置同步);

        环境变量、信号处理方式、工作目录等;

        内存地址空间(写时复制前共享)。

1.4.2、父子进程的关键区别

        PID/PPID:子进程的 PID 是新分配的,PPID 是父进程的 PID;

        文件锁:子进程不继承父进程的文件锁;

        未处理信号:子进程会重置父进程的未处理信号(避免信号被意外处理);

        内存修改:子进程修改内存时触发写时复制,与父进程分离。

        如下图所示:

1.5、示例

        首先创建fork.c文件:

        然后输入以下代码:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main()
{
        pid_t pid;
        pid = fork();
        if (pid < 0)//fork failed! 
        {
                perror("fork failed");
                return 1;
        }
        else if(pid > 0)//这是父进程的代码
        {
                printf("father pid is %d\n",getpid());//父进程
        }
        else if(pid == 0)//这是子进程的代码
        {
                printf("son pid is %d,my father pid is %d\n",getpid(),getppid());//子进程
        }
        printf("I love Linux!\n");
        return 0;
}

        然后使用gcc编译器进行编译,然后运行:

                可以看到在运行后,父进程和子进程都会执行一次,这是因为父进程执行fork 后,系统会复制出一个子进程,父子进程会fork 的下一条指令开始分别执行,因此fork之后的代码逻辑会被父子进程各执行一次

        上面的情况只是可能情况的一种,我们多运行几次试试看:

        可以看到每次执行 ./fork  程序时,系统都会创建一个新的父进程,可以看到最后一次有特殊情况,这是孤儿进程的典型表现,所谓孤儿进程就是一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程,会被系统的 init 进程(或其他收养进程,PID 可能为 1、1295 等)接管,此时子进程的 “父进程 PID” 会变为收养进程的 PID。

        接着看下面这种情况:

        可以看到,明明我们的代码中父进程是在子进程前面的,但是为什么子进程会先打印呢?

        这种情况是Linux 进程调度 “并发性” 的典型体现,Linux 采用抢占式调度,内核会动态为进程分配 CPU 时间片。fork创建子进程后,父进程和子进程是两个独立的 “执行流”,调度器会根据负载、优先级等因素,随机选择其中一个进程先执行,在运行这次程序中,子程序优先获得了CPU 时间片,所以会优先执行,之后调度器切换到父进程,父进程开始运行。

        这种顺序不是固定的,有时父进程先执行,有时子进程先执行,甚至中间会穿插执行,如下图所示:

        进程的执行顺序由内核调度器动态决定,没有绝对的先后

2、ps和kill命令

2.1、ps命令

2.1.1、定义

        定义:ps 是 process status 的缩写,用来查看当前系统中的进程列表及状态信息。包含进程 ID、用户、资源占用、启动命令等关键信息,是 Linux 进程管理的核心查询工具。

2.1.2、常用参数与组合
1、ps aux(BSD 风格):用于查看所有用户的所有进程。

      a:显示现行终端机下的所有程序,包括其他用户的程序;

      u:以用户为主的格式来显示程序状况;

      x:显示所有程序,不以终端机来区分;

      常见字段:USER(所属用户)、PID(进程ID)、%CPU(CPU占用率)、%MEM(内存占有率)、COMMAND(启动命令)等等。

2、ps -ef(Unix风格):用于查看所有进程的系统级信息,信息比较全。

      常见字段:UID(用户ID/用户名)、PID(进程ID)、PPID(父进程ID)、C(CPU使用率)、STIME(启动时间)、TIME(占用CPU总时间)等等。

2.1.3、示例

1、ps aux

        首先随便生成一个文件,编辑一段代码:

          然后使用aux指令:

        结果如下图所示,我们可以看到所有进程的相关信息;那么这么多的信息我们应该怎么快速查看到我们想看的文件的信息呢?如下图所示:

        ps aux | grep sub:通过管道 | 将ps aux的输出过滤,仅显示包含 “sub” 关键字的进程行。

2、ps -ef

        首先使用ps -ef指令:

        然后使用管道过滤我们ps -ef的输出:

        也可以看到sub进程的相关信息,不过信息相比ps aux是有所不同的。

2.2、kill指令

2.2.1、定义与基本用法

        定义:kill指令用于向进程发送信号,默认是终止信号。

        基本用法:

kill [选项] PID...
2.2.2、常见信号及含义

        常用信号如下:

信号编号 信号名 作用说明
1 SIGHUP 挂起信号,常用于重启服务(如kill -1 nginx可平滑重启 Nginx)
9 SIGKILL 强制终止信号,进程无法捕获 / 忽略,会立即终止(慎用,可能导致数据损坏)
15 SIGTERM 友好终止信号,进程可捕获并执行清理操作kill默认发送此信号)
18 SIGCONT 继续执行被暂停的进程(配合SIGSTOP使用)
19 SIGSTOP 暂停进程(进程无法忽略,常用于调试)
2.2.3、示例

        首先我们先创建一个死循环函数sxh.c:

#include <stdio.h>

int main()
{
        while(1)
        {
                printf("666\n");
                sleep(1);
        }
        return 0;
}

        使用gcc编译器进行编译,然后运行:

        可以看到程序在以1秒钟的间隔打印666,然后我们打开另一个终端,先查看死循环程序所对应的PID:

        其中PID为4385的进程是我们用户自己运行的进程,PID为4408的进程是grep自身的过滤进程(临时进程),我们使用kill指令的时候应该对应的是PID为4385的进程。

        如图所示,我们成功地将死循环进程给停止了下来。

3、父子进程间的数据共享

        首先要明确一个基本原则:Linux中每个进程都有自己独立的地址空间。这意味着默认情况下,一个进程不能直接访问另一个进程的内存数据。

3.1、fork函数的内存复制机制

        当父进程通过 fork 创建子进程时,内核会复制父进程的大部分资源,但为了性能优化,采用写时复制技术:

        初始状态:父子进程共享内存空间(代码段、数据段、栈、文件描述符等),但这些内存区域被标记为 “只读”。

        修改触发复制:若父子进程中任意一方修改共享内存中的数据(如修改变量、栈数据),内核会为修改方复制一份独立的内存副本,保证双方后续修改互不影响。

3.2、示例

        首先打开之前创建的fork.c文件,增加一个全局变量i=100:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int i = 100;
int main()
{
        pid_t pid;
        pid = fork();
        if (pid < 0)//fork failed! 
        {
                perror("fork failed");
                return 1;
        }
        else if(pid > 0)
        {
                i+=100;
                printf("i = %d\n",i);
                printf("father pid is %d\n",getpid());
        }
        else if(pid == 0)
        {
                i+=200;
                printf("i = %d\n",i);
                printf("son pid is %d,my father pid is %d\n",getpid(),getppid());
        }
        printf("I love Linux!\n");
        return 0;
}

        使用gcc编译器进行编译,然后运行:

        可以看到,我们分别在父进程和子进程中修改 i 的值时,会根据数的不同来算出不同的数,这就验证了写时复制技术。

4、exec函数族

        在 Linux 操作系统中,exec 函数族是一组用于替换当前进程映像的系统调用,核心作用是在现有进程中加载并执行新的程序,而无需创建新进程(与fork的 “创建新进程” 形成互补)。它们是实现 “启动新程序” 的核心机制(例如 Shell 执行命令的底层原理)。

4.1、exec函数族的核心特性

        1、进程映像替换:调用exec后,当前进程的代码段、数据段、堆、栈会被新程序完全替换,仅保留进程 ID(PID)、文件描述符、信号处理方式等核心属性。

        2、无返回值(成功时):若exec调用成功,新程序会直接开始执行,原进程的后续代码不再运行因此exec成功时无返回值);若失败,返回-1,原进程继续执行。

        3、与fork的配合:单独使用exec会替换当前进程,因此实际中常与fork配合:fork创建子进程后,在子进程中调用exec加载新程序(既保留父进程,又执行新程序)。

4.2、exec函数族的 6 个成员

        exec函数族包含 6 个函数,核心功能一致,差异主要体现在参数格式环境变量处理上。函数原型如下(定义在<unistd.h>):

函数名 函数原型 核心参数特点
execl int execl(const char *path, const char *arg0, ..., (char*)NULL); 路径(path)+ 可变参数列表(arg0开始,以NULL结尾)
execv int execv(const char *path, char *const argv[]); 路径(path)+ 参数数组(argv,最后一个元素为NULL
execlp int execlp(const char *file, const char *arg0, ..., (char*)NULL); 程序名(file)+ 可变参数列表(自动在PATH环境变量中查找程序路径)
execvp int execvp(const char *file, char *const argv[]); 程序名(file)+ 参数数组(自动在PATH中查找路径)
execle int execle(const char *path, const char *arg0, ..., (char*)NULL, char *const envp[]); 路径(path)+ 可变参数列表 + 自定义环境变量数组(envp
execve int execve(const char *path, char *const argv[], char *const envp[]); 路径(path)+ 参数数组 + 自定义环境变量数组(唯一的系统调用,其他函数基于它实现

4.3、参数差异

1、路径 vs 程序名

        带p的函数(execlpexecvp):参数为程序名(如"ls"),会自动在PATH环境变量指定的目录(/bin/usr/bin等)中查找可执行文件,无需写全路径。

        不带p的函数(execlexecvexecleexecve):参数为完整路径(如"/bin/ls"),否则会报错 “文件不存在”。

2、参数传递方式

        带l的函数(execlexeclpexecle):用可变参数列表传递程序参数(如"ls", "-l", "-a", NULL),需手动以NULL结尾。

        带v的函数(execvexecvpexecve):用字符串数组传递参数(如argv[] = {"ls", "-l", "-a", NULL}),数组最后一个元素必须为NULL

3、环境变量控制

        带e的函数(execleexecve):允许通过envp参数自定义新程序的环境变量(如envp[] = {"PATH=/usr/local/bin", "USER=test", NULL})。

        不带e的函数:默认继承当前进程的环境变量(通过environ全局变量获取)。

4.4、示例

1、execl:用可变参数 + 完整路径执行程序

        继续使用之前创建的fork.c文件,更改为以下代码:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int i = 100;
int main()
{
        pid_t pid;
        pid = fork();
        if (pid < 0)//fork failed! 
        {
                perror("fork failed");
                return 1;
        }
        else if(pid > 0)
        {
                i+=100;
                printf("i = %d\n",i);
                printf("father pid is %d\n",getpid());
        }
        else if(pid == 0)
        {
                execl("/bin/ls","-l",NULL);
                i+=200;
                printf("i  = %d\n",i);
                printf("son pid is %d,my father pid is %d\n",getpid(),getppid());
        }
        printf("I love Linux!\n");
        return 0;
}

        使用gcc编译器进行编译,运行结果如下:

        可以看到子进程执行了新的程序,原来的代码被覆盖,而父进程不受影响。

2、execvp:用参数数组 + 程序名(依赖 PATH)执行程序

        输入以下代码:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int i = 100;
int main()
{
        pid_t pid;
        pid = fork();
        if (pid < 0)//fork failed! 
        {
                perror("fork failed");
                return 1;
        }
        else if(pid > 0)
        {
                i+=100;
                printf("i = %d\n",i);
                printf("father pid is %d\n",getpid());
        }
        else if(pid == 0)
        {
                char*argv[] = {"ps","aux",NULL};
                execvp("ps",argv);
                i+=200;
                printf("i  = %d\n",i);
                printf("son pid is %d,my father pid is %d\n",getpid(),getppid());
       }
        printf("I love Linux!\n");
        return 0;
}

        使用gcc编译器进行编译,运行结果如下:

        可以看到子进程执行了新的程序,原来的代码被覆盖,而父进程不受影响;与execl不同的是  execl无需使用完整路径,并且使用数组包含参数。

3、execle自定义环境变量执行程序 

        输入以下代码:

#include <stdio.h>
#include <unistd.h>

int main() {
    // 自定义环境变量:覆盖默认 PATH,仅包含 /usr/bin
    char *envp[] = {"PATH=/usr/bin", "USER=custom_user", NULL};
    // 执行 /bin/echo,输出环境变量 USER(使用自定义的 envp)
    execlp("echo", "echo", "当前用户:$USER", NULL); // 注意:echo 会解析环境变量
    // 等价于:execle("/bin/echo", "echo", "当前用户:$USER", NULL, envp);
    perror("execle 失败");
    return 1;
}

        使用gcc编译器进行编译,运行结果如下:         运行后会输出 “当前用户:¥USER”,说明新程序使用了自定义的环境变量。

5、孤儿进程和僵尸进程

5.1、孤儿进程

5.1.1、定义

        父进程在子进程退出前先终止,子进程失去父进程,被系统的 “收养进程”(通常是 init 进程,PID=1,或 systemd 进程)接管,这类子进程称为孤儿进程。

5.1.2、核心特点与系统处理

        PPID 变化:子进程的父进程 ID(PPID)会从原父进程 PID 变为收养进程 PID。

        资源状态:孤儿进程本身是正常运行的进程,拥有完整的资源(内存、文件描述符等),仅父进程身份变更。

        系统处理:收养进程会自动调用wait()回收孤儿进程的退出资源,因此孤儿进程不会造成资源泄漏,对系统无负面影响。

5.1.3、示例

        首先创建一个guer.c文件:

        输入以下代码:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main()
{
        pid_t pid;
        pid  = fork();
        if(pid == 0)
        {
                sleep(1);
                printf("son pid is %d,father pid is %d\n",getpid(),getppid());
        }
        if(pid > 0)
        {
                printf("father pid is %d\n",getpid());
        }
        return 0;
}

        使用gcc编译器进行编译,运行结果如下:

        我们可以看到子进程的父进程PPID变了,从2772变为1290了,我们再使用ps指令查看1290是谁的PID:

        可以看到子进程被 systemd(PID为1290) 进程所“收养”。

5.2、僵尸进程

5.2.1、定义

        子进程已经结束进程,但父进程未调用wait()/waitpid()回收子进程的资源(仅保留进程控制块 PCB),子进程的 PID 和退出状态被保留,这类进程称为僵尸进程(状态标记为Z)。

5.2.2、核心特点与系统影响

        状态标记:通过ps aux查看时,状态为Z,COMMAND 列显示<defunct>(失效)。

        资源占用:不占用 CPU 和内存,但会占用PID 资源(系统 PID 是有限的)。

        系统风险:大量僵尸进程会耗尽 PID 资源,导致新进程无法创建,属于需要避免的异常场景。

5.2.3、示例

        首先创建一个js.c文件:

        接着输入以下代码:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main()
{
        pid_t pid;
        pid = fork();
        if(pid == 0)
        {
                printf("son pid is %d,father pid is %d\n",getpid(),getppid());
        }
        if(pid > 0)
        {
                while(1)
                {
                        sleep(1);
                        printf("father pid is %d\n",getpid());
                }
        }
        return 0;
}

        使用gcc编译器进行编译,运行结果如下:

        接着打开另一个终端使用ps指令查看PID和状态:

        可以看到目前js进程为僵尸进程(状态为Z,COMMAND 列显示<defunct>),说明这个进程已经“死掉”了,这时候使用kill指令对该进程无效,如下:

        这时候就需要将该子进程的父进程给停掉:

        这时候就显示该进程被回收。

6、进程回收

        在 Linux 操作系统中,进程回收是父进程对已退出子进程的资源清理操作,核心目的是释放子进程的进程控制块(PCB)资源,避免 “僵尸进程” 导致占用系统PID资源。Linux 通过以下两个函数调用实现进程回收,定义在<sys/wait.h>中:wait 阻塞函数与waitpid函数。

6.1、wait 阻塞函数

6.1.1、定义

        原型pid_t wait(int *status);

        功能:阻塞父进程,直到任意一个子进程退出,并回收其资源。

        参数status存储子进程的退出状态(如正常退出码、信号终止原因),若为NULL则不关注退出状态。

        status 可以用宏解析:

if (WIFEXITED(status)) {
    int code = WEXITSTATUS(status);  // 正常退出的 exit code
}
if (WIFSIGNALED(status)) {
    int sig = WTERMSIG(status);      // 被哪个信号杀死
}

        返回值:成功:退出子进程的 PID;失败:-1(如无待回收子进程)。

6.1.2、示例

        首先创建一个wait.c文件:

        接着输入以下代码:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>

int main()
{
        pid_t pid;
        pid = fork();
        if(pid > 0)
        {
                pid_t wpid;
                wpid = wait(NULL);
                printf("wpid is %d\n",wpid);
                printf("father pid is %d\n",getpid());
        }
        if(pid == 0)
        {
                printf("son pid is %d,father pid is %d\n",getpid(),getppid());
        }
        return 0;
}

         使用gcc编译器进行编译,运行结果如下:

        这里我们可以看到wpid返回了子进程的PID,但是我们参数里写的NULL,下面我们加入宏来判断子进程退出的状态:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>

int main()
{
        pid_t pid;
        pid = fork();
        if(pid > 0)
        {
                int status;
                pid_t wpid;
                wpid = wait(&status);
                if (WIFEXITED(status))
                {
                         printf("子进程正常退出,退出码=%d\n", WEXITSTATUS(status));
                }
                else if (WIFSIGNALED(status)) 
                {
                         printf("子进程被信号 %d 终止\n", WTERMSIG(status));
                }

                printf("wpid is %d\n",wpid);
                printf("father pid is %d\n",getpid());
        }
        if(pid == 0)
        {
                printf("son pid is %d,father pid is %d\n",getpid(),getppid());
        }
        return 0;
}

        使用gcc编译器进行编译,运行结果如下:

        可以看到子进程是正常退出,退出码为0,为什么是0呢?因为子进程正常执行到main函数末尾并返回了0,这是 C 语言程序的默认退出行为,在 C 语言中,main函数的return n等价于调用exit(n),其中n就是进程的退出码。因此,子进程的退出码被显式设置为0

        那么如果子进程异常退出呢?这里我们加个死循环,然后用kill指令来进行杀死子进程,看看退出码会是什么?

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>

int main()
{
        pid_t pid;
        pid = fork();
        if(pid > 0)
        {
                int status;
                pid_t wpid;
                wpid = wait(&status);
                if (WIFEXITED(status))
                {
                         printf("子进程正常退出,退出码=%d\n", WEXITSTATUS(status));
                }
                else if (WIFSIGNALED(status)) 
                {
                         printf("子进程被信号 %d 终止\n", WTERMSIG(status));
                }

                printf("wpid is %d\n",wpid);
                printf("father pid is %d\n",getpid());
        }
        if(pid == 0)
        {
                while(1)
                {
                        sleep(1);
                        printf("son pid is %d,father pid is %d\n",getpid(),getppid());
                }

        }
        return 0;
}

         使用gcc编译器进行编译,运行结果如下:

        这里子进程一直在循环,我们打开另一个终端来停止掉该进程:

        可以看到我们这里通过kill指令来讲子进程关闭后,显示被信号15终止(kill默认参数为15)。 

6.2、waitpid函数

6.2.1、定义  

        原型pid_t waitpid(pid_t pid, int *status, int options);

        功能:可指定回收的子进程,支持阻塞 / 非阻塞模式,是wait的增强版。       

        参数

     pid

     pid > 0回收指定 PID 的子进程;

     pid = 0回收与父进程同进程组的所有子进程;

     pid = -1回收任意子进程(等价于wait);

     pid < -1回收进程组 ID 为-pid的所有子进程(取反)。

     statuswait,存储退出状态。

     options

                  设置为0:阻塞模式;

     WNOHANG非阻塞模式,若无子进程退出则立即返回0

     WUNTRACED回收被暂停的子进程;

     WCONTINUED回收从暂停状态恢复的子进程。

       返回值

                成功:退出子进程的 PID(阻塞模式);或0(非阻塞模式下无子进程退出);

                失败:-1

6.2.2、示例

        首先我们创建一个文件waitpid.c文件:

        接着输入以下代码:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>

int main()
{
        pid_t pid;
        pid = fork();
        if(pid > 0)
        {
                int status;
                pid_t wpid;
                while((wpid = waitpid(-1,&status,WNOHANG))!=-1)
                {
                        if (WIFEXITED(status))
                        {
                                 printf("子进程正常退出,退出码=%d\n", WEXITSTATUS(status));
                        }
                        else if (WIFSIGNALED(status))
                        {
                                 printf("子进程被信号 %d 终止\n", WTERMSIG(status));
                        }
                        printf("wpid is %d\n",wpid);
                        printf("father pid is %d\n",getpid());
               }
        }
        if(pid == 0)
        {
                printf("son pid is %d,father pid is %d\n",getpid(),getppid());
        }
        return 0;
}

         使用gcc编译器进行编译,运行结果如下:

        可以看到这是阻塞状态下的运行结果,那么我们再将option设置为WNOHANG(非阻塞模式)再试试看:

        使用gcc编译器进行编译,运行结果如下:

        可以看到在获取到子进程的PID之前都是获得0,这是因为父进程进入while循环,调用waitpid时子进程尚未退出,因此waitpid返回0。但代码未判断wpid的有效性,直接解析status(此时status是未定义的随机值),导致误判 “子进程正常退出”。

        这时候可以加一个contionue来阻断:

         使用gcc编译器进行编译,运行结果如下:

        使用continue阻断后,后面的内容将不再打印,等到子进程退出时再进行打印。

7、进程退出

        在 Linux 操作系统中,进程退出是指进程终止执行并释放资源的过程,分为正常退出异常退出两大类,涉及资源回收、状态传递等核心机制。

7.1、正常退出

        正常终止是指进程主动或预期内地结束自己的生命。

1、main函数返回

        这是最常见的方式。在 C/C++ 程序中,main 函数的返回值实际上会作为参数传递给 exit 函数。(如return 0;表示正常退出),等价于调用 exit(退出码),会触发后续的清理流程。

2、调用exit函数

        函数原型:void exit(int status);(定义在<stdlib.h>

        exit() 是一个标准 C 库函数,它会执行一个“清理”过程,然后通知内核终止进程。

        status 参数是退出状态,父进程可以通过 wait() 系统调用获取这个值。按照惯例,0 表示成功非 0 表示失败或错误

3、调用_Exit/_exit函数

        函数原型:void _Exit(int status);(或_exit,定义在<unistd.h>

        直接终止进程,不进行任何清理工作,常用于子进程(避免干扰父进程的 I/O 或资源状态)

       这两个函数几乎是同义词,它们直接调用内核的退出系统调用。

7.2、异常退出

        异常终止是指进程因外部信号或严重错误而被迫结束。

1、接收终止信号

        当进程收到某些信号时,默认行为是终止进程。例如:SIGKILL (信号 9):无条件立即杀死进程,无法被捕获或忽略。SIGTERM (信号 15):请求进程终止,进程可以捕获它并进行清理后再退出。SIGINT (信号 2):通常由 Ctrl+C 产生,中断进程。SIGSEGV (信号 11):段错误,非法内存访问。SIGABRT (信号 6):通常由 abort() 函数产生。

2、调用 abort() 函数

  函数原型:void abort(void);头文件:#include <stdlib.h>

  该函数向进程自身发送 SIGABRT 信号,导致进程异常终止,并可能产生核心转储文件(core文件)。

7.3、exit() 与 _exit() 的关键区别

        如下图所示:

特性 exit() (库函数) _exit() (系统调用)
标准I/O缓冲区 会刷新。如果标准输出是行缓冲(如终端),printf 的内容会被输出。 不会刷新。缓冲区中的数据会丢失,printf 的内容可能看不到。
清理函数 会执行 通过 atexit() 或 on_exit() 注册的函数。 不会执行
临时文件 会删除 由 tmpfile() 创建的临时文件。 不会删除
关闭文件 会关闭所有已打开的文件流。 内核会关闭所有打开的文件描述符。

        一个经典的例子如下:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main() {
    printf("Hello, World"); // 注意:没有换行符\n,内容在缓冲区中

    // 情况1:使用 exit
    // exit(0); // 输出 "Hello, World" (因为exit刷新了缓冲区)

    // 情况2:使用 _exit
    _exit(0); // 可能没有任何输出 (因为_exit没有刷新缓冲区)
}

         通过这个例子可以对这两个函数的区别有个明确的认知。

        补:在子进程中,如果想避免执行父进程通过 atexit() 注册的清理函数,通常会使用 _exit() 而不是 exit();绝大多数情况下,在 main 函数中使用 return 或调用 exit() 是正确的选择。

7.4、进程退出的内部过程

        当一个进程调用 exit() 或接收到终止信号时,内核会按顺序执行以下操作:

1、设置退出状态:将进程的退出状态(status )保存在其进程描述符(task_struct)中;

2、关闭资源:关闭所有打开的文件流,释放该进程在其生命周期中分配的内存(用户空间);

释放其他内核资源,如信号量、消息队列等。

3、处理父子关系:将该进程的所有子进程“过继”给 init 进程(PID 1)。这样确保了系统中不会有“无父”的进程;向父进程发送 SIGCHLD 信号,通知其有一个子进程已经终止。

4、状态转换:进程状态变为 EXIT_ZOMBIE(僵尸状态),此时,进程占用的绝大多数资源(内存、文件等)都已被释放。但是,内核仍然保留着它的进程描述符(PCB),其中包含了 PID 和退出状态等信息。这是为了父进程后续查询。

5、最终清理:当父进程调用 wait() 或 waitpid() 系统调用后,内核会获取僵尸进程的退出状态,然后彻底删除其进程描述符,这个进程才真正从系统中消失。                        

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐