云计算百科
云计算领域专业知识百科平台

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

在这里插入图片描述

观众老爷们大家好 这里是邪修KING的独家频道

本文属于系列Linux系统篇 ——操作指令

一起学Linux的小伙伴可订阅专栏: Linux系统篇

我们常说 Linux 是多任务操作系统,“任务” 的核心载体就是进程。从你敲下一条终端命令,到后台常驻的服务程序,本质都是一个个进程在 CPU 上调度运行。而进程从哪里来?又怎么平稳消失?这背后藏着操作系统最经典的设计思想:fork 分裂创建、写时拷贝优化、分层退出机制。

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


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

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

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

#include <unistd.h>
pid_t fork(void);

很多初学者第一次接触 fork 都会困惑:为什么一个函数会有两个返回值? 我们可以把 fork 理解成细胞分裂:

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

【fork 执行流示意图】

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

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

#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 上甚至可以真正同时运行。

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

#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.3.5 为什么一定要设计写时拷贝
    • 提升 fork 性能:不用复制大量内存,fork 几乎可以瞬间完成
    • 节省内存资源:只读数据全局共享,内存利用率大幅提高
    • 适配 exec 场景:子进程执行新程序时,旧内存直接释放,没有多余开销
    1.3.6 读写权限的恢复注意事项
    • 拷贝完成后,新分配的页面会恢复读写权限,供修改的进程独立使用
    • 原来的旧页面如果还有其他进程共享,会继续保持只读状态
    • 当最后一个共享进程也退出或修改后,旧页面才会恢复正常读写权限

    1.4 fork 常见用法

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

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

    // 简化版网络服务模型
    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 不是永远能成功,常见失败原因有三类:

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

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

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

    2.1 进程退出场景

    2.1.1 进程退出的三种场景
  • 正常运行结束,自愿退出:main 函数执行 return 返回
  • 主动调用退出函数,自愿退出:代码中调用exit()、_exit()
  • 异常终止,被迫退出:收到外部信号(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 缓冲区的处理,我们用代码就能验证:

    #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()函数等待子进程退出,同时获取退出状态信息。

    #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()直接打印错误描述。

    #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 的区别,本质是用户态与内核态的边界划分
    • 父进程主动回收子进程,是操作系统资源管理的基本规则
    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Re: Linux系统篇(十八):进程篇(七): 进程深度解析:从 fork 创建到退出的完整旅程(附写时拷贝原理 + 代码实战)
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!