进程与线程:从程序到并发执行

本篇对应王道第二章上半(原 08 篇)。四个问题贯穿全篇:为什么要引入进程、进程是什么、进程由什么组成、进程是怎么解决问题的。最后是它的续集——线程。

一、为什么要引入进程

单道程序时代,程序独占整台机器,从头跑到尾,结果只取决于程序本身——这叫封闭性

多道程序并发之后,假设崩了:

  • 程序走走停停(时间片到了让出 CPU、等 IO 阻塞)
  • 程序之间相互制约(抢资源、要同步)
  • 失去封闭性:不同速度下推进,执行结果可能不同

为了描述和控制这种"并发执行的程序",让并发性和共享性真正落地,引入了进程

二、进程是什么

进程实体:程序段 + 数据段 + PCB

为了使参与并发执行的每个程序都能独立运行,必须配置一个专门的数据结构——进程控制块(PCB)

系统利用 PCB 描述进程的基本情况和运行状态,进而控制和管理进程。程序段、相关数据段、PCB 三部分构成进程实体(进程映像)

两个关键认知:

  • 创建进程 = 创建 PCB,撤销进程 = 撤销 PCB——操作的都是 PCB
  • PCB 是进程存在的唯一标志——系统只有通过 PCB 才能感知到进程的存在
  • 进程映像是静态的,进程是动态的(一个是数据结构,一个是运行过程)

定义(引入进程实体之后):

进程是进程实体的运行过程,是系统进行资源分配和调度的一个独立单位。

四大特征

特征 一句话 备注
动态性 有创建、活动、暂停、终止的生命周期 最基本的特征
并发性 多个进程同存于内存,一段时间内同时运行 引入进程的目的
独立性 独立运行、独立获取资源、独立接受调度的基本单位 没建 PCB 的程序不配
异步性 按各自独立的、不可预知的速度推进 可能导致结果不可再现 → 必须配同步机制

三、进程的状态与转换

五状态模型

三种基本状态 + 两个过渡状态:

状态 含义 备注
运行态 正在 CPU 上跑 单处理机中每个时刻最多一个(但可以零个,见盲点)
就绪态 万事俱备,只差 CPU 可能有多个,排成就绪队列
阻塞态 等 CPU 以外的资源或事件(等 IO 完成、等资源可用) 按阻塞原因排多个阻塞队列
创建态 正在被创建,还没转到就绪态 PCB 申请好了但资源没到位(比如内存不足)
终止态 正在从系统中消失 先置终止态,再做资源释放回收

就绪态和阻塞态的区别是本章第一道坎:就绪差的是 CPU,阻塞差的是 CPU 以外的东西——所以 CPU 空闲阻塞进程也不能跑,它得先变成就绪。

另一个体感区别:就绪↔运行切换非常频繁(分时轮转,毫秒级);阻塞相关的切换就少得多(资源分配和 IO 等待往往很长)。

image-97daf2d3

状态转换

转换 触发 方向性
就绪 → 运行 被调度,获得 CPU 被动(调度程序选的)
运行 → 就绪 时间片用完;或被更高优先级进程抢占 被动
运行 → 阻塞 请求资源/等待事件(系统调用形式) 主动行为
阻塞 → 就绪 等待的事件到来(IO 完成、中断结束) 被动(由中断处理程序协助)

⚠️ 记住两个"唯一":运行→阻塞是主动的(进程自己调用的);阻塞→就绪是被动的(要相关进程协助)。另外阻塞不能直接跳到运行,必须先进就绪队列排队。

四、进程由什么组成:PCB

PCB 里装了什么

信息类别 内容 干什么用
进程描述信息 进程标识符(唯一 PID)、用户标识符 标识进程、归属用户(共享和保护服务)
进程控制和管理信息 进程当前状态、优先级 处理机分配调度的依据
资源分配清单 内存地址空间/虚拟地址空间状况、打开文件列表、所用 IO 设备 说明进程占着哪些资源
处理机相关信息 各寄存器的值(处理机上下文) 切换时保存现场,恢复时从断点继续

整个生命周期中系统都靠 PCB 控制进程:

  1. 调度前:从 PCB 查现行状态和优先级
  2. 调度后:按 PCB 保存的处理机状态恢复现场,按 PCB 记的地址找到程序和数据
  3. 运行中:同步、通信、访问文件都要碰 PCB
  4. 暂停时:断点的处理机环境存回 PCB

PCB 怎么组织

系统里 PCB 很多(就绪的、各种原因阻塞的),常用两种组织方式:

  • 链接方式:同一状态的 PCB 链接成一个队列(就绪队列、多个阻塞队列)——主流
  • 索引方式:同一状态的进程组织在索引表中,表项指向 PCB(就绪索引表、阻塞索引表……)

程序段与数据段

  • 程序段:能被调度到 CPU 执行的程序代码。可以被多个进程共享——多个进程运行同一个程序
  • 相关数据段:程序加工的原始数据,也可以是执行时产生的中间/最终结果

五、进程怎么被控制:原语

进程控制 = 创建新进程、撤销已有进程、实现状态转换。做这些事的程序段称为原语——要么全做要么全不做(原子性靠关中断实现,见 操作系统概述 里的内核部分)。

创建原语(创建态 → 就绪态)

允许一个进程创建另一个进程:创建者是父进程,被创建的是子进程,子进程继承父进程的所有资源;子进程被撤销时资源归还父进程,父进程被撤销时所有子进程同时撤销——这点学过面向对象后应该很容易理解

触发场景:用户登录、作业调度、系统提供服务、应用请求。

流程:

  1. 分配唯一进程标识号,申请空白 PCB(PCB 是有限的,申请失败 = 创建失败)
  2. 分配运行所需资源(内存、文件、IO 设备……)——资源不足不是创建失败,是进创建态等资源
  3. 初始化 PCB(标志信息、处理机状态、控制信息、优先级)
  4. 插入就绪队列,等调度

终止原语(→ 终止态)

三种终止原因:正常结束(任务完成)、异常结束(存储区越界、保护错、非法指令、运行超时、IO 故障……)、外界干预(操作员/OS 干预、父进程请求或终止)。

流程:

  1. 按标识符检索出 PCB,读出状态
  2. 若处于运行态,立即终止执行,CPU 让给其他进程
  3. 若还有子孙进程,全部连带终止
  4. 所有资源归还给父进程或操作系统
  5. PCB 从所在队列删除(涉及随机删除 → 一般用链表实现队列)

阻塞原语与唤醒原语

  • 阻塞(block):正在运行的进程期待的某些事件未发生(资源请求失败、等 IO 完成、新数据未到达),进程自己调用阻塞原语,运行态 → 阻塞态。再次印证:阻塞是主动行为,只有运行态能变阻塞
  • 唤醒(wakeup):期待的事件出现时(IO 完成、数据到达),由相关进程(释放 IO 设备的进程、提供数据的进程)调用唤醒原语:等待队列中找到 PCB → 移出置就绪态 → 插入就绪队列

⚠️ Block 和 Wakeup 是一对作用相反的原语,必须成对使用——否则进程会因不能被唤醒而永久阻塞。

六、进程怎么说话:进程通信

低级通信:PV 操作

信号量 + P(尝试,申请资源,不可用则阻塞)/V(增加,释放资源,可能唤醒等待者),用于互斥(不同时碰共享资源)和同步(控制执行顺序)。传的只是信号不是数据,所以是"低级"通信。细节全在 04-同步与互斥 里展开。

高级通信:传大量数据

共享存储

通信进程之间存在一块可直接访问的共享空间,读写这片空间实现信息交换。

image-bee835bb
  • 低级方式:基于数据结构的共享(如共享空间里只放一个固定长度的数组)——速度慢、限制多
  • 高级方式:基于存储区的共享(划出一块存储区,形式、位置、存取速度都由进程控制)

操作系统只负责提供共享空间和同步互斥工具(如 PV 操作),数据怎么读写交换由用户自己安排

直观理解:AB 之间有个 C 麻袋,A 把东西放进麻袋,B 从麻袋里拿——但 B 不能直接从 A 手里拿,A 也不能直接从 B 手里拿,一切通过麻袋中转。而且用麻袋时双方要约定好规矩(互斥访问),不然两个人同时伸手就乱了。

注意:进程空间一般是独立的,进程运行期间不能访问别的进程的空间,想让两个进程共享空间必须通过特殊的系统调用;而进程内的线程是天然共享进程空间的。

消息传递

进程之间没有可直接访问的共享空间时,用操作系统提供的发送/接收两个原语,以格式化消息(消息头 + 消息体)为单位交换数据。

image-93aa69d6
  • 直接通信:消息直接挂到接收进程的消息缓冲队列上
  • 间接通信:消息先发到中间实体——信箱,接收进程再从信箱取

写信比喻:A 要告诉 B 某些事情,就写信让邮差送。直接通信就是邮差把信直接送到 B 手上;间接通信就是 B 门口有个邮箱,邮差把信放进邮箱。后者广泛应用于计算机网络,也隐藏了通信细节、对用户透明,是当前应用最广泛的 IPC 机制——微内核 OS 的微内核与服务器之间用的就是消息传递,也因为它能很好支持多处理机、分布式系统和计算机网络。

管道通信

管道是连接读写进程的一个特殊共享文件(pipe 文件),本质是内存中一个固定大小的缓冲区(Linux 中通常 4KB)。两个进程按生产者—消费者方式通信:生产者向一端写,消费者从另一端读,数据先进先出

image-d1e80d39
  • 管道非空 → 读进程可读;读空 → 读进程阻塞,等写进程写入新数据再唤醒
  • 管道不满 → 写进程可写;写满 → 写进程阻塞,等读进程读出数据再唤醒
  • 从管道读数据是一次性操作:数据一旦被读走就释放空间(不像文件可以反复读)
  • 半双工:普通管道只允许单向通信,父子进程要双向通信就得定义两个管道

管道机制必须提供三方面协调能力:互斥(同一时间只能一个进程操作管道)、同步(空等读、满等写的协调)、确定对方存在(对方没了别傻等,避免"悬挂"错误)。

和普通文件的区别:固定缓冲区大小(防止数据无限增长,资源可控)+ 阻塞机制(同步读写双方)。管道还有继承性:父进程创建的管道,子进程创建时自动继承(类似文件描述符),所以管道常用于父子进程通信。

七、线程:更小的调度单位

为什么要引入线程

引入进程是为了让多道程序并发执行,提高资源利用率和吞吐量;而引入线程是为了减小程序并发执行时付出的时空开销,提高并发性能

进程切换动辄换上下文(PCB 四大类信息、地址空间……),但如果是同一个进程内部的线程切换,地址空间都不用动,只需很少的时空开销。

线程是什么

最直接的理解:"轻量级进程"。它是基本的 CPU 执行单元,也是程序执行流的最小单元,由线程 ID、程序计数器、寄存器集合和堆栈组成。

  • 线程自己不拥有系统资源(只拥有一点运行中必不可少的东西),但可与同进程的其他线程共享进程的全部资源
  • 一个线程可以创建和撤销另一个线程;同进程内多线程并发执行
  • 既然并发,线程间也相互制约 → 线程也有就绪、阻塞、运行三种基本状态,转换关系同进程

引入线程后,进程的内涵变了

  • 以前:进程是资源分配和调度的独立单位
  • 现在:进程只作为除 CPU 以外系统资源的分配单元,线程作为处理机(CPU)的分配单元

线程 vs 进程

维度 进程 线程
调度 引入线程前是调度基本单位 处理机调度的基本单位;同进程内线程切换不引起进程切换
并发性 进程间并发 进程间、进程内多线程、甚至跨进程的线程都能并发
拥有资源 拥有资源的基本单位 不拥有系统资源,但可访问隶属进程的资源
独立性 独立地址空间,除共享全局变量外其他进程不可见 同进程线程共享地址空间;对其他进程的线程不可见
系统开销 创建/撤销要分配回收 PCB 及资源,切换要换整个上下文 切换只保存设置少量寄存器;同进程线程同步通信天然容易

💡 为什么线程必须"不拥有资源"?如果线程也是拥有资源的单位,切换线程就要较大的时空开销,线程这个概念也就失去意义了——它的价值恰恰建立在"轻"上。

线程控制块 TCB 与线程的组织

系统为每个线程配一个 TCB,记录:线程标识符、寄存器集(PC、SP 等,切换时保存恢复现场)、线程状态、线程堆栈(函数调用返回地址、局部变量)、优先级、链接指针(指向同进程其他线程或队列中的下一个线程)、同步机制信息(锁/信号量)、资源使用信息、CPU 使用时间等。

线程也有生命周期:由创建而产生(初始化线程负责创建新线程,创建函数传入口指针、堆栈大小、优先级,返回线程标识符)、由调度而执行、由终止而消亡。线程被终止后并不直接释放资源——等其他线程执行了分离函数后,被终止线程才与资源分离;未释放资源的终止线程甚至可以被重新恢复运行。

用户级线程 vs 内核级线程

用户级线程(ULT):线程管理(创建、撤销、切换)全部在用户空间由线程库完成,内核意识不到线程的存在,调度仍以进程为单位。

  • 优点:切换不需要进核心态,效率高;各应用可定制自己的调度算法;跨平台(不依赖内核实现)
  • 缺点:一个线程阻塞系统调用 → 整个进程全阻塞(内核只看得到进程);多处理机利用不足(内核认为进程是单线程的,同一时刻整个进程只能在一个 CPU 上跑)

💡 答疑:不是说多线程能把多个线程分到不同 CPU 提高效率吗? 那说的是内核级线程。ULT 的场景里 CPU 只看得到进程,多线程"多此一举";KLT 的场景里内核认识每个线程,才能真正发挥多核优势。这也是为什么要把两者分开设计——各有各的适用场景。

算法竞赛能不能用多线程提效? 大多数竞赛(ACM、LeetCode 等)在单线程环境下评测,多线程要么跑不了要么不符合评分逻辑;竞赛考察的是算法本身的复杂度优化。除非题目明确鼓励并行,否则专注单线程算法效率更实际。

内核级线程(KLT):线程管理全部由内核完成,每个线程有 TCB,内核认识并独立调度每个线程。

  • 优点:能利用多核真正并行;一个线程阻塞,内核可调度同进程其他线程继续跑;线程管理高效
  • 缺点:线程切换要在用户态/核心态之间转换,系统开销大;同步管理更复杂

⚠️ 即使是同一进程内的两个内核级线程切换,也需要从用户态转到核心态进行——系统开销较大。

组合方式:内核支持 KLT 的建立调度,用户程序又能建 ULT,若干 ULT 通过时分复用映射到 KLT 上——结合两者优点:既能并行,阻塞又不会连坐整个进程。

image-f2ae1028

多线程模型

按 ULT 和 KLT 的映射关系分三种:

模型 映射 优点 缺点
多对一 n 个 ULT → 1 个 KLT 线程管理在用户空间,效率高 一个线程访问内核时阻塞 → 整个进程阻塞;不能并行到多处理机
一对一 每个 ULT → 1 个 KLT 一个线程阻塞可调度别的,并发能力强 每建一个用户线程就建一个内核线程,开销大
多对多 n 个 ULT → m 个 KLT(n ≥ m) 集两家之长:并发性不差,开销可控 实现最复杂

线程库

线程库是给程序员提供创建和管理线程的 API。两种实现:用户空间库(调用库函数只是本地函数调用,如 POSIX Pthreads 在纯用户态实现时)和内核级库(调用 API 触发系统调用,如 Windows 线程)。常见:Pthreads(Unix-like 通用,pthread_create/pthread_join)、Windows(CreateThread)、Java(语言级内置,Thread/Runnable)、C#(System.Threading)、Python(threading,但受 GIL 限制——多核上也无法真正并行)。

八、盲点总复习

  1. 线程包含 CPU 现场,可以独立执行程序——对,线程是处理机调度的基本单位
  2. "单处理机系统中任何时刻只有一个进程处于运行态"——。死锁时可能全部进程阻塞,CPU 空转,一个运行的都没有(最多一个,可以零个
  3. 单处理机 10 个进程:就绪态最多 9 个(至少留一个运行态),阻塞态最多 10 个(可以全部阻塞)
  4. 并发进程失去封闭性:封闭性 = 结果只取决于进程本身、不受外界影响、与速度无关;并发后速度不同结果可能不同
  5. 进程实体各部分对应 C 程序(形成闭环):二进制代码和常量、全局赋值变量 → 共享正文段;malloc 动态分配 → 数据堆段;未赋值局部变量、函数实参 → 数据栈段;进程优先级 → PCB
  6. 同一程序多次创建运行在不同数据集上 → 不同的进程;系统动态 DLL 的系统线程被不同进程调用 → 相同的线程
  7. 进程是暂时的、动态的,程序是永久的、静态的;进程至少由代码、数据、PCB 组成,程序只需代码和数据
  8. 多道系统中就绪队列不空时,就绪进程越多,处理器效率不变——只要队列不空 CPU 就总能调度进程保持繁忙,与就绪数目无关(除非队列空了 CPU 等待才降效率)
  9. 两个合作进程无法利用全局变量交换数据——不同进程地址空间独立,全局变量只在进程内有意义。交换数据得靠:文件系统(管道也算一种文件)、共享内存、消息传递
  10. 内核级线程的切换(哪怕同进程内)也要从用户态转核心态,开销较大
  11. 父子进程共享一部分资源,但不能共享虚拟地址空间——创建子进程时会为它分配自己的虚拟地址空间

进程会"抢"CPU,那 CPU 到底该给谁?下一章讲调度 → 03-CPU调度

⬅️ 🏠 00-操作系统总览 ➡️ 03-CPU调度