第9讲 进程与线程I

进程与线程 I:从进程抽象到 fork / exec 的完整理解

本文根据上传的《第9讲 进程与线程I.md》整理而成,目标是把原始对话中的讲解、追问和修正重构为一篇可独立复习的学习文档。 输出方式遵循对话转正式文章的规则要求。


引言:本讲真正要解决的问题

“进程与线程 I”这一讲虽然标题同时包含进程和线程,但核心内容首先是进程。进程是操作系统管理程序运行的基本抽象。理解进程之后,才能继续理解线程、调度、同步、上下文切换等后续内容。

本讲的主线可以概括为:

操作系统把“正在运行的程序”抽象成进程,再通过 PCB 记录它,通过状态机管理它,通过上下文切换暂停和恢复它,并通过 forkexecwaitexit 等系统调用完成进程的创建、替换、等待和退出。

换言之,本讲不是单纯记几个概念,而是要理解操作系统如何把一个静态程序变成一个可管理、可调度、可暂停、可恢复、可终止的运行实体。


1. 为什么需要进程

早期计算机一次只运行一个任务。如果程序需要等待键盘输入、磁盘读取或网络响应,CPU 就可能空闲。为了提高 CPU 利用率,操作系统引入了多道程序和分时机制。

一个任务等待 I/O 时,操作系统可以切换到另一个任务继续执行。这样,CPU 不必陪着某个程序一起“干等”。

这张图说明:进程的存在不是为了给“程序”换一个名字,而是为了让操作系统可以统一管理多个正在运行的任务。


2. 程序与进程的区别

最常见的说法是:

进程是运行中的程序。

这句话正确,但不够完整。更准确地说:

概念 特征 例子
程序 静态文件,由代码和数据组成 hello.ca.out/bin/ls
进程 程序运行起来后的动态实体 执行中的 ./a.out
PCB 操作系统管理进程的数据结构 记录 pid、状态、上下文、地址空间等

程序是静态的,进程是动态的。程序放在磁盘上时,只是一组代码和数据;当它被加载、分配地址空间、初始化栈、开始执行后,才成为进程。

进程通常包含两类内容:

部分 含义 例子
静态部分 程序运行所需的代码和初始数据 代码段、数据段、只读数据
动态部分 程序运行过程中变化的状态 PC、寄存器、栈、堆、打开文件、运行状态

因此,不能简单说“进程就是程序”。程序更像菜谱,进程更像正在做菜的过程。


3. 进程状态:从创建到终止

操作系统通常用若干状态描述进程当前所处的位置。基本状态包括:

  1. 新生态 new
  2. 就绪态 ready
  3. 运行态 running
  4. 阻塞态 blocked
  5. 终止态 terminated

各状态的含义如下:

状态 含义
新生态 进程刚被创建,操作系统正在初始化它
就绪态 进程已经准备好运行,但暂时没有获得 CPU
运行态 进程正在 CPU 上执行
阻塞态 进程在等待某个事件,暂时无法继续执行
终止态 进程执行结束,等待资源回收

最重要的易错点是:

阻塞态不能直接进入运行态。

一个进程如果因为等待输入、磁盘、网络或其他事件进入阻塞态,那么事件完成后,它只能先回到就绪态。之后是否运行,还要看调度器是否选择它。

所以正确路径是:

1
blocked -> ready -> running

而不是:

1
blocked -> running

4. PCB:操作系统如何记住一个进程

PCB 是 Process Control Block,即进程控制块。可以把它理解为操作系统为每个进程建立的“档案袋”。

系统中不是只有一个 PCB,而是通常每个进程都有自己的 PCB:

1
2
3
进程 A -> PCB_A
进程 B -> PCB_B
进程 C -> PCB_C

PCB 中常见的信息包括:

信息 作用
pid 标识这是哪个进程
state 记录进程当前处于新生、就绪、运行、阻塞还是终止
context 保存进程被切换时的 CPU 状态
地址空间信息 记录进程的虚拟地址空间、页表等
内核栈 进程进入内核态时使用的栈
打开文件信息 记录进程打开了哪些文件
父子关系 记录父进程、子进程等关系
调度信息 记录优先级、运行时间等调度相关数据

一个考试版答案可以写成:

PCB 至少保存进程标识符、进程状态、上下文、地址空间、内核栈、打开文件等资源信息。

其中最关键的是 context,因为上下文切换依赖它。


5. 上下文切换:谁在保存,谁在恢复

上下文 context 指的是进程运行时 CPU 和相关硬件所处的状态,包括:

  • 通用寄存器
  • 程序计数器 PC
  • 栈指针
  • 页表基地址寄存器
  • CPU 状态寄存器
  • 其他与执行恢复有关的硬件状态

所谓上下文切换,就是 CPU 从进程 A 切换到进程 B 的过程。更准确地说,这个过程的主语不是进程 A 或进程 B,而是:

操作系统内核,尤其是调度器和上下文切换代码;部分动作由硬件辅助完成。

完整过程可以写成:

  1. 进程 A 因为时钟中断、系统调用或异常进入内核态。
  2. 内核保存进程 A 的 CPU 状态到 PCB_A
  3. 调度器选择下一个要运行的进程 B。
  4. 内核切换到进程 B 的地址空间和内核栈。
  5. 内核从 PCB_B 中恢复进程 B 的上下文。
  6. CPU 返回用户态,进程 B 从上次暂停的位置继续执行。

这说明,上下文切换不是进程主动“自己保存自己”,而是进入内核后由操作系统接管。PCB 之所以必须保存上下文,是因为操作系统需要依靠它暂停和恢复进程。


6. fork:一次调用,两次返回

fork() 是 Unix / Linux 中创建进程的经典接口。它的作用是:

为当前进程创建一个几乎一样的新进程。

调用 fork() 的进程叫父进程,新创建出来的进程叫子进程。

一个最小例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
#include <unistd.h>

int main() {
int x = 42;

int rc = fork();

printf("rc = %d, x = %d\n", rc, x);

return 0;
}

fork() 之前,系统中只有一个进程。执行 fork() 之后,进程变成两个:

1
2
父进程 P
子进程 C

它们都会从 fork() 返回之后的位置继续执行。最特殊的是,fork() 在两个进程中的返回值不同:

情况 fork() 返回值
父进程中 返回子进程的 pid,是一个正数
子进程中 返回 0
创建失败 返回负数

因此,fork() 的核心特征是:

一次调用,两次返回。

也正因为返回值不同,程序才能区分自己当前处于父进程还是子进程中:

1
2
3
4
5
6
7
8
9
int rc = fork();

if (rc == 0) {
// 子进程
} else if (rc > 0) {
// 父进程
} else {
// fork 失败
}

7. fork 后父子进程的关系

fork() 后,父进程和子进程有几个关键关系。

第一,父子进程拥有不同的 pid。它们是两个不同的进程。

第二,父子进程从 fork() 之后继续执行。子进程不是从 main() 开头重新执行,而是从 fork() 返回的位置继续往下走。

第三,父子进程看到的 fork() 返回值不同。父进程得到子进程 pid,子进程得到 0。这是判断父子分支的核心依据。

第四,普通用户态变量通常相互独立。父进程和子进程各自拥有独立的虚拟地址空间。子进程修改自己的普通变量,不会直接修改父进程的变量。

第五,某些内核对象可能共享。例如,父子进程可能共享同一个打开文件结构,因此文件偏移量也可能共享。

这解释了为什么父子进程共同读取同一个文件时,不一定会读到完全相同的内容。如果父进程先读走前 10 个字符,文件偏移量已经后移,子进程再读就可能读到后 10 个字符。反过来,如果子进程先读,也是同理。输出顺序不确定,但偏移量共享这一点非常重要。


8. fork 题的计算方法

考虑代码:

1
2
3
4
5
int main() {
fork();
fork();
printf("hello\n");
}

分析进程数量时,可以把每次 fork() 理解为当前所有进程各自复制一份。

初始时:

1
1 个进程

第一次 fork() 后:

1
1 -> 2 个进程

第二次 fork() 后:

1
2 -> 4 个进程

因此,最终有 4 个进程执行:

1
printf("hello\n");

所以 hello 打印 4 次。

需要注意:打印顺序不固定。因为多个进程的调度顺序由操作系统决定。


9. exec:不是创建新进程,而是替换当前进程

exec()fork() 经常一起出现,但二者作用完全不同。

操作 作用 是否创建新进程 是否替换当前程序内容
fork() 创建子进程
exec() 在当前进程中加载另一个程序
wait() 等待子进程结束
exit() 当前进程退出

exec() 的准确含义是:

在当前进程中加载一个新的可执行文件,用新程序的代码、数据和地址空间替换原来的程序映像。

它不会创建新进程。执行 exec() 前后,进程的 pid 通常不变,PCB 仍然代表同一个进程。变化的是这个进程里面运行的程序内容。

例如:

1
2
3
4
5
printf("before exec\n");

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

printf("after exec\n");

如果 execve() 成功,after exec 通常不会被打印。因为当前进程的代码段已经被 /bin/ls 的代码替换,原程序中 execve() 后面的语句已经不再存在于当前进程的地址空间中。

只有当 execve() 失败时,才会继续执行后面的代码。因此常见写法是:

1
2
execve("/bin/ls", argv, envp);
perror("execve failed");

这里的意思是:如果执行到了 perror,说明 execve 失败了。


10. 为什么 shell 通常是 fork 后再 exec

在 shell 中输入:

1
ls

用户真正希望的是:

  1. shell 仍然存在。
  2. 新启动一个程序去执行 ls
  3. ls 执行完后,shell 继续等待下一条命令。

如果 shell 直接调用 exec("ls"),那么 shell 自己就会被 ls 替换掉。ls 执行完后,shell 也就不存在了。这显然不符合交互式 shell 的需求。

因此,shell 通常采用如下流程:

对应代码结构大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
int rc = fork();

if (rc == 0) {
char *argv[] = {"ls", "-l", NULL};
execve("/bin/ls", argv, NULL);

// exec 成功后不会执行到这里
printf("exec failed\n");
} else {
wait(NULL);
printf("child finished\n");
}

return 0;
}

执行过程是:

  1. 开始只有 shell 进程。
  2. shell 调用 fork() 创建子进程。
  3. 父进程仍然是 shell。
  4. 子进程调用 execve(),把自己替换成 /bin/ls
  5. 父进程调用 wait() 等待子进程结束。
  6. 子进程执行完 ls 后退出。
  7. 父进程 shell 继续接收下一条命令。

这里需要精确区分:

shell 的父进程 wait 等的是子进程执行完毕;子进程结束后,shell 才继续读取用户的下一条命令。


11. wait 与 exit:进程结束如何被回收

wait() 用于父进程等待子进程结束,并获取子进程的退出状态。它还有一个重要作用:回收子进程结束后遗留的部分资源。

如果子进程已经结束,但父进程还没有调用 wait() 或相关接口获取其退出状态,那么子进程可能暂时处于僵尸状态。僵尸进程不是还在运行,而是进程已经结束,但仍保留少量信息等待父进程回收。

exit() 用于正常退出当前进程。abort() 更偏向异常终止。对本讲而言,只需要把握:

  • exit():当前进程正常退出。
  • abort():当前进程异常终止。
  • wait():父进程等待并回收子进程退出信息。

12. 本讲核心结构

本讲应掌握的主干知识包括:

  1. 进程不是静态程序,而是程序运行时的抽象。
  2. 每个进程通常有自己的 PCB。
  3. PCB 保存 pid、状态、上下文、地址空间、内核栈、打开文件等信息。
  4. 上下文切换由操作系统内核主导,核心是保存旧进程上下文并恢复新进程上下文。
  5. 阻塞态不能直接进入运行态,必须先回到就绪态。
  6. fork() 的核心是一次调用、两次返回。
  7. 父进程中 fork() 返回子进程 pid,子进程中返回 0。
  8. exec() 不创建新进程,而是在当前进程中替换程序映像。
  9. shell 通常先 fork(),再让子进程 exec(),父进程 wait()
  10. wait() 用于等待并回收子进程退出信息。

13. 易错点整理

易错说法 修正
进程就是程序 进程是程序运行起来后的动态抽象
系统中只有一个 PCB 通常每个进程都有自己的 PCB
进程自己保存上下文 上下文切换主要由操作系统内核完成,硬件辅助
blocked 可以直接 running blocked 必须先回到 ready,再由调度器选择进入 running
fork() 后子进程从 main() 开始执行 子进程从 fork() 返回的位置继续执行
fork() 在父子进程中返回值相同 父进程返回子进程 pid,子进程返回 0
exec() 会创建新进程 exec() 不创建新进程,只替换当前进程的程序映像
shell 执行命令时直接 exec 就行 直接 exec 会让 shell 被命令程序替换,所以通常要先 fork

14. 自测题答案修正版

题 1:为什么不能说“进程就是程序”?

程序是静态的代码和数据,进程是程序运行起来之后由操作系统管理的动态实体。进程不仅包含代码和数据,还包含运行状态、地址空间、寄存器上下文、栈、堆、打开文件等信息。

题 2:哪个状态转换不可能直接发生?

答案是:

1
blocked -> running

原因是阻塞态进程等待的事件完成后,只能先进入就绪态;之后要等调度器选择,才能进入运行态。

题 3:PCB 至少需要保存哪些信息?为什么上下文要放在 PCB 里?

PCB 至少需要保存:

  • pid
  • 进程状态
  • 上下文
  • 地址空间信息
  • 内核栈
  • 打开文件信息
  • 父子进程关系
  • 调度相关信息

上下文要放在 PCB 中,是因为进程被切走时,操作系统需要把它当前的 CPU 状态保存起来;之后恢复该进程时,又要从 PCB 中取出这些状态,让它从上次停止的位置继续执行。

题 4:fork 后父进程和子进程最关键的区别是什么?

最关键的是:

  • pid 不同;
  • fork() 返回值不同;
  • 父进程中返回子进程 pid;
  • 子进程中返回 0。

此外,父子进程从 fork() 后继续执行,普通变量通常各自独立,但部分内核对象可能共享,例如打开文件结构。

题 5:fork 和 exec 的区别是什么?为什么 shell 通常 fork 后再 exec?

fork() 用于创建子进程;exec() 不创建新进程,而是在当前进程中加载另一个程序,替换当前进程的代码、数据和地址空间。

shell 不希望自己被 lsgccvim 等命令替换掉,所以先 fork() 创建子进程,再让子进程 exec() 目标程序。父进程 shell 可以 wait() 子进程结束,之后继续读取用户输入。

题 6:下面代码打印几次 hello?

1
2
3
4
5
int main() {
fork();
fork();
printf("hello\n");
}

打印 4 次。

原因是:

1
2
3
开始:1 个进程
第一次 fork:1 -> 2
第二次 fork:2 -> 4

最终 4 个进程都会执行 printf("hello\n")


结论

“进程与线程 I”的关键不是背诵定义,而是建立一条完整链路:

程序被加载后成为进程;进程由 PCB 记录;PCB 保存状态、上下文、地址空间等信息;调度器通过上下文切换在多个进程之间切换 CPU;fork() 创建子进程;exec() 替换当前进程的程序映像;wait() 等待并回收子进程;exit() 终止当前进程。

掌握这一链路后,进程状态、PCB、上下文切换、forkexec 就不再是孤立概念,而是操作系统管理程序运行的一套连续机制。