
🔥个人主页:爱和冰阔乐
🐶学习方向:C++方向学习爱好者
⭐人生格言:得知坦然 ,失之淡然

🏠博主简介

文章目录
- 前言
- 一、进程创建
-
- [1.1 fork函数初识](#1.1 fork函数初识)
- [1.2 写时拷贝](#1.2 写时拷贝)
- [1.3 fork创建子进程的用途](#1.3 fork创建子进程的用途)
- [1.4 fork失败的原因](#1.4 fork失败的原因)
- 二、进程终止
-
- [2.1 进程退出的三种场景](#2.1 进程退出的三种场景)
- [2.2 main函数的返回值](#2.2 main函数的返回值)
- [2.3 strerror和errno](#2.3 strerror和errno)
- [2.4 异常退出要看信号](#2.4 异常退出要看信号)
- [2.5 return、exit与_exit](#2.5 return、exit与_exit)
- 三、进程等待
-
- [3.1 为什么需要进程等待](#3.1 为什么需要进程等待)
- [3.2 wait等待子进程](#3.2 wait等待子进程)
- [3.3 waitpid等待指定子进程](#3.3 waitpid等待指定子进程)
- 四、status如何保存退出信息
-
- [4.1 为什么exit(1)打印成256](#4.1 为什么exit(1)打印成256)
- [4.2 不要在业务代码里手写位解析](#4.2 不要在业务代码里手写位解析)
- [4.3 异常退出看终止信号](#4.3 异常退出看终止信号)
- [4.4 使用系统宏读取退出结果](#4.4 使用系统宏读取退出结果)
- 五、非阻塞等待
-
- [5.1 WNOHANG的返回值](#5.1 WNOHANG的返回值)
- [5.2 非阻塞等待代码](#5.2 非阻塞等待代码)
- [5.3 等待期间处理其他任务](#5.3 等待期间处理其他任务)
- 总结
前言
进程控制这一部分主要讲三件事:怎么创建子进程、进程怎么退出、父进程怎么回收子进程。
fork 创建子进程以后,父子进程会各自执行。子进程完成任务后,可能正常返回,也可能因为异常收到信号。父进程需要通过 wait 或 waitpid 获取结果并回收资源。
本文仍按学习时的顺序展开,从 fork、写时拷贝讲到退出码、进程等待和非阻塞等待。
一、进程创建
1.1 fork函数初识
Linux中使用 fork 从当前进程创建一个子进程:
c
#include <unistd.h>
pid_t fork(void);
fork 的返回值要结合执行位置判断:
| 执行位置 | 返回值 | 用途 |
|---|---|---|
| 子进程 | 0 |
进入子进程分支 |
| 父进程 | 子进程PID | 记录并管理新建的子进程 |
| 创建失败 | -1 |
根据错误信息处理失败 |
父进程返回子进程PID,是为了后续能够指定等待和管理这个子进程;子进程返回0,则可以直接作为分支判断标记。
一个函数看起来返回了两次,是因为 fork 之后出现了父子两个执行流。fork 之前只有父进程执行,fork 之后父子进程都从调用位置继续向后运行,谁先执行由调度器决定。
进入内核后,OS需要为子进程分配内核数据结构、复制父进程的部分进程信息、把子进程加入进程列表,最后再让调度器选择执行。
1.2 写时拷贝
创建子进程时,父子进程可以共享只读代码。数据需要保持独立,但操作系统不会在 fork 时立刻复制全部数据页。
刚创建完成时,父子页表先指向同一批物理数据页,并把相关页面设置成写时拷贝状态。某一方真正写入时,CPU触发缺页异常。OS确认虚拟地址合法、页表映射正常,而且这次访问属于写时拷贝后,再申请新的物理页、复制数据并修改页表映射。
因此准确说法是写时拷贝,不是"写实拷贝"。它不是普通程序错误,而是OS利用页权限实现延迟复制。
为什么要写时拷贝?如果 fork 时把父进程数据完整复制一份,数据越多,创建子进程越慢。子进程通常只修改少量数据,全部复制会让大量未修改内容在内存中重复保存。
写时拷贝把"创建时全部复制"改成"真正修改时再复制",减少了创建时间,也提高了物理内存利用率。
结论:
fork时不立即复制全部数据,真正写入时才复制对应物理页。
1.3 fork创建子进程的用途
子进程被创建出来,一定是为了完成某个任务。常见用途有两类:
- 子进程继续执行当前程序,通过
if/else分流,让父子进程完成不同任务; - 子进程调用
exec系列接口执行全新的程序。Shell运行外部命令时,通常就是先创建子进程,再让子进程替换成目标程序。
1.4 fork失败的原因
fork 失败时返回 -1。常见原因是系统资源不足,例如无法继续分配内核数据结构、达到进程数量限制或内存压力过大。失败后不能继续把返回值当成父进程分支处理。
二、进程终止
进程终止的本质,是OS释放该进程占用的内核数据结构、代码和数据等资源。
2.1 进程退出的三种场景
- 代码运行完毕,结果正确;
- 代码运行完毕,但结果不正确;
- 代码异常终止,例如除零、野指针等情况。
判断顺序: 正常退出看退出码,异常退出看终止信号。
子进程也是进程,执行结果同样分为这三种情况。子进程被创建是为了完成任务,父进程需要知道任务是否完成、结果是否正确,以及有没有异常退出。
2.2 main函数的返回值
写C/C++代码时,我们经常在 main 末尾写 return 0。这个返回值给谁?它又表示什么?
main 是程序入口。return 0 通常表示程序执行成功,非0表示任务失败。不同非0值可以对应不同错误原因,具体含义由程序自己约定。
只有 main 有"执行到函数末尾等价于返回0"的特殊规则。普通的非 void 函数如果没有返回值,不能简单理解成默认返回0。
子进程没有打印内容时,bash怎么判断它的执行情况?先看下面的例子:
c
#include<stdio.h>
int main()
{
// printf("hello world\n");
FILE*fp=fopen("log.txt","r");
if(fp==NULL) return 1;
fclose(fp);
return 0;
}
这里使用 echo $? 查看最近一个前台进程的退出码。

进程退出后,内核会保留退出状态,等待父进程读取。
2.3 strerror和errno
怎样把错误编号转换成文字描述?C标准库提供了 strerror,可以先查看 man 3 strerror。

c
#include<stdio.h>
#include<string.h>
int main()
{
int i=0;
//并不清楚多少个,随机写的200
for(;i<200;i++)
{
printf("%d->%s\n",i,strerror(i));
}
}


打印结果能看到当前系统支持的错误描述。具体数量与系统环境有关,不能把某台机器的结果当成所有Linux系统的固定值。
系统调用失败时通常会设置 errno。程序可以根据自己的约定返回对应值,但Shell退出状态通常只保留低8位,不适合直接承载任意大的错误编号。

退出码由程序自己约定,不要求必须对应C标准库错误码。例如程序可以使用13表示某个自定义错误。
退出码只能描述正常结束后的执行结果。若进程异常终止,还要继续看终止信号。
2.4 异常退出要看信号
下面先看一个异常退出场景,信号部分后续再详细介绍:
c
#include<stdio.h>
int main()
{
int a=10;
a/=0;
return 89;
}

进程在执行 return 89 之前已经异常终止,所以89没有机会成为退出码。异常退出通常表示进程收到了信号。
2.5 return、exit与_exit
进程退出常见有下面几种方式:
-
在
main中执行return,主函数结束,随后进程退出。普通函数中的return只结束当前函数; -
在代码任意位置调用
exit(code),整个进程直接结束。
c
// 在任意函数中结束整个进程
exit(23);

c
#include<stdio.h>
#include<string.h>
#include<errno.h>
#include<stdlib.h>
void fun()
{
printf("fun begin!\n");
exit(40);
printf("fun end!\n");
}
int main()
{
fun();
printf("man!\n");
}

任何位置调用 exit 都会结束整个进程,这与普通函数中的 return 不同。退出状态由内核保存,父进程通过等待接口读取。
_exit() 直接终止调用它的进程。

exit 是C标准库接口,_exit 是系统调用层接口。下面比较两者的差异。
调用 exit 时,若 printf 没有换行,内容可能先留在stdio缓冲区。进程退出前,exit 会刷新缓冲区,因此最终仍能看到输出。若字符串带换行,在终端环境下通常会提前刷新。
c
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
printf("man\n");
sleep(2);
exit(23);
}
把同样代码改成 _exit 后,带换行时可能已经触发终端行缓冲刷新;不带换行时,内容仍留在用户态缓冲区,_exit 不负责刷新,所以看不到对应输出。
exit 会执行C库清理并刷新stdio缓冲区;_exit 不会执行这些用户态清理,直接进入进程终止流程。
exit 在进入 _exit 之前,通常还会调用通过 atexit 注册的清理函数,并关闭标准I/O流。
因此这里观察到的是C库缓冲区 差异。若缓冲区完全由内核维护,调用 _exit 时就不应该出现这种现象。
| 方式 | 作用范围 | 是否执行C库清理 |
|---|---|---|
return |
结束当前函数;在 main 中会结束进程 |
由 main 返回时正常完成 |
exit(code) |
结束整个进程 | 是,会刷新stdio缓冲区 |
_exit(code) |
直接终止整个进程 | 否,不刷新stdio缓冲区 |
三、进程等待
3.1 为什么需要进程等待
子进程退出后,父进程如果一直不处理,就可能形成僵尸进程。
- 僵尸进程已经结束,
kill -9也无法再次终止它; - 父进程需要知道子进程任务是否完成、结果是否正确;
- 父进程通过等待回收子进程资源,并按需取得退出信息。
3.2 wait等待子进程
父进程创建子进程后,需要通过 wait 或 waitpid 等接口等待子进程。这个过程就是进程等待。

下面就是僵尸进程的代码:
c
#include<stdio.h>
#include<errno.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
pid_t id=fork();
if(id==0)
{
//子
int cnt=5;
while(cnt)
{
printf("我是一个子进程,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
cnt--;
}
exit(0);
}
//父进程
sleep(100);
return 0;
}

pid_t wait(int *status) 等待任意一个子进程。成功时返回被回收子进程的PID,并把退出信息写入 status;失败时返回 -1。不关心退出信息时可以传 NULL。
c
#include<stdio.h>
#include<errno.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
pid_t id=fork();
if(id==0)
{
//子
int cnt=5;
while(cnt)
{
printf("我是一个子进程,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
cnt--;
}
exit(0);
}
//父进程
// sleep(100);
// 情况:
// 1.子进程退出,wait回收解决僵尸进程
// 2.五秒以内子进程没有退出,那么wait在干嘛
sleep(10);
pid_t rid=wait(NULL);
if(rid>0)
printf("wait success,rid:%d\n",rid);//rid==子进程pid
sleep(10);
return 0;
}
可以使用下面的Shell循环持续监测。父进程执行 wait 后,退出的子进程不会继续保持僵尸状态:
bash
while :; do
ps ajx | head -1
ps ajx | grep '[c]ode'
sleep 1
done

现象:如果等待子进程,子进程没有退出,父进程会阻塞在wait调用处,这里与scanf场景类似
这个例子可以按时间看:前5秒父子进程同时运行;子进程退出后,在父进程调用 wait 前短暂进入僵尸状态;wait 返回后,子进程被回收。
3.3 waitpid等待指定子进程
waitpid 同样用于等待子进程。成功时返回子进程PID,status 是输出型参数,options 控制阻塞方式。传入指定PID时,父进程只等待对应子进程。

c
// 等待任意子进程
pid_t rid = waitpid(-1, NULL, 0);
// 等待指定子进程
pid_t rid = waitpid(id, NULL, 0);
父进程没有子进程、传入PID不属于当前进程或参数错误时,waitpid 会失败。
四、status如何保存退出信息
4.1 为什么exit(1)打印成256
父进程通过 status 取得子进程退出信息。它不只保存 main 的返回值,还要同时表示进程是否正常退出、终止信号等状态。
不要直接把原始
status当退出码。 先判断退出类型,再提取对应信息。
c
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid > 0) {
printf("raw status: %d\n", status);
}
这里设置的退出码是1,但原始 status 打印成256,这是为什么?

status 不是单纯的退出码,它同时编码正常退出、退出码、终止信号和core dump等信息。常见实现中,退出码位于较高的8位,所以 exit(1) 对应的原始值会表现为 1 << 8,也就是256。
4.2 不要在业务代码里手写位解析
右移8位可以帮助理解常见布局,但正式代码应优先使用 <sys/wait.h> 提供的宏。
c
if (WIFEXITED(status)) {
printf("exit code: %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("signal: %d\n", WTERMSIG(status));
}

这时就能解释bash为什么能拿到退出码。父子进程地址空间彼此独立,子进程修改全局变量会发生写时拷贝,父进程不能靠普通变量直接读取结果,只能通过OS提供的等待接口取得。
4.3 异常退出看终止信号
Linux中有多种信号,可以使用 kill -l 查看。

前面使用过 kill -9。列表左侧是信号编号,右侧是信号名称,这些名称在头文件中以宏定义形式存在。
bash
grep "#define SIGKILL" /usr/include/* -R
vim /usr/include/asm/signa

信号列表中没有0号终止信号。进程异常终止时,status 会记录对应的终止信号。
若进程正常退出,终止信号部分为0;若进程被信号终止,退出码就没有业务意义。
手工执行 status & 0x7F 可以帮助理解,但正式代码应使用 WIFSIGNALED 和 WTERMSIG。
由于进程具有独立性,父进程必须通过系统调用取得子进程退出信息。
wait 和 waitpid 从哪里取得信息?子进程退出后会释放大部分资源,但内核仍保留必要的进程结构和退出状态,等待父进程读取。

4.4 使用系统宏读取退出结果
OS不希望用户在业务代码里反复手写位运算,因此提供了对应宏:
-
WEXITSTATUS:获取正常退出码;
-
WIFEXITED:判断进程是否正常退出;
-
WIFSIGNALED:判断进程是否被信号终止;
-
WTERMSIG:获取终止信号。
五、非阻塞等待
5.1 WNOHANG的返回值
waitpid 的最后一个参数有多个选项。这里重点看 WNOHANG:子进程尚未结束时立即返回,不阻塞父进程。
非阻塞等待需要配合轮询。等待方可以在子进程运行期间继续处理自己的任务。
waitpid 使用 WNOHANG 时,可以按返回值区分三种结果:
| 返回值 | 含义 |
|---|---|
> 0 |
已回收对应子进程,返回值就是子进程PID |
= 0 |
子进程仍在运行,本次立即返回 |
< 0 |
等待失败,需要检查参数和错误信息 |
5.2 非阻塞等待代码
下面看非阻塞等待的完整判断:
c
while (1) {
int status = 0;
pid_t rid = waitpid(id, &status, WNOHANG);
if (rid > 0) {
if (WIFEXITED(status)) {
printf("exit code: %d\n", WEXITSTATUS(status));
}
break;
}
if (rid == 0) {
printf("子进程还在运行\n");
sleep(1);
continue;
}
perror("waitpid");
break;
}

5.3 等待期间处理其他任务
父进程在 waitpid 返回0时,可以周期性执行自己的任务:
c
if (rid == 0) {
Download();
Log();
sleep(1);
}

总结
fork 通过写时拷贝降低创建子进程的成本;return、exit 和 _exit 决定进程如何结束;wait 与 waitpid 负责获取退出结果并回收子进程。
正常退出先看退出码,异常退出先看终止信号。解析 status 时优先使用系统提供的宏,不要在业务代码里依赖手写位运算。
使用 WNOHANG 后,父进程可以在等待期间继续处理任务,但轮询需要控制频率,不能写成一直占用CPU的空转循环。
资源分享
【Linux进程】程序地址空间详解:虚拟地址、页表、写时拷贝与mm_struct