第9讲 进程与线程I

第9讲 进程与线程I
agsd进程与线程 I:从进程抽象到 fork / exec 的完整理解
本文根据上传的《第9讲 进程与线程I.md》整理而成,目标是把原始对话中的讲解、追问和修正重构为一篇可独立复习的学习文档。 输出方式遵循对话转正式文章的规则要求。
引言:本讲真正要解决的问题
“进程与线程 I”这一讲虽然标题同时包含进程和线程,但核心内容首先是进程。进程是操作系统管理程序运行的基本抽象。理解进程之后,才能继续理解线程、调度、同步、上下文切换等后续内容。
本讲的主线可以概括为:
操作系统把“正在运行的程序”抽象成进程,再通过 PCB 记录它,通过状态机管理它,通过上下文切换暂停和恢复它,并通过
fork、exec、wait、exit等系统调用完成进程的创建、替换、等待和退出。
换言之,本讲不是单纯记几个概念,而是要理解操作系统如何把一个静态程序变成一个可管理、可调度、可暂停、可恢复、可终止的运行实体。
1. 为什么需要进程
早期计算机一次只运行一个任务。如果程序需要等待键盘输入、磁盘读取或网络响应,CPU 就可能空闲。为了提高 CPU 利用率,操作系统引入了多道程序和分时机制。
一个任务等待 I/O 时,操作系统可以切换到另一个任务继续执行。这样,CPU 不必陪着某个程序一起“干等”。
flowchart TD
A[进程 A 运行] --> B[进程 A 等待 I/O]
B --> C[操作系统切换到进程 B]
C --> D[进程 B 运行]
D --> E[进程 A 等待事件完成]
E --> F[进程 A 重新进入就绪队列]
这张图说明:进程的存在不是为了给“程序”换一个名字,而是为了让操作系统可以统一管理多个正在运行的任务。
2. 程序与进程的区别
最常见的说法是:
进程是运行中的程序。
这句话正确,但不够完整。更准确地说:
| 概念 | 特征 | 例子 |
|---|---|---|
| 程序 | 静态文件,由代码和数据组成 | hello.c、a.out、/bin/ls |
| 进程 | 程序运行起来后的动态实体 | 执行中的 ./a.out |
| PCB | 操作系统管理进程的数据结构 | 记录 pid、状态、上下文、地址空间等 |
程序是静态的,进程是动态的。程序放在磁盘上时,只是一组代码和数据;当它被加载、分配地址空间、初始化栈、开始执行后,才成为进程。
进程通常包含两类内容:
| 部分 | 含义 | 例子 |
|---|---|---|
| 静态部分 | 程序运行所需的代码和初始数据 | 代码段、数据段、只读数据 |
| 动态部分 | 程序运行过程中变化的状态 | PC、寄存器、栈、堆、打开文件、运行状态 |
因此,不能简单说“进程就是程序”。程序更像菜谱,进程更像正在做菜的过程。
3. 进程状态:从创建到终止
操作系统通常用若干状态描述进程当前所处的位置。基本状态包括:
- 新生态
new - 就绪态
ready - 运行态
running - 阻塞态
blocked - 终止态
terminated
flowchart TD
New[新生态 new] --> Ready[就绪态 ready]
Ready --> Running[运行态 running]
Running --> Ready
Running --> Blocked[阻塞态 blocked]
Blocked --> Ready
Running --> Terminated[终止态 terminated]
各状态的含义如下:
| 状态 | 含义 |
|---|---|
| 新生态 | 进程刚被创建,操作系统正在初始化它 |
| 就绪态 | 进程已经准备好运行,但暂时没有获得 CPU |
| 运行态 | 进程正在 CPU 上执行 |
| 阻塞态 | 进程在等待某个事件,暂时无法继续执行 |
| 终止态 | 进程执行结束,等待资源回收 |
最重要的易错点是:
阻塞态不能直接进入运行态。
一个进程如果因为等待输入、磁盘、网络或其他事件进入阻塞态,那么事件完成后,它只能先回到就绪态。之后是否运行,还要看调度器是否选择它。
所以正确路径是:
1 | blocked -> ready -> running |
而不是:
1 | blocked -> running |
4. PCB:操作系统如何记住一个进程
PCB 是 Process Control Block,即进程控制块。可以把它理解为操作系统为每个进程建立的“档案袋”。
系统中不是只有一个 PCB,而是通常每个进程都有自己的 PCB:
1 | 进程 A -> PCB_A |
PCB 中常见的信息包括:
| 信息 | 作用 |
|---|---|
pid |
标识这是哪个进程 |
state |
记录进程当前处于新生、就绪、运行、阻塞还是终止 |
context |
保存进程被切换时的 CPU 状态 |
| 地址空间信息 | 记录进程的虚拟地址空间、页表等 |
| 内核栈 | 进程进入内核态时使用的栈 |
| 打开文件信息 | 记录进程打开了哪些文件 |
| 父子关系 | 记录父进程、子进程等关系 |
| 调度信息 | 记录优先级、运行时间等调度相关数据 |
一个考试版答案可以写成:
PCB 至少保存进程标识符、进程状态、上下文、地址空间、内核栈、打开文件等资源信息。
其中最关键的是 context,因为上下文切换依赖它。
5. 上下文切换:谁在保存,谁在恢复
上下文 context 指的是进程运行时 CPU 和相关硬件所处的状态,包括:
- 通用寄存器
- 程序计数器 PC
- 栈指针
- 页表基地址寄存器
- CPU 状态寄存器
- 其他与执行恢复有关的硬件状态
所谓上下文切换,就是 CPU 从进程 A 切换到进程 B 的过程。更准确地说,这个过程的主语不是进程 A 或进程 B,而是:
操作系统内核,尤其是调度器和上下文切换代码;部分动作由硬件辅助完成。
完整过程可以写成:
- 进程 A 因为时钟中断、系统调用或异常进入内核态。
- 内核保存进程 A 的 CPU 状态到
PCB_A。 - 调度器选择下一个要运行的进程 B。
- 内核切换到进程 B 的地址空间和内核栈。
- 内核从
PCB_B中恢复进程 B 的上下文。 - CPU 返回用户态,进程 B 从上次暂停的位置继续执行。
sequenceDiagram
participant A as 进程 A
participant Kernel as 操作系统内核
participant PCB_A as PCB_A
participant PCB_B as PCB_B
participant B as 进程 B
A->>Kernel: 中断 / 系统调用 / 异常
Kernel->>PCB_A: 保存 A 的上下文
Kernel->>Kernel: 调度器选择 B
Kernel->>PCB_B: 读取 B 的上下文
Kernel->>B: 恢复 B 并返回用户态
这说明,上下文切换不是进程主动“自己保存自己”,而是进入内核后由操作系统接管。PCB 之所以必须保存上下文,是因为操作系统需要依靠它暂停和恢复进程。
6. fork:一次调用,两次返回
fork() 是 Unix / Linux 中创建进程的经典接口。它的作用是:
为当前进程创建一个几乎一样的新进程。
调用 fork() 的进程叫父进程,新创建出来的进程叫子进程。
一个最小例子如下:
1 |
|
fork() 之前,系统中只有一个进程。执行 fork() 之后,进程变成两个:
1 | 父进程 P |
它们都会从 fork() 返回之后的位置继续执行。最特殊的是,fork() 在两个进程中的返回值不同:
| 情况 | fork() 返回值 |
|---|---|
| 父进程中 | 返回子进程的 pid,是一个正数 |
| 子进程中 | 返回 0 |
| 创建失败 | 返回负数 |
因此,fork() 的核心特征是:
一次调用,两次返回。
也正因为返回值不同,程序才能区分自己当前处于父进程还是子进程中:
1 | int rc = fork(); |
7. fork 后父子进程的关系
fork() 后,父进程和子进程有几个关键关系。
第一,父子进程拥有不同的 pid。它们是两个不同的进程。
第二,父子进程从 fork() 之后继续执行。子进程不是从 main() 开头重新执行,而是从 fork() 返回的位置继续往下走。
第三,父子进程看到的 fork() 返回值不同。父进程得到子进程 pid,子进程得到 0。这是判断父子分支的核心依据。
第四,普通用户态变量通常相互独立。父进程和子进程各自拥有独立的虚拟地址空间。子进程修改自己的普通变量,不会直接修改父进程的变量。
第五,某些内核对象可能共享。例如,父子进程可能共享同一个打开文件结构,因此文件偏移量也可能共享。
这解释了为什么父子进程共同读取同一个文件时,不一定会读到完全相同的内容。如果父进程先读走前 10 个字符,文件偏移量已经后移,子进程再读就可能读到后 10 个字符。反过来,如果子进程先读,也是同理。输出顺序不确定,但偏移量共享这一点非常重要。
8. fork 题的计算方法
考虑代码:
1 | int main() { |
分析进程数量时,可以把每次 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 | printf("before exec\n"); |
如果 execve() 成功,after exec 通常不会被打印。因为当前进程的代码段已经被 /bin/ls 的代码替换,原程序中 execve() 后面的语句已经不再存在于当前进程的地址空间中。
只有当 execve() 失败时,才会继续执行后面的代码。因此常见写法是:
1 | execve("/bin/ls", argv, envp); |
这里的意思是:如果执行到了 perror,说明 execve 失败了。
10. 为什么 shell 通常是 fork 后再 exec
在 shell 中输入:
1 | ls |
用户真正希望的是:
- shell 仍然存在。
- 新启动一个程序去执行
ls。 ls执行完后,shell 继续等待下一条命令。
如果 shell 直接调用 exec("ls"),那么 shell 自己就会被 ls 替换掉。ls 执行完后,shell 也就不存在了。这显然不符合交互式 shell 的需求。
因此,shell 通常采用如下流程:
flowchart TD
A[Shell 进程] --> B[fork 创建子进程]
B --> C[父进程仍是 Shell]
B --> D[子进程是 Shell 的复制品]
D --> E[子进程 exec 为 ls]
C --> F[父进程 wait 等待子进程结束]
E --> G[ls 执行结束]
G --> H[Shell 继续读取下一条命令]
对应代码结构大致如下:
1 |
|
执行过程是:
- 开始只有 shell 进程。
- shell 调用
fork()创建子进程。 - 父进程仍然是 shell。
- 子进程调用
execve(),把自己替换成/bin/ls。 - 父进程调用
wait()等待子进程结束。 - 子进程执行完
ls后退出。 - 父进程 shell 继续接收下一条命令。
这里需要精确区分:
shell 的父进程
wait等的是子进程执行完毕;子进程结束后,shell 才继续读取用户的下一条命令。
11. wait 与 exit:进程结束如何被回收
wait() 用于父进程等待子进程结束,并获取子进程的退出状态。它还有一个重要作用:回收子进程结束后遗留的部分资源。
如果子进程已经结束,但父进程还没有调用 wait() 或相关接口获取其退出状态,那么子进程可能暂时处于僵尸状态。僵尸进程不是还在运行,而是进程已经结束,但仍保留少量信息等待父进程回收。
exit() 用于正常退出当前进程。abort() 更偏向异常终止。对本讲而言,只需要把握:
exit():当前进程正常退出。abort():当前进程异常终止。wait():父进程等待并回收子进程退出信息。
12. 本讲核心结构
flowchart TD
A[程序] --> B[加载并运行]
B --> C[进程]
C --> D[PCB]
C --> E[进程状态]
C --> F[上下文切换]
C --> G[系统调用]
E --> E1[new]
E --> E2[ready]
E --> E3[running]
E --> E4[blocked]
E --> E5[terminated]
G --> G1[fork 创建子进程]
G --> G2[exec 替换程序映像]
G --> G3[wait 等待子进程]
G --> G4[exit 退出进程]
本讲应掌握的主干知识包括:
- 进程不是静态程序,而是程序运行时的抽象。
- 每个进程通常有自己的 PCB。
- PCB 保存 pid、状态、上下文、地址空间、内核栈、打开文件等信息。
- 上下文切换由操作系统内核主导,核心是保存旧进程上下文并恢复新进程上下文。
- 阻塞态不能直接进入运行态,必须先回到就绪态。
fork()的核心是一次调用、两次返回。- 父进程中
fork()返回子进程 pid,子进程中返回 0。 exec()不创建新进程,而是在当前进程中替换程序映像。- shell 通常先
fork(),再让子进程exec(),父进程wait()。 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 不希望自己被 ls、gcc、vim 等命令替换掉,所以先 fork() 创建子进程,再让子进程 exec() 目标程序。父进程 shell 可以 wait() 子进程结束,之后继续读取用户输入。
题 6:下面代码打印几次 hello?
1 | int main() { |
打印 4 次。
原因是:
1 | 开始:1 个进程 |
最终 4 个进程都会执行 printf("hello\n")。
结论
“进程与线程 I”的关键不是背诵定义,而是建立一条完整链路:
程序被加载后成为进程;进程由 PCB 记录;PCB 保存状态、上下文、地址空间等信息;调度器通过上下文切换在多个进程之间切换 CPU;
fork()创建子进程;exec()替换当前进程的程序映像;wait()等待并回收子进程;exit()终止当前进程。
掌握这一链路后,进程状态、PCB、上下文切换、fork、exec 就不再是孤立概念,而是操作系统管理程序运行的一套连续机制。




