【Linux系统篇】从 fork 到 WNOHANG:进程创建与等待机制详解
2026/7/23 21:03:16 网站建设 项目流程

🔥个人主页:爱和冰阔乐
📚专栏传送门:《数据结构与算法》 、C++
🐶学习方向:C++方向学习爱好者
⭐人生格言:得知坦然 ,失之淡然


🏠博主简介

文章目录

  • 前言
  • 一、进程创建
    • 1.1 fork函数初识
    • 1.2 写时拷贝
    • 1.3 fork创建子进程的用途
    • 1.4 fork失败的原因
  • 二、进程终止
    • 2.1 进程退出的三种场景
    • 2.2 main函数的返回值
    • 2.3 strerror和errno
    • 2.4 异常退出要看信号
    • 2.5 return、exit与_exit
  • 三、进程等待
    • 3.1 为什么需要进程等待
    • 3.2 wait等待子进程
    • 3.3 waitpid等待指定子进程
  • 四、status如何保存退出信息
    • 4.1 为什么exit(1)打印成256
    • 4.2 不要在业务代码里手写位解析
    • 4.3 异常退出看终止信号
    • 4.4 使用系统宏读取退出结果
  • 五、非阻塞等待
    • 5.1 WNOHANG的返回值
    • 5.2 非阻塞等待代码
    • 5.3 等待期间处理其他任务
  • 总结

前言

进程控制这一部分主要讲三件事:怎么创建子进程、进程怎么退出、父进程怎么回收子进程。

fork创建子进程以后,父子进程会各自执行。子进程完成任务后,可能正常返回,也可能因为异常收到信号。父进程需要通过waitwaitpid获取结果并回收资源。

本文仍按学习时的顺序展开,从fork、写时拷贝讲到退出码、进程等待和非阻塞等待。


一、进程创建

1.1 fork函数初识

Linux中使用fork从当前进程创建一个子进程:

#include<unistd.h>pid_tfork(void);

fork的返回值要结合执行位置判断:

执行位置返回值用途
子进程0进入子进程分支
父进程子进程PID记录并管理新建的子进程
创建失败-1根据错误信息处理失败

父进程返回子进程PID,是为了后续能够指定等待和管理这个子进程;子进程返回0,则可以直接作为分支判断标记。

一个函数看起来返回了两次,是因为fork之后出现了父子两个执行流。fork之前只有父进程执行,fork之后父子进程都从调用位置继续向后运行,谁先执行由调度器决定。

进入内核后,OS需要为子进程分配内核数据结构、复制父进程的部分进程信息、把子进程加入进程列表,最后再让调度器选择执行。

1.2 写时拷贝

创建子进程时,父子进程可以共享只读代码。数据需要保持独立,但操作系统不会在fork时立刻复制全部数据页。

刚创建完成时,父子页表先指向同一批物理数据页,并把相关页面设置成写时拷贝状态。某一方真正写入时,CPU触发缺页异常。OS确认虚拟地址合法、页表映射正常,而且这次访问属于写时拷贝后,再申请新的物理页、复制数据并修改页表映射。

因此准确说法是写时拷贝,不是“写实拷贝”。它不是普通程序错误,而是OS利用页权限实现延迟复制。

为什么要写时拷贝?如果fork时把父进程数据完整复制一份,数据越多,创建子进程越慢。子进程通常只修改少量数据,全部复制会让大量未修改内容在内存中重复保存。

写时拷贝把“创建时全部复制”改成“真正修改时再复制”,减少了创建时间,也提高了物理内存利用率。

结论:fork时不立即复制全部数据,真正写入时才复制对应物理页。

1.3 fork创建子进程的用途

子进程被创建出来,一定是为了完成某个任务。常见用途有两类:

  1. 子进程继续执行当前程序,通过if/else分流,让父子进程完成不同任务;
  2. 子进程调用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怎么判断它的执行情况?先看下面的例子:

#include<stdio.h>intmain(){// printf("hello world\n");FILE*fp=fopen("log.txt","r");if(fp==NULL)return1;fclose(fp);return0;}

这里使用echo $?查看最近一个前台进程的退出码。

进程退出后,内核会保留退出状态,等待父进程读取。

2.3 strerror和errno

怎样把错误编号转换成文字描述?C标准库提供了strerror,可以先查看man 3 strerror

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

打印结果能看到当前系统支持的错误描述。具体数量与系统环境有关,不能把某台机器的结果当成所有Linux系统的固定值。

系统调用失败时通常会设置errno。程序可以根据自己的约定返回对应值,但Shell退出状态通常只保留低8位,不适合直接承载任意大的错误编号。

退出码由程序自己约定,不要求必须对应C标准库错误码。例如程序可以使用13表示某个自定义错误。

退出码只能描述正常结束后的执行结果。若进程异常终止,还要继续看终止信号。

2.4 异常退出要看信号

下面先看一个异常退出场景,信号部分后续再详细介绍:

#include<stdio.h>intmain(){inta=10;a/=0;return89;}

进程在执行return 89之前已经异常终止,所以89没有机会成为退出码。异常退出通常表示进程收到了信号

2.5 return、exit与_exit

进程退出常见有下面几种方式:

  1. main中执行return,主函数结束,随后进程退出。普通函数中的return只结束当前函数;

  2. 在代码任意位置调用exit(code),整个进程直接结束。

// 在任意函数中结束整个进程exit(23);

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

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

_exit()直接终止调用它的进程。

exit是C标准库接口,_exit是系统调用层接口。下面比较两者的差异。

调用exit时,若printf没有换行,内容可能先留在stdio缓冲区。进程退出前,exit会刷新缓冲区,因此最终仍能看到输出。若字符串带换行,在终端环境下通常会提前刷新。

#include<stdio.h>#include<unistd.h>#include<stdlib.h>intmain(){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等待子进程

父进程创建子进程后,需要通过waitwaitpid等接口等待子进程。这个过程就是进程等待。

下面就是僵尸进程的代码:

#include<stdio.h>#include<errno.h>#include<string.h>#include<stdlib.h>#include<unistd.h>#include<sys/types.h>intmain(){pid_tid=fork();if(id==0){//子intcnt=5;while(cnt){printf("我是一个子进程,pid:%d,ppid:%d\n",getpid(),getppid());sleep(1);cnt--;}exit(0);}//父进程sleep(100);return0;}

pid_t wait(int *status)等待任意一个子进程。成功时返回被回收子进程的PID,并把退出信息写入status;失败时返回-1。不关心退出信息时可以传NULL

#include<stdio.h>#include<errno.h>#include<string.h>#include<stdlib.h>#include<unistd.h>#include<sys/types.h>#include<sys/wait.h>intmain(){pid_tid=fork();if(id==0){//子intcnt=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_trid=wait(NULL);if(rid>0)printf("wait success,rid:%d\n",rid);//rid==子进程pidsleep(10);return0;}

可以使用下面的Shell循环持续监测。父进程执行wait后,退出的子进程不会继续保持僵尸状态:

while:;dopsajx|head-1psajx|grep'[c]ode'sleep1done

现象:如果等待子进程,子进程没有退出,父进程会阻塞在wait调用处,这里与scanf场景类似

这个例子可以按时间看:前5秒父子进程同时运行;子进程退出后,在父进程调用wait前短暂进入僵尸状态;wait返回后,子进程被回收。

3.3 waitpid等待指定子进程

waitpid同样用于等待子进程。成功时返回子进程PID,status是输出型参数,options控制阻塞方式。传入指定PID时,父进程只等待对应子进程。

// 等待任意子进程pid_trid=waitpid(-1,NULL,0);// 等待指定子进程pid_trid=waitpid(id,NULL,0);

父进程没有子进程、传入PID不属于当前进程或参数错误时,waitpid会失败。

四、status如何保存退出信息

4.1 为什么exit(1)打印成256

父进程通过status取得子进程退出信息。它不只保存main的返回值,还要同时表示进程是否正常退出、终止信号等状态。

不要直接把原始status当退出码。先判断退出类型,再提取对应信息。

intstatus=0;pid_trid=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>提供的宏。

if(WIFEXITED(status)){printf("exit code: %d\n",WEXITSTATUS(status));}elseif(WIFSIGNALED(status)){printf("signal: %d\n",WTERMSIG(status));}

这时就能解释bash为什么能拿到退出码。父子进程地址空间彼此独立,子进程修改全局变量会发生写时拷贝,父进程不能靠普通变量直接读取结果,只能通过OS提供的等待接口取得。

4.3 异常退出看终止信号

Linux中有多种信号,可以使用kill -l查看。

前面使用过kill -9。列表左侧是信号编号,右侧是信号名称,这些名称在头文件中以宏定义形式存在。

grep"#define SIGKILL"/usr/include/*-Rvim/usr/include/asm/signa

信号列表中没有0号终止信号。进程异常终止时,status会记录对应的终止信号。

若进程正常退出,终止信号部分为0;若进程被信号终止,退出码就没有业务意义。

手工执行status & 0x7F可以帮助理解,但正式代码应使用WIFSIGNALEDWTERMSIG

由于进程具有独立性,父进程必须通过系统调用取得子进程退出信息。

waitwaitpid从哪里取得信息?子进程退出后会释放大部分资源,但内核仍保留必要的进程结构和退出状态,等待父进程读取。

4.4 使用系统宏读取退出结果

OS不希望用户在业务代码里反复手写位运算,因此提供了对应宏:

  • WEXITSTATUS:获取正常退出码;

  • WIFEXITED:判断进程是否正常退出;

  • WIFSIGNALED:判断进程是否被信号终止;

  • WTERMSIG:获取终止信号。

五、非阻塞等待

5.1 WNOHANG的返回值

waitpid的最后一个参数有多个选项。这里重点看WNOHANG:子进程尚未结束时立即返回,不阻塞父进程。

非阻塞等待需要配合轮询。等待方可以在子进程运行期间继续处理自己的任务。

waitpid使用WNOHANG时,可以按返回值区分三种结果:

返回值含义
> 0已回收对应子进程,返回值就是子进程PID
= 0子进程仍在运行,本次立即返回
< 0等待失败,需要检查参数和错误信息

5.2 非阻塞等待代码

下面看非阻塞等待的完整判断:

while(1){intstatus=0;pid_trid=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时,可以周期性执行自己的任务:

if(rid==0){Download();Log();sleep(1);}

总结

fork通过写时拷贝降低创建子进程的成本;returnexit_exit决定进程如何结束;waitwaitpid负责获取退出结果并回收子进程。

正常退出先看退出码,异常退出先看终止信号。解析status时优先使用系统提供的宏,不要在业务代码里依赖手写位运算。

使用WNOHANG后,父进程可以在等待期间继续处理任务,但轮询需要控制频率,不能写成一直占用CPU的空转循环。

资源分享
【Linux进程】程序地址空间详解:虚拟地址、页表、写时拷贝与mm_struct
【Linux排障实战】Docker容器启动失败怎么查:端口、日志、权限与网络
【Linux系统编程】环境变量深度解析——从 fork 继承到 export 内建命令,两张表打通进程上下文

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询