【Linux】进程控制(一)------进程创建、进程终止与进程等待
文章目录
- 【Linux】进程控制(一)------进程创建、进程终止与进程等待
-
- [一、 进程创建](#一、 进程创建)
-
- [1、 fork函数](#1、 fork函数)
- [2、 写时拷贝(COW)](#2、 写时拷贝(COW))
- [3、 Fork使用场景](#3、 Fork使用场景)
- [二、 进程终止](#二、 进程终止)
-
- [1、 进程中止内核在做什么](#1、 进程中止内核在做什么)
- [2、 进程终止的三种场景](#2、 进程终止的三种场景)
- [3、 三种进程正常退出方式](#3、 三种进程正常退出方式)
- [4、 exit与_exit对比](#4、 exit与_exit对比)
- [5、 Linux退出码含义](#5、 Linux退出码含义)
- [三、 进程等待](#三、 进程等待)
-
- 1、为什么需要进程等待?
- 2、wait()
- 3、waitpid
- 4、status位图(只用16位置)
- 5、子进程正常退出测试
- 6、子进程异常退出测试
- [7、waitpid 非阻塞轮询(WNOHANG)测试](#7、waitpid 非阻塞轮询(WNOHANG)测试)
一、 进程创建
1、 fork函数
Linux中fork函数是非常重要的函数,fork () 会以调用它的当前进程为模板,复制出一个全新的独立子进程,实现一个程序两份执行流同时跑。新进程为子进程,原进程为父进程
函数原型
cpp
#include <unistd.h>
pid_t fork(void);
Linux中定义typedef int pid_t ,pid_t本质是整形int,专门用来存储进程PID
- 返回值为 -1:说明fork调用失败,无法创建子进程
- 返回值为0:说明fork成功,当前执行流属于子进程
- 返回值为大于0的正整数:说明当前执行流属于父进程,返回值就是新建子进程的 PID
三个小问题
(1) 为什么子进程返回 0,父进程返回子进程 PID?
子进程没有后代,用0来标记很方便
父进程可能创建多个子进程,要想管理子进程 ,必须拿到每一个子进程唯一 PID,后续用来等待回收、发送信号、管控子进程
(2) 为什么一个 fork 函数会有两个返回值?
fork不是普通函数,调用后会执行拷贝构造:
- 首先内核会分配新task_struct(进程控制块 PCB)、虚拟地址空间给子进程
- 接着完整拷贝父进程页表、代码段、数据段给子进程
- 再将将子进程加入 CPU 调度队列
- 此时会有两个独立的进程,父进程和子进程会同时返回,父子进程各自拿到一份返回值,所以一个调用产生两个返回结果
(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、 进程中止内核在做什么
当一个进程结束运行,Linux 内核会执行固定回收逻辑
父进程正常运行时:
- 释放进程运行时申请的堆内存、打开的文件描述符、占用的 CPU/IO 等所有系统资源
- 但是保留 PCB(进程控制块)不销毁,PCB 里存放着本次进程的退出状态、退出码、终止信号等核心信息
- 之后内核会一直等待父进程调用 wait() / waitpid() 读取 PCB 里的退出信息 。如果父进程长期不去读取这份数据,残留的 PCB 会一直占用内核空间,这个进程就变成僵尸进程 。PCB残留会造成内存泄漏,且kill -9无法杀死已经终止的僵尸进程
如果父进程迟迟不回收僵尸子进程,父进程自己先终止运行:
- 所有未被回收的僵尸子进程,会被系统 1 号 init/systemd 进程领养(成为它们的新父进程)
- 1 号进程会自动、循环调用 wait 去批量回收所有养子的僵尸 PCB
- 最终所有僵尸全部被内核清理干净
2、 进程终止的三种场景
场景 1 :代码完整跑完,执行结果正确
程序所有代码正常走完,没有任何异常、中断、崩溃,最终通过 return 0 或 exit(0) 主动退出
退出码为 0,代表程序执行成功
场景 2:代码完整执行完毕,但执行结果错误
程序本身没有崩溃、没有被系统信号打断,完整跑完所有代码,但内部业务逻辑不符合预期 ,例如文件打开失败、参数校验不通过、计算结果错误等等
此时我们执行非 0 退出码标记错误类型,比如 exit(10),用来区分不同错误类型
场景3:代码未执行完毕,异常终止
程序运行中途被外部力量强制打断 ,没有执行到 return、exit 等主动退出语句
异常的根本原因:进程接收到操作系统下发的终止信号,举个例子:
- 终端按下 Ctrl+C,内核发送 SIGINT 信号终止进程
- 代码空指针访问、数组越界触发段错误,内核发送 SIGSEGV 杀死进程
- 手动执行 kill 命令向进程发送终止信号
无论哪一种场景结束,内核都会用两个通用参数完整描述退出结果
- exit_code :进程主动退出时携带的退出码
- sig :导致进程终止的信号编号
判断逻辑:
- sig = 0 :进程属于主动正常退出(对应场景 1、场景 2)。只需解析 exit_code:0 代表执行成功,非 0 代表执行错误
- sig ≠ 0:进程被信号异常终止(对应场景 3)。exit_code 不再具备参考价值,只需要通过信号编号找到错误原因
3、 三种进程正常退出方式
方式 1:main 函数 return 返回
- 在 main 函数中执行 return n,进程退出,且main 的返回值会被当作整个进程的全局退出参数
- 注意:如果在非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执行有三个流程
- 执行清理函数atexit() ,这个就像一个退出待办清单,用于完成收尾工作,例如释放动态内存、断开网络连接、输出退出日志等
- 自动刷新IO 缓冲区、关闭所有已打开的文件流
- 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 创建子进程后会出现一个重要的遗留问题:
- 子进程执行完毕调用 exit() 退出时,并不会直接被操作系统彻底销毁。它的PCB 进程控制块会短暂驻留在内核中 ,记录自身退出码、终止信号等信息,此时该进程就叫僵尸进程
- 僵尸进程无法用 kill 命令杀死,持续堆积会占用系统进程表资源,最终导致系统无法创建新进程
- 想要释放僵尸进程、回收内核 PCB 资源,唯一合法方式就是:父进程调用 wait() 或waitpid(), 阻塞等待 / 非阻塞等待子进程退出,并读取子进程退出状态
- 还有一个比较重要的原因是,如果父进程给子进程安排了任务,我们就需要知道这个任务完成的怎样,结果对还是不对,或者是否正常退出。
阻塞等待
- 父进程调用函数之后原地卡死、停住不动,啥也不干,一直傻傻等到某个子进程彻底结束运行,才继续往下执行自己的代码
- wait() 默认就是永久阻塞
- waitpid(..., 0) 第三个参数填 0,也是阻塞
非阻塞等待(WNOHANG)
- 父进程调用函数只做一次快速检查,如果子进程还在跑:函数立刻返回 0,父进程不卡住,转头去执行自己别的业务(比如打印日志、操作数据库)
- 如果子进程刚好退出了:直接回收子进程,拿到结果
- waitpid(..., WNOHANG)最后一个参数需要填WNOHANG
基于这个底层根源,进程等待有三大价值:
- 销毁僵尸进程,避免系统内存与进程表泄漏(最重要)
- 获取子进程退出结果:区分子进程是正常执行完毕退出,还是被异常信号强制杀死
- 管控父子进程执行流:父进程可以挂起自身逻辑,等所有子进程全部运行结束后,再继续执行后续代码
2、wait()
函数原型
cpp
#include <sys/wait.h>
pid_t wait(int *status);
参数int * status
位图参数,操作系统会把子进程退出的状态信息填充到这块内存中,如果不关心子进程如何退出,直接出传入NULL就行
返回值:
- 成功:返回被回收的子进程PID
- 失败:返回-1(比如当前进程没有任何子进程)
wait执行时,会永久阻塞等待,直到任意一个子进程退出,只要有一个子进程变为僵尸状态,wait 立刻回收并返回。如果没有子进程则直接报错
wait局限性
- 只能随机等待任意子进程,无法指定等待某一个 PID 的子进程
- 只能阻塞等待,父进程卡死无法执行自己的代码
- 如果批量创建多个子进程,管理起来非常麻烦
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(控制阻塞 / 非阻塞模式)
- options = 0:阻塞模式,子进程未退出时,父进程原地卡死等待;
- options = WNOHANG :非阻塞轮询模式,立刻检测一次子进程状态:
子进程还在运行 :直接返回 0,父进程可以往下执行自己的业务代码
子进程已经退出:返回子进程 PID 完成回收
waitpid 返回值
- 返回 >0:成功回收子进程,返回值为被回收子进程 PID
- 返回 =0:仅开启 WNOHANG 非阻塞时生效,代表子进程仍在运行中
- 返回 -1:函数调用出错(无子进程、参数非法等)
4、status位图(只用16位置)
前面我们介绍了进程终止的3种场景,当进程终止时,操作系统内核会将子进程的退出码 exit_code 以及导致进程终止的信号编号 sig 这两组关键信息,以位图分段存储的形式打包存放在同一个整型变量 status 当中。之后父进程通过调用 wait 或者 waitpid 系统调用获取到这个承载了完整终止信息的 status 变量,再借助系统预先定义好的解析宏从 status 里分别提取出原始退出码和终止信号,以此反向判定子进程具体的终止原因与运行结果
- sig == 0,说明子进程主动退出(场景 1、场景 2),只看 exit_code
- 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) 出来的是混合后的乱数,根本看不到真实退出码,
所以必须用系统提供的宏拆开解读
配套解析宏(对应位图结构)
处理【子进程正常退出】的两个宏
- WIFEXITED(status)
- 功能:判断函数,检查子进程是不是通过 exit()、_exit() 或者 main 函数里return正常走完程序退出的
- 返回 真(非 0) :正常主动退出,此时 status 低 7 位信号自动为 0
- 返回 假(0) :进程是被外部信号强行杀死的,走第二组逻辑
- WEXITSTATUS(status)
- 功能:取值提取函数
只有上面 WIFEXITED(status) 返回为真的时候,它才有意义,作用是从 status 高 8 位中,抠出当初子进程 exit(n) 里面填写的数字n(也就是 0~255 的自定义退出码) - 注意:如果进程是被信号杀死的,调用这个宏拿到的数据是无效垃圾值
处理【子进程被信号异常杀死】的两个宏
- WIFSIGNALED(status)
- 功能:判断子进程是否被信号终止(比如Ctrl+C、段错误 SIGSEGV、kill命令)
- 返回真:被信号干掉
- 返回假 :正常退出
- 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;
}
