Lecture 2–3: 线程、进程与调度(Threads, Processes, and Dispatching)
Lecture 2–3: 线程、进程与调度(Threads, Processes, and Dispatching)
概述
本讲介绍操作系统最核心的两个执行抽象:线程(thread,最小的执行单元)与进程(process,一个或多个线程及其执行状态)。随后深入调度机制(dispatching):OS 如何在有限的核(core)上运行远多于核数的线程,包括进程控制块(PCB)、线程状态机、上下文切换(context switch),以及陷阱(trap)与中断(interrupt)如何驱动调度器运行。
核心概念与系统机制图解
线程(Thread)
- 定义:在一颗核上顺序执行的一段代码;它按顺序执行一串指令,因此”一次只发生一件事”,易于推理。
- 直观解释:线程是”房子里的住户”。同一进程内的住户共享公共空间(全局变量、堆)和设施(打开的文件),但各自有自己的私人物品(寄存器、调用栈)。
- 为什么需要多线程:利用多核并行;简化应用结构(如 Web 服务器为每个连接开一个线程)。
- 执行状态(Execution State):一切能影响或被线程影响的东西——代码、数据、寄存器、调用栈、打开的文件、网络连接、时间等。
进程(Process)
- 定义:一个或多个线程 + 它们的执行状态;是操作系统资源分配(内存、文件句柄等)的基本单位。
- 直观解释:进程是一座”房子”,拥有自己的地址空间(房间布局)、财产(数据)和住户(线程)。房子之间相互隔离——一个房子的问题通常不影响另一个房子。
线程间共享 vs 私有状态(Lecture 2 课堂问题):
| 状态 | 进程内线程间共享? |
|---|---|
| 代码(Code) | ✅ 共享 |
| 变量(Variables,全局/堆) | ✅ 共享 |
| 寄存器(Registers) | ❌ 每个线程私有 |
| 调用栈(Call Stack) | ❌ 每个线程私有 |
| 打开的文件 / 网络连接 | ✅ 共享 |
| 时钟(Time of day) | ✅ 共享 |
系统调用(System Call)
- 定义:用户进程请求内核执行特权操作的机制;调用序列因 OS 而异,但本质都是”陷入内核”。
- 机制:进程执行特殊指令(如 x86-64 的
syscall),CPU 切换到内核模式,跳到内核中预先注册的处理代码,执行完再返回用户模式。
用户进程 OS 内核
┌─────────────┐ ┌──────────────┐
│ Code │ 系统调用 │ 特权代码 │
│ Stack │ ──────────► │ 文件/进程/内存 │
│ Heap │ ◄────────── │ 管理 │
└─────────────┘ 返回 └──────────────┘
进程创建:fork / exec / wait(Linux)
fork():复制当前进程,产生一个几乎完全相同的子进程(子进程从 fork 返回处继续执行,返回 0;父进程得到子进程 PID)。execvp():用新程序的代码和数据覆盖当前进程——不创建新进程,只是换内容。waitpid():等待子进程结束,回收其退出状态。- 为什么”先复制再覆盖”:优点——在 exec 之前可以修改进程状态(改环境变量、重定向文件描述符,例如
ls > ls.out);缺点——大部分复制的工作被浪费。Windows 用CreateProcess(10 个参数)一步到位,Linux 只需 fork(0 参数)+ exec(2 参数)。
fork() 前 fork() 后 exec("ls") 后
父进程 父进程 子进程 父进程 子进程
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ 代码 │ │ 代码 │ │ 代码 │ │ 代码 │ │ ls代码 │
│ 数据 │ │ 数据 │ │ 数据(副本)│ │ 数据 │ │ ls数据 │
│ 栈 │ │ 栈 │ │ 栈 │ │ 栈 │ │ ls栈 │
└────────┘ └────────┘ └────────┘ └────────┘ └────────┘
\____ 复制(Copy) ____/ \___ 覆盖 ____/
线程创建
- 系统调用级:Linux
clone、macOSthread_create_running、WindowsNtCreateThreadEx;需要提供:起始程序计数器(要调用的函数)、线程栈区域与初始栈指针、参数传递方式。 - 语言库包装:C++
std::thread t(func); t.join();、Cpthread_create(...)、Gogo func()、Pythonthreading.Thread(...)。
核(Core)与多核演化
- 早期一台机器一个 CPU(单核);把多个处理器放进一颗芯片 → 多核(multicore);一颗核同时跑两个线程 → 同时多线程 SMT(Intel 超线程)。
- 今天的核数:手机约 8 核,笔记本 12–16,台式机 16–24,服务器 128–256 核(256–512 硬件线程)。
进程控制块(PCB, Process Control Block)
- 定义:内核为每个进程维护的数据结构,包含:
- 每个线程保存的执行状态(保存的寄存器等);
- 调度信息;
- 进程所用内存的信息;
- 打开文件的信息;
- 记账及其他杂项信息。
线程状态机
┌──────────┐
│ Create │
└────┬─────┘
▼
Load ┌──────────┐ Blocks ┌──────────┐
┌──────────►│ Ready │───────────►│ Blocked │
│ └────┬─────┘ └────┬─────┘
│ │ Unload │ Unblocks
│ ▼ │
│ ┌──────────┐ │
└───────────│ Running │◄───────────────┘
└──────────┘
│ Exit
▼
(Terminated)
- Ready:就绪,等待被调度到核上;Running:正在核上执行;Blocked:阻塞(等待 I/O、锁、事件)。
调度器(Dispatcher)与上下文切换
- 单核调度器主循环: ``` while true:
- 让某个线程运行一段时间
- 保存它的执行状态
- 加载另一个线程的状态 end while ← 第2、3步合称”上下文切换” ```
- 上下文切换机制图解(以线程 A 切换到 B 为例):
硬件寄存器 进程A控制块 进程B控制块 ┌──────────┐ ┌────────────┐ ┌────────────┐ │ R0..RN │ │ 保存的寄存器 │ │ 保存的寄存器 │ │ SP │──┐ │ (除SP外的 │ │ │ │ PC │ │ │ 全部状态) │ │ │ └──────────┘ │ │ SP → A3栈 │ │ SP → B1栈 │ │ └────────────┘ └────────────┘ ▼ ┌─────────────┐ ┌─────────────┐ │ A3 线程栈 │ │ B1 线程栈 │ │ (A 已运行) │ │ (B 已运行) │ └─────────────┘ └─────────────┘ 步骤: 1) 调用 context_switch 2) 把寄存器保存进 A 的 PCB(线程状态) 3) 把 SP 切换为 B 的栈指针, 从 B 的 PCB 恢复寄存器 4) 从 B 上次被中断/让出的地址继续执行 - 是什么触发调度器运行?
- 让进程自己回调 OS?→ 协作式多任务:坏进程会搞死机器。✗
- 陷阱(trap):系统调用、非法指令、段错误、页错误等,自动跳入 OS;
- 中断(interrupt):键盘按键、磁盘传输完成等外部事件;
- 定时器(timer):如果进程没有主动让出 CPU,定时器周期性中断强制切换(抢占式调度的基础)。
- 线程是怎么”出生”的:创建线程时,内核把 PCB 线程状态和栈初始化为”看起来刚从第一行指令上下文切换而来”的样子。
代码示例与系统调用解说
示例 A:fork + exec + wait 创建并运行新程序
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork(); // 1. 创建子进程(复制当前进程)
if (pid < 0) { // fork 失败
perror("fork failed");
return 1;
}
if (pid == 0) {
// 子进程代码: 用 ls 程序覆盖自己
char* argv[] = {"ls", "-l", nullptr};
execvp("ls", argv); // 2. 覆盖当前进程的代码和数据
perror("exec failed"); // 只有 exec 失败才会执行到这里
return 1;
} else {
// 父进程代码: 等待子进程结束
int status;
waitpid(pid, &status, 0); // 3. 阻塞直到子进程退出
printf("Child (pid %d) finished\n", pid);
}
return 0;
}
【代码做了什么?】
fork()调用一次、返回两次:父进程得到子进程 PID,子进程得到 0。- 子进程调用
execvp("ls", ...)把自己替换成ls程序;exec 成功则不返回。 - 父进程
waitpid阻塞等待,子进程结束后回收其退出状态。
【系统机制透视】
fork()在内核中做了什么:分配新的 PCB 和地址空间结构,把父进程的页表/内存复制(现代实现是写时复制 COW:父子共享物理页并标记只读,任一方向写入时内核才复制该页)给子进程,把子进程加入就绪队列。exec做了什么:释放/替换进程的代码段、数据段,载入新可执行文件,重置栈与堆,保留文件描述符与环境变量(这正是”先 fork 再 exec”的意义——可以在 exec 前重定向 I/O)。- 隔离性:父进程和子进程的地址空间彼此独立,任何一方的修改不会影响另一方。
示例 B:std::thread 创建线程
#include <iostream>
#include <thread>
void worker(int id) {
// 这段代码将与主线程并发执行
std::cout << "Thread " << id << " running\n";
}
int main() {
std::thread t(worker, 42); // 创建一个新线程, 从 worker(42) 开始
std::cout << "Main thread continues\n";
t.join(); // 等待线程 t 结束(否则进程退出时会崩溃/未定义行为)
return 0;
}
【代码做了什么?】 std::thread 构造器在库内部调用线程创建系统调用(Linux 上是 clone),为新线程分配独立栈并从 worker 函数开始执行;join() 阻塞主线程直到工作线程完成。
【系统机制透视】
- 与 fork 的本质区别:
std::thread不复制地址空间——新线程与已有线程共享进程的全部代码、全局变量、堆和打开文件,只有寄存器和栈是私有的。这就是”轻量级”的含义。 - 新线程的”出生”:内核创建一个线程控制块(TCB),把它的寄存器现场初始化成”刚从函数入口被调度进来”的样子(返回地址指向线程结束后的清理代码,栈指针指向新分配的栈顶)。
- 代价:由于共享地址空间,线程间必须用同步机制(后续讲座的锁/条件变量)避免数据竞争。
关键要点
- 线程是最小的执行单元;进程 = 线程 + 执行状态,是资源分配与隔离的基本单位。
- 上下文切换 = 保存当前线程状态 + 恢复目标线程状态;由陷阱(trap)或中断(interrupt)触发,定时器是实现抢占式调度的关键硬件。
fork/exec/wait是 Unix 进程管理三件套;fork复制进程、exec换程序、wait回收。- 线程切换比进程切换便宜:同一进程内线程共享页表,切换时无需刷新 TLB;进程切换必须换页表。
- 调度器(dispatcher)是机制(怎么切),调度策略(scheduler)是政策(切给谁)——Lecture 8 详述。
常见陷阱与注意事项
- 僵尸进程(Zombie):子进程退出后其 PCB 仍存在,直到父进程
wait()回收;父进程不 wait 会造成内核资源泄漏。 - 孤儿进程(Orphan):父进程先退出,子进程被 init(PID 1)收养并负责回收。
- 忘写
t.join()/t.detach():std::thread析构时线程仍可 join 会调用std::terminate。 - 线程安全:多线程共享全局变量而无同步 → 数据竞争,结果不确定。
- exec 失败检查:
exec成功后不返回,必须检查失败情况(否则子进程会”悄悄”继续执行父进程代码)。
思考题
- 问题:为什么线程上下文切换通常比进程上下文切换开销小?
- 答案:同一进程内的线程共享地址空间和页表,切换时不需要更换页表、不必刷新 TLB(缓存失效是进程切换的主要开销之一);进程切换必须载入新页表并使 TLB 全部失效,还要承受缓存冷启动。
- 问题:
fork()之后父进程修改一个全局变量,子进程能看到吗?- 答案:看不到。
fork()创建的是独立副本(写时复制技术下,写入才复制物理页),父进程的修改只影响自己的副本,子进程的页不受影响。
- 答案:看不到。
- 问题:为什么现代 OS 用定时器中断而不是”请求进程主动让出 CPU”来驱动调度器?
- 答案:协作式(cooperative)方案下,一个拒绝让出 CPU 的(恶意或出错的)进程会独占机器、使系统无法响应;定时器中断提供了抢占(preemption)能力,保证每个线程最终都能获得 CPU(公平性与可用性)。
