一篇围绕 Unix 进程与线程基础展开的学习笔记。


概述 中介绍操作系统时,将操作系统分为这样几个章节

  1. 概述 - 操作系统的组成/作用/发展历史等
  2. 进程 - 操作系统分配资源的最小单位
  3. 内存管理 - 内存如何虚拟化,操作系统如何分配内存资源
  4. 文件管理 - 文件系统与第一个I/O设备 —— 磁盘
  5. I/O设备 - 磁盘/网络/用户终端等

此篇文章是介绍 进程 的第一篇文章,主要整理这些内容:

  • 进程的基本组成
  • PCB 与进程生命周期
  • 调度机制与上下文切换
  • 常见调度算法
  • 线程的基本概念

这里暂时不涉及进程间通信,像管道、信号、共享内存、socket 这些内容放到后面的文章中再展开。

进程是什么

进程是一个正在运行的程序实例。

磁盘上的可执行文件只是静态的程序;只有当它被加载到内存、分配资源并开始执行时,才成为动态的进程。同一个程序文件可以同时启动多个进程,而这些进程彼此独立,各自拥有自己的运行状态。

从操作系统视角看,进程是资源分配和调度的重要对象;从程序员视角看,进程则是一个拥有独立地址空间、文件描述符表和执行上下文的运行实体。

进程的基本组成

典型的进程包括下面几个重要组分:

组成部分作用
程序代码要执行的机器指令
数据区域全局变量、静态变量等运行时数据
动态分配内存时使用
保存函数调用过程中的局部变量、参数、返回地址
寄存器上下文记录当前执行位置和当前计算状态
文件描述符表记录当前进程打开了哪些文件、管道、终端、socket

程序代码通常来自可执行文件的代码段,本身相对稳定;多个进程甚至可以共享同一份代码映像,但各自的运行状态彼此独立。

数据区域通常保存全局变量、静态变量、已初始化和未初始化的数据,它的生命周期通常与整个进程一致。

堆用于动态内存分配,例如 malloc 申请出来的空间通常就来自堆;堆的大小可以在运行过程中变化。

栈主要服务于函数调用。函数参数、局部变量、返回地址等信息通常保存在栈上。后面学习线程时会看到,每个执行线程都必须有自己的栈。

寄存器上下文记录了进程当前的执行状态,例如程序计数器、栈指针和通用寄存器。程序计数器尤其重要,因为它决定了“下一条要执行哪条指令”。

文件描述符表是 Unix 系统里非常重要的一部分。它记录当前进程打开了哪些对象,而这些对象并不只包括普通文件,还包括终端、管道、socket 和设备文件。

程序启动时,通常已经默认打开了下面 3 个文件描述符:

名称fd默认含义
stdin0标准输入
stdout1标准输出
stderr2标准错误

后面学习重定向、管道和进程间通信时,会发现很多操作本质上都和这张表有关。

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
孤儿进程父进程先结束,子进程仍然继续运行

一个典型的流转过程大致如下:

text
新建 -> 就绪 -> 运行 -> 终止
              |
              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 寄存器通用寄存器、栈指针等
进程状态当前是运行、就绪还是等待
内存相关信息页表、地址空间切换信息
调度信息当前时间片、优先级等

一个典型的进程切换过程大致如下:

  1. 时钟中断或其他事件发生
  2. 硬件先自动保存中断所需的最基本上下文
  3. 内核进入中断处理程序或调度路径
  4. 内核把当前进程的关键上下文保存到它的 PCB 中
  5. 调度器从就绪队列中选出新的进程
  6. 内核把新进程 PCB 中的关键上下文重新加载到 CPU
  7. 返回后,CPU 从新进程原先停止的位置继续执行

上下文切换本身是有开销的,因此调度策略总是在“响应更快”和“切换次数更少”之间做折中。

4. 中断上下文与进程上下文

这里还需要区分两类容易混淆的概念:中断上下文和进程上下文。

对比项中断上下文进程上下文
触发来源硬件中断、陷入调度器切换不同进程
保存主体主要由硬件先自动保存最基本部分主要由内核软件保存到 PCB
目的保存原始状态,再返回原执行流保存原进程信息,并让新进程运行
生命周期通常很短,只服务于一次中断处理与具体进程绑定
典型内容返回地址、状态字、部分寄存器程序计数器、寄存器、栈、页表、调度信息
管理者一般是硬件一般是软件

时钟中断发生时,发生两种上下文切换:

  1. 中断发生时,硬件先保存“中断上下文”
  2. 中断服务程序(调度程序)决定切换进程时,再处理“进程上下文”
  3. 中断服务程序退出时,硬件恢复中断上下文

常见调度策略

调度机制解决的是“怎么切换”,调度策略解决的是“该选谁”。

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 和比例份额调度
  • 线程是进程中的执行线程,同一进程中的线程共享资源,但各自保留部分独立的执行上下文

后面的文章再继续进入更具体的内容,例如进程控制系统调用、线程同步,以及进程间通信。