进程与线程:从程序到并发执行
本篇对应王道第二章上半(原 08 篇)。四个问题贯穿全篇:为什么要引入进程、进程是什么、进程由什么组成、进程是怎么解决问题的。最后是它的续集——线程。
一、为什么要引入进程
单道程序时代,程序独占整台机器,从头跑到尾,结果只取决于程序本身——这叫封闭性。
多道程序并发之后,假设崩了:
- 程序走走停停(时间片到了让出 CPU、等 IO 阻塞)
- 程序之间相互制约(抢资源、要同步)
- 失去封闭性:不同速度下推进,执行结果可能不同
为了描述和控制这种"并发执行的程序",让并发性和共享性真正落地,引入了进程。
二、进程是什么
进程实体:程序段 + 数据段 + PCB
为了使参与并发执行的每个程序都能独立运行,必须配置一个专门的数据结构——进程控制块(PCB)。
系统利用 PCB 描述进程的基本情况和运行状态,进而控制和管理进程。程序段、相关数据段、PCB 三部分构成进程实体(进程映像)。
两个关键认知:
- 创建进程 = 创建 PCB,撤销进程 = 撤销 PCB——操作的都是 PCB
- PCB 是进程存在的唯一标志——系统只有通过 PCB 才能感知到进程的存在
- 进程映像是静态的,进程是动态的(一个是数据结构,一个是运行过程)
定义(引入进程实体之后):
进程是进程实体的运行过程,是系统进行资源分配和调度的一个独立单位。
四大特征
| 特征 | 一句话 | 备注 |
|---|---|---|
| 动态性 | 有创建、活动、暂停、终止的生命周期 | 最基本的特征 |
| 并发性 | 多个进程同存于内存,一段时间内同时运行 | 引入进程的目的 |
| 独立性 | 独立运行、独立获取资源、独立接受调度的基本单位 | 没建 PCB 的程序不配 |
| 异步性 | 按各自独立的、不可预知的速度推进 | 可能导致结果不可再现 → 必须配同步机制 |
三、进程的状态与转换
五状态模型
三种基本状态 + 两个过渡状态:
| 状态 | 含义 | 备注 |
|---|---|---|
| 运行态 | 正在 CPU 上跑 | 单处理机中每个时刻最多一个(但可以零个,见盲点) |
| 就绪态 | 万事俱备,只差 CPU | 可能有多个,排成就绪队列 |
| 阻塞态 | 等 CPU 以外的资源或事件(等 IO 完成、等资源可用) | 按阻塞原因排多个阻塞队列 |
| 创建态 | 正在被创建,还没转到就绪态 | PCB 申请好了但资源没到位(比如内存不足) |
| 终止态 | 正在从系统中消失 | 先置终止态,再做资源释放回收 |
就绪态和阻塞态的区别是本章第一道坎:就绪差的是 CPU,阻塞差的是 CPU 以外的东西——所以 CPU 空闲阻塞进程也不能跑,它得先变成就绪。
另一个体感区别:就绪↔运行切换非常频繁(分时轮转,毫秒级);阻塞相关的切换就少得多(资源分配和 IO 等待往往很长)。
状态转换
| 转换 | 触发 | 方向性 |
|---|---|---|
| 就绪 → 运行 | 被调度,获得 CPU | 被动(调度程序选的) |
| 运行 → 就绪 | 时间片用完;或被更高优先级进程抢占 | 被动 |
| 运行 → 阻塞 | 请求资源/等待事件(系统调用形式) | 主动行为 |
| 阻塞 → 就绪 | 等待的事件到来(IO 完成、中断结束) | 被动(由中断处理程序协助) |
⚠️ 记住两个"唯一":运行→阻塞是主动的(进程自己调用的);阻塞→就绪是被动的(要相关进程协助)。另外阻塞不能直接跳到运行,必须先进就绪队列排队。
四、进程由什么组成:PCB
PCB 里装了什么
| 信息类别 | 内容 | 干什么用 |
|---|---|---|
| 进程描述信息 | 进程标识符(唯一 PID)、用户标识符 | 标识进程、归属用户(共享和保护服务) |
| 进程控制和管理信息 | 进程当前状态、优先级 | 处理机分配调度的依据 |
| 资源分配清单 | 内存地址空间/虚拟地址空间状况、打开文件列表、所用 IO 设备 | 说明进程占着哪些资源 |
| 处理机相关信息 | 各寄存器的值(处理机上下文) | 切换时保存现场,恢复时从断点继续 |
整个生命周期中系统都靠 PCB 控制进程:
- 调度前:从 PCB 查现行状态和优先级
- 调度后:按 PCB 保存的处理机状态恢复现场,按 PCB 记的地址找到程序和数据
- 运行中:同步、通信、访问文件都要碰 PCB
- 暂停时:断点的处理机环境存回 PCB
PCB 怎么组织
系统里 PCB 很多(就绪的、各种原因阻塞的),常用两种组织方式:
- 链接方式:同一状态的 PCB 链接成一个队列(就绪队列、多个阻塞队列)——主流
- 索引方式:同一状态的进程组织在索引表中,表项指向 PCB(就绪索引表、阻塞索引表……)
程序段与数据段
- 程序段:能被调度到 CPU 执行的程序代码。可以被多个进程共享——多个进程运行同一个程序
- 相关数据段:程序加工的原始数据,也可以是执行时产生的中间/最终结果
五、进程怎么被控制:原语
进程控制 = 创建新进程、撤销已有进程、实现状态转换。做这些事的程序段称为原语——要么全做要么全不做(原子性靠关中断实现,见 操作系统概述 里的内核部分)。
创建原语(创建态 → 就绪态)
允许一个进程创建另一个进程:创建者是父进程,被创建的是子进程,子进程继承父进程的所有资源;子进程被撤销时资源归还父进程,父进程被撤销时所有子进程同时撤销——这点学过面向对象后应该很容易理解。
触发场景:用户登录、作业调度、系统提供服务、应用请求。
流程:
- 分配唯一进程标识号,申请空白 PCB(PCB 是有限的,申请失败 = 创建失败)
- 分配运行所需资源(内存、文件、IO 设备……)——资源不足不是创建失败,是进创建态等资源
- 初始化 PCB(标志信息、处理机状态、控制信息、优先级)
- 插入就绪队列,等调度
终止原语(→ 终止态)
三种终止原因:正常结束(任务完成)、异常结束(存储区越界、保护错、非法指令、运行超时、IO 故障……)、外界干预(操作员/OS 干预、父进程请求或终止)。
流程:
- 按标识符检索出 PCB,读出状态
- 若处于运行态,立即终止执行,CPU 让给其他进程
- 若还有子孙进程,全部连带终止
- 所有资源归还给父进程或操作系统
- PCB 从所在队列删除(涉及随机删除 → 一般用链表实现队列)
阻塞原语与唤醒原语
- 阻塞(block):正在运行的进程期待的某些事件未发生(资源请求失败、等 IO 完成、新数据未到达),进程自己调用阻塞原语,运行态 → 阻塞态。再次印证:阻塞是主动行为,只有运行态能变阻塞
- 唤醒(wakeup):期待的事件出现时(IO 完成、数据到达),由相关进程(释放 IO 设备的进程、提供数据的进程)调用唤醒原语:等待队列中找到 PCB → 移出置就绪态 → 插入就绪队列
⚠️ Block 和 Wakeup 是一对作用相反的原语,必须成对使用——否则进程会因不能被唤醒而永久阻塞。
六、进程怎么说话:进程通信
低级通信:PV 操作
信号量 + P(尝试,申请资源,不可用则阻塞)/V(增加,释放资源,可能唤醒等待者),用于互斥(不同时碰共享资源)和同步(控制执行顺序)。传的只是信号不是数据,所以是"低级"通信。细节全在 04-同步与互斥 里展开。
高级通信:传大量数据
共享存储
通信进程之间存在一块可直接访问的共享空间,读写这片空间实现信息交换。
- 低级方式:基于数据结构的共享(如共享空间里只放一个固定长度的数组)——速度慢、限制多
- 高级方式:基于存储区的共享(划出一块存储区,形式、位置、存取速度都由进程控制)
操作系统只负责提供共享空间和同步互斥工具(如 PV 操作),数据怎么读写交换由用户自己安排。
直观理解:AB 之间有个 C 麻袋,A 把东西放进麻袋,B 从麻袋里拿——但 B 不能直接从 A 手里拿,A 也不能直接从 B 手里拿,一切通过麻袋中转。而且用麻袋时双方要约定好规矩(互斥访问),不然两个人同时伸手就乱了。
注意:进程空间一般是独立的,进程运行期间不能访问别的进程的空间,想让两个进程共享空间必须通过特殊的系统调用;而进程内的线程是天然共享进程空间的。
消息传递
进程之间没有可直接访问的共享空间时,用操作系统提供的发送/接收两个原语,以格式化消息(消息头 + 消息体)为单位交换数据。
- 直接通信:消息直接挂到接收进程的消息缓冲队列上
- 间接通信:消息先发到中间实体——信箱,接收进程再从信箱取
写信比喻:A 要告诉 B 某些事情,就写信让邮差送。直接通信就是邮差把信直接送到 B 手上;间接通信就是 B 门口有个邮箱,邮差把信放进邮箱。后者广泛应用于计算机网络,也隐藏了通信细节、对用户透明,是当前应用最广泛的 IPC 机制——微内核 OS 的微内核与服务器之间用的就是消息传递,也因为它能很好支持多处理机、分布式系统和计算机网络。
管道通信
管道是连接读写进程的一个特殊共享文件(pipe 文件),本质是内存中一个固定大小的缓冲区(Linux 中通常 4KB)。两个进程按生产者—消费者方式通信:生产者向一端写,消费者从另一端读,数据先进先出。
- 管道非空 → 读进程可读;读空 → 读进程阻塞,等写进程写入新数据再唤醒
- 管道不满 → 写进程可写;写满 → 写进程阻塞,等读进程读出数据再唤醒
- 从管道读数据是一次性操作:数据一旦被读走就释放空间(不像文件可以反复读)
- 半双工:普通管道只允许单向通信,父子进程要双向通信就得定义两个管道
管道机制必须提供三方面协调能力:互斥(同一时间只能一个进程操作管道)、同步(空等读、满等写的协调)、确定对方存在(对方没了别傻等,避免"悬挂"错误)。
和普通文件的区别:固定缓冲区大小(防止数据无限增长,资源可控)+ 阻塞机制(同步读写双方)。管道还有继承性:父进程创建的管道,子进程创建时自动继承(类似文件描述符),所以管道常用于父子进程通信。
七、线程:更小的调度单位
为什么要引入线程
引入进程是为了让多道程序并发执行,提高资源利用率和吞吐量;而引入线程是为了减小程序并发执行时付出的时空开销,提高并发性能。
进程切换动辄换上下文(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 上——结合两者优点:既能并行,阻塞又不会连坐整个进程。
多线程模型
按 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 限制——多核上也无法真正并行)。
八、盲点总复习
- 线程包含 CPU 现场,可以独立执行程序——对,线程是处理机调度的基本单位
- "单处理机系统中任何时刻只有一个进程处于运行态"——错。死锁时可能全部进程阻塞,CPU 空转,一个运行的都没有(最多一个,可以零个)
- 单处理机 10 个进程:就绪态最多 9 个(至少留一个运行态),阻塞态最多 10 个(可以全部阻塞)
- 并发进程失去封闭性:封闭性 = 结果只取决于进程本身、不受外界影响、与速度无关;并发后速度不同结果可能不同
- 进程实体各部分对应 C 程序(形成闭环):二进制代码和常量、全局赋值变量 → 共享正文段;malloc 动态分配 → 数据堆段;未赋值局部变量、函数实参 → 数据栈段;进程优先级 → PCB
- 同一程序多次创建运行在不同数据集上 → 不同的进程;系统动态 DLL 的系统线程被不同进程调用 → 相同的线程
- 进程是暂时的、动态的,程序是永久的、静态的;进程至少由代码、数据、PCB 组成,程序只需代码和数据
- 多道系统中就绪队列不空时,就绪进程越多,处理器效率不变——只要队列不空 CPU 就总能调度进程保持繁忙,与就绪数目无关(除非队列空了 CPU 等待才降效率)
- 两个合作进程无法利用全局变量交换数据——不同进程地址空间独立,全局变量只在进程内有意义。交换数据得靠:文件系统(管道也算一种文件)、共享内存、消息传递
- 内核级线程的切换(哪怕同进程内)也要从用户态转核心态,开销较大
- 父子进程共享一部分资源,但不能共享虚拟地址空间——创建子进程时会为它分配自己的虚拟地址空间
进程会"抢"CPU,那 CPU 到底该给谁?下一章讲调度 → 03-CPU调度
💬 评论