
文章目录
-
- 一、进程创建:从`fork`开始的 "分身术"
-
- [1.1 `fork`函数:一次调用,两次返回](#1.1
fork
函数:一次调用,两次返回) - [1.2 写时拷贝:高效的内存共享策略](#1.2 写时拷贝:高效的内存共享策略)
- [1.3 `fork`的常见用法与失败原因](#1.3
fork
的常见用法与失败原因)
- [1.1 `fork`函数:一次调用,两次返回](#1.1
- 二、进程终止:优雅结束与资源释放
-
- [2.1 进程退出的三种场景](#2.1 进程退出的三种场景)
- [2.2 正常终止的三种方式](#2.2 正常终止的三种方式)
- [2.3 退出码:进程的 "遗言"](#2.3 退出码:进程的 "遗言")
- 三、进程等待:避免僵尸进程,获取退出状态
-
- [3.1 为什么需要进程等待?](#3.1 为什么需要进程等待?)
- [3.2 两种等待函数:`wait`与`waitpid`](#3.2 两种等待函数:
wait
与waitpid
) -
- [3.2.1 `wait`函数:简单的阻塞等待](#3.2.1
wait
函数:简单的阻塞等待) - [3.2.2 `waitpid`函数:更灵活的等待](#3.2.2
waitpid
函数:更灵活的等待)
- [3.2.1 `wait`函数:简单的阻塞等待](#3.2.1
- [3.3 解析`status`参数:子进程的 "最后消息"](#3.3 解析
status
参数:子进程的 "最后消息") - [3.4 阻塞与非阻塞等待](#3.4 阻塞与非阻塞等待)
- 四、进程程序替换:让子进程执行全新程序
-
- [4.1 替换原理:不换进程换 "内容"](#4.1 替换原理:不换进程换 "内容")
- [4.2 `exec`函数簇:6 个函数的命名密码](#4.2
exec
函数簇:6 个函数的命名密码) - [4.3 替换后的注意事项](#4.3 替换后的注意事项)
- 总结:进程控制的核心逻辑
进程是操作系统的基本执行单位,而进程控制则是管理进程生命周期的核心技术。无论是创建新进程、终止运行中的进程,还是回收子进程资源、替换进程执行的程序,都离不开关键的系统调用。本文将以通俗易懂的方式,带你梳理进程控制的四大核心环节: 进程创建 、 进程终止 、 进程等待 和 程序替换 ,让你轻松掌握 fork
、 wait
、 exec
等核心函数的工作原理与实践技巧。
一、进程创建:从fork
开始的 "分身术"
当你需要一个进程 "分身" 执行不同任务时,fork
函数就是实现这一功能的关键。它能从已存在的进程(父进程)中创建一个全新的进程(子进程),二者如同 "双胞胎" 却又各自独立。

1.1 fork
函数:一次调用,两次返回
fork
函数的神奇之处在于:一次调用会产生两个返回值。父进程会得到子进程的 PID(进程 ID),而子进程则返回 0。如果调用失败,返回 - 1。
c
#include <unistd.h>
pid_t fork(void); // 父进程返回子进程PID,子进程返回0,失败返回-1
来看一个简单例子:
c
int main() {
printf("Before: pid is %d\n", getpid()); // 父进程ID
pid_t pid = fork();
if (pid == -1) { perror("fork failed"); return 1; }
printf("After: pid is %d, fork return %d\n", getpid(), pid);
return 0;
}
运行结果会出现一行 "Before" 和两行 "After":
plaintext
Before: pid is 1234
After: pid is 1234, fork return 1235 // 父进程输出
After: pid is 1235, fork return 0 // 子进程输出
为什么子进程不打印 "Before"?

因为fork
是在 "Before" 之后调用的,子进程从fork
返回后才开始执行,相当于复制了父进程在fork
时刻的执行状态。所以,fork之前父进程独立执行,fork之后,父子两个执行流分别执行。注意,fork之后,谁先执行完全由调度器决定。
1.2 写时拷贝:高效的内存共享策略
你可能会疑惑:子进程是完全复制父进程的内存吗?如果是这样,内存消耗也太大了。实际上,操作系统采用了写时拷贝(Copy-On-Write) 技术优化这一过程:
- 未写入时:父子进程共享同一份代码和数据(只读),不占用额外内存。
- 需要写入时:操作系统才会为修改的部分创建副本,仅复制被修改的数据,避免不必要的内存浪费。

- 修改前:父子进程的页表(记录虚拟内存与物理内存映射),都指向同一块物理内存(粉色、蓝色块 ),且标记为"只读",防止直接修改共享内容。
- 修改后:假设父进程要改"页表项100"对应的数据,系统会拷贝一份物理内存(新蓝色块 ) 给父进程,父进程页表指向新拷贝,子进程仍指向原物理内存。这样父子数据分离,互不干扰。
这种 "延时复制" 策略大幅提高了内存利用率,也是进程独立性的技术保障(父子进程修改数据互不影响)。
1.3 fork
的常见用法与失败原因
fork
的典型应用场景有两个:
- 父子分工:父进程等待客户端请求,子进程处理具体请求(如 Web 服务器)。
- 执行新程序 :子进程通过
fork
创建后,调用exec
系列函数执行全新程序(后续会详细讲解)。
fork
失败的情况较少见,主要原因包括:
- 系统进程数量超过上限(如 Linux 的
pid_max
限制)。 - 普通用户创建的进程数超过系统对该用户的限制。
二、进程终止:优雅结束与资源释放
进程终止的本质是释放系统资源(包括内核数据结构、内存、文件描述符等)。无论是正常结束还是异常崩溃,进程最终都会走向终止。
2.1 进程退出的三种场景
- 正常结束,结果正确:如程序完成计算并输出正确结果。
- 正常结束,结果错误:如逻辑错误导致计算结果错误,但程序未崩溃。
- 异常终止 :如被信号杀死(如
ctrl+c
触发的SIGINT
信号)、访问非法内存等。
2.2 正常终止的三种方式
方式 | 特点 | 适用场景 |
---|---|---|
return n (从main 函数) |
等同于exit(n) ,由运行时库处理 |
主程序退出 |
exit(int status) |
先执行清理函数(atexit 或on_exit 定义),刷新缓冲区,再调用_exit |
需要释放资源的正常退出 |
_exit(int status) |
直接终止进程,不刷新缓冲区,不执行清理函数 | 紧急退出(如内核态) |

关键区别 :exit
会确保缓冲区数据写入文件,而_exit
直接丢弃缓冲区数据。例如:
c
// 使用exit:会打印"hello"(缓冲区被刷新)
printf("hello");
exit(0);
// 使用_exit:不会打印"hello"(缓冲区数据被丢弃)
printf("hello");
_exit(0);
2.3 退出码:进程的 "遗言"
进程终止时会返回一个退出码(status),用于告知父进程执行结果。Linux 中:
- 退出码为0表示执行成功。
- 非 0 表示执行失败(具体含义由程序定义)。
可以通过echo $?
在 Shell 中查看上一个进程的退出码,常见退出码及含义如下:
退出码 | 含义 | 示例 |
---|---|---|
0 | 成功执行 | ls 命令正常列出文件 |
1 | 通用错误 | 无权限执行命令、除以 0 |
127 | 命令未找到 | 执行不存在的程序 |
130 | 被SIGINT (ctrl+c )终止 |
手动中断运行中的程序 |
143 | 被SIGTERM 终止 |
kill 命令默认信号 |
255/* | 退出码超过了零到255的范围,因此重新计算 | void _exit(int status),虽然status是int,但是仅有低8位可以被父进程所用。所以_exit(-1)时,在终端执行$?发现 返回值是255。 |
通过strerror(int errnum)
函数可获取退出码对应的描述文本,例如strerror(1)
返回 "Operation not permitted"。
三、进程等待:避免僵尸进程,获取退出状态
如果父进程不处理子进程的终止,子进程会变成僵尸进程(Zombie)------ 保留 PID 和退出状态,占用系统资源且无法被杀死。进程等待就是父进程回收子进程资源、获取退出状态的关键机制。
3.1 为什么需要进程等待?
- 之前的文章提到,子进程退出,父进程如果不管不顾,就可能造成'僵尸进程'的问题,进而造成内存泄漏。
- 另外,进程一旦变成僵尸状态,那就刀枪不入,"杀人不眨眼"的kill-9也无能为力,因为谁也没有办法杀死一个已经死去的进程。
- 最后,我们需要知道,父进程派给子进程的任务完成的如何。如,子进程运行完成,结果对还是 不对,或者是否正常退出。
- 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息。
3.2 两种等待函数:wait
与waitpid
3.2.1 wait
函数:简单的阻塞等待
c
#include<sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
// 返回值:成功返回子进程PID,失败返回-1
// 参数:输出型参数,获取子进程退出状态,不关心则可设为NULL
wait
会阻塞等待任意子进程终止,并回收其资源。例如:
c
pid_t pid = fork();
if (pid == 0) { exit(10); } // 子进程退出码10
else {
int status;
wait(&status); // 等待子进程
if (WIFEXITED(status)) { // 判断是否正常退出
printf("子进程退出码:%d\n", WEXITSTATUS(status)); // 输出10
}
}
3.2.2 waitpid
函数:更灵活的等待
waitpid
是wait
的增强版,支持指定等待的子进程、非阻塞等待等:
c
pid_t waitpid(pid_t pid, int *status, int options);
参数 | 含义 |
---|---|
pid |
pid=-1 :等待任意子进程(同wait );pid>0 :等待 PID 为pid 的子进程 |
status |
同wait ,通过宏解析退出状态(如WIFEXITED 、WEXITSTATUS ) |
options |
0 :阻塞等待;WNOHANG :非阻塞等待(若pid 指定的子进程没有结束,则waitpid() 函数返回0,不予以等待。若正常结束,则返回该子进程的ID。) |
返回值:
- 当正常返回的时候
waitpid
返回收集到的子进程的进程ID; - 如果设置了选项
WNOHANG
,而调用中waitpid
发现没有已退出的子进程可收集,则返回0; - 如果调用中出错,则返回-1,这时
errno
会被设置成相应的值以指示错误所在;
- "如果子进程已经退出,调用 wait / waitpid 时,会立即返回,释放资源,拿到子进程退出信息"
- 比如:子进程早就跑完、退出了,父进程这时候调用 wait ,会 立刻得到结果(不用等),还能拿到子进程的退出状态(比如 exit(n) 里的 n ),同时系统会回收子进程占的资源。
- "如果任意时刻调用 wait / waitpid ,子进程还在正常运行,则父进程可能阻塞"
- 比如:子进程还在忙活着(没执行 exit ),父进程调用 wait ,就会 卡住(阻塞),直到子进程退出,父进程才会"被唤醒",继续往下执行。
- "如果不存在该子进程,则立即出错返回"
- 比如:父进程要等的子进程压根没创建,或者已经被回收了,调用 wait 就会直接报错(返回错误码),告诉你"没这个子进程可以等"。

- 左边:父进程阻塞在 wait
- 父进程执行到 parent_code() 里的 wait(&s) 时,子进程还在 child_code() 里跑(没 exit ),所以父进程会卡在 wait 这里不动(阻塞)。
- 右边:子进程退出后,父进程继续
- 子进程执行 exit(n) 退出后,父进程的 wait(&s) 被"唤醒",拿到子进程的退出状态(存在 s 里 ),然后父进程继续往下执行 parent_code() 后续代码(比如图里的 exit(&s) ,实际更常见的是处理退出状态后干别的活 )。
3.3 解析status
参数:子进程的 "最后消息"
status
是一个 32 位整数,其低 16 位存储退出信息,结构如下:
- 高 8 位:正常退出时存储退出码(仅当低 7 位为 0 时有效)。
- 低 7 位:存储终止信号(若进程被信号杀死,则非 0)。

1.wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
2.如果传递NULL,表示不关心子进程的退出状态信息。
3.否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
4.status不能简单的当作整形来看待,要按 二进制位(比特位)拆分 理解,低 16 位藏着关键信息,分两种场景:
(1)子进程"正常退出"(比如自己调用 exit(n) 或 return n )
- 低 16 位里,高 8 位(第 8~15 位) 存的是 exit(n) 的 n (退出状态码 );
- 低 8 位(第 0~7 位) 是 0 (标记"正常退出" )。
(2)子进程"被信号杀死"(比如被 kill 命令、段错误信号终止 )
- 低 16 位里,高 8 位(第 8~15 位) 没用(标记为"未用" );
- 低 7 位(第 0~6 位) 存的是"终止信号编号"(比如 9 是 SIGKILL ,强制杀死 );
- 第 7 位 是 core dump 标志( 1 表示程序崩溃时生成了 core 文件,可用来调试;0表示未生成 )。
测试代码:
c
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
int main( void )
{
pid_t pid;
if ( (pid=fork()) == -1 )
perror("fork"),exit(1);
if ( pid == 0 )
{
sleep(20);
exit(10);
}
else
{
int st;
int ret = wait(&st);
if ( ret > 0 && ( st & 0X7F ) == 0 )
{ // 正常退出
printf("child exit code:%d\n", (st>>8)&0XFF);//把st右移8位,再取低8位,就得到子进程的退出码
}
else if( ret > 0 )
{ // 异常退出
printf("sig code : %d\n", st&0X7F );// st的低7位,这里存的是信号编号,(比如9表示SIGKILL,强制杀死)
}
}
}
测试结果:
bash
# ./a.out #等20秒退出
child exit code:10
# ./a.out #在其他终端kill掉
sig code : 9
通过以下宏可解析status
:
-
WIFEXITED(status)
:若为真,表示子进程正常退出。- 组成及含义: W 是 "wait" 的缩写,表明这是与等待子进程状态相关的操作; IF 表示 "是否",用来进行条件判断; EXITED 意为 "已退出"。
- 功能对应:这个宏用于判断子进程是否正常退出,从命名上就很清晰地表达出,是在等待子进程状态的过程中,判断子进程是否处于已正常退出的状态 。
-
WEXITSTATUS(status)
:若WIFEXITED
为真,返回退出码。- 组成及含义:同样开头的 W 代表 "wait" ; EXIT 表示 "退出" , STATUS 表示 "状态、码值" 。
- 功能对应:该宏的作用是在确认子进程正常退出(即 WIFEXITED 为真时 ),获取子进程的退出码,命名直观地体现了它是获取与子进程退出相关状态码的功能 。
-
WIFSIGNALED(status)
:若为真,表示子进程被信号杀死。- 组成及含义: W 还是 "wait" 的意思 ; IF 依旧是 "是否" 的判断含义 ; SIGNALED 表示 "被信号终止" 。
- 功能对应:它用于判断子进程是否是被信号杀死的,命名直接反映出是在等待子进程状态时,判断子进程是否处于被信号终止的状态 。
-
WTERMSIG(status)
:若WIFSIGNALED
为真,返回终止信号编号。- 组成及含义: W 为 "wait" ; TERM 是 "terminate(终止 )" 的缩写 , SIG 是 "signal(信号 )" 的缩写。
- 功能对应:当确认子进程是被信号杀死(即 WIFSIGNALED 为真时 ),这个宏用于获取导致子进程终止的信号编号 ,从名字能看出其与获取子进程终止信号相关 。
WEXITSTATUS解析status时,只关注低8位(二进制最后8位),超出部分会被截断; WTERMSIG是取低7位。
3.4 阻塞与非阻塞等待
- 阻塞等待 :父进程暂停执行,直到子进程终止(如
waitpid(-1, &status, 0)
)。
进程的阻塞等待方式:
c
int main()
{
pid_t pid;
pid = fork();
if(pid < 0)
{
printf("%s fork error\n",__FUNCTION__);//__FUNCTION__是当前函数名的快捷方式,这里是"main"
return 1;
}
else if( pid == 0 )
{ //child
printf("child is run, pid is : %d\n",getpid());
sleep(5);
exit(257);
}
else
{
int status = 0;
pid_t ret = waitpid(-1, &status, 0);//阻塞式等待,等待5S
printf("this is test for wait\n");
if( WIFEXITED(status) && ret == pid )
{
printf("wait child 5s success, child return code is
:%d.\n",WEXITSTATUS(status));
}
else
{
printf("wait child failed, return.\n");
return 1;
}
}
return 0;
}
运行结果:
c
[root@localhost linux]# ./a.out
child is run, pid is : 45110
this is test for wait
wait child 5s success, child return code is :1.//257的二进制是1 0000 0001,但WEXITSTATUS解析status时只关注低8位,即0000 0001 所以是1
- 非阻塞等待 :父进程不暂停,若子进程未退出则立即返回 0,可用于 "轮询" 等待(如
waitpid(-1, &status, WNOHANG)
)。
进程的非阻塞等待方式:
c
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h> // 提供waitpid等进程等待函数
#include <unistd.h> // 提供fork、sleep、sleep等函数
#include <vector> // 提供C++的vector容器
// 定义函数指针类型,用于指向指向无参数无返回值的函数
typedef void (*handler_t)();
// 存储函数指针的vector容器,用于管理需要执行的任务
std::vector<handler_t> handlers;
// 任务函数1
void fun_one()
{
printf("这是一个临时任务1\n");
}
// 任务函数2
void fun_two()
{
printf("这是一个临时任务2\n");
}
// 加载任务函数到vector容器中
void Load()
{
handlers.push_back(fun_one); // 添加任务1
handlers.push_back(fun_two); // 添加任务2
}
// 执行所有已加载的任务
void handler()
{
// 如果任务容器为空,则先加载任务
if (handlers.empty())
Load();
// 遍历容器,执行每个任务函数
for (auto iter : handlers)
iter(); // 调用函数指针指向的任务
}
int main()
{
pid_t pid; // 用于存储fork的返回值(进程ID)
// 创建子进程
pid = fork();
// fork失败的情况
if (pid < 0)
{
// __FUNCTION__宏表示当前函数名(此处为"main")
printf("%s fork error\n", __FUNCTION__);
return 1; // 异常退出
}
// 子进程执行的代码
else if (pid == 0)
{
printf("child is run, pid is : %d\n", getpid()); // 输出子进程ID
sleep(5); // 子进程休眠5秒,模拟执行任务
exit(1); // 子进程退出,退出码为1
}
// 父进程执行的代码
else
{
int status = 0; // 用于存储子进程的退出状态
pid_t ret = 0; // 用于存储waitpid的返回值
// 循环进行非阻塞等待
do {
// 非阻塞等待任意子进程:
// -1表示等待任意子进程
// status用于接收退出状态
// WNOHANG表示非阻塞模式(子进程未退出则立即返回0)
ret = waitpid(-1, &status, WNOHANG);
// 如果ret为0,说明子进程还在运行
if (ret == 0)
{
printf("child is running\n"); // 提示子进程正在运行
}
// 执行预设的任务(无论子进程是否退出,每次循环都执行)
handler();
} while (ret == 0); // 当子进程还在运行时,继续循环等待
// 子进程退出后,判断退出状态
// WIFEXITED(status):检查子进程是否正常退出
// ret == pid:确保回收的是目标子进程
if (WIFEXITED(status) && ret == pid)
{
// WEXITSTATUS(status):提取子进程的退出码
printf("wait child 5s success, child return code is :%d.\n",
WEXITSTATUS(status));
}
// 等待失败的情况(如子进程被信号终止等)
else
{
printf("wait child failed, return.\n");
return 1; // 异常退出
}
}
return 0; // 正常退出
}
四、进程程序替换:让子进程执行全新程序
fork
创建的子进程默认执行与父进程相同的代码,若想让子进程执行全新程序(如从磁盘加载新的可执行文件),需通过程序替换实现。
4.1 替换原理:不换进程换 "内容"
程序替换通过exec
系列函数实现,其核心是:
- 替换用户空间的代码和数据:丢弃原进程的代码和数据,加载新程序的代码、数据和堆栈。
- 不创建新进程:进程 PID 保持不变,仅替换进程的执行内容。
例如:子进程通过exec
加载/bin/ls
,之后就会执行ls
命令,而非原父进程的代码。

4.2 exec
函数簇:6 个函数的命名密码
exec
系列有 6 个函数,均以exec
开头,区别在于参数格式和是否自动搜索路径:
函数 | 特点 | 示例 |
---|---|---|
execl(path, arg0, arg1, ..., NULL) |
参数列表形式(l =list),需指定程序全路径 |
execl("/bin/ls", "ls", "-l", NULL) |
execlp(file, arg0, ..., NULL) |
自动搜索PATH 环境变量(p =path),无需全路径 |
execlp("ls", "ls", "-l", NULL) |
execle(path, arg0, ..., NULL, envp) |
自定义环境变量(e =env) |
char* env[] = {"PATH=/bin", NULL}; execle("/bin/ls", "ls", NULL, env); |
execv(path, argv) |
参数数组形式(v =vector) |
char* argv[] = {"ls", "-l", NULL}; execv("/bin/ls", argv); |
execvp(file, argv) |
结合v 和p ,参数数组 + 自动搜路径 |
execvp("ls", argv); |
execve(path, argv, envp) |
系统调用原函数(其他函数均调用它),自定义环境变量 | 内核级实现,用户态少直接使用 |
命名解释:
- 这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。
- 如果调用出错则返回-1
- 所以exec函数只有出错的返回值而没有成功的返回值。
命名规律:
l
(list):参数以列表形式传递(如arg0, arg1, NULL
)。v
(vector):参数以数组形式传递(如argv[]
)。p
(path):自动从PATH
环境变量搜索程序路径。e
(env):允许自定义环境变量(默认继承父进程环境)。

exec调用举例如下:
c
#include <unistd.h>
int main()
{
char *const argv[] = {"ps", "-ef", NULL};
char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};
execl("/bin/ps", "ps", "-ef", NULL);
// 带p的,可以使⽤环境变量PATH,⽆需写全路径
execlp("ps", "ps", "-ef", NULL);
// 带e的,需要⾃⼰组装环境变量
execle("ps", "ps", "-ef", NULL, envp);
execv("/bin/ps", argv);
// 带p的,可以使⽤环境变量PATH,⽆需写全路径
execvp("ps", argv);
// 带e的,需要⾃⼰组装环境变量
execve("/bin/ps", argv, envp);
exit(0);
}
事实上,只有execve
是真正的系统调用,其它五个函数最终都调用execve
,所以execve
在man
手册第2节, 其它函数在man
手册第3节。
execve
是 "真·系统调用":直接和操作系统内核交互,负责加载新程序替换当前进程,在man
手册第 2 节(系统调用分类 )。- 其他 5 个(
execlp / execl / execle / execvp / execv
)是 "库函数封装":它们最终会调用execve
,只是帮你简化了参数准备(比如自动找路径、传环境变量 ),在man
手册第 3 节(库函数分类 )。
这些函数之间的关系如下图所示。 下图exec
函数簇一个完整的例子:

4.3 替换后的注意事项
- 替换成功后,原进程的代码和数据被完全丢弃,不会返回(若返回则表示替换失败)。
- 替换不改变进程的 PID、文件描述符(已打开的文件仍可访问)等内核属性。
- 通常与
fork
配合使用:父进程fork
后,子进程调用exec
执行新程序,父进程继续等待或执行其他任务。
总结:进程控制的核心逻辑
进程控制围绕生命周期管理展开,四大环节环环相扣:
- 创建 :
fork
通过写时拷贝高效创建子进程,父子进程 PID 不同是关键标识。 - 终止 :
exit
与_exit
实现正常退出,退出码传递执行结果。 - 等待 :
wait
/waitpid
解决僵尸进程问题,通过status
解析子进程状态。 - 替换 :
exec
系列函数让子进程执行全新程序,实现进程功能的灵活扩展。
掌握这些机制,你就能轻松应对进程管理的各种场景,从简单的多进程程序到复杂的服务器架构,都能游刃有余。记住:进程控制的核心是资源管理 与生命周期把控 ,理解这一点,技术细节便迎刃而解。