【Linux】进程控制(一)——进程创建、进程终止与进程等待

【Linux】进程控制(一)------进程创建、进程终止与进程等待

文章目录

一、 进程创建

1、 fork函数

Linux中fork函数是非常重要的函数,fork () 会以调用它的当前进程为模板,复制出一个全新的独立子进程,实现一个程序两份执行流同时跑。新进程为子进程,原进程为父进程

函数原型

cpp 复制代码
#include <unistd.h>
pid_t fork(void);

Linux中定义typedef int pid_tpid_t本质是整形int,专门用来存储进程PID

  1. 返回值为 -1:说明fork调用失败,无法创建子进程
  2. 返回值为0:说明fork成功,当前执行流属于子进程
  3. 返回值为大于0的正整数:说明当前执行流属于父进程,返回值就是新建子进程的 PID

三个小问题

(1) 为什么子进程返回 0,父进程返回子进程 PID?

子进程没有后代,用0来标记很方便

父进程可能创建多个子进程,要想管理子进程必须拿到每一个子进程唯一 PID,后续用来等待回收、发送信号、管控子进程

(2) 为什么一个 fork 函数会有两个返回值?

fork不是普通函数,调用后会执行拷贝构造

  1. 首先内核会分配新task_struct(进程控制块 PCB)、虚拟地址空间给子进程
  2. 接着完整拷贝父进程页表、代码段、数据段给子进程
  3. 再将将子进程加入 CPU 调度队列
  4. 此时会有两个独立的进程,父进程和子进程会同时返回,父子进程各自拿到一份返回值,所以一个调用产生两个返回结果

(3) 为什么同一个变量pid既等于 0,又大于 0?

pid变量在父子进程虚拟地址空间中是两份独立副本,父进程的pid存的是子进程 PID(>0),子进程的pid存的是 0,每个进程的虚拟地址隔离,互不干扰

cpp 复制代码
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>

int main()
{
  printf("fork前:父进程PID=%d\n",getpid());

  pid_t id=fork();
  if(id==-1)
  {
    perror("fork failed");
    exit(1);
  }
  else if(id==0)
  {
    printf("我是子进程PID=%d,我的父进程是%d\n",getpid(),getppid());
  }
  else 
  {
    printf("我是父进程PID=%d\n",getpid());
  }
  return 0;
}

2、 写时拷贝(COW)

fork() 创建子进程时一开始根本不复制物理内存,父子进程共用同一份物理内存只有某一方修改数据的瞬间,操作系统才单独复制一份内存给它,这就叫写时拷贝

fork刚创建完时 :内核给子进程创建独立的虚拟地址空间、PCB、页表,父子两个进程的页表全部指向同一块物理内存 ,操作系统把这块内存标记为只读权限

任意一个进程(父/子)尝试修改全局/局部变量时触发写时构造 。CPU访问只读内存,触发缺页异常,操作系统开辟一块新物理内存,把要修改的那一页数据单独复制过去 ,新拷贝出来的这一页,权限直接设置为可读可写。修改进程的页表映射到新内存,另一个进程依旧指向老内存。两块内存彻底隔离,互不干扰

之后两个进程各自操作自己的内存,修改任何数据都不会影响对方,达到进程地址空间完全隔离的设计目标

fork 的时候,如果没有写时拷贝:创建子进程就要把父进程全部物理内存完整复制一份 。父进程占用几百 MB 内存,fork 就要一次性拷贝几百 MB,耗时、浪费物理内存

写时拷贝核心思想:先共享,真正修改的时候才复制

fork 系统调用瞬间完成,不复制任何用户数据物理内存。仅仅拷贝页表(很小),父子进程共享全部物理页面。哪怕父进程占用很大内存,fork 依旧飞快

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int global_val = 100;

int main()
{
    pid_t pid = fork();
    if (pid < 0)
    {
        perror("fork fail");
        return 1;
    }
    else if (pid == 0)
    {
        // 子进程修改全局变量,触发写时拷贝
        global_val = 200;
        printf("子进程 pid:%d, global_val = %d\n", getpid(), global_val);
    }
    else
    {
        sleep(1); // 等子进程先执行完修改
        printf("父进程 pid:%d, global_val = %d\n", getpid(), global_val);
    }
    return 0;
}

fork 完成瞬间,父子进程虚拟地址中global_val指向同一块物理内存页,页面权限为只读

当子进程执行 global_val = 200; 写操作,触发写保护缺页异常操作系统执行写时拷贝:复制这一页物理内存,子进程页表映射到新的可读可写物理页

父进程页表映射关系完全不变,依旧访问原来物理页

所以子进程修改的是自己副本的数据,父进程的global_val仍然是 100,实现进程数据隔离

3、 Fork使用场景

服务器多进程并发处理

父进程负责监听客户端连接,每接收到一个用户请求,就 fork() 生成一个子进程,由子进程单独处理这个客户端的全部业务;父进程立刻回头继续等待下一个连接

优点:进程之间相互隔离,某个子进程崩溃不会把整个服务器搞挂,稳定性更强

fork + exec 执行外部程序(Linux Shell 底层原理)

我们现在敲的终端敲所有命令本质都是这套流程

shell(父进程)调用 fork() 创建子进程;子进程调用 exec 系列函数(execl/execvp),彻底替换自身代码,加载磁盘上的可执行程序(ls、pwd、gcc、cd)

父进程用 wait() 阻塞等待子进程运行完毕,回收资源,再回到命令行等待你输入下一条指令

了解即可,我们后边再详细介绍

fork 调用失败的两种原因

  1. 系统全局运行进程总数达到内核上限:整个操作系统所有用户加起来的总进程数超了内核设定最大值(系统级全局限制)
  2. 当前登录用户创建的进程数量超出操作系统资源限制:当前这一个登录账号自己创建的进程数量超限(用户级单独配额限制)

二、 进程终止

1、 进程中止内核在做什么

当一个进程结束运行,Linux 内核会执行固定回收逻辑

父进程正常运行时

  1. 释放进程运行时申请的堆内存、打开的文件描述符、占用的 CPU/IO 等所有系统资源
  2. 但是保留 PCB(进程控制块)不销毁,PCB 里存放着本次进程的退出状态、退出码、终止信号等核心信息
  3. 之后内核会一直等待父进程调用 wait() / waitpid() 读取 PCB 里的退出信息 。如果父进程长期不去读取这份数据,残留的 PCB 会一直占用内核空间,这个进程就变成僵尸进程PCB残留会造成内存泄漏,且kill -9无法杀死已经终止的僵尸进程

如果父进程迟迟不回收僵尸子进程,父进程自己先终止运行

  1. 所有未被回收的僵尸子进程,会被系统 1 号 init/systemd 进程领养(成为它们的新父进程)
  2. 1 号进程会自动、循环调用 wait 去批量回收所有养子的僵尸 PCB
  3. 最终所有僵尸全部被内核清理干净

2、 进程终止的三种场景

场景 1代码完整跑完,执行结果正确

程序所有代码正常走完,没有任何异常、中断、崩溃,最终通过 return 0 或 exit(0) 主动退出
退出码为 0
,代表程序执行成功

场景 2:代码完整执行完毕,但执行结果错误

程序本身没有崩溃、没有被系统信号打断,完整跑完所有代码,但内部业务逻辑不符合预期 ,例如文件打开失败、参数校验不通过、计算结果错误等等

此时我们执行非 0 退出码标记错误类型,比如 exit(10),用来区分不同错误类型

场景3:代码未执行完毕,异常终止

程序运行中途被外部力量强制打断 ,没有执行到 return、exit 等主动退出语句

异常的根本原因:进程接收到操作系统下发的终止信号,举个例子:

  1. 终端按下 Ctrl+C,内核发送 SIGINT 信号终止进程
  2. 代码空指针访问、数组越界触发段错误,内核发送 SIGSEGV 杀死进程
  3. 手动执行 kill 命令向进程发送终止信号

无论哪一种场景结束,内核都会用两个通用参数完整描述退出结果

  • exit_code进程主动退出时携带的退出码
  • sig导致进程终止的信号编号

判断逻辑

  1. sig = 0 :进程属于主动正常退出(对应场景 1、场景 2)。只需解析 exit_code:0 代表执行成功,非 0 代表执行错误
  2. sig ≠ 0:进程被信号异常终止(对应场景 3)。exit_code 不再具备参考价值,只需要通过信号编号找到错误原因

3、 三种进程正常退出方式

方式 1:main 函数 return 返回

  1. 在 main 函数中执行 return n,进程退出,且main 的返回值会被当作整个进程的全局退出参数
  2. 注意:如果在非main函数内部执行return,只是当前子函数调用结果,并没有终止整个进程

方式 2:库函数 exit ()

在全程序任意位置调用exit(n)都会结束进程

return 只能依靠逐层返回到 main 才能退出程序,但 exit() 在任意子函数、任意代码分支中调用,都会立刻终结整个进程,不需要代码层层回溯

头文件依赖 :#include <stdlib.h>

函数原型:void exit(int status);

exit(n) 传给内核的退出数字 n,只保留低 8 位(0~255),高位直接丢掉

可以使用系统本来就设置好的退出值,也可以自定义退出值数字实际代表的含义(比如自己定义exit(10)为文件打开失败)

方式3:系统调用 _exit ()

在全程序任意位置调用_exit(n)都会结束进程,但是不会刷新缓冲区,可能会导致数据丢失

_exit 的 status 参数同样只保留低 8 位,如果执行 _exit(-1),父进程最终读取到的退出码为 255

4、 exit与_exit对比

exit执行有三个流程

  1. 执行清理函数atexit()这个就像一个退出待办清单,用于完成收尾工作,例如释放动态内存、断开网络连接、输出退出日志等
  2. 自动刷新IO 缓冲区、关闭所有已打开的文件流
  3. exit的退出功能其实是靠封装内核的 _exit() 实现的,底层调用_exit()真正进入内核,由操作系统完成真正的进程资源销毁

_exit执行逻辑

  • _exit() 是 Linux 内核原生系统调用依赖头文件 <unistd.h>,直接进入内核空间执行
  • _exit()不会执行任何清理函数,也不会刷新IO缓冲区,直接强制终止进程

5、 Linux退出码含义

exit(n) 里的 n只是原始退出值

wait(&status) 里的 status,才是退出码,退出码的高8位存放的是exit(n)中的n。n只是退出码的一部分

一个程序运行结束后,可以在终端执行echo &? 打印上一个进程的退出码

且只保留低 8 位,范围只能 0~255,exit(256) 等价 exit(0)

系统标准退出码

退出码 官方含义
0 命令 / 程序正常完整执行完毕
1 通用未知运行错误
2 命令参数使用格式不当
126 权限不足,文件无法执行
127 系统找不到对应的可执行命令
130 Ctrl+C 发送 SIGINT 信号终止进程
143 kill 默认 SIGTERM 信号杀死进程

三、 进程等待

1、为什么需要进程等待?

fork 创建子进程后会出现一个重要的遗留问题

  1. 子进程执行完毕调用 exit() 退出时,并不会直接被操作系统彻底销毁。它的PCB 进程控制块会短暂驻留在内核中 ,记录自身退出码、终止信号等信息,此时该进程就叫僵尸进程
  2. 僵尸进程无法用 kill 命令杀死,持续堆积会占用系统进程表资源,最终导致系统无法创建新进程
  3. 想要释放僵尸进程、回收内核 PCB 资源,唯一合法方式就是:父进程调用 wait() 或waitpid(), 阻塞等待 / 非阻塞等待子进程退出,并读取子进程退出状态
  4. 还有一个比较重要的原因是,如果父进程给子进程安排了任务,我们就需要知道这个任务完成的怎样,结果对还是不对,或者是否正常退出。

阻塞等待

  • 父进程调用函数之后原地卡死、停住不动,啥也不干,一直傻傻等到某个子进程彻底结束运行,才继续往下执行自己的代码
  • wait() 默认就是永久阻塞
  • waitpid(..., 0) 第三个参数填 0,也是阻塞

非阻塞等待(WNOHANG)

  • 父进程调用函数只做一次快速检查,如果子进程还在跑:函数立刻返回 0,父进程不卡住,转头去执行自己别的业务(比如打印日志、操作数据库)
  • 如果子进程刚好退出了:直接回收子进程,拿到结果
  • waitpid(..., WNOHANG)最后一个参数需要填WNOHANG

基于这个底层根源,进程等待有三大价值

  1. 销毁僵尸进程,避免系统内存与进程表泄漏(最重要)
  2. 获取子进程退出结果:区分子进程是正常执行完毕退出,还是被异常信号强制杀死
  3. 管控父子进程执行流:父进程可以挂起自身逻辑,等所有子进程全部运行结束后,再继续执行后续代码

2、wait()

函数原型

cpp 复制代码
#include <sys/wait.h>
pid_t wait(int *status);

参数int * status

位图参数,操作系统会把子进程退出的状态信息填充到这块内存中,如果不关心子进程如何退出,直接出传入NULL就行

返回值:

  • 成功:返回被回收的子进程PID
  • 失败:返回-1(比如当前进程没有任何子进程)

wait执行时,会永久阻塞等待,直到任意一个子进程退出,只要有一个子进程变为僵尸状态,wait 立刻回收并返回。如果没有子进程则直接报错

wait局限性

  1. 只能随机等待任意子进程,无法指定等待某一个 PID 的子进程
  2. 只能阻塞等待,父进程卡死无法执行自己的代码
  3. 如果批量创建多个子进程,管理起来非常麻烦
cpp 复制代码
#include<stdio.h>
#include<unistd.h>
#include<sys/wait.h>
#include<stdlib.h>


void child_code()
{
    printf("子进程运行中...\n");
    sleep(3);
    exit(10); // 子进程退出,携带退出码10
}

void parent_code()
{
    int s;
    printf("父进程调用wait开始阻塞等待\n");
    wait(&s);  
    printf("子进程已退出,父进程解除阻塞,子进程退出码:%d\n", WEXITSTATUS(s));
}

int main(void)
{
    pid_t pid = fork();
    if (pid == 0)
    {
        // 子进程分支
        child_code();
    }
    else
    {
        // 父进程分支
        parent_code();
    }
    return 0;
}

3、waitpid

函数原型

cpp 复制代码
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);

参数pid

可以控制等待哪一个子进程

  • pid > 0:直接匹配该PID的子进程
  • pid = -1:等待任意一个子进程,功能完全等价于 wait()
  • pid = 0:等待和父进程属于同一个进程组的任意子进程
  • pid < -1:等待绝对值对应进程组内的任意子进程

进程组

Linux 里把一批有关联的进程打包成一个小组,这个整体就叫进程组,给组分配一个编号叫 PGID (进程组 ID)

在终端敲一条管道命令,比如

bash 复制代码
ls -l | grep txt

ls 和 grep 两个进程自动归入同一个进程组,PGID 相同

绝大多数我们写的简单 fork() 单进程程序,所有子进程默认继承父进程的进程组 ID,也就是都和父进程同组

参数:status(和 wait 完全通用)

同样是操作系统填充的状态位图,需要用系统提供的固定宏去解析里面存储的二进制信息,后面单独讲底层结构

参数options(控制阻塞 / 非阻塞模式)

  1. options = 0:阻塞模式,子进程未退出时,父进程原地卡死等待;
  2. options = WNOHANG :非阻塞轮询模式,立刻检测一次子进程状态:
    子进程还在运行 :直接返回 0,父进程可以往下执行自己的业务代码
    子进程已经退出:返回子进程 PID 完成回收

waitpid 返回值

  1. 返回 >0:成功回收子进程,返回值为被回收子进程 PID
  2. 返回 =0:仅开启 WNOHANG 非阻塞时生效,代表子进程仍在运行中
  3. 返回 -1:函数调用出错(无子进程、参数非法等)

4、status位图(只用16位置)

前面我们介绍了进程终止的3种场景,当进程终止时,操作系统内核会将子进程的退出码 exit_code 以及导致进程终止的信号编号 sig 这两组关键信息,以位图分段存储的形式打包存放在同一个整型变量 status 当中。之后父进程通过调用 wait 或者 waitpid 系统调用获取到这个承载了完整终止信息的 status 变量,再借助系统预先定义好的解析宏从 status 里分别提取出原始退出码和终止信号,以此反向判定子进程具体的终止原因与运行结果

  1. sig == 0,说明子进程主动退出(场景 1、场景 2),只看 exit_code
  2. sig != 0 ,说明信号杀死异常退出(场景 3),只看信号编号

WEXITSTATUS

WEXITSTATUS是 Linux 系统给我们定义的一个宏(可以理解成官方封装好的工具函数)

  • 头文件:#include <sys/wait.h>
  • 作用:从 wait/waitpid 拿到的 status 变量里,提取子进程正常退出时的数字退出码

为什么非要用它,不能直接 status 打印?

status 并不是普通整数变量,它是一个 int 类型的位图(二进制打包数据)

一个整数分段存储两块完全不同的数据:

  • 低 7 位终止信号 sig,子进程是不是被信号杀死、是被几号信号杀死的。如果为0就是正常退出
  • 第 8 位core dump 标志位(是否生成崩溃转储文件)
  • 高 8 位退出码 exit_code,仅子进程正常调用

直接 printf("%d", status) 出来的是混合后的乱数,根本看不到真实退出码,

所以必须用系统提供的宏拆开解读

配套解析宏(对应位图结构)

处理【子进程正常退出】的两个宏

  1. WIFEXITED(status)
  • 功能:判断函数,检查子进程是不是通过 exit()、_exit() 或者 main 函数里return正常走完程序退出的
  • 返回 真(非 0) :正常主动退出,此时 status 低 7 位信号自动为 0
  • 返回 假(0) :进程是被外部信号强行杀死的,走第二组逻辑
  1. WEXITSTATUS(status)
  • 功能:取值提取函数
    只有上面 WIFEXITED(status) 返回为真的时候,它才有意义,作用是从 status 高 8 位中,抠出当初子进程 exit(n) 里面填写的数字n(也就是 0~255 的自定义退出码)
  • 注意:如果进程是被信号杀死的,调用这个宏拿到的数据是无效垃圾值

处理【子进程被信号异常杀死】的两个宏

  1. WIFSIGNALED(status)
  • 功能:判断子进程是否被信号终止(比如Ctrl+C、段错误 SIGSEGV、kill命令)
  • 返回真:被信号干掉
  • 返回假 :正常退出
  1. WTERMSIG(status)
  • 功能:仅在WIFSIGNALED为真时使用,从 status 的低 7 位二进制里,提取出杀死进程的那个信号的编号
  • 这种场景下子进程根本没有执行exit(n),所以高 8 位的退出码区域是空的、完全没有参考意义

5、子进程正常退出测试

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include<stdlib.h>

int main()
{
    pid_t pid = fork();
    if (pid == 0)
    {
        // 测试1:正常退出,自定义退出码5
        exit(5);
    }

    int status;
    wait(&status);

    // 第一组:判断是否正常exit/return退出
    if (WIFEXITED(status))
    {
        int code = WEXITSTATUS(status);
        printf("子进程正常退出,提取退出码:%d\n", code);
    }
  
    return 0;
}

6、子进程异常退出测试

子进程被信号异常终止时,父进程通过 wait+ 状态宏,可以识别出子进程是被哪个信号杀死

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <stdlib.h>

int main()
{
    pid_t pid = fork();
    if (pid == 0)
    {
        // 测试2:后台运行程序再用kill杀死子进程测试信号终止
        // 不直接用Ctrl+c避免影响父进程导致程序直接退出
      printf("子进程pid:%d\n",getpid()); 
      while(1) sleep(1);
    }

    int status;
    wait(&status);

    // 第二组:判断是否被信号异常杀死
    if (WIFSIGNALED(status))
    {
        int sig_num = WTERMSIG(status);
        printf("子进程被信号终止,信号编号:%d\n", sig_num);
    }

    return 0;
}

如果直接终端按Ctrl+C,信号会发给整个进程组,父子会一起被杀掉,父进程的wait根本没机会解析 status,看不到打印结果

7、waitpid 非阻塞轮询(WNOHANG)测试

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main()
{
    pid_t pid = fork();
    if (pid == 0)
    {
        sleep(3); // 子进程3秒后才退出
        exit(10);
    }

    int status;
    while(1)
    {
        // WNOHANG 非阻塞:子进程没退出立刻返回0,父进程可以做其他事
        pid_t ret = waitpid(pid, &status, WNOHANG);
        if (ret == 0)
        {
            printf("子进程还在运行,父进程做其他任务...\n");
            sleep(1);
        }
        else if (ret > 0)
        {
            printf("成功回收子进程,退出码:%d\n", WEXITSTATUS(status));
            break;
        }
    }
    return 0;
}
相关推荐
wsad05321 小时前
CentOS 10 上使用 Docker Compose 部署 Discuz X5.0 论坛完整教程
linux·docker·centos·discuz
张宇Joaquin1 小时前
openEuler编译和更换内核的方法
java·linux·服务器
小此方1 小时前
Re:Linux系统篇(五十六)线程篇 · 九:基于阻塞队列的生产者消费者模型与条件变量
linux·运维·服务器
迷途之人不知返1 小时前
自动化工程的构建
linux·自动化
数模竞赛Paid answer10 小时前
2026年中青杯数学建模A题数学建模论文智能评估系统与多智能体优化方法求解全过程论文及程序
算法·数学建模·中青杯
银-豆豆10 小时前
数据结构与算法-动态规划、回溯与贪心
算法·动态规划
三言老师11 小时前
秘诀-如何远程SSN访问家里的八台电脑(frp 实操方案)
linux·运维·ssh
想吃火锅100512 小时前
【leetcode】200. 岛屿数量
算法·leetcode·职场和发展
举手12 小时前
Dispatcher模块剖析
linux·c++