Re: Linux系统篇(十八):进程篇(七): 进程深度解析:从 fork 创建到退出的完整旅程(附写时拷贝原理 + 代码实战)


观众老爷们大家好 这里是邪修KING的独家频道 本文属于系列Linux系统篇 ------操作指令 一起学Linux的小伙伴可订阅专栏: Linux系统篇 我们常说 Linux 是多任务操作系统,"任务" 的核心载体就是进程。从你敲下一条终端命令,到后台常驻的服务程序,本质都是一个个进程在 CPU 上调度运行。而进程从哪里来?又怎么平稳消失?这背后藏着操作系统最经典的设计思想:fork 分裂创建、写时拷贝优化、分层退出机制。

今天我们顺着「创建 → 运行 → 退出 → 回收」这条完整链路,把每个底层机制讲透,配上可运行代码和原理示意图,彻底搞懂 Linux 进程的一生。


一、进程创建:fork 函数的分裂魔法

1.1 fork 函数初识:一次调用,两次返回

在 Linux 系统编程中,创建新进程最核心的函数就是fork()

函数原型

c 复制代码
#include <unistd.h>
pid_t fork(void);

很多初学者第一次接触 fork 都会困惑:为什么一个函数会有两个返回值?

我们可以把 fork 理解成细胞分裂

  • 调用 fork 之前,只有一个父进程在独立运行
  • 调用 fork 之后,操作系统会复制出一个几乎一模一样的子进程
  • 父进程中,fork 返回子进程的 PID 号(大于 0 的整数)
  • 子进程中,fork 返回0
  • 创建失败时,返回**-1**

【fork 执行流示意图】

复制代码
        父进程 (pid=1234)
            |
          fork()
         /      \
父进程继续执行   子进程开始执行
返回值=子PID    返回值=0

我们写一段最简测试代码,直观感受这个过程:

c 复制代码
#include <stdio.h>
#include <unistd.h>

int main() {
    printf("=== fork之前,只有父进程在运行,pid=%d ===\n", getpid());
    
    pid_t id = fork();
    if (id == 0) {
        // 子进程执行分支
        printf("我是子进程,pid=%d,fork返回值 = %d\n", getpid(), id);
    } else if (id > 0) {
        // 父进程执行分支
        printf("我是父进程,pid=%d,fork返回值 = %d\n", getpid(), id);
    } else {
        perror("fork创建失败");
        return 1;
    }
    
    return 0;
}

运行结果解读

fork之前的内容只会打印一次,而后续两行会分别输出父子进程的信息。这就证明:fork 之后代码执行流一分为二,两个进程各自独立执行后续代码。

1.2 fork 之后的执行流表现

很多人会问:fork 之后是父进程先运行,还是子进程先运行?

答案是:完全不确定,由操作系统的进程调度器决定。

父子进程是两个完全独立的执行单元,谁先抢到 CPU 时间片谁就先执行。单核 CPU 上是分时交替运行,多核 CPU 上甚至可以真正同时运行。

我们可以用循环打印验证调度的随机性:

c 复制代码
#include <stdio.h>
#include <unistd.h>

int main() {
    fork();
    
    // 父子进程都会执行这段循环
    for (int i = 0; i < 3; i++) {
        printf("pid=%d, 打印第%d次\n", getpid(), i);
        sleep(1);
    }
    
    return 0;
}

多次运行你会发现,输出顺序每次都可能不同,这就是内核调度的随机性。

1.3 写时拷贝(Copy-On-Write):fork 的灵魂优化

如果 fork 真的把父进程所有内存完完整整复制一份,那创建大进程会非常慢;而且很多场景下,子进程创建后马上会执行全新的程序,原来的内存根本用不上,复制就成了纯浪费。

于是 Linux 设计了写时拷贝(COW) 机制,这是操作系统 "惰性优化" 思想的经典体现。

1.3.1 写时拷贝是如何实现的

fork 创建子进程时,并不会立刻复制物理内存 ,而是让父子进程的虚拟地址空间,映射到同一块物理内存页面上,同时把这些页面的权限标记为 "只读"。

  • 当父子进程都只是读取数据时,大家共享同一份物理内存,相安无事
  • 一旦任意一方尝试修改内存,CPU 就会触发页错误(Page Fault)
  • 操作系统捕获异常后,分配新的物理页、复制数据、更新页表映射,再让进程继续执行

【写时拷贝原理示意图】

复制代码
修改前:
父进程虚拟地址 → 物理页A(只读标记)
子进程虚拟地址 → 物理页A(只读标记)

子进程修改数据后:
父进程虚拟地址 → 物理页A(保持只读)
子进程虚拟地址 → 物理页B(读写权限,复制了A的全部数据)
1.3.2 为什么操作系统会发生写时拷贝

本质是**"能晚做就晚做、能不做就不做" 的惰性思想**。

最典型的场景就是 shell 执行命令:shell fork 出子进程,子进程立刻加载新的命令程序,原来的内存数据完全用不上。如果 fork 时全量复制,所有拷贝工作都会白白浪费。写时拷贝完美避开了这种无意义的开销。

1.3.3 操作系统怎么分辨哪些是数据段

不是所有内存都会触发写时拷贝,操作系统通过页表标记精准区分:

  • 代码段:本身就是只读属性,父子进程永久共享,永远不需要拷贝
  • 数据段、堆、栈 :可读写区域,才会设置写时拷贝标记
    只有可写区域的写入操作,才会触发页错误和拷贝流程。
1.3.4 查询页表的几种情况
  1. 只读访问:直接共享物理页,不做任何额外操作
  2. 写入访问:触发页错误,执行写时拷贝,分配独立的新页面
  3. 执行访问:代码段只读可执行,直接共享即可
1.3.5 为什么一定要设计写时拷贝
  • 提升 fork 性能:不用复制大量内存,fork 几乎可以瞬间完成
  • 节省内存资源:只读数据全局共享,内存利用率大幅提高
  • 适配 exec 场景:子进程执行新程序时,旧内存直接释放,没有多余开销
1.3.6 读写权限的恢复注意事项
  • 拷贝完成后,新分配的页面会恢复读写权限,供修改的进程独立使用
  • 原来的旧页面如果还有其他进程共享,会继续保持只读状态
  • 当最后一个共享进程也退出或修改后,旧页面才会恢复正常读写权限

1.4 fork 常见用法

fork 的目的从来不是创建两个一模一样的进程,实际开发中主要有两种经典用法:

用法 1:主进程派生子进程处理任务

比如网络服务器模型,主进程负责监听连接,每来一个客户端就 fork 子进程去处理交互,主进程继续等待新连接。

c 复制代码
// 简化版网络服务模型
while (1) {
    int client = accept(listen_fd, NULL, NULL);
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程:处理客户端业务逻辑
        handle_client(client);
        exit(0);
    }
    // 父进程:关闭客户端描述符,继续循环等待
    close(client);
}

用法 2:fork + exec 执行全新程序

这就是 shell 终端的工作原理:你输入一条命令,shell fork 出子进程,子进程调用 exec 加载命令程序,替换掉自己的内存空间,执行新的程序逻辑。

1.5 fork 调用失败的原因

fork 不是永远能成功,常见失败原因有三类:

  1. 用户进程数达到上限 :每个用户都有最大进程数限制,可通过ulimit -u查看
  2. 系统内存不足:连页表结构都无法分配时
  3. 系统 PID 达到上限 :内核参数pid_max耗尽,无法分配新的进程号

二、进程退出:善始也要善终

进程有创建就有退出,退出不是简单的 "消失",而是有一套完整的清理、传值、回收流程。

2.1 进程退出场景

2.1.1 进程退出的三种场景
  1. 正常运行结束,自愿退出:main 函数执行 return 返回
  2. 主动调用退出函数,自愿退出 :代码中调用exit()_exit()
  3. 异常终止,被迫退出:收到外部信号(Ctrl+C、kill 命令)、段错误崩溃、调用 abort 终止
2.1.2 什么是退出码

进程退出时,会给父进程留下一个8 位的退出状态码(取值 0~255):

  • 行业约定:0 代表程序执行成功,非0 代表执行失败
  • 不同的非 0 值对应不同的错误原因,方便父进程判断子进程的执行结果

2.2 进程常见退出方法与核心机制

2.2.1 return 机制与返回值编码原理

只有在main函数里执行return才会触发进程退出,普通函数的 return 只是返回函数值。

main 函数 return 的返回值,最终会被系统传递给 exit 函数,本质上还是走 exit 的完整退出流程。

2.2.2 exit 与 _exit 的底层区别

这是系统编程的高频考点,二者核心差异在于是在用户态清理还是直接进内核。

表格

函数 层级 执行行为
exit() C 库函数 1. 执行用户注册的 atexit 清理函数2. 刷新所有 C 库 IO 缓冲区3. 关闭打开的文件流4. 最终调用_exit进入内核
_exit() 系统调用 直接陷入内核,释放进程资源,不处理任何用户态数据

最直观的区别就是对 IO 缓冲区的处理,我们用代码就能验证:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
    printf("hello linux"); // 注意:没有加\n,缓冲区不会自动刷新
    
    // 分别注释两行测试效果
    exit(0);    // 会打印 hello linux
    // _exit(0); // 不会打印,缓冲区直接丢弃
}

原理说明 :printf 是 C 库函数,终端默认行缓冲,遇到\n才会把数据刷到屏幕。没加\n时,数据还停留在用户态的缓冲区里。

  • exit会主动刷新所有缓冲区,所以内容能正常打印
  • _exit直接进入内核,用户态缓冲区直接被丢弃,所以什么都不输出
2.2.3 深度解剖:我们谈论的缓冲区

这里的缓冲区是C 标准库在用户态维护的 IO 缓冲区,和内核缓冲区不是一回事。分为三种类型:

  • 全缓冲:普通文件默认,缓冲区满了才刷新
  • 行缓冲:终端默认,遇到换行符\n才刷新
  • 无缓冲:比如标准错误输出 stderr,出错立刻输出

这也是为什么编码规范里常建议 printf 加\n------ 不只是换行,更是确保内容及时输出,不会留在缓冲区里丢失。

2.3 退出码的回收与访问

子进程退出后,系统资源不会立刻释放,必须由父进程回收,否则子进程会变成 "僵尸进程",占用系统资源。

2.3.1 子进程退出码回收

父进程通过wait()waitpid()函数等待子进程退出,同时获取退出状态信息。

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>

int main() {
    pid_t pid = fork();
    if (pid == 0) {
        printf("子进程开始运行,pid=%d\n", getpid());
        sleep(2);
        exit(123); // 子进程退出,返回退出码123
    }
    
    // 父进程阻塞等待子进程退出
    int status;
    wait(&status); 
    
    if (WIFEXITED(status)) {
        // 子进程正常退出,提取退出码
        printf("子进程正常退出,退出码:%d\n", WEXITSTATUS(status));
    } else if (WIFSIGNALED(status)) {
        // 子进程被信号终止
        printf("子进程被信号杀死,信号编号:%d\n", WTERMSIG(status));
    }
    
    return 0;
}
2.3.2 退出码的访问

status变量不是直接的退出码,里面同时包含了退出类型和退出码信息,必须用宏来解析:

  • WIFEXITED(status):非零表示进程正常退出
  • WEXITSTATUS(status):获取正常退出的退出码
  • WIFSIGNALED(status):非零表示进程被信号终止
  • WTERMSIG(status):获取终止进程的信号编号
2.3.3 退出码可以转化为错误信息

纯数字的错误码不直观,我们可以用strerror()把错误码转成可读字符串,或者用perror()直接打印错误描述。

c 复制代码
#include <stdio.h>
#include <string.h>
#include <errno.h>

int main() {
    errno = ENOENT; // 模拟"文件不存在"错误
    printf("错误描述:%s\n", strerror(errno));
    perror("打开配置文件失败");
    return 0;
}

三、全文总结

进程的一生,从 fork 分裂创建开始,借助写时拷贝机制高效地复制出独立运行环境;运行完成后,通过 exit/_exit 分层退出并留下退出状态;最后由父进程调用 wait 回收资源,完整闭环。

理解这整套机制,你就看懂了 Linux 多任务的底层逻辑:

  • fork 的 "一次调用两次返回",是多进程的起点
  • 写时拷贝是性能优化的精髓,用惰性复制换来了极高的创建效率
  • exit 和_exit 的区别,本质是用户态与内核态的边界划分
  • 父进程主动回收子进程,是操作系统资源管理的基本规则
相关推荐
m0_587383001 小时前
24小时自助健身系统源码实战:从架构设计到部署落地
java·架构·系统架构·需求分析
Golden_Chen1 小时前
Win10 WSL安装ubuntu
linux
RobinDevNotes1 小时前
RoCEv2如何扛起大模型训练网络
linux·网络·ai·网络linux
卓怡学长1 小时前
w156一周穿搭App的设计与实现
java·spring boot·spring·maven·intellij-idea
万年咸鱼1 小时前
Java PrintStream 详解:从基础用法到实战技巧
java·开发语言·python
吴声子夜歌1 小时前
ApacheCommons——commons-compress(解压缩工具)
java·apache
程序员清风1 小时前
聊聊怎么缓解找工作的焦虑感?
java·后端·面试
sunshine22 girl2 小时前
Java学习一 环境配置3 Idea的基本设置和插件
java·学习