【操作系统】程序与进程:从状态机到 fork、execve 与进程生命周期

头像

🔥 星恒随风: 个人主页
❄️ 个人专栏: 《指针合集》 《C语言基础》 《数据结构》 《机器学习导论》 《前端基础》 《python基础》 《C++从入门到入土》
✨ 数据即知识,压缩即智能

声明

本文根据南京大学 JYY《操作系统》2026 春季课程 Lecture 5“程序和进程”整理,并参考 Operating Systems: Three Easy Pieces(OSTEP)第 4、5 章及 Linux 官方手册进行补充。各位感兴趣的话可以上官网看看

课程网站 https://jyywiki.cn


一、从虚拟化开始:为什么每个程序都像独占了一台计算机?

站在应用程序的角度看,操作系统可以理解成“对象 + API”:

  • 文件是对象,可以通过 openreadwrite 等接口访问;
  • 进程是对象,可以通过 forkexecvewaitpid_exit 等接口管理;
  • 虚拟内存、网络套接字也都可以用类似的方式理解。

站在硬件的角度看,操作系统本身又只是一个程序。它从机器启动后的某个入口开始执行,管理 CPU、内存和各种外部设备。

这两种视角最终在“虚拟化”上汇合:

操作系统把一台真实的物理计算机,抽象成许多台彼此隔离的“虚拟计算机”。

我们明明只有有限数量的 CPU 核心,但浏览器、编辑器、终端、音乐播放器却好像都在同时运行。原因并不是每个程序真的拥有一颗 CPU,而是操作系统不断让不同进程轮流运行:

while (1) {
    Process *p = choose_one_ready_process();
    p->run_for_a_while();
    handle_event_or_syscall(p);
}

这段代码当然只是概念模型。真实操作系统通常不会逐条解释应用程序指令,而是让程序直接在 CPU 上执行,再借助时钟中断、异常、系统调用和上下文切换重新获得控制权。

但这个看似简单的循环,已经揭示了 CPU 虚拟化的核心:

  1. 操作系统保存多个程序的运行状态;
  2. 每次选择一个可以运行的程序;
  3. 让它执行一段时间;
  4. 保存它的新状态,再切换到另一个程序。

OSTEP 将这种方法称为 time sharing(时间共享)。操作系统通过在时间上切分 CPU,让多个进程都产生“自己拥有 CPU”的错觉。


二、程序和进程到底有什么区别?

这是本节课最先需要说清楚的问题。

假设磁盘中有下面这样一个可执行文件:

#include <unistd.h>

int main(void) {
    while (1) {
        write(STDOUT_FILENO, "Hello, World!\n", 14);
    }
}

当它静静地躺在磁盘中时,它是一个程序(program);当操作系统把它加载到内存并开始执行后,它就成为一个进程(process)

可以从状态机的角度进一步理解:

  • 程序描述了初始状态以及每一步如何迁移;
  • 进程是这个状态机正在运行的一个实例;
  • 同一个程序可以同时运行多次,从而产生多个彼此独立的进程。

例如同时打开三个终端,并在其中分别运行同一个程序,磁盘上仍然只有一份可执行文件,但系统里会出现三个进程。它们拥有不同的 PID,也可能执行到不同的位置、保存不同的数据。

1. 一个进程包含什么?

进程绝不只是“正在执行的代码”。按照 OSTEP 的描述,一个进程在某个时刻的状态至少包括:

组成部分 典型内容
地址空间 代码段、全局数据、堆、栈、动态库和内存映射
CPU 上下文 通用寄存器、程序计数器 PC、栈指针 SP、状态寄存器
I/O 状态 打开的文件描述符、当前文件偏移、管道、套接字
身份信息 PID、PPID、用户 ID、组 ID、进程组、会话
内核管理信息 调度状态、优先级、信号状态、资源使用量等

因此可以写出一个更完整的关系:

进程 = 程序的运行时状态 + 操作系统维护的管理状态。

操作系统通常会用某种内核数据结构记录这些信息。教材中经常把它概括为 PCB(Process Control Block,进程控制块)。不同内核的实际结构并不完全相同,但思想是一致的:想暂停一个进程并在未来恢复它,就必须保存足够多的现场。

2. 进程不是程序文件的“另一个名字”

可以用下面的对比加深印象:

程序 进程
静态的 动态的
通常存放在磁盘中 正在内存和 CPU 上演化
描述“应该怎样执行” 表示“已经执行到了哪里”
一份程序可以长期存在 进程有创建、运行和退出的生命周期
同一程序通常只有一份代码文件 同一程序可以对应多个进程

如果把程序比作一份菜谱,那么进程不是菜谱本身,而是“某位厨师按照菜谱做菜的整个现场”:做到哪一步、手中有什么材料、炉火是否打开,这些都属于运行状态。


三、进程为什么会有不同状态?

多个进程共享有限的 CPU,但并不是每个进程都随时能够运行。OSTEP 使用三个基本状态帮助我们建立模型:

状态 含义
Running(运行) 正在某个 CPU 核心上执行
Ready(就绪) 已经具备运行条件,只是在等待 CPU
Blocked(阻塞) 正在等待某个事件,例如磁盘 I/O、网络数据或锁

常见的状态迁移如下:

  • Ready → Running:调度器选中了该进程;
  • Running → Ready:时间片耗尽,或者进程被更高优先级任务抢占;
  • Running → Blocked:进程发起 I/O,暂时无法继续;
  • Blocked → Ready:等待的事件完成,进程重新获得运行资格。

这里最容易混淆的是“就绪”和“阻塞”:

  • 就绪进程什么都不缺,只缺 CPU;
  • 阻塞进程即使立刻分配 CPU,也无法继续完成当前工作。

例如,一个进程调用 read 等待网络数据时,它可以进入阻塞状态。操作系统不会让 CPU 陪着它空等,而会切换到其他就绪进程。等网卡收到数据并产生中断后,内核再把它转回就绪状态。

真实系统还会有创建中、停止、僵尸等更细的状态,但 Running、Ready、Blocked 已经足以解释调度的基本逻辑。


四、在 Linux 中观察一个真实进程

抽象最终要落到真实系统上。在 Linux 中,最适合观察进程的入口之一是 procfs

/proc 看起来像一个普通目录,实际上是内核动态提供的虚拟文件系统。对于 PID 为 1234 的进程,/proc/1234/ 中保存了大量与它相关的信息。

可以先在终端中完成下面这组实验:

# 当前 shell 的 PID
echo $$

# 查看进程的可读状态信息
cat /proc/$$/status

# 查看它正在执行的程序
readlink /proc/$$/exe

# 查看命令行参数;其中原本使用 '\0' 分隔
tr '\0' ' ' < /proc/$$/cmdline
echo

# 查看环境变量
tr '\0' '\n' < /proc/$$/environ | head

# 查看打开的文件描述符
ls -l /proc/$$/fd

# 查看内存映射
head /proc/$$/maps

几个常用文件的含义如下:

路径 内容
/proc/[pid]/status PID、PPID、内存、线程数、信号等可读信息
/proc/[pid]/stat 面向程序解析的进程状态
/proc/[pid]/cmdline 启动该程序时的参数
/proc/[pid]/environ 环境变量
/proc/[pid]/fd/ 已打开文件描述符对应的符号链接
/proc/[pid]/maps 虚拟地址空间中的内存映射
/proc/[pid]/exe 当前可执行文件
/proc/[pid]/cwd 当前工作目录

还有一个很有意思的路径:

cat /proc/self/status

/proc/self 会自动指向“正在访问 /proc 的那个进程”。所以上述命令显示的通常是 cat 自己,而不是启动 cat 的 shell。

除了 procfs,程序还可以通过系统调用获取自己的身份:

getpid();   // 当前进程 PID
getppid();  // 父进程 PID
getpgrp();  // 进程组
getsid(0);  // 会话 ID
getuid();   // 真实用户 ID
geteuid();  // 有效用户 ID
getgid();   // 真实组 ID
getegid();  // 有效组 ID

需要注意,/proc 是 Linux 特有的接口,并不是所有 UNIX 系统都提供完全相同的目录和字段。写可移植程序时,应优先使用标准 API;调试和探索 Linux 时,/proc 则是一座宝库。


五、UNIX 如何管理进程:复制、重置和销毁状态机

如果让我们自己设计进程 API,最直观的方案可能是:

spawn(path, argv)  创建并运行一个新程序
exit(status)       结束当前进程

Windows 的 CreateProcess 在接口形态上更接近这种思路。

UNIX 却选择了一个看起来有些奇怪的组合:

目标 UNIX 接口 状态机视角
创建 fork() 复制当前状态机
执行另一程序 execve() 用新程序重置当前状态机
等待子进程 wait() / waitpid() 等待子状态机发生特定变化并回收结果
退出 _exit() 销毁当前状态机并留下退出状态

所以,UNIX 中“启动一个新程序”的经典流程并不是一个调用,而是:

fork + execve

父进程先复制出一个子进程,再由子进程把自己替换成目标程序。这个设计看似绕了一步,却给 shell 的重定向、管道、权限调整和文件描述符安排留下了非常灵活的操作空间。


六、fork:复制一个正在运行的进程

fork 的函数原型很简单:

#include <unistd.h>

pid_t fork(void);

它最特殊的地方在于:调用一次,却可能返回两次。

成功调用后,父进程和子进程都会从 fork() 返回的位置继续执行,只是返回值不同:

所在进程 fork() 返回值
父进程 子进程的 PID,大于 0
子进程 0
调用失败 父进程得到 -1,且不会创建子进程

下面是最基本的例子:

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
    int value = 100;
    pid_t pid = fork();

    if (pid < 0) {
        perror("fork");
        return EXIT_FAILURE;
    }

    if (pid == 0) {
        value++;
        printf("[child] pid=%ld, ppid=%ld, value=%d\n",
               (long)getpid(), (long)getppid(), value);
        return 0;
    }

    value += 10;
    printf("[parent] pid=%ld, child=%ld, value=%d\n",
           (long)getpid(), (long)pid, value);

    waitpid(pid, NULL, 0);
    return 0;
}

编译并运行:

gcc -Wall -Wextra -O2 fork_basic.c -o fork_basic
./fork_basic

你可能看到父进程先输出,也可能看到子进程先输出。两者都正确,因为 fork 之后谁先被调度并不确定。

value 一定分别是:

  • 子进程中的 101
  • 父进程中的 110

原因是父子进程拥有彼此独立的虚拟地址空间。fork 时二者内容相同,之后一方对内存的修改不会改变另一方。

1. “复制整个内存”会不会很慢?

从语义上看,fork 像是复制了父进程的整个地址空间;但现代 Linux 通常使用 Copy-on-Write(写时复制,COW)

  1. fork 刚完成时,父子进程的页表可以暂时指向相同的物理页;
  2. 这些页面会被保护起来;
  3. 当某一方尝试写入时,CPU 触发缺页异常;
  4. 内核再为即将写入的一方复制对应页面。

因此,fork 的主要初始开销通常是复制页表、创建内核任务结构等,而不是立刻复制父进程每一个物理内存页。

这也解释了一个经典用法:程序先完成耗时的共享数据初始化,再 fork 出多个工作进程。只读数据可以继续共享物理页,真正发生修改的部分才需要复制。Android 的 Zygote 进程也利用了“先预加载,再派生应用进程”的思路。

不过 COW 并不等于“没有开销”。父进程页表很大、子进程大量写内存,或者频繁创建进程时,成本仍然可能很高。

2. fork 到底复制和继承了什么?

这就要讲到:

父子进程在 fork 返回的瞬间看起来几乎一样,但它们不是同一个进程。

几个最重要的细节是:

  • 子进程有自己的 PID;
  • 子进程的 PPID 指向父进程;
  • 父子进程拥有独立的虚拟地址空间;
  • 子进程继承父进程打开的文件描述符副本;
  • 这些文件描述符通常指向相同的内核 open file description,因此可能共享文件偏移;
  • 某些信号、锁、计时器等状态有特殊继承规则,不能简单理解成“所有东西逐位复制”。

最后一点非常重要。课堂中的“复制状态机”是理解语义的好模型,但真正写系统程序时,仍然需要查看 fork(2) 手册确认具体资源的继承行为。

3. fork 之后为什么会形成进程树?

每次 fork 都会建立父子关系,于是整个系统中的进程自然形成一棵树。

假设关系为:

A 创建 B,B 创建 C

如果 B 先退出,C 的父进程并不一定简单地变成 A。在 Linux 中,仍在运行的孤儿进程会被重新托管给最近的 subreaper;如果不存在合适的 subreaper,则通常由所在 PID 命名空间中的 init 进程接管。

这就是孤儿进程(orphan process) 问题:父进程先退出,但子进程仍在运行。

另一个容易混淆的概念是僵尸进程(zombie process)

  • 子进程已经结束运行;
  • 内核仍需保留它的 PID、退出状态和少量统计信息;
  • 父进程尚未调用 waitwaitpid 取走这些信息。

孤儿进程还活着,僵尸进程已经死了。两者完全不是一回事。

4. 为什么 fork 会带来指数增长?

看下面的循环:

for (int i = 0; i < n; i++) {
    fork();
}

如果每次 fork 都成功,而且所有新进程都会继续执行剩余循环,那么进程数量会这样增长:

1 → 2 → 4 → 8 → ... → 2^n

连续执行 n 次无条件 fork 后:

  • 最终进程总数是 2^n
  • 新创建的进程数是 2^n - 1

这也是 fork bomb 危险的根源:它会快速耗尽 PID、进程数限制、内存和调度资源。现代 Linux 可以使用 RLIMIT_NPROC、cgroup 的 pids.max 等机制限制进程数量,但 fork bomb 依然可能严重影响系统。

不要在真实环境中尝试 fork bomb。理解指数增长即可,没有必要用机器稳定性做实验。

5. fork 的实际用途

尽管接口看起来古怪,fork 具有很强的表达能力:

  • shell 创建子进程执行命令;
  • Web 服务器或守护进程派生工作进程;
  • 完成公共数据预处理后创建多个只读工作进程;
  • 为搜索算法的不同分支保存状态快照;
  • 通过进程边界实现故障隔离;

当然,并行程序不应看到分支就无条件 fork。进程创建、调度和通信都有成本,任务规模过小时,开销可能大于并行收益。


七、一道看似简单,却很容易答错的 fork 题

课堂中给出了下面的程序:

for (int i = 0; i < 2; i++) {
    fork();
    printf("Hello\n");
}

先暂时忽略缓冲,数一数 printf 被执行多少次:

  • 第 1 轮 fork 后有 2 个进程,因此输出 2 次;
  • 第 2 轮开始时有 2 个进程,每个再复制一次,于是有 4 个进程,输出 4 次;
  • 总计 2 + 4 = 6 次。

一般地,如果循环执行 n 轮,并在每轮 fork 后输出一次,输出语句的执行次数是:

2 + 4 + ... + 2^n = 2^(n+1) - 2

但实验时可能出现一个反常现象:

./a.out
./a.out | wc -l

前者通常看到 6 行,而后者可能统计出 8 行。为什么?

1. 关键不在 fork,而在 stdio 缓冲

连接终端时,stdout 通常采用行缓冲,遇到换行符就会刷新。因此第一轮产生的两行文本在第二轮 fork 前大概率已经通过 write 交给内核,不会再次复制。

连接管道时,stdout 通常采用全缓冲。第一轮的两个 printf("Hello\n") 只是把文本放进各自进程的用户态缓冲区,还没有真正执行 write

接下来第二轮 fork 复制进程地址空间,也就复制了缓冲区:

  1. 第一轮后有 2 个进程,每个缓冲区中有一行 Hello
  2. 第二轮 fork 后有 4 个进程,每个都带着被复制的一行;
  3. 4 个进程又各自追加一行;
  4. 正常退出时,每个进程刷新两行,共得到 4 × 2 = 8 行。

所以,真正被复制的不是已经写入终端或管道的数据,而是还留在用户空间内存中的 stdio 缓冲区

可以用下面几种方法验证:

// 方法一:关闭 stdout 缓冲
setbuf(stdout, NULL);

// 方法二:在 fork 前主动刷新
fflush(stdout);

// 方法三:对比底层 write 系统调用
write(STDOUT_FILENO, "Hello\n", 6);

还可以用 strace 观察实际发生的系统调用:

strace -f -e trace=process,write ./a.out
strace -f -e trace=process,write ./a.out 2>&1 | less

八、execve:不是创建进程,而是替换当前进程

execve 的原型如下:

#include <unistd.h>

int execve(const char *path,
           char *const argv[],
           char *const envp[]);

如果说 fork 是“复制状态机”,那么 execve 就是“重置状态机”:

它把当前进程正在运行的程序替换成 path 指定的新程序。

成功调用后:

  • 原程序的代码、数据、堆和栈被新程序替换;
  • CPU 从新程序入口开始执行;
  • 新程序获得新的参数和环境变量;
  • 调用成功时 execve 不会返回。

最后一条尤其重要。下面的输出只会在 execve 失败时出现:

execve(path, argv, envp);
perror("execve");  // 能执行到这里,说明 execve 失败

1. execve 的三个参数

path:可执行文件路径

例如:

"/bin/echo"

底层 execve 需要明确的路径,它本身不会像 shell 那样自动搜索 PATH

argv:命令行参数

argv 是以空指针结尾的字符串指针数组:

char *argv[] = {
    "echo",
    "Hello from execve",
    NULL
};

按照约定,argv[0] 通常是程序名。新程序的 main(int argc, char *argv[]) 参数正是由这里建立起来的。

envp:环境变量

环境变量同样使用以空指针结尾的字符串数组:

char *envp[] = {
    "LANG=C",
    "MY_KEY=hello",
    NULL
};

每一项通常采用 KEY=VALUE 的形式。PATHHOMEPWDLANG 等都属于环境变量。

Shell 中的:

export MY_KEY=hello

本质上是在修改 shell 自己的环境,使它之后创建并执行的子进程能够继承或获得相应变量。

2. 哪些状态被替换,哪些状态会保留?

execve 并没有创建新的进程,所以调用前后 PID 不变。可以把常见状态分成两类:

被新程序替换或重新初始化 通常继续保留
代码段、数据段、堆、栈 PID、PPID
程序计数器和用户态执行现场 当前工作目录
原程序的内存映像 大部分进程身份信息
新的 argvenvp 由调用者提供 未设置 close-on-exec 的文件描述符

这张表只列出最核心的行为。信号处理方式、凭据、内存映射、定时器等都有更细的规则,系统编程时应查阅 execve(2)

打开的文件描述符默认可以跨越 execve 保留,这一点极其重要:shell 正是借助它实现重定向和管道。

如果某个文件描述符不应该泄漏给新程序,应设置 FD_CLOEXEC,或者在创建文件描述符时使用 O_CLOEXEC 等原子选项。

3. execve 与 execl、execvp 有什么关系?

在 Linux 上,真正进入内核执行新程序的基础接口是 execve。libc 又提供了一组便于使用的 exec* 函数:

函数 参数形式 是否搜索 PATH 是否显式传入环境
execl 参数列表 list
execv 参数数组 vector
execlp 参数列表
execvp 参数数组
execle 参数列表
execve 参数数组

可以用后缀帮助记忆:

  • l:list,参数逐个写出;
  • v:vector,参数放在数组中;
  • p:按照 PATH 搜索可执行文件;
  • e:显式提供 environment。

因此:

char *args[] = {"ls", "-l", NULL};
execvp(args[0], args);

会根据 PATH 查找 ls;而:

execve("/bin/ls", args, envp);

则直接执行给定路径。

4. PATH 为什么会影响程序执行?

PATH 保存了一组用冒号分隔的目录:

echo "$PATH"

当 shell 或 execvp 需要执行一个不带 / 的程序名时,会按照 PATH 中的目录顺序尝试查找。

例如 gcc 在编译过程中还需要调用汇编器、链接器等其他程序。如果把 PATH 清空:

PATH="" /usr/bin/gcc a.c

即使 /usr/bin/gcc 本身能够启动,它也可能找不到后续需要执行的工具。

这里要区分两层:

  • execve 负责执行一个明确路径;
  • shell 或带 p 后缀的 libc 包装函数负责搜索 PATH,最终仍会落到类似 execve 的底层执行操作上。

九、wait 和 waitpid:父进程如何等待并回收子进程?

fork 之后父子进程并发执行,父进程有时需要等子进程结束再继续。最常用的接口是:

#include <sys/wait.h>

pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);

wait(NULL) 等待任意一个子进程;waitpid 可以指定等待哪个子进程,并提供更多选项。

一个典型写法是:

int status;
pid_t result = waitpid(pid, &status, 0);

if (result == -1) {
    perror("waitpid");
} else if (WIFEXITED(status)) {
    printf("child exited with status %d\n",
           WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
    printf("child killed by signal %d\n",
           WTERMSIG(status));
}

几个常见宏的含义:

含义
WIFEXITED(status) 子进程是否正常退出
WEXITSTATUS(status) 正常退出时的退出码
WIFSIGNALED(status) 是否被信号终止
WTERMSIG(status) 导致终止的信号编号
WIFSTOPPED(status) 是否因信号而停止
WNOHANG 作为 waitpid 选项时,不阻塞等待

wait 不只是“让父进程暂停”,还承担了回收子进程退出信息的责任。如果父进程一直不等待已经退出的子进程,子进程就可能以僵尸状态留在进程表中。


十、exit 与 _exit:看起来相似,行为并不相同

课程中用 _exit 表示立即销毁进程:

#include <unistd.h>

void _exit(int status);

但 C 程序中还经常见到:

#include <stdlib.h>

void exit(int status);

二者最关键的差异是:

exit _exit
libc 函数 POSIX 进程终止接口
刷新并关闭 stdio 流 不刷新 stdio 流
调用 atexit 注册的函数 不调用 atexit 处理函数
完成用户态清理后终止进程 更直接地终止进程

这也是为什么 fork 后的子进程在 execve 失败时,通常写成:

execve(path, argv, envp);
perror("execve");
_exit(127);

而不是直接调用 exit。如果父进程在 fork 前存在尚未刷新的 stdio 缓冲,子进程调用 exit 可能再次刷新被复制的缓冲,造成重复输出。

在 Linux 的实现细节中,进程与线程还会带来 exitexit_group 的区别。现代 glibc 的 _exit 包装函数会终止整个进程中的线程组,而原始内核系统调用层面的行为需要结合具体接口理解。初学阶段先掌握“exit 会做 libc 清理,_exit 不会刷新 stdio”即可。


十一、一个完整的 fork + execve + waitpid

下面的程序展示了 UNIX 创建新程序的标准骨架:

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

extern char **environ;

int main(void) {
    pid_t pid = fork();

    if (pid == -1) {
        perror("fork");
        return EXIT_FAILURE;
    }

    if (pid == 0) {
        char *argv[] = {
            "echo",
            "Hello from the new program!",
            NULL
        };

        execve("/bin/echo", argv, environ);

        // 只有 execve 失败时才会到达这里
        perror("execve");
        _exit(127);
    }

    int status;
    if (waitpid(pid, &status, 0) == -1) {
        perror("waitpid");
        return EXIT_FAILURE;
    }

    if (WIFEXITED(status)) {
        printf("child %ld exited with status %d\n",
               (long)pid, WEXITSTATUS(status));
    } else if (WIFSIGNALED(status)) {
        printf("child %ld was killed by signal %d\n",
               (long)pid, WTERMSIG(status));
    }

    return 0;
}

编译运行:

gcc -Wall -Wextra -O2 spawn_demo.c -o spawn_demo
./spawn_demo

整个过程可以按时间顺序理解:

  1. 父进程调用 fork
  2. 操作系统创建子进程;
  3. 子进程调用 execve,把自身替换成 /bin/echo
  4. 父进程在 waitpid 中等待;
  5. echo 输出并退出;
  6. 父进程获得退出状态,回收子进程信息;
  7. 父进程继续执行并退出。

这就是一个最小版 shell 的核心流程。


十二、为什么 UNIX 要把 fork 和 exec 分开?

乍看之下,先复制自己,再立即把子进程替换掉,似乎有些多余。OSTEP 给出的关键答案是:

forkexec 之间的空隙,让父进程或子进程有机会修改新程序即将继承的运行环境。

例如执行:

wc -l input.txt > result.txt

shell 可以在子进程中先完成这些操作:

  1. fork 创建子进程;
  2. 子进程打开 result.txt
  3. 使用 dup2 让标准输出 STDOUT_FILENO 指向该文件;
  4. 调用 execvp 执行 wc
  5. wc 并不知道自己被重定向,仍然只向标准输出写数据;
  6. 由于文件描述符跨 exec 保留,输出最终进入 result.txt

管道的思路也类似:

cat input.txt | grep hello

shell 先创建管道,再通过 fork 建立子进程,并在 exec 前把不同进程的标准输入、标准输出接到管道两端。

因此,分离 forkexec 并不是历史上随意留下的怪接口,而是 UNIX 组合式设计的重要基础:

  • fork 决定“由哪个进程来执行”;
  • 中间步骤决定“以怎样的环境执行”;
  • exec 决定“最终执行哪个程序”。

十三、用 strace 观察进程生命周期

只看代码容易把库函数、系统调用和 shell 行为混在一起。strace 可以记录程序与内核交互的过程。

观察进程相关系统调用:

strace -f -e trace=process ./spawn_demo

其中:

  • -f 表示继续跟踪 fork 创建的子进程;
  • -e trace=process 只显示进程创建、执行、等待和退出相关调用。

观察 execve 和文件描述符:

strace -f -e trace=execve,openat,close,dup2 ./your_program

观察输出究竟何时进入内核:

strace -f -e trace=write ./a.out

在现代 Linux/glibc 上,你还可能看到:

  • 源代码调用的是 fork,跟踪结果中出现 cloneclone3
  • 源代码调用 _exit,底层出现 exit_group
  • printf 并不立刻对应 write,而是在缓冲区刷新时集中写出。

这并不矛盾。应用程序调用的 libc 接口可以在内部选择合适的底层系统调用。学习操作系统时,要有意识地区分:

  1. 源代码中的库函数;
  2. 用户态运行库的实现;
  3. 真正进入内核的系统调用;
  4. 内核内部完成工作的机制。

十四、理解状态机

把整节课压缩成一条主线,可以得到:

  1. 程序描述状态机的初始状态和迁移规则;
  2. 进程是状态机正在运行的实例;
  3. 操作系统保存进程状态,并在多个进程之间分配 CPU;
  4. fork 复制当前进程;
  5. execve 用新程序替换当前进程;
  6. _exit 终止进程并留下退出状态;
  7. wait / waitpid 让父进程等待并回收子进程;
  8. shell 通过组合这些接口实现命令执行、重定向和管道。

因此,下面这段代码不只是固定模板:

pid_t pid = fork();

if (pid == -1) {
    // 创建失败
} else if (pid == 0) {
    // 子进程:设置环境、重定向、exec
} else {
    // 父进程:继续工作或等待子进程
}

它实际上表达了一套完整的状态机管理思想:

复制状态 → 调整环境 → 装入新程序 → 等待结果 → 回收状态

理解到这里,进程就不再是课本里一句“正在运行的程序”,而是一个可以被观察、复制、替换、调度和回收的真实对象。


Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐