【Linux】从 fork 到进程终止:写时拷贝细节与常见退出方式

前言

如果你已经了解了fork创建子进程的基础用法,那这篇内容会带你深入fork的 "后半段":从fork背后的写时拷贝机制 (它是如何高效创建子进程的?),到进程生命周期的收尾 ------常见的进程退出方式(不同退出方法有什么区别?该怎么选?)。

比起零散的知识点罗列,这篇会聚焦 "从创建到终止" 的完整逻辑,帮你把fork的底层原理和进程退出的实践细节串联起来,避免只知 "怎么用" 却不懂 "为什么这么用" 的问题。

⚙️ Linux 进程篇

【 冯诺依曼体系 + 操作系统 】

【 进程概念 + PID + fork函数 】

【 进程状态 】

【 进程优先级 】

【 进程调度 + 进程切换 + 环境变量 】

【 进程地址空间 】


目录

[一、fork 函数:从 "会用" 到 "懂原理" 的再认识](#一、fork 函数:从 “会用” 到 “懂原理” 的再认识)

1、fork简单介绍

2、fork的内核执行流程

3、写时拷贝

4、fork常规用法

5、fork调用失败的原因

补档:某个指令片段解析

二、进程终止是什么?

三、进程终止的触发场景

[1. 主动触发(正常终止)](#1. 主动触发(正常终止))

[2. 被动触发(异常 / 强制终止)](#2. 被动触发(异常 / 强制终止))

3、程序的退出码

四、进程常见退出方式

正常终止:

[♢ main函数返回](#♢ main函数返回)

[♢ exit](#♢ exit)

[♢ exit和return的区别](#♢ exit和return的区别)

[♢ _exit](#♢ _exit)

异常退出:

[♢ ctrl + c,信号终止](#♢ ctrl + c,信号终止)

[♢ 未定义行为](#♢ 未定义行为)

五、拓展问题

[☆ 问题:进程运行时,谁会关心它的情况?](#☆ 问题:进程运行时,谁会关心它的情况?)

[☆ 问题:为啥要有退出码?直接打印错误不行吗?](#☆ 问题:为啥要有退出码?直接打印错误不行吗?)


一、fork 函数:从 "会用" 到 "懂原理" 的再认识

1、fork简单介绍

在 Linux 系统编程中,fork() 是进程管理领域的核心系统调用 ------ 它以已运行的进程(父进程)为模板,创建一个全新的子进程

子进程会继承父进程的绝大多数资源(包括代码段、数据段、文件描述符、环境变量等),但二者是相互独立的进程实体;fork() 调用的特殊之处在于 "调用一次,返回两次":父进程会得到子进程的 PID(进程 ID,非负整数),子进程则返回 0,程序会从 fork() 调用处开始,父子进程各自独立执行后续逻辑。

cpp 复制代码
#include <unistd.h>
pid_t fork(void);
返回值:自进程中返回0,父进程返回子进程id,出错返回-1
返回值 所属进程 含义说明
0 子进程 子进程成功创建,返回 0 作为 "标识",子进程可通过 getppid () 获取父进程 PID
大于 0 父进程 返回值为新创建子进程的 PID(进程唯一标识),父进程可通过该值管理子进程
-1 父进程 子进程创建失败,errno 会被设置为对应错误码(如 EAGAIN 表示资源不足、ENOMEM 表示内存不足)

2、fork的内核执行流程

当进程调用fork(),控制流进入内核后,内核会完成以下步骤:

1、资源分配:为新创建的子进程分配独立的内存空间与内核数据结构(如进程控制块 PCB);

当内核为子进程创建 PCB 时,会立刻完成以下关键字段的初始化(无需拷贝父进程数据):

**PID:**内核从可用的进程 ID 池中为子进程分配唯一的 PID(绝对不会和父进程 / 其他进程重复);

**PPID:**直接设置为父进程的 PID(明确父子关系,这是进程树的核心);

**进程状态:**初始化为 "就绪态"(TASK_RUNNING),等待调度器分配 CPU;

基本权限 / 优先级:继承父进程的基础优先级,但会标记为独立的进程实体。

这些信息是子进程的 "身份标识",必须在创建 PCB 时就确定 ------ 否则操作系统根本无法区分 "这个空 PCB 属于谁",也无法把它加入进程列表进行管理。


2、数据拷贝:将父进程的部分核心数据结构(如 PCB 中的进程信息)拷贝至子进程;

在完成资源分配后,内核会将父进程的核心数据结构(如 PCB 中的进程属性信息)拷贝至子进程,而父进程的非核心标识类数据,会通过以下方式完成继承:

🔸 文件描述符表、信号处理方式、环境变量 → 直接拷贝到子进程 PCB;

🔸 代码段、数据段、堆 / 栈 → 基于写时拷贝共享(仅标记只读,不立即拷贝数据);

🔸 页表 → 初始化后指向父进程的物理内存页(写时拷贝的基础)。


3、进程注册:将子进程添加到系统的进程列表中,使其成为可被调度的进程;

4、返回与调度fork()系统调用返回,子进程进入操作系统调度器的调度队列,等待被分配 CPU 资源。

3、写时拷贝

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

修改前:父子进程虚拟内存结构一致,页表项(如 50、100)均标记 "只读",且指向同一块物理内存页,实现内存共享;

当子进程尝试修改数据时:触发 "只读" 权限的缺页中断,内核仅为子进程拷贝新物理页,并修改子进程页表(指向新页、取消 "只读");

关键修正:父进程的页表项仍保持 "只读 + 指向原物理页",图中 "父进程只读标记消失" 是简化表达,实际父进程页表无变化,仅修改方(子进程)完成拷贝 + 权限修改。

核心总结:写时拷贝是 "谁修改、谁拷贝",父进程仅在自己发起修改时才会触发自身页表的拷贝与权限变更。

**小tip:**只有原本可修改的数据,在多进程共享后被临时设为只读时,才会触发写时拷贝。

4、fork****常规用法

用法 1:父进程生个子进程,分工干活

**场景:**比如你写了个服务器程序,父进程的工作是 "蹲守" 端口,等客户端发请求;但如果父进程自己又蹲守又处理请求,同一时间只能忙一个活儿,效率很低。

用法: 父进程收到请求后,立刻fork出一个子进程,让子进程去处理这个请求,父进程继续回去蹲守下一个请求。

**核心逻辑:**相当于开 "分身"------ 父进程专注 "接活",子进程专注 "干活",实现同时处理多个请求。


用法 2:fork+exec,启动新程序

场景: 比如你在终端输入ls命令,终端进程(父进程)其实不是自己去执行ls,而是通过fork+exec启动新程序。

用法: 父进程先fork出一个子进程(此时子进程和父进程的代码 / 资源是一样的),然后子进程调用exec函数,把自己的代码 / 资源替换成新程序(比如ls)的内容 ,最终子进程变成了ls进程。

核心逻辑: fork负责 "复制一个空壳子进程",exec负责 "把新程序装进这个空壳"------ 相当于用 "克隆 + 换内容" 的方式启动新程序,比直接创建进程更高效。

5、fork调用失败的原因

原因 1:系统中进程太多

系统能创建的进程总数有上限(比如 PID 总数),哪怕不同用户创建进程,只要总数满了(比如多用户同时大量建进程),所有用户的fork都会失败。

原因 2:实际用户的进程数超过限制

仅限制某一个用户能创建的进程数,系统还有空闲进程名额,但该用户自己的进程数超配额(比如单用户循环建进程),只有这个用户的fork会失败。

简单说:前者是 "整个停车场停满了车",谁来都停不了;后者是 "你自己的车位用完了",但停车场还有空位,别人能停就你不行~


补档:某个指令片段解析

bash 复制代码
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(显示所有进程的详细信息,包括 PID、PPID、状态等),再通过 head -1 取进程信息的表头,用于清晰展示列含义
ps ajx | grep mytouch | grep -v grep 执行 ps ajx 后,通过 grep mytouch 过滤含 mytouch 的进程,再用 grep -v grep 排除 grep 自身进程(避免干扰)
echo "--------" 输出分隔符,区分每次循环的输出结果
sleep 1 每次循环后暂停 1 秒,实现 "每秒监控一次" 的效果
;(指令分隔符) 分隔多个指令,无论前一个指令是否成功,后续指令都会执行
&&(逻辑与) 连接多个指令,仅当前一个指令执行成功,才会执行后续指令
||(逻辑或) 连接多个指令,仅当前一个指令执行失败,才会执行后续指令

二、进程终止是什么?

进程终止是指正在运行的进程停止执行、释放占用的系统资源(CPU、内存、文件句柄等),并从系统的进程列表中移除的过程,是操作系统管理进程生命周期的关键环节之一。

它是进程生命周期的最终阶段,分为 "正常终止" 和 "异常终止" 两类,既可以由进程主动触发,也可以由外部(操作系统、用户)强制触发。

三、进程终止的触发场景

1. 主动触发(正常终止)

进程完成自身所有预定任务后,主动调用退出接口(如 Linux 下的 exit()/_exit()、C 语言的 return)结束运行,是进程 "自然完成生命周期" 的表现。对应进程退出场景:

  • 代码运行完毕,结果正确
  • 代码运行完毕,结果不正确

2. 被动触发(异常 / 强制终止)

进程未完成预定任务,被外部因素强制中断运行,核心是 "非自愿结束",分为两类:

人为干预 :用户通过 kill/kill -9 命令、Ctrl+C 等发送终止信号;

系统干预:操作系统因进程出错(如内存越界、除零错误)、资源耗尽、达到运行时限等,主动发送终止信号。

总结:

✅ :只要进程能捕获错误(比如内存开辟失败),并主动走完错误处理逻辑,最后通过return/exit()等主动退出 ,就属于正常结束(主动触发)

❌ 反例:如果内存开辟失败后,程序没做任何捕获,直接崩溃(系统发送SIGSEGV信号强制终止),那就是异常结束(被动触发)

哈哈,是不是突然发现 "正常 / 异常终止" 的关键不是 "有没有错",而是 "进程有没有自己掌控退出的节奏"~


3、程序的退出码

**核心定义:**进程终止时返回给操作系统的一个整数(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函数返回

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


这是正常终止但执行失败 的典型案例:程序完整执行了代码逻辑(属于主动退出的正常终止),但通过return 1返回了非 0 的退出码,标识 "执行结果不符合预期(即执行失败)"。


♢ exit

cpp 复制代码
#include <unistd.h>
void exit(int status);

参数status是进程的退出码(规则与main函数的return一致:0 代表执行成功,非 0 代表执行失败 / 错误)。

什么时候用exit

exit主动终止进程的函数,常用于:

1、程序执行到非main函数的位置(比如子函数),需要直接终止整个进程;

2、程序执行过程中遇到无法继续的错误(比如文件打开失败),需主动退出并返回错误码。


exit执行后还会自动完成一系列 "进程清理操作",完整流程如下:

1、执行用户通过atexit等函数注册的自定义清理函数(比如释放资源、记录日志);

2、自动冲刷缓冲区 (把内存中未写入文件 / 终端的数据强制输出)、关闭已打开的流(比如文件、标准输入输出);

3、最终调用内核的_exit()函数,将进程的退出码传递给操作系统,完成进程的终止。

简单说:exit不是 "直接杀死进程",而是先帮程序 "收尾(清理资源、刷缓存)",再通知内核终止进程。


exit演示


♢ exit和return的区别

维度 return exit
本质 函数返回(仅退出当前函数) 进程终止(退出整个程序)
生效范围 所有函数可用,在 main 中退进程 任意函数调用都直接退进程
额外操作 main 中 return 会隐式调 exit 主动执行清理(刷缓存 / 关流)+ 调内核_exit

一句话总结:return 是 "函数级返回",仅 main 里 return 等效 exit;exit 是 "进程级终止",在哪调都直接结束程序,且会先做资源清理。


♢ _exit

cpp 复制代码
#include <unistd.h>
void _exit(int status);
参数:status 定义了进程的终止状态,父进程通过wait来获取该值

本质 :是直接调用内核的系统调用,无任何用户态清理操作(不刷缓存、不关文件流、不执行自定义清理函数);

exit的区别exit是 "先清理再终止",_exit是 "直接终止";

平时用吗? 几乎不用 ------ 只有需要 "立刻终止、放弃所有清理" 的极端场景(比如子进程退出)才会用;

参数作用status是退出码,和exit规则一致(0 成功 / 非 0 失败),父进程可以通过wait函数获取这个值。

总结:

_exit是 "进程终止的最后一步内核操作",平时用exit就够,_exit是底层工具(一般不用)。


异常退出:

♢ ctrl + c,信号终止

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

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


♢ 未定义行为

对空指针进行解引用是非法操作,会触发系统的内存保护机制,系统会发送SIGSEGV信号强制中断程序 ------ 此时程序未执行到return 0,属于 "被动终止",是典型的异常终止

五、拓展问题

☆ 问题:进程运行时,谁会关心它的情况?

从 "程序运行的逻辑主体" 来讲,是子进程的父进程直接关心子进程的运行 / 退出情况;但从 "最终需求方" 来讲,本质是用户(开发者 / 运维人员)借助父进程这个 "媒介",来获取子进程的退出码、终止原因等关键信息 ------ 父进程是承接用户需求的载体,用户才是最终想知道子进程运行情况的人。

简单说:父进程是 "执行者"(负责捕获子进程信息),用户是 "需求者"(想通过父进程拿到子进程的结果),我们平时说 "父进程关心子进程",本质是用户通过父进程实现对子女程运行状态的掌控。

☆ 问题:为啥要有退出码?直接打印错误不行吗?

退出码是系统 / 程序间快速判断结果的标准化数字信号(比如 0 = 成功),能让脚本、父进程等自动化判断;打印错误是给人看的文本,没法高效做自动化处理。且有些场景(后台进程)没法打印,但退出码一定存在 ------ 两者是 "自动化信号 + 人工信息" 的互补关系,退出码的 "轻量、标准化" 是打印替代不了的。

相关推荐
TG:@yunlaoda360 云老大5 小时前
华为云国际站代理商GES的图引擎服务有哪些优势?
服务器·数据库·华为云
大聪明-PLUS6 小时前
面向开发者的实用 GNU/Linux 命令(第二部分)
linux·嵌入式·arm·smarc
sorry#10 小时前
top简单使用
linux·运维
广东大榕树信息科技有限公司10 小时前
如何通过动环监控系统提升机房运行安全与效率?
运维·网络·物联网·国产动环监控系统·动环监控系统
半壶清水11 小时前
开源免费的在线考试系统online-exam-system部署方法
运维·ubuntu·docker·开源
QQ__176461982411 小时前
Ubuntu系统创建新用户与删除用户
linux·运维·服务器
渣渣盟11 小时前
Linux邮件服务器快速搭建指南
linux·服务器·开发语言
6极地诈唬11 小时前
【PG漫步】DELETE不会改变本地文件的大小,VACUUM也不会
linux·服务器·数据库
ArrebolJiuZhou11 小时前
00 arm开发环境的搭建
linux·arm开发·单片机·嵌入式硬件