【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 = 成功),能让脚本、父进程等自动化判断;打印错误是给人看的文本,没法高效做自动化处理。且有些场景(后台进程)没法打印,但退出码一定存在 ------ 两者是 "自动化信号 + 人工信息" 的互补关系,退出码的 "轻量、标准化" 是打印替代不了的。

相关推荐
小稻穗15 小时前
市场调研样本选型坐标系:2026六家国内样本平台能力横向校验
大数据·运维·数据分析
栩栩云生15 小时前
AI 写代码犯的错,早被写进了错题集
linux·安全·ai编程
AOwhisky15 小时前
Python 学习笔记(第十三期)——运维自动化(下·前篇):远程命令执行——paramiko基础篇
运维·python·学习·云原生·自动化·运维开发·paramiko
AOwhisky16 小时前
Python 学习笔记(第十四期)——运维自动化(下·中篇):远程文件传输——paramiko进阶篇
运维·python·学习·云原生·自动化·文件传输·paramiko
imc.1116 小时前
linux基础IO
linux·运维·服务器
躺不平的理查德16 小时前
让Ubuntu 的第二块网卡 ens37 永久固定为 192.168.30.111
运维·服务器
bitbrowser17 小时前
Facebook 验证反复回到原页面,应该从哪里排查
运维·服务器·facebook
Land032917 小时前
AI网页元素变化无法自动修复?自带元素自愈的自动化解决方案
运维·人工智能·ai·自动化·rpa
fengyehongWorld17 小时前
Jenkins 安装与简单配置
运维·jenkins
云智慧AIOps社区17 小时前
2026 国产化 ITSM 替代指南:横向测评 ServiceNow、轻帆云、Jira等五款主流IT服务管理平台
运维·人工智能·运维开发·it服务管理·itsm平台