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

🔥 星恒随风:
个人主页
❄️ 个人专栏:
《指针合集》
|
《C语言基础》
|
《数据结构》
|
《机器学习导论》
|
《前端基础》
|
《python基础》
|
《C++从入门到入土》
✨ 数据即知识,压缩即智能
文章目录
- 【操作系统】程序与进程:从状态机到 fork、execve 与进程生命周期
-
- 声明
- 一、从虚拟化开始:为什么每个程序都像独占了一台计算机?
- 二、程序和进程到底有什么区别?
-
- 1. 一个进程包含什么?
- 2. 进程不是程序文件的“另一个名字”
- 三、进程为什么会有不同状态?
- 四、在 Linux 中观察一个真实进程
- 五、UNIX 如何管理进程:复制、重置和销毁状态机
- 六、fork:复制一个正在运行的进程
-
- 1. “复制整个内存”会不会很慢?
- 2. fork 到底复制和继承了什么?
- 3. fork 之后为什么会形成进程树?
- 4. 为什么 fork 会带来指数增长?
- 5. fork 的实际用途
- 七、一道看似简单,却很容易答错的 fork 题
-
- 1. 关键不在 fork,而在 stdio 缓冲
- 八、execve:不是创建进程,而是替换当前进程
-
- 1. execve 的三个参数
-
- path:可执行文件路径
- argv:命令行参数
- envp:环境变量
- 2. 哪些状态被替换,哪些状态会保留?
- 3. execve 与 execl、execvp 有什么关系?
- 4. PATH 为什么会影响程序执行?
- 九、wait 和 waitpid:父进程如何等待并回收子进程?
- 十、exit 与 _exit:看起来相似,行为并不相同
- 十一、一个完整的 fork + execve + waitpid
- 十二、为什么 UNIX 要把 fork 和 exec 分开?
- 十三、用 strace 观察进程生命周期
- 十四、理解状态机
声明
本文根据南京大学 JYY《操作系统》2026 春季课程 Lecture 5“程序和进程”整理,并参考 Operating Systems: Three Easy Pieces(OSTEP)第 4、5 章及 Linux 官方手册进行补充。各位感兴趣的话可以上官网看看
课程网站 https://jyywiki.cn
一、从虚拟化开始:为什么每个程序都像独占了一台计算机?
站在应用程序的角度看,操作系统可以理解成“对象 + API”:
- 文件是对象,可以通过 open、read、write 等接口访问;
- 进程是对象,可以通过 fork、execve、waitpid、_exit 等接口管理;
- 虚拟内存、网络套接字也都可以用类似的方式理解。
站在硬件的角度看,操作系统本身又只是一个程序。它从机器启动后的某个入口开始执行,管理 CPU、内存和各种外部设备。
这两种视角最终在“虚拟化”上汇合:
操作系统把一台真实的物理计算机,抽象成许多台彼此隔离的“虚拟计算机”。
我们明明只有有限数量的 CPU 核心,但浏览器、编辑器、终端、音乐播放器却好像都在同时运行。原因并不是每个程序真的拥有一颗 CPU,而是操作系统不断让不同进程轮流运行:
while (1) {
Process *p = choose_one_ready_process();
p->run_for_a_while();
handle_event_or_syscall(p);
}
这段代码当然只是概念模型。真实操作系统通常不会逐条解释应用程序指令,而是让程序直接在 CPU 上执行,再借助时钟中断、异常、系统调用和上下文切换重新获得控制权。
但这个看似简单的循环,已经揭示了 CPU 虚拟化的核心:
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 却选择了一个看起来有些奇怪的组合:
| 创建 | fork() | 复制当前状态机 |
| 执行另一程序 | execve() | 用新程序重置当前状态机 |
| 等待子进程 | wait() / waitpid() | 等待子状态机发生特定变化并回收结果 |
| 退出 | _exit() | 销毁当前状态机并留下退出状态 |
所以,UNIX 中“启动一个新程序”的经典流程并不是一个调用,而是:
fork + execve
父进程先复制出一个子进程,再由子进程把自己替换成目标程序。这个设计看似绕了一步,却给 shell 的重定向、管道、权限调整和文件描述符安排留下了非常灵活的操作空间。
六、fork:复制一个正在运行的进程
fork 的函数原型很简单:
#include <unistd.h>
pid_t fork(void);
它最特殊的地方在于:调用一次,却可能返回两次。
成功调用后,父进程和子进程都会从 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):
因此,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、退出状态和少量统计信息;
- 父进程尚未调用 wait 或 waitpid 取走这些信息。
孤儿进程还活着,僵尸进程已经死了。两者完全不是一回事。
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 复制进程地址空间,也就复制了缓冲区:
所以,真正被复制的不是已经写入终端或管道的数据,而是还留在用户空间内存中的 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 的形式。PATH、HOME、PWD、LANG 等都属于环境变量。
Shell 中的:
export MY_KEY=hello
本质上是在修改 shell 自己的环境,使它之后创建并执行的子进程能够继承或获得相应变量。
2. 哪些状态被替换,哪些状态会保留?
execve 并没有创建新的进程,所以调用前后 PID 不变。可以把常见状态分成两类:
| 代码段、数据段、堆、栈 | PID、PPID |
| 程序计数器和用户态执行现场 | 当前工作目录 |
| 原程序的内存映像 | 大部分进程身份信息 |
| 新的 argv 和 envp 由调用者提供 | 未设置 close-on-exec 的文件描述符 |
这张表只列出最核心的行为。信号处理方式、凭据、内存映射、定时器等都有更细的规则,系统编程时应查阅 execve(2)。
打开的文件描述符默认可以跨越 execve 保留,这一点极其重要:shell 正是借助它实现重定向和管道。
如果某个文件描述符不应该泄漏给新程序,应设置 FD_CLOEXEC,或者在创建文件描述符时使用 O_CLOEXEC 等原子选项。
3. execve 与 execl、execvp 有什么关系?
在 Linux 上,真正进入内核执行新程序的基础接口是 execve。libc 又提供了一组便于使用的 exec* 函数:
| 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);
二者最关键的差异是:
| libc 函数 | POSIX 进程终止接口 |
| 刷新并关闭 stdio 流 | 不刷新 stdio 流 |
| 调用 atexit 注册的函数 | 不调用 atexit 处理函数 |
| 完成用户态清理后终止进程 | 更直接地终止进程 |
这也是为什么 fork 后的子进程在 execve 失败时,通常写成:
execve(path, argv, envp);
perror("execve");
_exit(127);
而不是直接调用 exit。如果父进程在 fork 前存在尚未刷新的 stdio 缓冲,子进程调用 exit 可能再次刷新被复制的缓冲,造成重复输出。
在 Linux 的实现细节中,进程与线程还会带来 exit 和 exit_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
整个过程可以按时间顺序理解:
这就是一个最小版 shell 的核心流程。
十二、为什么 UNIX 要把 fork 和 exec 分开?
乍看之下,先复制自己,再立即把子进程替换掉,似乎有些多余。OSTEP 给出的关键答案是:
fork 与 exec 之间的空隙,让父进程或子进程有机会修改新程序即将继承的运行环境。
例如执行:
wc -l input.txt > result.txt
shell 可以在子进程中先完成这些操作:
管道的思路也类似:
cat input.txt | grep hello
shell 先创建管道,再通过 fork 建立子进程,并在 exec 前把不同进程的标准输入、标准输出接到管道两端。
因此,分离 fork 和 exec 并不是历史上随意留下的怪接口,而是 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,跟踪结果中出现 clone 或 clone3;
- 源代码调用 _exit,底层出现 exit_group;
- printf 并不立刻对应 write,而是在缓冲区刷新时集中写出。
这并不矛盾。应用程序调用的 libc 接口可以在内部选择合适的底层系统调用。学习操作系统时,要有意识地区分:
十四、理解状态机
把整节课压缩成一条主线,可以得到:
因此,下面这段代码不只是固定模板:
pid_t pid = fork();
if (pid == –1) {
// 创建失败
} else if (pid == 0) {
// 子进程:设置环境、重定向、exec
} else {
// 父进程:继续工作或等待子进程
}
它实际上表达了一套完整的状态机管理思想:
复制状态 → 调整环境 → 装入新程序 → 等待结果 → 回收状态
理解到这里,进程就不再是课本里一句“正在运行的程序”,而是一个可以被观察、复制、替换、调度和回收的真实对象。
网硕互联帮助中心





评论前必须登录!
注册