Linux 进程 fork 写时拷贝机制与常见退出方式
Linux 进程 fork 系统调用实现写时拷贝机制以优化内存使用。进程终止分为正常退出(main 返回、exit、_exit)和异常退出(信号、未定义行为)。exit 执行用户态清理后调用内核_exit,而_exit 直接终止无清理。退出码用于标识进程状态,0 表示成功,非 0 表示错误。父进程通过 wait 获取子进程退出信息。理解这些机制有助于编写更稳健的多进程程序。

Linux 进程 fork 系统调用实现写时拷贝机制以优化内存使用。进程终止分为正常退出(main 返回、exit、_exit)和异常退出(信号、未定义行为)。exit 执行用户态清理后调用内核_exit,而_exit 直接终止无清理。退出码用于标识进程状态,0 表示成功,非 0 表示错误。父进程通过 wait 获取子进程退出信息。理解这些机制有助于编写更稳健的多进程程序。

Linux 中创建子进程的核心系统调用,以父进程为模板创建独立子进程,调用一次、返回两次。
#include <unistd.h>
pid_t fork(void);
返回值:自进程中返回 0,父进程返回子进程 id,出错返回 -1。
| 返回值 | 所属进程 | 含义说明 |
|---|---|---|
| 0 | 子进程 | 子进程成功创建,返回 0 作为'标识',子进程可通过 getppid() 获取父进程 PID |
| 大于 0 | 父进程 | 返回值为新创建子进程的 PID(进程唯一标识),父进程可通过该值管理子进程 |
| -1 | 父进程 | 子进程创建失败,errno 会被设置为对应错误码(如 EAGAIN 表示资源不足、ENOMEM 表示内存不足) |
当进程调用 fork(),控制流进入内核后,内核会完成以下步骤:
1. 资源分配:为新创建的子进程分配独立的内存空间与内核数据结构(如进程控制块 PCB);
当内核为子进程创建 PCB 时,会立刻完成以下关键字段的初始化(无需拷贝父进程数据):
**PID:**内核从可用的进程 ID 池中为子进程分配唯一的 PID(绝对不会和父进程/其他进程重复);
**PPID:**直接设置为父进程的 PID(明确父子关系,这是进程树的核心);
**进程状态:**初始化为'就绪态'(TASK_RUNNING),等待调度器分配 CPU;
基本权限/优先级:继承父进程的基础优先级,但会标记为独立的进程实体。
这些信息是子进程的'身份标识',必须在创建 PCB 时就确定 —— 否则操作系统根本无法区分'这个空 PCB 属于谁',也无法把它加入进程列表进行管理。
2. 数据拷贝:将父进程的部分核心数据结构(如 PCB 中的进程信息)拷贝至子进程;
在完成资源分配后,内核会将父进程的核心数据结构(如 PCB 中的进程属性信息)拷贝至子进程,而父进程的非核心标识类数据,会通过以下方式完成继承:
3. 进程注册:将子进程添加到系统的进程列表中,使其成为可被调度的进程;
4. 返回与调度:fork() 系统调用返回,子进程进入操作系统调度器的调度队列,等待被分配 CPU 资源。

通常情况下,父子进程的代码段是共享的;若父子进程都不修改数据,数据也会保持共享状态。而当任意一方尝试修改数据时,系统会通过写时拷贝的方式,为修改方单独复制一份数据副本。具体过程可参考下图:

修改前:父子进程虚拟内存结构一致,页表项(如 50、100)均标记'只读',且指向同一块物理内存页,实现内存共享;
当子进程尝试修改数据时:触发'只读'权限的缺页中断,内核仅为子进程拷贝新物理页,并修改子进程页表(指向新页、取消'只读');
**关键修正:**父进程的页表项仍保持'只读 + 指向原物理页',图中'父进程只读标记消失'是简化表达,实际父进程页表无变化,仅修改方(子进程)完成拷贝 + 权限修改。
**核心总结:**写时拷贝是'谁修改、谁拷贝',父进程仅在自己发起修改时才会触发自身页表的拷贝与权限变更。
**小 Tip:**只有原本可修改的数据,在多进程共享后被临时设为只读时,才会触发写时拷贝。
用法 1:父进程生个子进程,分工干活
**场景:**比如你写了个服务器程序,父进程的工作是'蹲守'端口,等客户端发请求;但如果父进程自己又蹲守又处理请求,同一时间只能忙一个活儿,效率很低。
**用法:**父进程收到请求后,立刻 fork 出一个子进程,让子进程去处理这个请求,父进程继续回去蹲守下一个请求。
**核心逻辑:**相当于开'分身'—— 父进程专注'接活',子进程专注'干活',实现同时处理多个请求。
用法 2:fork+exec,启动新程序
**场景:**比如你在终端输入 ls 命令,终端进程(父进程)其实不是自己去执行 ls,而是通过 fork+exec 启动新程序。
**用法:**父进程先 fork 出一个子进程(此时子进程和父进程的代码/资源是一样的),然后子进程调用 exec 函数,把自己的代码/资源替换成新程序(比如 ls)的内容,最终子进程变成了 ls 进程。
核心逻辑:fork 负责'复制一个空壳子进程',exec 负责'把新程序装进这个空壳'—— 相当于用'克隆 + 换内容'的方式启动新程序,比直接创建进程更高效。
原因 1:系统中进程太多
系统能创建的进程总数有上限(比如 PID 总数),哪怕不同用户创建进程,只要总数满了(比如多用户同时大量建进程),所有用户的 fork 都会失败。
原因 2:实际用户的进程数超过限制
仅限制某一个用户能创建的进程数,系统还有空闲进程名额,但该用户自己的进程数超配额(比如单用户循环建进程),只有这个用户的 fork 会失败。
简单说:前者是'整个停车场停满了车',谁来都停不了;后者是'你自己的车位用完了',但停车场还有空位,别人能停就你不行~
while :; do ps ajx | head -1 && ps ajx | grep mytouch | grep -v grep; echo "--------"; sleep 1; done
| 指令片段 | 作用说明 |
|---|---|
| while : | 无限循环(: 是 Shell 中的空命令,始终返回真) |
| do ... done | 循环体,包含要重复执行的操作 |
| ps ajx | head -1 |
| ps ajx | grep mytouch |
| echo "--------" | 输出分隔符,区分每次循环的输出结果 |
| sleep 1 | 每次循环后暂停 1 秒,实现'每秒监控一次'的效果 |
| ;(指令分隔符) | 分隔多个指令,无论前一个指令是否成功,后续指令都会执行 |
| &&(逻辑与) | 连接多个指令,仅当前一个指令执行成功,才会执行后续指令 |
进程终止是指正在运行的进程停止执行、释放占用的系统资源(CPU、内存、文件句柄等),并从系统的进程列表中移除的过程,是操作系统管理进程生命周期的关键环节之一。
它是进程生命周期的最终阶段,分为'正常终止'和'异常终止'两类,既可以由进程主动触发,也可以由外部(操作系统、用户)强制触发。
进程完成自身所有预定任务后,主动调用退出接口(如 Linux 下的 exit()/_exit()、C 语言的 return)结束运行,是进程'自然完成生命周期'的表现。对应进程退出场景:
进程未完成预定任务,被外部因素强制中断运行,核心是'非自愿结束',分为两类:
人为干预:用户通过
kill/kill -9命令、Ctrl+C等发送终止信号;
系统干预:操作系统因进程出错(如内存越界、除零错误)、资源耗尽、达到运行时限等,主动发送终止信号。
总结:
✅ :只要进程能捕获错误(比如内存开辟失败),并主动走完错误处理逻辑,最后通过 return/exit() 等主动退出,就属于正常结束(主动触发)。
❌ 反例:如果内存开辟失败后,程序没做任何捕获,直接崩溃(系统发送 SIGSEGV 信号强制终止),那就是异常结束(被动触发)。
**核心定义:**进程终止时返回给操作系统的一个整数(0~255),用来标识进程的终止状态。
默认规则:
**1. 0:**代表进程正常终止(执行成功);
2. 非 0 值(如 1、-1):
可代表两种情况:进程正常终止但执行失败(主动捕获错误后退出,比如内存开辟失败后
return -1);进程异常/错误终止(被外部强制中断或未捕获错误崩溃);(具体数值可自定义,标识不同错误类型)。
**实际用途:**后续程序/脚本可通过 $?(Shell 中)获取这个值,判断进程的执行结果(比如脚本里根据退出码决定是否继续执行)。
注:
$?是 Shell 内置变量,仅能获取'最近一次执行的进程/命令'的退出码,且会被下一次命令覆盖,需在目标进程执行后立即使用。


以下是通过 strerror 函数打印了当前系统对应的错误码描述:
strerror(i) 的作用是将整数错误码 i 转换为对应的文字描述(比如错误码 0 对应'Success',错误码 2 对应'No such file or directory')。
由于系统错误码的数量可能因操作系统版本略有差异,这里循环打印了 0 到 255 的错误码描述,便于直观查看不同错误码对应的含义。


这是 main 函数正常返回的典型结构:程序完整执行代码逻辑后,通过 return 主动退出,对应的退出码为 0(标识执行成功)。


这是正常终止但执行失败的典型案例:程序完整执行了代码逻辑(属于主动退出的正常终止),但通过 return 1 返回了非 0 的退出码,标识'执行结果不符合预期(即执行失败)'。
#include <unistd.h>
void exit(int status);
参数:status 是进程的退出码(规则与 main 函数的 return 一致:0 代表执行成功,非 0 代表执行失败/错误)。
什么时候用 exit?
exit 是主动终止进程的函数,常用于:
- 程序执行到非
main函数的位置(比如子函数),需要直接终止整个进程;- 程序执行过程中遇到无法继续的错误(比如文件打开失败),需主动退出并返回错误码。
exit 执行后还会自动完成一系列'进程清理操作',完整流程如下:

- 执行用户通过
atexit等函数注册的自定义清理函数(比如释放资源、记录日志);- 自动冲刷缓冲区(把内存中未写入文件/终端的数据强制输出)、关闭已打开的流(比如文件、标准输入输出);
- 最终调用内核的
_exit()函数,将进程的退出码传递给操作系统,完成进程的终止。
简单说:exit 不是'直接杀死进程',而是先帮程序'收尾(清理资源、刷缓存)',再通知内核终止进程。
exit 演示


| 维度 | return | exit |
|---|---|---|
| 本质 | 函数返回(仅退出当前函数) | 进程终止(退出整个程序) |
| 生效范围 | 所有函数可用,在 main 中退进程 | 任意函数调用都直接退进程 |
| 额外操作 | main 中 return 会隐式调 exit | 主动执行清理(刷缓存/关流)+ 调内核_exit |
**一句话总结:**return 是'函数级返回',仅 main 里 return 等效 exit;exit 是'进程级终止',在哪调都直接结束程序,且会先做资源清理。
#include <unistd.h>
void _exit(int status);
参数:status 定义了进程的终止状态,父进程通过 wait 来获取该值。
**本质:**是直接调用内核的系统调用,无任何用户态清理操作(不刷缓存、不关文件流、不执行自定义清理函数);
和 exit 的区别:exit 是'先清理再终止',_exit 是'直接终止';
**平时用吗?**几乎不用 —— 只有需要'立刻终止、放弃所有清理'的极端场景(比如子进程退出)才会用;
参数作用:status 是退出码,和 exit 规则一致(0 成功/非 0 失败),父进程可以通过 wait 函数获取这个值。
总结:
_exit 是'进程终止的最后一步内核操作',平时用 exit 就够,_exit 是底层工具(一般不用)。

ctrl + C 会向当前进程发送 SIGINT(中断信号),触发进程的异常退出

kill -9 PID 会向指定进程发送 SIGKILL(强制终止信号),这是无法被进程捕获的信号,直接强制终止进程


对空指针进行解引用是非法操作,会触发系统的内存保护机制,系统会发送 SIGSEGV 信号强制中断程序 —— 此时程序未执行到 return 0,属于'被动终止',是典型的异常终止。
从'程序运行的逻辑主体'来讲,是子进程的父进程直接关心子进程的运行/退出情况;但从'最终需求方'来讲,本质是用户(开发者/运维人员)借助父进程这个'媒介',来获取子进程的退出码、终止原因等关键信息 —— 父进程是承接用户需求的载体,用户才是最终想知道子进程运行情况的人。
简单说:父进程是'执行者'(负责捕获子进程信息),用户是'需求者'(想通过父进程拿到子进程的结果),我们平时说'父进程关心子进程',本质是用户通过父进程实现对子女程运行状态的掌控。
退出码是系统/程序间快速判断结果的标准化数字信号(比如 0 = 成功),能让脚本、父进程等自动化判断;打印错误是给人看的文本,没法高效做自动化处理。且有些场景(后台进程)没法打印,但退出码一定存在 —— 两者是'自动化信号 + 人工信息'的互补关系,退出码的'轻量、标准化'是打印替代不了的。
| 终止类型 | 触发特征 | 核心关注对象 | 核心逻辑 | | --- | --- | --- | | 正常终止 | 程序主动结束 | 退出码 | 1. 退出码可自定义(0-255),0 表示执行成功,非 0 标识特定业务错误(如参数错误、文件缺失);2. 用于向用户/父进程传递执行结果,定位业务层面问题。 | | 异常终止 | 程序被动结束 | 终止信号 | 1. 信号由人为(kill/Ctrl+C)或系统内核(非法操作/资源耗尽)发送;2. 信号直接对应异常根因(如 SIGSEGV = 内存越界、SIGINT = 终端中断),无需关注退出码。 |
退出码:程序员自己设置,标记进程正常运行后的最终结果(0 = 成功,非 0 = 业务逻辑错误);
错误码(errno):系统自动赋值,标记函数调用失败的原因(如 fork() 失败、文件打不开);
终止信号:内核/外部程序发送,标记进程异常退出的原因(如段错误、被强制杀死)。
三者各司其职,覆盖了进程从创建到结束的全流程错误/结果标识~


微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online