第 17 讲:进程、线程与状态转换

程序是磁盘上静态的指令和数据,进程是“这份程序正在某份资源和 CPU 现场上执行”。操作系统不能只管代码文件,它要管理每一次动态执行,所以需要进程这个抽象。

课件还区分作业:作业是用户提交给系统的一项完整工作,通常带程序、数据和控制说明;高级调度接纳一个作业后,它可以在执行阶段建立一个或多个进程。于是“程序”是静态内容,“作业”是提交/管理单位,“进程”是正在推进并竞争资源的执行实体。

并发不等于并行

  • 顺序:活动 A 结束后 B 才开始;
  • 并发:A 和 B 的生命周期在时间上重叠,可能在单核上交替执行;
  • 并行:同一物理时刻,A 和 B 真的在不同执行单元上运行。

单核也有并发,多核才提供硬件并行的可能。并发程序具有间断性、共享资源导致的非封闭性,以及时序不同导致的不可再现性。

一个丢失更新的例子

共享变量 x=5,两个执行流都做 x=x+1。这条高级语句至少要读、加、写:

A: read x -> 5
B: read x -> 5
A: write 6
B: write 6

结果是 6,而不是期望的 7。问题不是 CPU “算错了”,而是结果依赖并发执行的相对时序,这就是竞争条件。

Bernstein 条件:两段代码能否独立并发

对语句 SiS_i,记读变量集为 R(Si)R(S_i),写变量集为 W(Si)W(S_i)。S1,S2S_1,S_2 可无冲突并发的必要检查是:

R(S1)∩W(S2)=∅,R(S_1)\cap W(S_2)=\varnothing, W(S1)∩R(S2)=∅,W(S_1)\cap R(S_2)=\varnothing, W(S1)∩W(S2)=∅.W(S_1)\cap W(S_2)=\varnothing.

前两个排除“一个读时另一个写”,第三个排除写写冲突。例如:

S1: c = a + b       R1={a,b}, W1={c}
S2: d = a - b       R2={a,b}, W2={d}

三个交集都为空,两者可并发。如果 S2: a=d-b,则 W(S2)W(S_2) 含 aa,与 R(S1)R(S_1) 冲突。

进程不只是代码

一个进程至少包含:

  • 代码、全局数据、堆和用户栈;
  • 程序计数器、通用寄存器、栈指针等 CPU 现场;
  • 独立虚拟地址空间的页表根;
  • 打开文件、信号处理、权限、计时和调度信息;
  • 内核为它维护的进程控制块 PCB。

进程的动态性表现在从创建到终止有寿命;同一程序可被运行多次成为多个进程;同一进程也可通过 exec 替换正在执行的程序映像。

基本状态机

              被调度
     就绪 ───────→ 运行
       ↑               │  │
       │ I/O/事件完成   │  └─时间片到/被抢占─→ 就绪
       │               │
     阻塞 ←─等待 I/O/信号量/事件─┘
  • 就绪:除 CPU 外的运行条件已具备;
  • 运行:正在某个 CPU 上执行;
  • 阻塞/等待:即使现在给 CPU 也无法继续,必须等事件。

最常见的错误是把“等 CPU”称为阻塞。那是就绪;阻塞的进程不应放在就绪队列里空转。

更完整模型还可加新建、终止、就绪挂起、阻塞挂起。“挂起”强调进程已被中级调度移出主存,与是否正在等事件是两个维度。

包含新建和终止状态的进程状态转换图

PCB 是 OS 眼中的进程

PCB 常记录:

类别示例
身份PID、父进程、用户/组
CPU 现场PC、SP、寄存器、处理器状态
调度状态、优先级、时间片、队列链接
内存页表根、地址空间描述
资源打开文件、I/O 状态、信号
计费/统计CPU 时间、创建时间等

内核常按状态将 PCB 组成就绪队列和各事件等待队列。所谓“把进程从阻塞变为就绪”,在实现上就是修改 PCB 状态,把它从一条队列移到另一条。

进程控制原语改变什么

提交批作业、用户启动程序、正在运行的进程创建子进程或系统服务需要工作者,都可能触发创建;正常退出、致命异常、被父进程/管理员终止或系统关闭则会撤销进程。创建/撤销、阻塞/唤醒等控制路径要原子地维护:

  1. 分配或释放 PID、PCB、地址空间和资源引用;
  2. 保存/建立 CPU 初始现场;
  3. 修改父子关系、状态和统计;
  4. 把 PCB 加入或移出正确队列。

课件所说的“原语”强调这段状态修改不能被观察成半完成。例如唤醒不能只把状态字改成就绪却忘了入队,否则进程逻辑上可运行、调度器却永远找不到它。

fork 与 exec 不是同一件事

fork() 复制当前执行环境,一次调用在父子两个进程中各返回一次:

  • 父进程得到子 PID;
  • 子进程得到 0;
  • 失败时父调用返回负值。

现代实现使用写时复制,不会立即拷贝全部物理内存。exec 则保留进程身份等内核上下文,用新可执行文件替换当前地址空间和用户寄存现场。

常见 Shell 流程是:先 fork 创建子进程,子进程再 exec 目标程序;Shell 自己就不会被覆盖。

模式切换与进程上下文切换

模式切换:同一进程因系统调用/异常从用户态进入内核态,服务完后可直接返回原进程。

进程切换:调度器决定让 B 代替 A 占用 CPU,需要:

  1. 保存 A 的 PC、SP 和寄存器到 A 的 PCB/内核栈;
  2. 更新 A 状态并加入就绪或阻塞队列;
  3. 选出 B,更新 B 状态;
  4. 切换地址空间、内核栈等与 B 相关的执行环境;
  5. 恢复 B 现场,从 B 上次停下的位置继续。

切换本身不完成用户工作,还会扰动 Cache/TLB,是并发性的成本。

为什么在进程里再引入线程

进程原本同时承担两种角色:

  1. 拥有地址空间、文件等资源;
  2. 作为 CPU 上的执行流。

线程把第二个角色拆出来。同一进程的线程共享代码、全局数据、堆、地址空间和打开文件;每个线程自有 PC、寄存器、调度状态和栈。

因此:

  • 创建和切换线程通常比进程轻;
  • 共享内存通信很直接;
  • 一个线程等 I/O 时,同进程其他线程可继续;
  • 但共享也意味着更容易出现数据竞争,一个线程的非法写可破坏整个进程。

线程的三种实现路线

用户级线程

线程库在用户空间管理线程表和切换,内核只看到一个进程。

  • 切换无需陷入内核,快且可自定调度;
  • 若一个线程执行会阻塞整个进程的系统调用,内核无法改调同进程其他用户线程;
  • 若只映射到一个内核执行实体,也不能真正在多核并行。

内核级线程

内核看见并调度每个线程。某线程阻塞时可运行其他线程,也可分布到多核;但创建、管理和切换需内核参与,开销较大。

混合模型

将 nn 个用户线程多路复用到 mm 个内核线程,试图兼顾快速用户级管理与内核并行/阻塞感知,但运行时和内核之间需要更复杂的协调。

同一进程的线程共享全局变量、堆和文件,但每个线程需要自己的栈、寄存器现场、调度状态以及线程局部存储(TLS)。正因为共享多,线程创建/通信较轻,也更容易因竞态破坏彼此。任务彼此不可信、需要强隔离,或共享状态收益很小而同步成本很高时,多进程可能比“全部改多线程”更稳妥。

映射含义主要特点
多对一多用户线程 → 1 内核线程快,但阻塞与多核受限
一对一每用户线程 → 1 内核线程并行好,内核开销大
多对多nn 用户线程 → mm 内核线程灵活,实现复杂

Pthreads 是 API 标准,不强制它必须由用户级还是内核级方式实现。Linux 也不必在核心调度对象中强行分出“进程类”和“线程类”,clone 可通过共享地址空间、文件等标志组合出不同程度的资源共享。

最后用三个“拥有者”检查

  • 程序:拥有静态代码和数据布局;
  • 进程:拥有地址空间、文件和保护边界;
  • 线程:拥有一条可被调度的 CPU 执行现场与栈。

回答资源是否共享、切换要保存什么、一个阻塞会不会拖住其他执行流时,先找对这三层归属。

评论