进程和线程
2.1 进程和线程
1) 为什么要引入进程?
引入进程(Process)的主要目的是为了实现多道程序设计(Multiprogramming),从而提高系统的资源利用率和吞吐量。
在早期的计算机系统中,程序是顺序执行的,当一个程序等待I/O操作(例如读写文件)时,CPU必须空闲等待,造成了宝贵的CPU资源浪费。
- 核心原因: 解决CPU和I/O设备速度不匹配的问题。
- 进程的作用: 进程是程序在并发环境下的执行实体。引入进程后,操作系统可以同时加载多个程序到内存中,当一个程序(进程)被阻塞(如等待I/O)时,操作系统可以将CPU切换给另一个处于就绪状态的进程执行,从而让CPU始终保持忙碌状态。
- 结果: 提高了系统效率和资源利用率。
2) 什么是进程?进程由什么组成?
什么是进程 (What is a Process)?
进程是操作系统中一个正在执行的程序的实例,它是系统进行资源分配和调度的独立单位。
简单来说,程序(Program)是一个静态的指令集合(文件),而进程(Process)是程序在数据集上动态执行的过程。
进程由什么组成 (What Constitutes a Process)?
一个完整的进程实体主要由以下三部分组成:
- 程序段 (Program Segment): 进程要执行的指令集。
- 数据段 (Data Segment): 程序运行时所需处理的数据,如全局变量、常量等。
- 进程控制块 (Process Control Block, PCB): 这是进程存在的唯一标志,也是操作系统用于管理和控制进程的所有信息的集合。PCB包含关键信息,例如:
- 进程标识符 (PID): 唯一的数字ID。
- 进程状态: (就绪、运行、阻塞等)。
- 程序计数器 (PC): 记录下一条要执行的指令地址。
- CPU寄存器值: 进程被中断时的现场信息。
- 内存管理信息: 进程的内存起始地址和大小等。
- I/O状态信息: 分配给进程的I/O设备列表等。
3) 进程是如何解决问题的?
“进程是如何解决问题的?”这个问题通常指的是进程在操作系统环境下如何实现并发性,以及如何管理和组织任务。
- 解决并发执行问题:
- 通过进程调度(例如时间片轮转),操作系统可以快速地在不同进程间切换CPU,从宏观上看,多个任务似乎是同时进行的(即并发)。这使得用户可以一边听音乐一边写文档,提高了效率。
- 解决资源管理问题:
- 进程是资源分配的基本单位。操作系统将内存、文件、I/O设备等资源以“进程”为界限进行分配和隔离。一个进程拥有的资源不会被另一个进程随意访问,从而保证了系统的稳定性和安全性。
- 解决程序组织和控制问题:
- 通过PCB,操作系统可以清晰地记录每个任务(进程)的运行状态、执行进度和所需资源,从而实现对复杂多任务环境的有效控制和管理。
2.1.1 进程的概念和特征
1. 进程核心定义 (教科书标准)
在操作系统中,进程是程序的一次执行过程。
更严谨的定义是:进程是进程实体的运行过程,是系统进行资源分配和调度的一个独立单位。
通常理解: 资源 = 物理硬件(CPU、内存条)。
深度理解: 这里的资源是指服务于某个进程的“时间”。
- 例如:CPU 资源 = CPU 时间片。
- 因为进程是分配这些“时间片”的独立单位,所以进程注定是一个动态的、过程性的概念(因为它占用的资源本质上是时间)。
注意: 在引入“线程(Thread)”的现代操作系统中,进程主要作为资源分配的单位,而线程才是CPU调度的基本单位。但在传统定义(考研或基础理论)中,进程既是资源分配单位,也是调度单位。
2. 生动的比喻:食谱 vs. 做菜
为了区分“程序”和“进程”,我们可以用做菜来打比方:
- 程序 (Program) = 食谱
- 它是一本写在纸上的书(文件)。
- 它是静态的,放在那里如果不去读它,它永远不会变。
- 它只是指令的集合。
- 进程 (Process) = 做菜的过程
- 它是动态的。厨师(CPU)读取食谱,拿锅碗瓢盆(资源),切菜炒菜。
- 它有生命周期:开始做菜(创建)、正在炒(运行)、等待水烧开(阻塞/等待)、做完了(终止)。
- 它有状态:如果你正在做菜,突然电话响了,你必须停下来(中断/切换),记住菜做到哪一步了(保存现场/PCB),打完电话回来继续做(恢复现场)。
3. 进程与程序的主要区别 (考点)
这是考试中非常常见的一个对比点:
| 比较维度 | 程序 (Program) | 进程 (Process) |
|---|---|---|
| 存在形式 | 静态的指令集合,存储在磁盘上。 | 动态的执行过程,存在于内存中。 |
| 生命周期 | 永久存在,只要不删除文件就在。 | 暂时存在,有创建、运行、消亡的过程。 |
| 组成 | 代码、数据。 | 代码、数据、PCB (进程控制块)。 |
| 对应关系 | 一个程序可以对应多个进程(如同时打开三个Word文档)。 | 一个进程也可以执行多个程序(如动态链接库)。 |
| 特性 | 被动实体。 | 主动实体,拥有独立的资源(内存、I/O)。 |
4.进程的四大基本特征
1. 动态性—— 最基本的特征
- 课本解释: 进程是程序的一次执行过程,有生命周期(创建、活动、暂停、终止)。
- 通俗解释: 就像人的生命一样,进程从被“生”出来(创建),到“干活”(运行),累了或者等待资源时会“休息”(阻塞/暂停),最后任务完成会“死”掉(终止)。
- 关键点: 程序(代码)是可以永久存在的,但进程是暂时的,它一定会经历产生和消亡。
2. 并发性 —— 重要特征
- 课本解释: 多个进程同时存在于内存中,在一段时间内同时运行。
- 通俗解释:
- 想象你在用电脑,一边听歌,一边写文档,一边挂着QQ。这三个软件同时在运行。
- 在单核CPU时代,并不是真正的“同时”在做(那是并行),而是操作系统让它们快速轮流使用CPU(并发)。因为切换速度极快,你感觉它们是同时在跑。
- 关键点: 引入进程就是为了让计算机能“一心多用”,提高效率。
3. 独立性
- 课本解释: 进程是独立运行、独立获得资源、独立接受调度的基本单位。必须建立PCB(进程控制块)。
- 通俗解释:
- 每个进程都有自己的“地盘”(内存空间)和“身份证”(PCB)。
- PCB (Process Control Block) 是核心:就像你有身份证,国家才知道你是合法公民。程序如果没有PCB,操作系统就不承认它是一个独立的单位,也就不会给它分配CPU和内存。
- 关键点: 进程之间互不干扰(理想状态下),一个进程崩溃通常不应该影响另一个进程(这就是独立性的体现)。
4. 异步性
- 课本解释: 进程按各自独立的、不可预知的速度向前推进。会导致结果不可再现,需要同步机制。
- 通俗解释:
- 不可预知: 就像几辆车在路上跑,你不知道哪辆车会先到终点,因为中间可能会遇到红灯(等待I/O)、堵车(等待CPU)等情况。
- 风险: 如果两个进程都要去抢同一个东西(比如都要写入同一个文件),谁先抢到是不确定的。如果不管控,数据就会乱套(结果不可再现)。
- 解决: 所以操作系统必须有“交通规则”(进程同步机制),比如信号量、锁,来指挥谁先走谁后走。
2.1.2 进程的组成
1. 为什么需要 PCB?
!!!PCB是操作系统感知进程存在的唯一标志!!!
在多道程序环境下,程序是并发执行的(一会儿跑这个,一会儿跑那个)。 CPU 在切换进程时,需要知道:
- 刚才那个进程跑到哪一行代码了?
- 它的计算结果存在哪个寄存器里了?
- 它打开了哪些文件?
- 它的优先级够不够高?
这些信息不能乱放,必须有一个专门的数据结构来记录,这就是 PCB。
一句话总结: 系统利用 PCB 来控制和管理进程,PCB 随进程创建而建立,随进程撤销而回收。
2. PCB 里到底存了什么?
PCB 中的信息非常庞杂,但逻辑上可以分为四大类:
① 进程标识符 (Process Identification) —— “我是谁?”
用于唯一地标识一个进程。
- PID (Process ID): 进程的唯一身份证号(比如
1024)。 - PPID (Parent PID): 父进程 ID(谁创建了我)。
- UID (User ID): 属于哪个用户(是管理员 root 跑的,还是普通用户跑的)。
② 处理机状态信息 (Processor State) —— “刚才干到哪了?”
也叫上下文 (Context) 或 现场信息。当进程被暂停(切换)时,必须保存这些硬件状态,以便下次恢复时能接着跑。
- 通用寄存器: 暂存的计算数据(如
AX,BX等)。 - 指令计数器 (PC): 下一模要执行哪条指令的地址。
- 程序状态字 (PSW): 记录状态标志(比如是否有溢出、当前是用户态还是内核态)。
- 栈指针 (Stack Pointer): 指向当前的函数调用栈。
③ 进程调度信息 (Process Control Info) —— “该不该轮到我?”
操作系统根据这些信息决定把 CPU 给谁。
- 进程状态:
New(新建),Ready(就绪),Running(运行),Waiting/Blocked(阻塞),Terminated(结束)。 - 优先级 (Priority): 谁更重要,谁先跑。
- 调度参数: 比如已经占用了多少 CPU 时间、等待了多久(用于防饥饿)。
- 事件: 如果是阻塞状态,记录在等待什么事件(比如等待键盘输入)。
④ 进程控制信息 (Resource Info) —— “我有多少家底?”
- 程序和数据的地址: 告诉内存管理单元,代码和数据存在内存的哪个角落。
- 资源清单: 打开的文件列表(File Descriptors)、使用的 I/O 设备。
- 链接指针: 指向下一个 PCB 的指针(用于排队)。
3. PCB 是怎么组织的?
操作系统里可能有成百上千个进程,如何快速找到特定的 PCB?通常有两种方式:
方式 A:链接方式 (Linked List) —— 最常用
操作系统维护多个队列。
- 就绪队列: 所有准备好、只等 CPU 的进程,用指针连成一串。
- 阻塞队列: 通常按阻塞原因分多个队列(比如“等待磁盘队列”、“等待打印机队列”)。
- 优点: 灵活,插入和删除进程(创建/销毁)很方便,不需要预先分配固定空间。
方式 B:索引方式 (Index Table)
建立几张索引表(就绪表、阻塞表),表里记录 PCB 的内存地址。
- 优点: 查找速度稍快。
- 缺点: 必须预先确定最大进程数,不够灵活。
4. 关键应用场景:进程切换 (Context Switch)
理解了 PCB,你就理解了为什么“进程切换”会有开销。
当 CPU 从 进程 A 切换到 进程 B 时,操作系统实际上在做这些事:
- 保存 A 的现场: 把 CPU 寄存器里的所有数据拷贝到 A 的 PCB 里。
- 更新 A 的状态: 在 A 的 PCB 里把状态改为“就绪”或“阻塞”,并把它移到相应队列。
- 调度: 决定运行 B。
- 恢复 B 的现场: 把 B 的 PCB 里存储的寄存器数据,拷贝回 CPU 的寄存器。
- 执行 B: CPU 接着 B 上次停下的地方继续跑。
这个过程需要消耗 CPU 时间,所以进程切换不能太频繁,否则 CPU 都在忙着搬运 PCB 数据,没空干正事了。
2. 程序段 (Program Segment) —— 身体/指令
- 定义: 程序的代码(指令序列)。
- 作用: 告诉 CPU 具体要做什么操作。
- 特点: 它是只读的。
- 注意: 如果多个用户同时运行同一个程序(比如大家都打开了 Word),这个程序段是可以被多个进程共享的,这样节省内存。
3. 数据段 (Data Segment) —— 物资/素材
- 定义: 进程运行过程中产生的数据。
- 包含内容:
- 原始数据: 输入的数据。
- 中间数据: 运算过程中产生的变量、数组。
- 工作区: 比如函数调用时用到的栈(Stack)和堆(Heap)。
- 特点: 它是可读写的,每个进程的数据段通常是独立的(除非专门使用了共享内存)。
通俗类比:做菜
为了方便记忆,我们可以继续用“做菜”的例子来类比:
| 进程的组成 | 厨房里的类比 | 解释 |
|---|---|---|
| PCB (进程控制块) | 厨师的任务单 + 状态记录 | 记录这道菜是谁点的(PID),做到哪一步了(保存现场),下一波该切菜还是炒菜(调度信息)。 |
| 程序段 | 菜谱 (做法) | 具体的步骤:先放油、再放盐。这本菜谱是可以复用的(共享)。 |
| 数据段 | 食材 + 锅里的菜 | 具体的原料(肉、菜)和正在烹饪的半成品。这部分是这道菜独有的。 |
重点总结 (考试/理解常考点)
- 谁是核心? PCB。操作系统只认 PCB,不认代码。
- 谁能共享? 程序段(代码)通常可以共享。
- 谁不能共享? PCB 绝对不能共享,数据段默认不共享(除非通过特定的通信机制)。
2.1.3 进程的状态和转化
1、 进程的三种基本状态
这是进程在内存中运行时最核心的三个状态:
- 运行态 (Running):
- 定义: 进程正在 CPU 上运行。
- 注意: 在单核 CPU 中,同一时刻只有一个进程处于运行态。
- 就绪态 (Ready):
- 定义: 万事俱备,只欠东风(CPU)。进程已经获得了除 CPU 以外的所有资源,只要获得 CPU 就能立刻执行。
- 场景: 排队等着被调度。
- 阻塞态 (Blocked / Waiting):
- 定义: 进程正在等待某个事件发生(如等待 I/O 完成、等待键盘输入、等待信号)。
- 特点: 即使此时给它 CPU,它也运行不了,因为它缺少数据或条件。
二、 五态模型(加上创建和终止)
为了更完整地描述进程的生命周期,在上述三态基础上加入了起点和终点:
- 创建态 (New):
- 进程正在被创建中。操作系统正在为它分配 PCB、分配内存资源,但还没放入就绪队列。
- 终止态 (Terminated):
- 进程运行结束(正常结束或报错退出)。操作系统正在回收它的资源(如内存),最后删除 PCB。
三、 状态之间的转换(核心考点)
你需要脑补一个循环图,理解它们是如何跳变的。这里有一个非常重要的因果关系:
1. 就绪 $\rightarrow$ 运行 (Dispatch/Schedule)
- 原因: 调度程序(Scheduler)选中了这个进程,把 CPU 分配给它。
- 动作: 进程从就绪队列头部取出,开始执行。
2. 运行 $\rightarrow$ 就绪 (Timeout / Preemption)
- 原因:
- 时间片用完: 操作系统给每个进程分配的时间(如 100ms)到了,强制暂停,换别人跑。
- 被抢占: 一个优先级更高的进程突然来了(比如急诊病人),当前进程被迫让出 CPU。
- 注意: 此时进程依然是可运行的,只是暂时没有 CPU 而已。
3. 运行 $\rightarrow$ 阻塞 (I/O Request) 主动
- 原因: 进程主动请求等待某件事。例如:代码里写了
scanf(等待用户输入)或者read file(读取硬盘)。 - 注意: 这是一个主动行为。进程自己发现没法往下跑了,主动让出 CPU。
- 这是用户调用操作系统内核的过程
4. 阻塞 $\rightarrow$ 就绪 (I/O Complete) 被动 需要其他进程协助
- 原因: 等待的事情发生了(用户敲回车了,硬盘数据读完了)。
- 注意: 阻塞解除后,进程不能直接回到运行态!它必须先去就绪队列排队,等待调度器再次选中它。

四、 七态模型(引入“挂起”)
有些教材会提到“挂起 (Suspend)”状态。这是因为内存不够用了。
- 挂起 (Suspend): 操作系统把暂时不用的进程从内存踢到外存(磁盘/硬盘)的对换区中,以腾出内存空间。
- 于是多了两个状态:
- 就绪挂起: 进程在硬盘里,但只要调入内存就能跑。
- 阻塞挂起: 进程在硬盘里,而且还在等 I/O。
2.1.4 进程控制
进程控制所用的程序段叫做原语
2.1.4.1进程的创建
1. 什么时候会创建进程?(创建原语)
通常有四种事件会导致进程创建:
- 系统初始化: 开机时,系统启动第一个进程(如 Linux 下的
init或systemd)。 - 正在运行的进程发起请求: 一个进程调用系统调用(如
fork)来创建一个新进程。(这是最常见的场景,比如浏览器每开一个新标签页)。 - 用户请求: 用户双击一个图标,或者在终端输入
./program。 - 批处理作业初始化: 在大型机系统中,批处理队列中的作业被选中。
2. 操作系统创建进程的步骤(底层视角)
当需要创建一个新进程时,操作系统(内核)会执行以下标准步骤:
-
申请 PCB (Process Control Block):
- 从 PCB 集合中索取一个空白的 PCB,并为新进程分配一个唯一的 PID (Process ID)。
-
分配资源:
- 为新进程分配运行所需的内存空间(代码段、数据段、堆栈等)。
- 资源不足处于创建态,而不是失败
- 注:这里涉及到底层的内存管理,可能会复用父进程的资源(见下文 COW 技术)。
-
初始化 PCB:
-
初始化标志信息: 如 PID、PPID(父进程ID)、UID(用户ID)。
初始化 CPU 状态信息: 如程序计数器 (PC) 指向程序入口,栈指针 (SP) 指向栈顶。
初始化 CPU 控制信息: 设置进程的优先级、调度参数等。
-
设置状态: 将进程状态设置为 就绪态 (Ready) 或 创建态 (New)。
-
-
插入队列:
- 将初始化好的 PCB 插入到 就绪队列 中,等待调度器调度。
3. Linux 中的实现:fork() 和 exec()
在 Linux/Unix 世界里,创建进程的逻辑非常独特,分为两步:
第一步:fork() —— 克隆
- 动作: 父进程调用
fork()。 - 结果: 操作系统完全复制一份父进程(复制 PCB、复制堆栈、复制数据段)。
- 父子关系: 新的进程是“子进程”。它和父进程几乎一模一样,连执行到的代码位置都一样。
- 返回值的区别(重点):
- 在父进程中,
fork()返回子进程的 PID。 - 在子进程中,
fork()返回 0。 - (程序通过判断返回值来区分自己是爸爸还是儿子)
- 在父进程中,
第二步:exec() —— 变身
- 动作: 虽然子进程复制了父进程,但我们通常希望它干点别的事(比如父进程是 Shell,子进程想运行 Python)。
- 结果: 子进程调用
exec()系列函数。 - 原理: 操作系统用一个新的程序(比如 python.exe)的代码段和数据段,覆盖掉子进程原本复制来的内存空间。
- 变身: 此时,子进程就变成了一个全新的程序开始运行。
🖼️ Mermaid 流程图 (Typora 可用)
4. 高级考点:写时复制 (Copy-on-Write, COW)
你可能会问:“如果 fork() 要把父进程的所有内存都复制一遍,岂不是非常慢?而且浪费内存?”
答案:操作系统非常聪明,它使用了 COW 技术。
- 假复制: 当
fork()发生时,OS 并不真的复制 物理内存中的数据段和堆栈。它只是复制了页表,并将父子进程的页表都指向同一块物理内存。 - 只读标记: OS 将这块共享的内存标记为“只读”。
- 写时触发:
- 只要父子进程都只是“读”数据,大家就一直共用同一块内存,速度极快。
- 一旦其中一个进程试图修改(写)数据,CPU 触发异常。
- OS 捕获异常,这才把那一页内存真正复制一份出来给修改者。
总结: 只有在真正需要修改数据时,才会分配新的物理内存。这使得进程创建极其迅速。
2.1.4.2 进程的终止
一、 引起进程终止的“死因”
进程之所以会死,通常有以下三类原因:
1. 正常结束 (Normal Exit) —— “寿终正寝”
- 场景: 进程的任务干完了。
- 例子:
- 你在 C/C++ 程序里写了
return 0;或调用了exit()函数。 - 编译器编译完整个文件后自动退出。
- 你在 Linux 终端输入
ls,它列出文件后就自动结束了。
- 你在 C/C++ 程序里写了
- 性质: 自愿的。
2. 异常结束 (Error Exit) —— “因病早逝”
- 场景: 进程在运行过程中发生了内部错误,无法继续运行。
- 常见类型:
- 越界错误: 访问了不该访问的内存地址(Segmentation Fault)。
- 算术错误: 除数为零(Divide by zero)。
- 非法指令: 试图执行 CPU 不认识的指令。
- I/O 故障: 比如读文件时发现文件损坏或不存在。
- 性质: 被动的,通常由 CPU 发出中断,操作系统介入将其杀死。
3. 外界干预 (Fatal Error / Killed) —— “被他杀”
- 场景: 进程本身还在跑,但外界强制让它停下。
- 例子:
- 用户操作: 你打开任务管理器,点击“结束任务”;或者在终端执行
kill -9 <pid>。 - 父进程请求: 父进程觉得子进程没用了,或者父进程自己要死了(有些系统规定父进程死,子进程必须陪葬)。
- 超时: 运行时间超过了规定的限制。
- 用户操作: 你打开任务管理器,点击“结束任务”;或者在终端执行
- 性质: 被动的,强制性的。
二、 操作系统是如何终止进程的?(终止过程)
当上述情况发生时,操作系统内核会执行一系列“善后”操作。你可以把它想象成员工离职流程:
- 检索 PCB: 根据进程 ID,从 PCB 集合中找到该进程的档案。
- 停止执行: 如果该进程还在 CPU 上跑,立刻把 CPU 停下来,触发调度机制(准备换人)。
- 终止子进程: 如果该进程还有子进程,根据系统策略,可能需要将子进程一并终止(或者把子进程过继给 init 进程,见下文)。
- 归还资源 (重点):
- 内存: 释放它占用的内存空间。
- 文件: 关闭它打开的所有文件句柄。
- 设备: 释放它占用的 I/O 设备(如打印机)。
- 这一步是为了防止内存泄漏。
- 核算与汇报: 将 CPU 使用时间等统计信息记录下来。将退出码 (Exit Code) 填入 PCB,等待父进程来读取(告诉父进程我是怎么死的)。
- 删除 PCB: 当父进程提取完信息后,系统将 PCB 从队列中移除,彻底释放 PCB 占用的空间。
- 注意:在父进程读取之前,PCB 会暂时保留,这时的进程叫“僵尸进程”。
三、 两个特殊的终止概念(面试/考试高频)
在 Linux/Unix 系统中,进程终止时经常会涉及两个有趣的概念:
1. 僵尸进程 (Zombie Process) —— “死而不僵”
- 定义: 进程已经运行结束了(死了),资源也释放了,但它的 PCB 还在。
- 原因: 子进程死了,但父进程太忙,还没来得及调用
wait()来收取子进程的“遗言”(退出状态)。 - 危害: 虽然不占内存和 CPU,但占用了 进程号 (PID)。如果僵尸太多,系统就没有可用的 PID 分配给新进程了。
2. 孤儿进程 (Orphan Process) —— “父母双亡”
- 定义: 父进程先死了,子进程还在跑。
- 结果: 这个子进程就成了“孤儿”。
- 处理: 操作系统非常人道,通常会把孤儿进程“过继”给系统的 init 进程(PID 为 1 的老祖宗进程)。init 进程会负责在孤儿进程死后回收它的遗体(PCB),所以孤儿进程通常无害。
2.1.4.3 进程的阻塞和唤醒
进程的阻塞(Blocking)和唤醒(Waking up)是操作系统中控制进程状态转换的两个核心原语(Primitives)。它们必须成对使用,否则进程就会“睡死”过去。
简单来说:
- 阻塞是进程自己觉得“我现在干不下去了”,主动让出 CPU。
- 唤醒是外界告诉进程“你要的东西来了”,被动回到队伍里排队。
一、 进程的阻塞 (Blocking)
1. 什么是阻塞?
当正在运行的进程,发现自己必须等待某个事件发生才能继续运行时,它不能占着茅坑不拉屎(不能空占 CPU),于是它便调用阻塞原语 (block),把自己变成阻塞状态。
2. 为什么会阻塞?(常见原因)
- 等待资源: 比如申请打印机,但打印机正在被别人用;或者申请信号量(锁)失败。
- 等待 I/O 完成: 比如程序里写了
scanf(等待用户键盘输入)或者read(等待磁盘读取文件)。 - 等待其他进程的数据: 比如管道通信中,读进程要等写进程把数据送过来。
3. 阻塞的过程
这是一个“自我流放”的过程:
- 进程执行到阻塞指令(如
wait)。 - 进程自动停止运行。
- 操作系统修改该进程 PCB 的状态:
Running$\rightarrow$Blocked。 - 操作系统把该进程的 PCB 挂到对应的阻塞队列(等待 I/O 的去 I/O 队列,等待磁盘的去磁盘队列)。
- CPU 此时空闲了,调度程序会选一个新的进程来运行。
关键点: 阻塞是进程自身的主动行为。
二、 进程的唤醒 (Waking up)
1. 什么是唤醒?
当被阻塞的进程所期待的那个事件终于发生了,有关进程(比如提供数据的进程)或硬件中断处理程序会调用唤醒原语 (wakeup),将该进程“叫醒”。
2. 谁来唤醒?
被阻塞的进程是无法唤醒自己的(因为它已经停了,不再执行代码)。必须由“发现者”来唤醒它:
- 硬件中断: 比如磁盘读写完了,磁盘控制器发中断告诉 CPU,中断处理程序负责唤醒等待磁盘的进程。
- 其他进程: 比如进程 A 给进程 B 发了一个消息,A 就会去唤醒正在死等消息的 B。
3. 唤醒的过程
这是一个“重获资格”的过程:
- 把阻塞进程的 PCB 从阻塞队列中摘下来。
- 修改 PCB 的状态:
Blocked$\rightarrow$Ready(就绪)。 - 把 PCB 插入到就绪队列中,等待调度。
关键点:
- 唤醒是被动的(由操作系统或其他进程发起)。
- 唤醒后不是直接运行,而是去排队(变就绪态)。
三、 原语的概念 (Primitives)
在操作系统中,阻塞和唤醒被称为“原语”。
- 原子性 (Atomicity): 意味着这两个操作是不可中断的。
- 也就是说,在执行“阻塞”或“唤醒”的一连串动作(改状态、移队列)时,必须一气呵成。如果做到一半被打断,PCB 的状态就会乱套(比如状态改了但没进队列,进程就丢了)。
- 通常通过“关中断”指令来实现。
四、 核心总结 (考试必记)
| 特征 | 阻塞 (Block) | 唤醒 (Wakeup) |
|---|---|---|
| 方向 | 运行 $\rightarrow$ 阻塞 | 阻塞 $\rightarrow$ 就绪 |
| 主动/被动 | 主动 (进程自己调用的) | 被动 (由于事件发生触发的) |
| 发生时机 | 请求资源失败 / 等待某种操作完成 | 资源被释放 / 等待的操作完成了 |
| 关系 | Block 和 Wakeup 必须成对出现。 如果在代码里只 Block 不 Wakeup,进程就会永远死锁;如果还没 Block 就先 Wakeup,由于信号错位,也可能导致后续问题。 |
2.1.5 进程的通信
PV操作时低级通信方式,高级通信方式是指以较高的速率传输大量数据的方式
2.1.5.1 共享存储
共享存储 (Shared Memory) 是操作系统中进程间通信 (IPC, Inter-Process Communication) 的一种方式,也是目前速度最快的一种 IPC 方式。
在之前的“内存分段”和“进程创建”中,我们知道进程的内存空间通常是独立的、相互隔离的。而共享存储的核心思想是:打破隔离,让两个不同的进程“看到”同一块物理内存。
以下是详细讲解和图解:
1. 核心原理
通常,进程 A 和进程 B 的虚拟地址映射到完全不同的物理内存区域。 但在共享存储模式下,操作系统会在物理内存中开辟一块专门的区域,然后将这块物理内存同时映射到进程 A 和进程 B 的虚拟地址空间中。
- 进程 A 往这块内存写数据,进程 B 立刻就能看到,反之亦然。
- 这就好比两个不同的房间(进程),中间打通了一扇窗户(共享内存),可以直接递东西。
2. 原理图解 (Mermaid)
3. 为什么它是最快的 IPC?
与其他通信方式(如管道 Pipe、消息队列 Message Queue)相比,共享存储最快,原因如下:
- 零拷贝 (Zero-Copy):
- 管道/消息队列: 数据需要从 A 进程拷贝到内核 (Kernel),再从内核拷贝到 B 进程。至少发生 2 次内存拷贝。
- 共享存储: 数据直接写入内存,不需要在内核和用户空间之间来回拷贝。0 次拷贝(或者说除了写入本身的 1 次外,无额外拷贝)。
- 直接访问: 进程像访问自己的变量一样访问共享区域,没有系统调用的开销。
4. 致命缺点:同步问题 (Synchronization)
虽然速度快,但它有一个巨大的风险:操作系统不负责数据的同步!
- 场景: 进程 A 正在写 "Hello",只写了 "Hel...",CPU 时间片到了切换给进程 B。进程 B 读取数据,读到了残缺的 "Hel..."。
- 后果: 数据错乱(竞态条件, Race Condition)。
解决方案: 程序员必须自己使用辅助工具来保证读写顺序,通常配合 信号量 (Semaphore / PV操作) 或 互斥锁 (Mutex) 一起使用。
口诀:共享存储快如闪电,但要配合 PV 防乱战。
5. 两种分类 (考研/教材常见分类)
在操作系统教材(如王道)中,共享存储通常分为两类:
- 基于数据结构的共享 (低级方式):
- 比如共享一个数组、一个结构体。
- 限制多,灵活性差,只能存放特定格式的数据。
- 基于存储区的共享 (高级方式):
- 操作系统只给你划一块“空地”(比如 4KB),你想存字符串、图片还是二进制流都可以。
- 这是现代操作系统(如 Linux 的
shmget)主要采用的方式。
2.1.5.2 管道通信 (用户态到内核态的转变)
这种方式最大的特点是:进程间不直接读写对方的内存,而是通过操作系统提供的原语(Primitives)来进行数据交换。
以下是关于消息传递的详细解析:
一、 核心机制:发送与接收
在消息传递系统中,进程主要通过两个核心原语来操作:
send(destination, message):发送一条消息给目标。receive(source, message):从源头接收一条消息。
二、 两种通信方式(寻址方式)
根据“信”是直接交给对方,还是扔进一个中间的信箱,分为两种模式:
1. 直接通信 (Direct Communication) —— “面对面交接”
发送进程必须明确指定接收进程的 ID(身份证号)。
- 发送:
send(Process_B, message)—— "把这封信给进程 B"。 - 接收:
receive(Process_A, message)—— "我要收来自进程 A 的信"。 - 特点:
- 通信链路是自动建立的。
- 通常是点对点的(一对一)。
- 对称性: 发送方和接收方通常都需要知道对方的名字(虽然有些系统允许接收方不指定源,即
receive(id, msg),表示接收任意人的信)。
2. 间接通信 (Indirect Communication / Mailbox) —— “信箱模式”
进程之间通过一个中间实体——信箱 (Mailbox) 或 端口 (Port) 来传递消息。
- 信箱: 每个信箱都有一个唯一的 ID。
- 发送:
send(Mailbox_A, message)—— "把信投到 A 号信箱里"。 - 接收:
receive(Mailbox_A, message)—— "从 A 号信箱里取信"。 - 特点:
- 灵活性高: 只有两个进程共享同一个信箱时,它们才能通信。
- 多对多: 一个信箱可以被多个进程写入,也可以被多个进程读取(比如三个进程往里写,一个进程往外读)。
- 生命周期: 信箱可以由操作系统拥有(独立存在),也可以由某个进程拥有(私有信箱)。
三、 同步与阻塞 (Synchronization)
发信和收信的时候,如果条件不满足(比如信还没到,或者信箱满了),进程该怎么办?这就涉及到了阻塞(Blocking)与非阻塞(Non-blocking)的设计:
| 模式 | 术语 | 行为描述 | 类比 |
|---|---|---|---|
| 阻塞发送 | 同步发送 | 发送方发完消息后,暂停运行,直到接收方把消息收走(或收到确认)。 | 你把快递送给客户,必须当面等他签字才能走。 |
| 非阻塞发送 | 异步发送 | 发送方把消息往缓冲区一扔,直接继续运行,不管接收方看没看。 | 你把信扔进邮筒,转头就走,去干别的事。 |
| 阻塞接收 | 同步接收 | 接收方如果发现没消息,就暂停运行(睡觉),直到有消息来。 | 你在等重要的快递,一直守在门口,哪也不去。 |
| 非阻塞接收 | 异步接收 | 接收方去查一下,有消息就收,没消息就返回一个“空”或者错误码,继续干活。 | 你偶尔看一眼手机短信,有就回,没有就继续工作。 |
注意: 在实际操作系统(如 Linux 管道或消息队列)中,通常默认是 阻塞接收(没数据就死等)和 非阻塞发送(除非缓冲区满了,否则发完就走)。
四、 缓冲区 (Buffering)
消息在传递过程中,通常暂存在操作系统的内核空间里。这个存储区域(队列)的大小决定了系统的行为:
- 零容量 (Zero capacity): 链路中不能存消息。发送方必须等接收方准备好,两者必须同时在线(握手/Rendezvous)。
- 有限容量 (Bounded capacity): 队列能存 n 条消息。如果队列满了,发送方必须阻塞(等待腾出空间)。
- 无限容量 (Unbounded capacity): 理论上可以无限存,发送方永远不需要等待。
五、 优缺点总结
- 优点:
- 安全: 进程间内存隔离,不会出现“野指针”乱改别人数据的情况。
- 分布式友好: 这种模式天然适合网络通信(微内核架构、分布式系统常用)。
- 同步简单: 接收原语本身就自带了“等待”功能,不需要像共享内存那样额外写复杂的信号量锁。
- 缺点:
- 开销大 (Overhead): 每一条消息都需要从用户态拷贝到内核态,再拷贝回用户态。也就是“中间商赚差价”,速度比共享内存慢。
2.1.5.3 管道通信
管道通信 (Pipe Communication) 是操作系统中最古老、也是最基础的一种进程间通信 (IPC) 方式。
如果说“共享存储”是两个房间开了一扇窗户,那么“管道”就是连接两个房间的一根水管。
以下是关于管道通信的核心考点和原理图解:
1. 核心定义
管道本质上是一个打开的文件,但它不在磁盘上,而是在内核内存中维护的一个固定大小的缓冲区(通常是一个内存页,如 4KB)。
- 像流水一样: 数据只能像水流一样,从一端流进去,从另一端流出来。
- 读后即焚: 管道里的数据一旦被读取,就消失了(不可重复读)。
2. 四大核心特点 (考试必背)
1. 半双工通信 (Half-duplex)
- 单行道: 数据同一时刻只能朝一个方向流动(A 发 B 收,或者 B 发 A 收)。
- 想双向怎么办? 如果需要 A 和 B 互相聊天,必须建立两个管道。
-
互斥与同步 (自动处理)
这是管道和共享存储最大的区别:操作系统帮你管秩序。
- 互斥: 当一个进程正在对管道进行读/写操作时,另一个进程不能同时操作。
- 同步 (阻塞机制):
- 写满了: 如果管道(缓冲区)被写满了,写进程(Writer)会被阻塞(去睡觉),直到有空间。
- 读空了: 如果管道里没数据了,读进程(Reader)会被阻塞(去睡觉),直到有新数据来。
-
以“字节流”形式传输
- 管道不认识任何数据结构(没有结构体、整数的概念),它只把数据看作一连串的字节 (Byte Stream)。
- 发送方和接收方必须事先约定好数据的格式,否则读出来就是乱码。
4. 固定容量
- 管道是有大小限制的。如果写得太快读得太慢,管道满了就会阻塞写进程。
3. 原理图解 (Mermaid)
4. 管道的分类:匿名管道 vs 命名管道
考试中经常考察这两者的区别:
| 特性 | 匿名管道 (Anonymous Pipe) | 命名管道 (Named Pipe / FIFO) |
|---|---|---|
| 存在形式 | 只存在于内存中,没有文件名 | 在文件系统中有一个文件名 (如 my_pipe) |
| 使用限制 | 只能用于有亲缘关系的进程 (父子、兄弟) | 可以用于任意两个进程 (哪怕毫无关系) |
| 生命周期 | 进程结束,管道就消失 | 即使进程结束,文件还在 (除非手动删除) |
| 经典命令 | Linux 中的竖线 ` | <br> (例:ps |
5. 管道 vs 共享存储 (对比总结)
| 维度 | 管道通信 (Pipe) | 共享存储 (Shared Memory) |
|---|---|---|
| 传输效率 | 低 (数据需要在用户态/内核态之间拷贝) | 高 (直接读写内存,零拷贝) |
| 同步机制 | OS 自动处理 (写满/读空会自动阻塞) | 需手动处理 (必须配合信号量/PV操作) |
| 数据流向 | 单向 (半双工) | 双向 (只要能访问内存就能读写) |
| 适用场景 | 传输少量数据,简单指令 | 传输大量数据,频繁交互 |
2.1.5.6 信号
一、 什么是信号?
- 本质: 信号是一种模拟中断机制,因此也被称为“软件中断”。
- 特点:
- 异步性 (Asynchronous): 进程永远不知道信号什么时候会来。它正在专心跑代码,信号突然就来了,它必须马上停下手头的工作去响应。
- 信息量小: 信号本身只是一个整数(比如 9, 11, 15),它只代表事件的类型,不携带具体的数据内容。
- 在操作系统内核(PCB)里,管理信号其实就是维护了两个“表格”(位图/Bitmaps):
- 待处理信号集 (Pending Signals):
- 作用: 记录“收到了哪些信”。
- 结构: 一个 32 位的整数。比如第 2 位是
1,说明收到了SIGINT(中断信号);第 9 位是1,说明收到了SIGKILL。 - 逻辑: 收到信号 $\rightarrow$ 对应位变
1;处理完信号 $\rightarrow$ 对应位变0。
- 信号屏蔽字/阻塞信号集 (Blocked/Masked Signals):
- 作用: 记录“现在不想听哪些信”。
- 结构: 同样是一个 32 位的整数。
- 逻辑: 如果第 2 位是
1,表示进程当前屏蔽了SIGINT。即使待处理集中第 2 位变1了(信来了),进程也会假装没看见,暂时不处理,直到解除屏蔽。
- 待处理信号集 (Pending Signals):
- 当操作系统把一个进程从内核态切换到用户态时(如系统调用返回时),会检查该进程是否有未被阻塞的待处理信号。”
二、 信号是从哪里来的?
信号的来源通常有三种:
- 用户操作 (终端输入):
- 你在终端按下
Ctrl+C,就会产生一个SIGINT信号,强制终止当前进程。 - 按下
Ctrl+Z,产生SIGSTOP,暂停进程。
- 你在终端按下
- 硬件异常 (内核检测):
- 代码里写了“除以 0”,CPU 报错,内核发送
SIGFPE(浮点错误)。 - 访问了非法内存(野指针),内核发送
SIGSEGV(段错误)。
- 代码里写了“除以 0”,CPU 报错,内核发送
- 软件/其他进程:
- 在命令行输入
kill -9 <pid>,就是手动发信号。 - 进程 A 调用
kill()系统函数给进程 B 发信号。
- 在命令行输入
三、 进程收到信号后怎么办?(处理方式)
当信号到来时,进程有三种选择(处理策略):
1. 执行默认操作 (Default)
大多数信号的默认操作就是 “自杀”(终止进程)或者 “生成 Core Dump 文件”(尸检报告,用于调试)。
- 例子: 收到
Ctrl+C,进程直接退出。
2. 忽略信号 (Ignore)
进程假装没听见,继续干活。
- 例子: 有些后台服务进程会忽略终端发来的退出信号,保持常驻。
- 特例:
SIGKILL(9号) 和SIGSTOP(19号) 是绝对不能被忽略的! 这是操作系统为了能控制失控进程留的“后门”。
3. 捕捉信号 (Catch / Handler)
进程预先写好一个函数(信号处理函数),告诉系统:“如果这个信号来了,先别杀我,去执行这个函数”。
- 例子: Word 软件在收到“关机信号”时,不会直接断电,而是先执行“保存当前文档”的函数,然后再退出。
四、 几个必须记住的经典信号 (面试/考试高频)
在 Linux/Unix 系统中,信号用数字或宏名表示:
| 信号名称 | 数字 | 含义 | 备注 |
|---|---|---|---|
| SIGINT | 2 | Interrupt (中断) | 用户按下 Ctrl+C 触发。比较“礼貌”的终止请求,进程可以捕捉并清理资源。 |
| SIGKILL | 9 | Kill (强制杀死) | 用户执行 kill -9 触发。最霸道的信号,不能被忽略,不能被捕捉。直接由内核从物理上抹杀进程。 |
| SIGSEGV | 11 | Segmentation Violation | 段错误。访问了不该访问的内存(越界、空指针)。通常导致进程崩溃 (Core Dump)。 |
| SIGTERM | 15 | Terminate (终止) | 执行 kill (缺省) 触发。程序结束的默认信号,建议进程做好善后工作再退出。 |
| SIGSTOP | 19 | Stop (暂停) | 用户按下 Ctrl+Z 触发。让进程暂停(挂起),而不是结束。同样不能被忽略。 |
2.1.6 线程和多线程模型
1.线程的基本概念
一、 核心定义(金科玉律)
在引入线程的操作系统中,有两个最基本的定义,请务必背下来:
- 进程 (Process): 是资源分配的基本单位。
- (它负责占有内存、文件句柄等资源)。
- 线程 (Thread): 是处理机调度(CPU 执行)的基本单位。
- (它负责在 CPU 上真正跑代码)。
一句话概括: 进程是“房东”,提供了场地和工具;线程是“租客/工人”,负责在里面干活。一个进程里至少要有一个线程(主线程)。
二、 通俗类比:工厂与工人
为了讲清楚它们的关系,我们继续用“工厂”做比喻:
- 进程 = 工厂的车间
- 它拥有独立的厂房(内存空间)、机器(I/O设备)和原材料(数据)。
- 建一个车间很麻烦,需要申请地皮、盖房子(资源分配开销大)。
- 线程 = 车间里的工人
- 共享资源: 同一个车间里的工人,共享车间里的机器、原材料和墙上的规章制度(共享代码段、数据段、文件)。
- 独立任务: 每个工人有自己的工位、自己的干活进度(PC 指针)和自己的临时草稿纸(栈空间)。
- 并发: 一个车间里可以有多个工人同时干活, collaborative 完成一个大订单。
三、 为什么要引入线程?(优点)
既然已经有进程了,为什么还要搞个线程出来?主要是为了“快”和“省”。
- 开销小 (轻量级):
- 创建/撤销快: 创建一个线程只需要分配很少的栈空间,不需要重新分配内存页表(因为共用进程的)。
- 切换快: 线程切换时,不需要切换内存地址空间(页表不变),这大大减少了 CPU 的负担。
- 并发性高:
- 在多核 CPU 上,一个进程的多个线程可以同时在不同的核上跑,真正的并行计算。
- 通信容易:
- 进程间通信 (IPC) 需要过内核(像发信、共享内存映射)。
- 线程间通信不需要过内核,因为大家共享同一个全局变量,A 改了 B 立马看见(直接读写内存)。
四、 线程的“私有”与“共享” (考试高频点)
虽然线程寄生在进程里,但它必须有一些私房钱才能独立运行。
1. 线程私有的(独享)
这部分是线程为了记录“自己干到哪了”必须独有的:
- 线程控制块 (TCB): 类似 PCB,记录线程 ID、状态、优先级。
- 程序计数器 (PC): 记录下一条指令执行哪里。
- 寄存器集合: 记录当前的计算中间值。
- 用户栈 (User Stack): 记录函数调用链(比如 A调B,B调C,必须记在自己的栈里)。
2. 线程共享的(进程里的)
这部分是大家共用的,也是通信方便的基础:
- 地址空间: 代码段、数据段(全局变量)。
- 堆 (Heap):
malloc/new出来的内存大家都能访问。 - 文件: 打开的文件描述符(File Descriptors)。如果线程 A 打开了一个文件,线程 B 可以直接读写。
五、 进程 vs. 线程 (对比总结表)
| 比较维度 | 进程 (Process) | 线程 (Thread) |
|---|---|---|
| 角色 | 资源分配的基本单位 | CPU 调度的基本单位 |
| 内存空间 | 相互独立,互不干扰 | 同一进程内的线程共享内存 |
| 切换开销 | 很大 (需切换页表、刷新缓存 TLB) | 很小 (只需保存寄存器和栈) |
| 通信方式 | 需要 IPC (管道、消息队列等) | 直接读写共享变量 (需同步机制) |
| 健壮性 | 高。一个进程崩了,不影响其他进程 | 低。一个线程崩了 (如段错误),整个进程死掉 |
2.1.6.3 线程的属性
1. 轻型实体 (Lightweight)
- 概念: 线程也被称为“轻量级进程”。
- 解释: 线程自己基本上不拥有系统资源。它不拥有独立的内存地址空间(不占有页表),只拥有运行过程中必不可少的、非常少量的资源(如线程控制块 TCB、程序计数器、一组寄存器、栈)。
- 意义: 因为“行李”少,所以创建、撤销和切换都非常快。
2. 独立调度和分派的基本单位
- 概念: 在多线程操作系统中,CPU 调度是针对线程进行的,而不是进程。
- 解释: 进程变成了资源的拥有者,而线程变成了真正干活的单位。每个线程都有不同的优先级,操作系统会根据优先级来决定让哪个线程上 CPU 跑。
- 注意: 同一个进程中的多个线程,可以并发执行。
3. 可并发性 (Concurrency)
- 概念: 既然是调度单位,线程天然支持并发。
- 解释:
- 不同进程的线程可以并发。
- 同一个进程中的多个线程也可以并发(甚至在多核 CPU 上并行)。
- 这大大提高了系统的吞吐量和资源利用率。
4. 共享进程资源 (Resource Sharing) —— 核心考点
这是线程最显著的特征。同一个进程内的所有线程,都共享该进程的所有资源。
- 共享内容:
- 地址空间: 所有线程都“住”在同一个房子里,都能访问进程的代码段、数据段。
- 全局变量: 线程 A 修改了全局变量
x,线程 B 立马就能看到。 - 打开的文件: 线程 A 打开了一个文件,得到了文件描述符
fd,线程 B 可以直接用这个fd去读写。 - 信号处理函数: 如果进程定义了收到
SIGINT信号的处理方式,所有线程都遵循。
5. 线程的状态与生命周期
- 概念: 和进程一样,线程也有生命周期。
- 状态:
- 就绪 (Ready): 准备好了,等 CPU。
- 运行 (Running): 正在 CPU 上跑。
- 阻塞 (Blocked): 等锁、等 I/O。
- 注意: 如果一个内核级线程被阻塞(比如读磁盘),CPU 可以调度同一进程的其他线程继续跑;但如果是用户级线程,一个阻塞可能会导致整个进程阻塞。
重点区分:线程“私有” vs “共享” (背诵表格)
这是考试中关于属性最容易混淆的地方,请务必分清:
| 属性归属 | 具体内容 | 解释/原因 |
|---|---|---|
| 【线程私有】 (每个线程独有一份) | 1. 线程 ID | 唯一的标识符。 |
| 2. 程序计数器 (PC) | 记录自己执行到哪一行代码了。 | |
| 3. 寄存器集合 | 切换时保存自己的现场(上下文)。 | |
| 4. 堆栈 (Stack) | 非常重要! 维护自己的函数调用链和局部变量。 | |
| 5. 错误码 (errno) | 防止线程 A 的错误覆盖了线程 B 的错误。 | |
| 6. 信号屏蔽字 | 线程可以决定自己屏蔽哪些信号。 | |
| 【线程共享】 (属于进程,大家公用) | 1. 地址空间 | 代码段、数据段。 |
| 2. 全局变量 / 堆 (Heap) | malloc 分配的内存大家都能用。 |
|
| 3. 文件描述符表 | 打开的文件、网络套接字。 | |
| 4. 每种信号的处理函数 | 信号来了怎么处理,是全家统一的。 | |
| 5. 用户 ID / 组 ID | 权限是统一的。 |
2.1.6.4线程的组织和控制
一、 线程的组织:线程控制块 (TCB)
所有的线程管理都始于 TCB。TCB 是线程存在的唯一标志。
1. TCB 里有什么?
TCB 记录了操作系统(或线程库)管理线程所需的所有信息。它比 PCB 要“轻”很多,主要包含:
- 线程标识符 (TID): 类似 PID,每个线程独有的 ID。
- 一组寄存器 (Register Context): 包括程序计数器 (PC)、通用寄存器、状态寄存器。这是线程切换时必须保存的“现场”。
- 线程运行状态: 运行、就绪、阻塞。
- 优先级 (Priority): 决定谁先上 CPU。
- 线程专有存储区 (Thread Local Storage, TLS): 比如线程私有的 errno。
- 堆栈指针 (Stack Pointer): 指向线程私有的堆栈(用于保存函数调用链)。
2. TCB 的组织方式
- TCB 通常被组织成链表或队列。
- 系统会有“就绪线程队列”、“阻塞线程队列”等,调度器在这些队列中选择 TCB 进行操作。
二、 线程的控制 (Thread Control)
线程的控制主要包括创建、终止、阻塞、唤醒和切换。
1. 线程的创建 (Creation)
- 进程启动时默认有一个主线程。
- 主线程可以调用 API(如
pthread_create或 Windows 的CreateThread)来“派生”新的线程。 - 过程: 分配 TCB $\rightarrow$ 分配私有栈空间 $\rightarrow$ 初始化寄存器(PC指向函数入口) $\rightarrow$ 放入就绪队列。
2. 线程的终止 (Termination)
- 自然死亡: 线程函数执行完毕(return)。
- 自杀: 调用
pthread_exit等退出函数。 - 他杀: 被其他线程“取消” (Cancel)。
- 资源回收: 线程结束后,只有它的 TCB 和私有栈会被回收;它所属进程的共享资源(内存、文件)不会被释放(除非它是最后一个线程)。
3. 线程的切换 (Switching)
- 原理: 当 CPU 从线程 A 切换到线程 B 时,需要保存 A 的上下文,恢复 B 的上下文。
- 优势: 如果 A 和 B 属于同一个进程,只需要切换寄存器和栈,不需要切换页表(内存映射),因此速度极快。
三、 线程的实现方式(核心考点)
线程的“组织”不仅仅是 TCB,还涉及到由谁来管理 TCB。根据管理者的不同,分为三种模型:
1. 用户级线程 (User-Level Threads, ULT)
- 管理者: 应用程序自己(通过线程库,如早期的 Java Green Threads)。
- TCB 位置: 在用户空间。操作系统内核根本不知道线程的存在,它只看到一个单线程的进程。
- 优点:
- 快: 切换不需要切入内核态,开销极小。
- 跨平台: 可以在不支持线程的 OS 上运行。
- 缺点:
- 假并发: 如果一个线程发起系统调用(如读文件)被阻塞,整个进程的所有线程都会被阻塞(因为内核看来这只是一个进程被阻塞了)。
- 无法利用多核 CPU(内核一次只分配一个核给该进程)。
2. 内核级线程 (Kernel-Level Threads, KLT)
- 管理者: 操作系统内核。
- TCB 位置: 在内核空间。Windows、Linux、macOS 等现代 OS 都主要使用这种方式。
- 优点:
- 真并发: 可以在多核 CPU 上并行执行。
- 不连累: 一个线程阻塞,内核会调度同一进程的其他线程继续跑。
- 缺点:
- 慢: 线程切换需要从用户态进入内核态,开销比 ULT 大。
3. 混合实现 / 轻量级进程 (LWP)
- 结合了上述两者的优点。
- 用户层有多个 ULT,内核层有多个 KLT。
- 通过 轻量级进程 (LWP, Light Weight Process) 作为桥梁,将用户线程映射到内核线程上(多对多模型)。
- 注:Linux 中的线程(pthread)主要采用 1:1 模型,即一个用户线程直接对应一个内核线程(LWP)。
总结表格
| 特性 | 用户级线程 (ULT) | 内核级线程 (KLT) |
|---|---|---|
| TCB 位置 | 用户空间 (线程库管理) | 内核空间 (OS内核管理) |
| OS 感知 | 不可见 (OS 以为是单线程) | 可见 (OS 负责调度) |
| 切换开销 | 极小 (无需陷入内核) | 较大 (需陷入内核) |
| 阻塞影响 | 全军覆没 (一个阻塞全进程阻塞) | 独立自主 (互不影响) |
| 多核支持 | 不支持 (只能在一个核上跑) | 支持 (真正的并行) |
| 常见系统 | 早期 UNIX, 部分嵌入式系统 | Windows, Linux, Android |
2.1.6.5 多线程模型
一、 用户级线程 (User-Level Threads, ULT)
“多对一模型” (Many-to-One Model)
1. 原理
- 管理者: 应用程序通过线程库(如早期的 POSIX Pthreads 用户态实现)来管理。
- TCB 位置: 在用户空间。
- 内核视角: 操作系统内核不知道线程的存在。内核只看到一个单线程的进程。
2. 工作机制
- 线程的创建、撤销、同步、通信都在用户态完成,不需要请求操作系统的帮助(不涉及系统调用)。
- 调度算法由用户程序(线程库)决定。
3. 优缺点
- 优点:
- 切换快: 不需要切换到内核态,开销极小。
- 跨平台: 可以在不支持多线程的操作系统上运行(因为是库实现的)。
- 缺点 (致命伤):
- 假并发: 如果线程 A 发起系统调用(如读文件)被阻塞,内核会认为整个进程都被阻塞了,导致该进程内其他所有线程都停止运行。
- 无法利用多核: 内核一次只分配一个 CPU 核心给该进程,所以在这个进程内,同一时刻只能有一个线程在跑。
二、 内核级线程 (Kernel-Level Threads, KLT)
“一对一模型” (One-to-One Model) —— 现代主流 (Windows, Linux)
1. 原理
- 管理者: 操作系统内核。
- TCB 位置: 在内核空间。
- 内核视角: 操作系统非常清楚这个进程里有几个线程,并对它们进行单独调度。
2. 工作机制
- 线程的创建和管理都需要通过系统调用 (System Call) 进入内核态来完成。
- 内核负责将不同的线程分配到不同的 CPU 核心上。
3. 优缺点
- 优点:
- 真并发: 能利用多核 CPU 并行计算。
- 不连累: 如果线程 A 被阻塞,内核知道线程 B 还是就绪的,会调度线程 B 继续运行。
- 缺点:
- 开销大: 每次线程切换都需要从用户态 $\leftrightarrow$ 内核态切换,成本比 ULT 高。
三、 组合方式 / 混合实现
“多对多模型” (Many-to-Many Model)
1. 原理
- 结合了 ULT 和 KLT 的优点。
- 引入了 轻量级进程 (LWP, Light Weight Process) 的概念。
- LWP 的角色: LWP 是连接用户线程和内核线程的桥梁。用户线程跑在 LWP 上,LWP 跑在内核线程上。
2. 映射关系 (M : N)
- M 个用户线程 对应 N 个内核线程 (通常 $M \ge N$)。
- 用户可以在用户态快速切换线程(只要在同一个 LWP 上切)。
- 当一个 LWP 阻塞时,内核可以调度其他的 LWP 继续运行,保证并发性。
四、 核心考点:三种模型对比图解
为了方便记忆,我们可以通过映射关系来对比这三种方式:
| 维度 | 多对一 (ULT) | 一对一 (KLT) | 多对多 (混合) |
|---|---|---|---|
| 描述 | 多个用户线程 $\rightarrow$ 1个内核线程 | 1个用户线程 $\rightarrow$ 1个内核线程 | M个用户线程 $\rightarrow$ N个内核线程 |
| 并发性 | 无 (一旦阻塞全卡死) | 高 (真正的并行) | 高 (集两者之长) |
| 系统开销 | 极小 (用户态切换) | 较大 (内核态切换) | 中等 |
| 多核支持 | 不支持 | 支持 | 支持 |
| 典型代表 | Java Green Threads (旧) | Windows, Linux (主流) | Solaris (旧), Go语言协程(类似) |
3.补充
-
进程实体(进程映像)由三部分组成:PCB(大脑) + 程序段(身体) + 数据段(物资)
-
操作系统对进程进行隔离,一个进程绝对不能 直接访问另一个进程的私有地址空间,否则就不安全了(就像你不能直接闯进邻居家拿东西)。
-
进程与程序的根本区别是静态和动态特点
-
进程 $\neq$ 程序。进程是程序的一次执行,而不是程序本身。一个程序可以对应多个进程(比如你开了三个 QQ)。
-
在很多语境下(尤其是 Linux),线程被称为“轻量级进程 (LWP)”,所以说它是特殊的进程是可以接受的。
-
进程的执行是间断性的(走走停停),不是连续的。
-
由于异步性,如果你不加控制(同步),每次跑出来的结果可能都不一样,这就是不可再现性。
-
- 想要 CPU 但没给它 $\rightarrow$ 就绪态 (Ready)(排队等着)。
- 想要 I/O 资源/事件 但还没来 $\rightarrow$ 阻塞态 (Blocked)(因缺少条件而无法运行)。
- 题目说的是“申请处理器得不到满足”,说明它万事俱备只欠 CPU,所以应该是 就绪态。
-
进程是通过调度获得 CPU 的
-
死锁的时候所有进程处于阻塞态
-
并发进程是走走停停的。一个进程什么时候跑、跑多久、什么时候停,完全取决于操作系统(调度程序)的心情(策略)。
- 如果是先来先服务 (FCFS),谁先来谁跑得快。
- 如果是优先级调度,重要的进程跑得快。
- 如果是时间片轮转,大家轮流跑,速度相对均匀。
- 进程自己(选项 B)无法控制自己的执行速度,它只能被动接受 CPU 的分配。
-
考点: 进程创建 vs 进程调度。
- 进程创建原语 (Creation): 负责把进程“生”出来。包括:填户口(申请 PCB A)、分房子(分配内存 B)、去排队(放入就绪队列 D)。此时进程处于 就绪态。
- 进程调度/分派 (Scheduling/Dispatching): 负责让孩子“去上课”。只有调度程序才能决定把 CPU(课堂)分配给哪个进程。这不是创建阶段做的事。
-
一个进程 = 一个执行流(在单线程语境下)。在任意一个物理时刻,它只能执行一条指令,也就是运行一个程序。它不可能“分身”同时跑两个程序(除非是多进程并发,那是多个进程,不是一个进程)。
-
用户登录 (I): 无论是终端登录还是图形界面登录,系统都要创建一个“Shell”进程或桌面环境进程来为你服务。
高级调度 (作业调度) (II): 作业调度(High-Level Scheduling)的任务就是从后备队列中挑选作业,为其创建 PCB、分配内存,将其变成进程。所以必然涉及创建。
响应用户请求 (III): 比如打印服务。当用户请求打印时,操作系统可能会创建一个专门的打印进程(或线程)来处理这个请求。
用户打开程序 (IV): 双击图标,最直观的创建进程。
-
调度类型 别名 发生场所 频率 核心任务 状态转换 高级调度 作业调度 外存 $\rightarrow$ 内存 最低 (几分钟一次) 接纳:从后备队列挑作业,建 PCB,给内存。 无 $\rightarrow$ 创建 $\rightarrow$ 就绪 中级调度 内存调度 外存 $\leftrightarrow$ 内存 中等 交换 (Swapping):内存不够时把进程踢到硬盘(挂起),够了再拉回来。 挂起 $\leftrightarrow$ 就绪 低级调度 进程调度 内存 $\rightarrow$ CPU 最高 (毫秒级) 执行:决定就绪队列里谁上 CPU 跑。 就绪 $\rightarrow$ 运行 -
PCB (Process Control Block) 是进程存在的唯一标志。它是操作系统感知进程、控制进程和管理进程的核心数据结构。
-
系统守护进程(Daemon)一直存在于系统中,直到被操作人员撤销
-
高地址
- 栈 (Stack): 局部变量、函数参数 (向下增长 $\downarrow$)
- ... (空洞) ...
- 堆 (Heap):
malloc/new出来的 (向上增长 $\uparrow$) - 数据段 (Data): 全局变量、静态变量
- 正文段 (Text): 代码指令、常量 (只读)
低地址
-
A (主动): 运行 $\rightarrow$ 阻塞。这是进程自己执行了系统调用(如
scanf,read,wait),请求等待某个事件。这是代码逻辑决定的,是主动行为。B (被动): 运行 $\rightarrow$ 就绪。这是时间片用完或被更高优先级抢占。这是操作系统(调度器)决定的,进程自己通常不想停。
C (被动): 就绪 $\rightarrow$ 运行。这是调度器选中了你,把 CPU 给你。进程自己不能决定什么时候上台。
D (被动): 阻塞 $\rightarrow$ 就绪。这是事件发生了(如硬盘读完了,中断来了),操作系统把你唤醒。这取决于外部硬件或事件,不是进程决定的。
-
操作系统内核对每种信号都有默认处理程序,绝大多数信号的默认动作是终止进程,少部分是忽略或暂停。
-
操作系统内核可以给进程发送信号,比如 CPU 运算除以零,内核会发送
SIGFPE;非法访问内存,内核发送SIGSEGV -
线程共享进程的地址空间
-
安全性低: 因为同一进程内的所有线程共享地址空间。
后果: 一个线程写崩了内存(比如野指针),或者一个线程崩溃(SegFault),往往会导致整个进程的所有线程一起挂掉。线程之间没有防火墙,缺乏隔离性,所以安全性不如独立的进程。
-
无约束是错的。线程之间有同步约束(比如争抢锁),而且受限于 CPU 核心数。
-
原理: 键盘驱动运行在内核底层,通过中断处理输入。
不合理性: 驱动程序不可能知道当前有多少个“正在运行的应用”,也不可能为系统中成百上千个进程每人分配一个“键盘监听线程”。
实际做法: 驱动把按键放入一个公共缓冲区(消息队列),当前的焦点窗口所在的应用进程自己去读取消息。而不是驱动主动派线程去“喂”应用。
-
既然是用户级线程,内核根本不知道线程的存在,内核眼里只有进程,所以调度单位自然是进程。
-
用户级线程是以库函数的形式实现的(如早期的 pthreads),内核看来这就是普通的函数调用,所以只要有这个库,在任何 OS 上都能跑。
-
用户级线程: 整个进程只分到一个时间片。进程内的线程要在这个时间片里切来切去,每个线程分到的时间很少。
内核级线程: 比如一个进程有 3 个线程,系统会把它当做 3 个独立的调度单位,该进程能分到 3 个时间片。所以效果完全不同。
-
跨进程调度必然涉及地址空间切换(切换页表、刷新 TLB),这属于特权操作,必须由内核参与!用户级线程库(运行在用户态)只能控制同一个进程内**的线程切换,手伸不到隔壁进程去。
-
内核级线程的程序实体可以在内核态运行
-
用户级线程 允许每个进程定制自己的调度算法
-
用户级线程 无论你内部有多少个用户线程,它们都只能在这个单独的核心上轮流跑,无法利用多核进行并行计算。想要多核并行,必须用内核级线程。
-
用户级线程的创建(
malloc一个栈)、销毁、调度(改 PC 指针)全部在用户空间完成。内核根本不知道这些线程的存在,更不会来干预。如果需要内核干预,那就变成内核级线程了。
-
线程库中线程的切换不会导致进程切换(只有一个CPU)
-
共享资源: 同一个进程内的所有线程都共享进程的地址空间,因此 代码段(A)、全局变量(C,位于数据段)都是共享的。同时,打开的文件描述符表(B)也是共享的,一个线程打开文件,另一个线程可以直接读写。
私有资源: 线程为了能够独立执行函数调用,必须拥有自己独立的 栈 (Stack)。因此,栈指针 (Stack Pointer, SP) 必须是线程私有的(保存在 TCB 中)。如果共享栈指针,大家函数调用就乱套了。
-
线程是调度的单位,进程是资源分配的单位。
-
管道可以支持多个进程同时读或写(虽然通常是一对一使用,但技术上允许多对一或一对多,只是要注意原子性问题)。
-
时间片用完是将运行态变为就绪态,这叫超时或重置,不叫唤醒(唤醒的前提是你得先睡着/阻塞)。
-
用户级线程 (ULT) 对内核是不可见的。内核不知道有这些线程,自然不会为它们建立 TCB。ULT 的 TCB 是由用户空间的线程库维护的。
-
72. 【2023 统考真题】
题目: 下列操作完成时,导致 CPU 从内核态转为用户态的是 ( )。 A. 阻塞进程 B. 执行 CPU 调度 C. 唤醒进程 D. 执行系统调用
答案:D
解析:
- A、B、C:这些都是操作系统内核内部的管理操作,执行过程中和执行后通常仍处于内核态,或者接着运行其他内核代码。
- D (正确):系统调用 (System Call) 是用户程序请求内核服务。当系统调用执行完成并返回时,CPU 控制权会交还给用户程序,此时 CPU 状态会从 内核态 (Kernel Mode) 切换回 用户态 (User Mode)。
浙公网安备 33010602011771号