一篇围绕 Unix 进程与线程基础展开的学习笔记。
在 概述 中介绍操作系统时,将操作系统分为这样几个章节
- 概述 - 操作系统的组成/作用/发展历史等
- 进程 - 操作系统分配资源的最小单位
- 内存管理 - 内存如何虚拟化,操作系统如何分配内存资源
- 文件管理 - 文件系统与第一个I/O设备 —— 磁盘
- I/O设备 - 磁盘/网络/用户终端等
此篇文章是介绍 进程 的第一篇文章,主要整理这些内容:
- 进程的基本组成
- PCB 与进程生命周期
- 调度机制与上下文切换
- 常见调度算法
- 线程的基本概念
这里暂时不涉及进程间通信,像管道、信号、共享内存、socket 这些内容放到后面的文章中再展开。
进程是什么
进程是一个正在运行的程序实例。
磁盘上的可执行文件只是静态的程序;只有当它被加载到内存、分配资源并开始执行时,才成为动态的进程。同一个程序文件可以同时启动多个进程,而这些进程彼此独立,各自拥有自己的运行状态。
从操作系统视角看,进程是资源分配和调度的重要对象;从程序员视角看,进程则是一个拥有独立地址空间、文件描述符表和执行上下文的运行实体。
进程的基本组成
典型的进程包括下面几个重要组分:
| 组成部分 | 作用 |
|---|---|
| 程序代码 | 要执行的机器指令 |
| 数据区域 | 全局变量、静态变量等运行时数据 |
| 堆 | 动态分配内存时使用 |
| 栈 | 保存函数调用过程中的局部变量、参数、返回地址 |
| 寄存器上下文 | 记录当前执行位置和当前计算状态 |
| 文件描述符表 | 记录当前进程打开了哪些文件、管道、终端、socket |
程序代码通常来自可执行文件的代码段,本身相对稳定;多个进程甚至可以共享同一份代码映像,但各自的运行状态彼此独立。
数据区域通常保存全局变量、静态变量、已初始化和未初始化的数据,它的生命周期通常与整个进程一致。
堆用于动态内存分配,例如 malloc 申请出来的空间通常就来自堆;堆的大小可以在运行过程中变化。
栈主要服务于函数调用。函数参数、局部变量、返回地址等信息通常保存在栈上。后面学习线程时会看到,每个执行线程都必须有自己的栈。
寄存器上下文记录了进程当前的执行状态,例如程序计数器、栈指针和通用寄存器。程序计数器尤其重要,因为它决定了“下一条要执行哪条指令”。
文件描述符表是 Unix 系统里非常重要的一部分。它记录当前进程打开了哪些对象,而这些对象并不只包括普通文件,还包括终端、管道、socket 和设备文件。
程序启动时,通常已经默认打开了下面 3 个文件描述符:
| 名称 | fd | 默认含义 |
|---|---|---|
stdin | 0 | 标准输入 |
stdout | 1 | 标准输出 |
stderr | 2 | 标准错误 |
后面学习重定向、管道和进程间通信时,会发现很多操作本质上都和这张表有关。
PCB:操作系统如何管理进程
系统里同时可能有很多进程在运行,操作系统必须有一个地方记录“这个进程是谁、当前处于什么状态、执行到哪了、占了哪些资源”。这个记录结构通常就叫 PCB,也就是进程控制块(Process Control Block)。
可以把 PCB 理解成“操作系统为每个进程建立的一份管理档案”。 操作系统通过 PCB 来识别、调度和回收一个进程。
1. pid 与父子进程关系
每个进程都有一个进程 ID,也就是 pid。它可以看成是操作系统为该进程分配的唯一编号。
进程通常还会记录它的父进程 ID,也就是 ppid。这表示“是谁创建了我”。后面学习 fork 时会看到:父进程调用 fork 之后,就会产生一个新的子进程。
父子关系之所以重要,是因为它会影响进程生命周期中的一些特殊情况:
- 如果子进程已经结束,但父进程还没有调用
wait回收它,那么子进程会暂时处于僵尸状态 - 如果父进程先结束,而子进程还在运行,那么子进程会成为孤儿进程
在 Unix 系统上,通常不采用“父进程终止时级联终止所有子进程”的做法。也就是说,父进程先结束,并不会自动把所有子进程一起杀掉。
更常见的行为是:
- 子进程继续运行
- 它的父进程关系会被重新挂接给
init或类似的系统进程 - 该系统进程会在适当时候调用
wait,把结束后的孤儿子进程真正回收
因此,wait 的意义之一,就是让已经结束的子进程从“僵尸状态”进入真正被系统释放资源的终止状态。
2. 进程状态与生命周期
进程并不是一创建出来就一直运行到结束。典型的进程生命周期会在几种状态之间切换。
| 状态 | 含义 |
|---|---|
| 新建 | 进程刚被创建,还未真正进入运行阶段 |
| 就绪 | 已经具备运行条件,只是在等待 CPU |
| 运行 | 当前正在 CPU 上执行 |
| 等待 / 阻塞 | 正在等待某个事件,例如 I/O 完成 |
| 终止 | 进程执行结束,等待资源回收 |
如果结合前面的父子关系,还可以补充两个常见的 Unix 术语:
| 状态 / 类型 | 含义 |
|---|---|
| 僵尸进程 | 子进程已经结束,但父进程还没有 wait 它 |
| 孤儿进程 | 父进程先结束,子进程仍然继续运行 |
一个典型的流转过程大致如下:
新建 -> 就绪 -> 运行 -> 终止
|
v
等待
|
v
就绪如果再结合队列来看:
- 进程创建后,状态通常是“新建”
- 满足运行条件后,进入“就绪”
- 被调度到 CPU 后,进入“运行”
- 发起 I/O 或等待某事件时,进入“等待”
- 事件完成后,再回到“就绪”
- 执行结束后,进入“终止”
3. PCB 的典型组分
一个 PCB 中通常会包含下面这些信息:
| 类别 | 典型内容 |
|---|---|
| 标识信息 | pid、父进程 ID、用户 ID |
| 进程状态 | 新建、就绪、运行、阻塞、终止等 |
| 程序计数器 | 下一条即将执行的指令位置 |
| CPU 上下文 | 通用寄存器、栈指针等 |
| 调度信息 | 优先级、时间片、队列指针 |
| 内存信息 | 地址空间、页表、代码段/数据段相关信息 |
| I/O 信息 | 打开的文件、设备、文件描述符表 |
| 统计信息 | 已使用 CPU 时间、创建时间等 |
这里单独强调一下程序计数器。它记录了进程所执行的当前指令位置,在上下文恢复时,将从此条指令继续执行;对于多线程进程,每个线程也必须有自己的程序计数器。
有了 PCB 来结构化进程,内核才能完成很多关键操作:
- 把进程挂到就绪队列或等待队列里
- 在上下文切换时保存和恢复进程上下文
- 查出这个进程当前打开了哪些资源
- 在进程结束时统一回收资源
调度机制
CPU 是有限资源,而进程往往有很多个。调度的目的,就是让多个进程能够共享 CPU。
从这个角度看,调度也常被视为“CPU 的虚拟化”:对于进程而言,调度过程通常是透明的,每个进程总像是在连续地执行;而从操作系统视角看,则是多个进程轮流占用 CPU。
1. 调度队列
为了完成调度,操作系统通常会维护几类队列。
| 队列 | 含义 |
|---|---|
| 作业队列 | 位于内存和大容量存储设备中的作业集合 |
| 就绪队列 | 已经在内存中、随时可以运行的进程 |
| 等待队列 | 正在等待某个事件完成的进程 |
| 设备队列 | 某个具体 I/O 设备自身维护的请求队列 |
这里要区分“等待队列”和“设备队列”:
- 等待队列是从 CPU 调度视角看,表示进程暂时不能运行
- 设备队列是从具体设备调度视角看,例如磁盘请求自身也可能排队
如果把队列和状态结合起来,一个进程在生命周期里通常这样迁移:
| 位置 / 动作 | 对应状态 |
|---|---|
| 创建后进入作业集合 | 新建 |
| 进入就绪队列 | 就绪 |
| 被调度到 CPU 运行 | 运行 |
| 发起 I/O 或等待事件 | 等待 |
| 事件完成后回到就绪队列 | 就绪 |
| 执行结束并等待回收 | 终止 |
2. 调度器
从这些队列里选择进程去运行的模块,就叫调度器(scheduler)。
教材里常把它分成三类:
| 调度器 | 作用 | 典型方向 |
|---|---|---|
| 长期调度器 | 决定哪些作业从长期存储进入内存 | 长期存储 -> 内存 |
| 短期调度器 | 决定内存中哪个就绪进程占用 CPU | 就绪队列 <-> CPU |
| 中期调度器 | 决定哪些进程暂时从内存换出 | 内存 -> 长期存储 |
短期调度器通常也被叫做 CPU 调度器,它是最频繁参与运行时调度的那一个。
3. 上下文切换
调度的目标是共享有限的 CPU 资源,而要让这种共享对进程看起来是连续、透明的,就需要在进程切换时保留并恢复进程的上下文。
这里的“进程上下文”,指的是一个进程恢复执行所必需的那组状态信息。
这件事和前面的 PCB 是直接对应的:上下文切换时,本质上就是把 PCB 中与运行相关的一部分信息从CPU与寄存器等位置保存到内存里,或者从内存中重新加载到 CPU与寄存器等位置中。
进程上下文通常包括:
| 内容 | 例子 |
|---|---|
| 程序计数器 | 下一条要执行的指令位置 |
| CPU 寄存器 | 通用寄存器、栈指针等 |
| 进程状态 | 当前是运行、就绪还是等待 |
| 内存相关信息 | 页表、地址空间切换信息 |
| 调度信息 | 当前时间片、优先级等 |
一个典型的进程切换过程大致如下:
- 时钟中断或其他事件发生
- 硬件先自动保存中断所需的最基本上下文
- 内核进入中断处理程序或调度路径
- 内核把当前进程的关键上下文保存到它的 PCB 中
- 调度器从就绪队列中选出新的进程
- 内核把新进程 PCB 中的关键上下文重新加载到 CPU
- 返回后,CPU 从新进程原先停止的位置继续执行
上下文切换本身是有开销的,因此调度策略总是在“响应更快”和“切换次数更少”之间做折中。
4. 中断上下文与进程上下文
这里还需要区分两类容易混淆的概念:中断上下文和进程上下文。
| 对比项 | 中断上下文 | 进程上下文 |
|---|---|---|
| 触发来源 | 硬件中断、陷入 | 调度器切换不同进程 |
| 保存主体 | 主要由硬件先自动保存最基本部分 | 主要由内核软件保存到 PCB |
| 目的 | 保存原始状态,再返回原执行流 | 保存原进程信息,并让新进程运行 |
| 生命周期 | 通常很短,只服务于一次中断处理 | 与具体进程绑定 |
| 典型内容 | 返回地址、状态字、部分寄存器 | 程序计数器、寄存器、栈、页表、调度信息 |
| 管理者 | 一般是硬件 | 一般是软件 |
时钟中断发生时,发生两种上下文切换:
- 中断发生时,硬件先保存“中断上下文”
- 中断服务程序(调度程序)决定切换进程时,再处理“进程上下文”
- 中断服务程序退出时,硬件恢复中断上下文
常见调度策略
调度机制解决的是“怎么切换”,调度策略解决的是“该选谁”。
1. 调度指标
比较不同调度算法时,最常见的两个指标是周转时间和响应时间。
| 指标 | 含义 |
|---|---|
| 周转时间 | 从作业进入系统到完成为止的总时间 |
| 响应时间 | 从作业进入系统到第一次得到 CPU 响应的时间 |
之所以要区分这两个指标,是因为不同系统关注点不同:
- 批处理系统更看重周转时间
- 交互式系统更看重响应时间
这里还需要引入一个很常见的思想:摊销。
调度本身有开销,因此如果每次调度后只运行极短时间,切换成本就会显得很高;如果一次调度后能运行更长时间,那么调度开销就会被分摊到更长的执行时间上。
2. 先来先服务(FIFO)
先来先服务也常被写作 FIFO 或 FCFS。它的规则很简单:谁先进入就绪队列,就先运行谁。
| 优点 | 缺点 |
|---|---|
| 简单、容易实现 | 短作业可能被长作业拖住,响应时间差 |
这种现象常被叫做“护航效应”:一个很长的 CPU 密集型任务挡在前面,后面的短任务都得等。
3. 短作业优先(SJF/PSJF)
短作业优先(SJF, Shortest Job First)倾向于优先运行预计执行时间更短的任务。
| 优点 | 缺点 |
|---|---|
| 平均周转时间通常更好 | 很难准确知道一个进程未来还要运行多久 |
如果把它做成抢占式版本,通常叫 STCF 或 PSJF,也就是最短剩余时间优先:每当有新任务到来时,调度器会比较当前任务和新任务的剩余时间,把 CPU 分配给剩余时间更短的那个。
这类算法的共同特点是:
- 倾向于优先完成短任务
- 更有利于优化平均周转时间
- 但依赖“知道未来还要运行多久”这一现实中很难完全满足的条件
4. 时间片轮转(RR)
时间片轮转(RR, Round Robin)是分时系统里非常经典的一种策略。
它的核心思想是:
- 每个进程最多运行一个时间片
- 时间片用完还没结束,就被放回就绪队列尾部
- 然后轮到下一个进程运行
| 时间片效果 | 结果 |
|---|---|
| 时间片很短 | 响应更快,但调度开销更高 |
| 时间片很长 | 调度开销更容易摊销,但响应更慢 |
还需要注意一点:进程即使时间片还没用完,也可能因为发起 I/O 而主动让出 CPU。
例如下面这个简化示意里,A 在发起 I/O 后,B 即使没有等到时间片结束,也会获得 CPU:
| 进程A | 进程B |
|---|---|
| 新建 | - |
| 就绪 | 新建 |
| 执行 | 就绪 |
| 执行(发起 I/O) | 就绪 |
| 等待 I/O | 执行 |
| 等待 I/O | 执行 |
| 执行(I/O 完成) | 完成 |
| 完成 | 完成 |
5. 多级反馈队列(MLFQ)
多级反馈队列(MLFQ, Multi-Level Feedback Queue)是更接近真实系统的一类策略,也是非常重要的一类调度思想。
它的关键目标,是在不知道未来作业长度的情况下,同时尽量兼顾周转时间和响应时间。
它通常维护多个优先级不同的就绪队列,并允许进程在这些队列之间移动。可以把它的核心规则整理成下面几条:
| 规则类型 | 含义 |
|---|---|
| 优先级规则 | 如果 A 的优先级高于 B,则优先运行 A |
| 同级规则 | 如果 A 和 B 在同一级队列,则按轮转方式运行 |
| 新进程进入规则 | 新进入系统的任务通常先放到最高优先级队列 |
| 降级规则 | 某任务如果持续用完该层分配的时间配额,就下降到更低一级队列 |
| 提升规则 | 经过一段时间后,可以把任务重新提升,避免长期饥饿 |
它背后的直觉是:
- 经常很快让出 CPU 的任务,更像交互型或 I/O 密集型任务,应优先响应
- 长时间持续占用 CPU 的任务,更像 CPU 密集型任务,可以放到较低优先级队列
很多系统还会加入一个重要保护:不是只看“单次时间片有没有用完”,而是记录某个任务在这一层累计消耗的总时间,只要总配额用完,就应该降级。这样可以避免任务通过频繁发起 I/O 来欺骗调度器。
| 优点 | 代价 |
|---|---|
| 同时兼顾响应性与吞吐量,更接近真实需求 | 实现复杂,参数较多 |
6. 比例份额调度
比例份额调度的核心思想不是“按先后顺序”或“按时间长短”调度,而是按某种比例来分配 CPU。
一种经典思路是彩票调度(lottery scheduling):
- 每个进程拿到若干“彩票”
- 调度时随机抽一张彩票
- 谁的彩票被抽中,谁就获得 CPU
这样一来:
- 彩票越多,获得 CPU 的概率越高
- 长期来看,CPU 时间会按彩票比例分配
这种方法的重点不在“完全确定的顺序”,而在“按比例公平地分享资源”。
线程是什么
在线程部分,更直接的说法是:线程是进程中的执行线程。 进程负责拥有资源,而线程是进程中的实际执行流。
1. 线程与进程的关系
| 对象 | 典型内容 |
|---|---|
| 进程 | 地址空间、文件描述符、全局数据、堆等资源 |
| 线程 | 程序计数器、寄存器集合、栈、调度状态 |
同一进程中的多个线程通常会共享:
- 代码段
- 数据段
- 堆
- 打开的文件
- 进程的地址空间
但每个线程仍然需要自己独立拥有:
- 程序计数器
- 寄存器集合
- 线程栈
- 调度状态
2. 单线程进程与多线程进程
单线程进程只有一个执行线程,也就只有一组程序计数器、寄存器和栈。
多线程进程则包含多个执行线程。它们共享同一个进程的资源,但每个线程各自维护自己的程序计数器、寄存器集合和栈。
| 类型 | 特点 |
|---|---|
| 单线程进程 | 进程内部只有一条执行线程 |
| 多线程进程 | 进程内部有多条执行线程,共享同一组资源 |
3. 为什么需要线程
引入线程,并不是为了解决并发问题;从原理上说,多进程也可以实现并发。
线程的意义在于:它为原本依赖多进程的并发场景,提供了一种更轻量的实现方式。
例如一个图形界面程序里,可能同时需要:
- 一个执行线程负责界面响应
- 一个执行线程负责网络请求
- 一个执行线程负责后台计算
如果完全依赖 单线程进程 来做这些事,那么创建、切换、资源共享和进程间通信的成本通常更高。而在同一进程内使用多个线程,则往往更轻量。
线程相对多进程的典型优势是:
| 方面 | 线程 |
|---|---|
| 创建和销毁成本 | 通常更低 |
| 切换成本 | 通常更小 |
| 数据共享 | 同一进程内共享数据更直接 |
但线程也带来新的问题,例如竞争条件、同步复杂度和调试难度。相关内容后面再单独展开。
4. 用户级线程与内核级线程
线程实现方式也可以粗略分成两类:
| 类型 | 特点 |
|---|---|
| 用户级线程 | 在线程库中管理,内核不一定直接知道每个线程 |
| 内核级线程 | 由内核直接管理和调度 |
它们的大致差异如下:
| 对比项 | 用户级线程 | 内核级线程 |
|---|---|---|
| 调度者 | 用户态线程库 | 操作系统内核 |
| 切换开销 | 往往更小 | 往往更大 |
| 阻塞影响 | 一个线程阻塞,可能拖住整个进程 | 单个线程阻塞时,其他线程仍可继续调度 |
| 并行能力 | 受实现方式限制 | 更容易利用多核并行 |
现代主流系统里,内核级线程更常见,因为它更容易与多核 CPU 和内核调度机制结合。
小结
我们介绍了进程的基本思想,也看到了进程如何管理与调度,最后介绍了多线程。如果把这篇文章压缩成几句话,那么可以表述如下:
- 进程是一个正在运行的程序实例,包含代码、数据、上下文和系统资源
- 操作系统通过 PCB 记录和管理每个进程
- 调度机制决定“如何切换” —— 保存并恢复进程上下文
- 调度策略解决“下一个选谁运行”,常见策略有 FCFS、SJF、STCF、RR、MLFQ 和比例份额调度
- 线程是进程中的执行线程,同一进程中的线程共享资源,但各自保留部分独立的执行上下文
后面的文章再继续进入更具体的内容,例如进程控制系统调用、线程同步,以及进程间通信。