进程和线程

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)?

一个完整的进程实体主要由以下三部分组成:

  1. 程序段 (Program Segment): 进程要执行的指令集。
  2. 数据段 (Data Segment): 程序运行时所需处理的数据,如全局变量、常量等。
  3. 进程控制块 (Process Control Block, PCB): 这是进程存在的唯一标志,也是操作系统用于管理和控制进程的所有信息的集合。PCB包含关键信息,例如:
    • 进程标识符 (PID): 唯一的数字ID。
    • 进程状态: (就绪、运行、阻塞等)。
    • 程序计数器 (PC): 记录下一条要执行的指令地址。
    • CPU寄存器值: 进程被中断时的现场信息。
    • 内存管理信息: 进程的内存起始地址和大小等。
    • I/O状态信息: 分配给进程的I/O设备列表等。

3) 进程是如何解决问题的?

“进程是如何解决问题的?”这个问题通常指的是进程在操作系统环境下如何实现并发性,以及如何管理和组织任务

  1. 解决并发执行问题:
    • 通过进程调度(例如时间片轮转),操作系统可以快速地在不同进程间切换CPU,从宏观上看,多个任务似乎是同时进行的(即并发)。这使得用户可以一边听音乐一边写文档,提高了效率。
  2. 解决资源管理问题:
    • 进程是资源分配的基本单位。操作系统将内存、文件、I/O设备等资源以“进程”为界限进行分配和隔离。一个进程拥有的资源不会被另一个进程随意访问,从而保证了系统的稳定性和安全性。
  3. 解决程序组织和控制问题:
    • 通过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 时,操作系统实际上在做这些事:

  1. 保存 A 的现场: 把 CPU 寄存器里的所有数据拷贝到 A 的 PCB 里。
  2. 更新 A 的状态: 在 A 的 PCB 里把状态改为“就绪”或“阻塞”,并把它移到相应队列。
  3. 调度: 决定运行 B。
  4. 恢复 B 的现场:B 的 PCB 里存储的寄存器数据,拷贝回 CPU 的寄存器。
  5. 执行 B: CPU 接着 B 上次停下的地方继续跑。

这个过程需要消耗 CPU 时间,所以进程切换不能太频繁,否则 CPU 都在忙着搬运 PCB 数据,没空干正事了。

2. 程序段 (Program Segment) —— 身体/指令

  • 定义: 程序的代码(指令序列)。
  • 作用: 告诉 CPU 具体要做什么操作。
  • 特点: 它是只读的。
    • 注意: 如果多个用户同时运行同一个程序(比如大家都打开了 Word),这个程序段是可以被多个进程共享的,这样节省内存。

3. 数据段 (Data Segment) —— 物资/素材

  • 定义: 进程运行过程中产生的数据。
  • 包含内容:
    • 原始数据: 输入的数据。
    • 中间数据: 运算过程中产生的变量、数组。
    • 工作区: 比如函数调用时用到的栈(Stack)和堆(Heap)。
  • 特点: 它是可读写的,每个进程的数据段通常是独立的(除非专门使用了共享内存)。

通俗类比:做菜

为了方便记忆,我们可以继续用“做菜”的例子来类比:

进程的组成 厨房里的类比 解释
PCB (进程控制块) 厨师的任务单 + 状态记录 记录这道菜是谁点的(PID),做到哪一步了(保存现场),下一波该切菜还是炒菜(调度信息)。
程序段 菜谱 (做法) 具体的步骤:先放油、再放盐。这本菜谱是可以复用的(共享)。
数据段 食材 + 锅里的菜 具体的原料(肉、菜)和正在烹饪的半成品。这部分是这道菜独有的。

重点总结 (考试/理解常考点)

  1. 谁是核心? PCB。操作系统只认 PCB,不认代码。
  2. 谁能共享? 程序段(代码)通常可以共享。
  3. 谁不能共享? PCB 绝对不能共享,数据段默认不共享(除非通过特定的通信机制)。

2.1.3 进程的状态和转化

1、 进程的三种基本状态

这是进程在内存中运行时最核心的三个状态:

  1. 运行态 (Running):
    • 定义: 进程正在 CPU 上运行。
    • 注意: 在单核 CPU 中,同一时刻只有一个进程处于运行态。
  2. 就绪态 (Ready):
    • 定义: 万事俱备,只欠东风(CPU)。进程已经获得了除 CPU 以外的所有资源,只要获得 CPU 就能立刻执行。
    • 场景: 排队等着被调度。
  3. 阻塞态 (Blocked / Waiting):
    • 定义: 进程正在等待某个事件发生(如等待 I/O 完成、等待键盘输入、等待信号)。
    • 特点: 即使此时给它 CPU,它也运行不了,因为它缺少数据或条件。

二、 五态模型(加上创建和终止)

为了更完整地描述进程的生命周期,在上述三态基础上加入了起点和终点:

  1. 创建态 (New):
    • 进程正在被创建中。操作系统正在为它分配 PCB、分配内存资源,但还没放入就绪队列。
  2. 终止态 (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) 被动 需要其他进程协助

  • 原因: 等待的事情发生了(用户敲回车了,硬盘数据读完了)。
  • 注意: 阻塞解除后,进程不能直接回到运行态!它必须先去就绪队列排队,等待调度器再次选中它。

![image-20251205175344090](2.1 进程和线程.assets/image-20251205175344090.png)


四、 七态模型(引入“挂起”)

有些教材会提到“挂起 (Suspend)”状态。这是因为内存不够用了。

  • 挂起 (Suspend): 操作系统把暂时不用的进程从内存踢到外存(磁盘/硬盘)的对换区中,以腾出内存空间。
  • 于是多了两个状态:
    • 就绪挂起: 进程在硬盘里,但只要调入内存就能跑。
    • 阻塞挂起: 进程在硬盘里,而且还在等 I/O。

2.1.4 进程控制

进程控制所用的程序段叫做原语

2.1.4.1进程的创建

1. 什么时候会创建进程?(创建原语)

通常有四种事件会导致进程创建:

  1. 系统初始化: 开机时,系统启动第一个进程(如 Linux 下的 initsystemd)。
  2. 正在运行的进程发起请求: 一个进程调用系统调用(如 fork)来创建一个新进程。(这是最常见的场景,比如浏览器每开一个新标签页)。
  3. 用户请求: 用户双击一个图标,或者在终端输入 ./program
  4. 批处理作业初始化: 在大型机系统中,批处理队列中的作业被选中。

2. 操作系统创建进程的步骤(底层视角)

当需要创建一个新进程时,操作系统(内核)会执行以下标准步骤:

  1. 申请 PCB (Process Control Block):

    • 从 PCB 集合中索取一个空白的 PCB,并为新进程分配一个唯一的 PID (Process ID)
  2. 分配资源:

    • 为新进程分配运行所需的内存空间(代码段、数据段、堆栈等)。
    • 资源不足处于创建态,而不是失败
    • 注:这里涉及到底层的内存管理,可能会复用父进程的资源(见下文 COW 技术)。
  3. 初始化 PCB:

    • 初始化标志信息: 如 PID、PPID(父进程ID)、UID(用户ID)。

      初始化 CPU 状态信息: 如程序计数器 (PC) 指向程序入口,栈指针 (SP) 指向栈顶。

      初始化 CPU 控制信息: 设置进程的优先级、调度参数等。

    • 设置状态: 将进程状态设置为 就绪态 (Ready)创建态 (New)

  4. 插入队列:

    • 将初始化好的 PCB 插入到 就绪队列 中,等待调度器调度。

3. Linux 中的实现:fork()exec()

在 Linux/Unix 世界里,创建进程的逻辑非常独特,分为两步:

第一步:fork() —— 克隆
  • 动作: 父进程调用 fork()
  • 结果: 操作系统完全复制一份父进程(复制 PCB、复制堆栈、复制数据段)。
  • 父子关系: 新的进程是“子进程”。它和父进程几乎一模一样,连执行到的代码位置都一样。
  • 返回值的区别(重点):
    • 父进程中,fork() 返回子进程的 PID
    • 子进程中,fork() 返回 0
    • (程序通过判断返回值来区分自己是爸爸还是儿子)
第二步:exec() —— 变身
  • 动作: 虽然子进程复制了父进程,但我们通常希望它干点别的事(比如父进程是 Shell,子进程想运行 Python)。
  • 结果: 子进程调用 exec() 系列函数。
  • 原理: 操作系统用一个新的程序(比如 python.exe)的代码段和数据段,覆盖掉子进程原本复制来的内存空间。
  • 变身: 此时,子进程就变成了一个全新的程序开始运行。

🖼️ Mermaid 流程图 (Typora 可用)

graph TD Parent["父进程 (Shell)"] -->|"1. 调用 fork()"| Kernel(("OS 内核")) Kernel -->|"复制 PCB 和资源"| Child["子进程 (Shell 克隆体)"] Child -->|"我是子进程?"| Check{"是否需要<br>运行新程序?"} Check -- "No, 继续干活" --> Logic1["继续执行 Shell 代码"] Check -- "Yes, 变身" --> CallExec["2. 调用 exec('ls')"] CallExec -->|"加载 'ls' 代码<br>覆盖内存"| NewProg["新进程 ('ls' 程序)"] Parent -->|"我是父进程"| WaitNode["3. wait() 等待子进程"]

4. 高级考点:写时复制 (Copy-on-Write, COW)

你可能会问:“如果 fork() 要把父进程的所有内存都复制一遍,岂不是非常慢?而且浪费内存?”

答案:操作系统非常聪明,它使用了 COW 技术。

  1. 假复制:fork() 发生时,OS 并不真的复制 物理内存中的数据段和堆栈。它只是复制了页表,并将父子进程的页表都指向同一块物理内存
  2. 只读标记: OS 将这块共享的内存标记为“只读”。
  3. 写时触发:
    • 只要父子进程都只是“读”数据,大家就一直共用同一块内存,速度极快。
    • 一旦其中一个进程试图修改(写)数据,CPU 触发异常。
    • OS 捕获异常,这才把那一页内存真正复制一份出来给修改者。

总结: 只有在真正需要修改数据时,才会分配新的物理内存。这使得进程创建极其迅速。

2.1.4.2 进程的终止

一、 引起进程终止的“死因”

进程之所以会死,通常有以下三类原因:

1. 正常结束 (Normal Exit) —— “寿终正寝”
  • 场景: 进程的任务干完了。
  • 例子:
    • 你在 C/C++ 程序里写了 return 0; 或调用了 exit() 函数。
    • 编译器编译完整个文件后自动退出。
    • 你在 Linux 终端输入 ls,它列出文件后就自动结束了。
  • 性质: 自愿的。
2. 异常结束 (Error Exit) —— “因病早逝”
  • 场景: 进程在运行过程中发生了内部错误,无法继续运行。
  • 常见类型:
    • 越界错误: 访问了不该访问的内存地址(Segmentation Fault)。
    • 算术错误: 除数为零(Divide by zero)。
    • 非法指令: 试图执行 CPU 不认识的指令。
    • I/O 故障: 比如读文件时发现文件损坏或不存在。
  • 性质: 被动的,通常由 CPU 发出中断,操作系统介入将其杀死。
3. 外界干预 (Fatal Error / Killed) —— “被他杀”
  • 场景: 进程本身还在跑,但外界强制让它停下。
  • 例子:
    • 用户操作: 你打开任务管理器,点击“结束任务”;或者在终端执行 kill -9 <pid>
    • 父进程请求: 父进程觉得子进程没用了,或者父进程自己要死了(有些系统规定父进程死,子进程必须陪葬)。
    • 超时: 运行时间超过了规定的限制。
  • 性质: 被动的,强制性的。

二、 操作系统是如何终止进程的?(终止过程)

当上述情况发生时,操作系统内核会执行一系列“善后”操作。你可以把它想象成员工离职流程

  1. 检索 PCB: 根据进程 ID,从 PCB 集合中找到该进程的档案。
  2. 停止执行: 如果该进程还在 CPU 上跑,立刻把 CPU 停下来,触发调度机制(准备换人)。
  3. 终止子进程: 如果该进程还有子进程,根据系统策略,可能需要将子进程一并终止(或者把子进程过继给 init 进程,见下文)。
  4. 归还资源 (重点):
    • 内存: 释放它占用的内存空间。
    • 文件: 关闭它打开的所有文件句柄。
    • 设备: 释放它占用的 I/O 设备(如打印机)。
    • 这一步是为了防止内存泄漏
  5. 核算与汇报: 将 CPU 使用时间等统计信息记录下来。将退出码 (Exit Code) 填入 PCB,等待父进程来读取(告诉父进程我是怎么死的)。
  6. 删除 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. 阻塞的过程

这是一个“自我流放”的过程:

  1. 进程执行到阻塞指令(如 wait)。
  2. 进程自动停止运行。
  3. 操作系统修改该进程 PCB 的状态:Running $\rightarrow$ Blocked
  4. 操作系统把该进程的 PCB 挂到对应的阻塞队列(等待 I/O 的去 I/O 队列,等待磁盘的去磁盘队列)。
  5. CPU 此时空闲了,调度程序会选一个新的进程来运行。

关键点: 阻塞是进程自身的主动行为


二、 进程的唤醒 (Waking up)

1. 什么是唤醒?

当被阻塞的进程所期待的那个事件终于发生了,有关进程(比如提供数据的进程)或硬件中断处理程序会调用唤醒原语 (wakeup),将该进程“叫醒”。

2. 谁来唤醒?

被阻塞的进程是无法唤醒自己的(因为它已经停了,不再执行代码)。必须由“发现者”来唤醒它:

  • 硬件中断: 比如磁盘读写完了,磁盘控制器发中断告诉 CPU,中断处理程序负责唤醒等待磁盘的进程。
  • 其他进程: 比如进程 A 给进程 B 发了一个消息,A 就会去唤醒正在死等消息的 B。
3. 唤醒的过程

这是一个“重获资格”的过程:

  1. 把阻塞进程的 PCB 从阻塞队列中摘下来。
  2. 修改 PCB 的状态:Blocked $\rightarrow$ Ready(就绪)。
  3. 把 PCB 插入到就绪队列中,等待调度。

关键点:

  1. 唤醒是被动的(由操作系统或其他进程发起)。
  2. 唤醒后不是直接运行,而是去排队(变就绪态)。

三、 原语的概念 (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)

graph LR classDef highlight fill:#f96,stroke:#333,stroke-width:2px; subgraph ProcessA ["进程 A (虚拟地址空间)"] CodeA[代码段] DataA[数据段] SharedA[共享区映射]:::highlight end subgraph ProcessB ["进程 B (虚拟地址空间)"] CodeB[代码段] DataB[数据段] SharedB[共享区映射]:::highlight end subgraph PhysMem ["物理内存 (Physical Memory)"] P_CodeA[进程 A 物理页] P_CodeB[进程 B 物理页] P_Shared((实际共享的物理帧)):::highlight end %% 关键:两个进程的不同虚拟地址,指向同一个物理地址 SharedA -- 映射 --> P_Shared SharedB -- 映射 --> P_Shared

3. 为什么它是最快的 IPC?

与其他通信方式(如管道 Pipe、消息队列 Message Queue)相比,共享存储最快,原因如下:

  1. 零拷贝 (Zero-Copy):
    • 管道/消息队列: 数据需要从 A 进程拷贝到内核 (Kernel),再从内核拷贝到 B 进程。至少发生 2 次内存拷贝
    • 共享存储: 数据直接写入内存,不需要在内核和用户空间之间来回拷贝。0 次拷贝(或者说除了写入本身的 1 次外,无额外拷贝)。
  2. 直接访问: 进程像访问自己的变量一样访问共享区域,没有系统调用的开销。

4. 致命缺点:同步问题 (Synchronization)

虽然速度快,但它有一个巨大的风险:操作系统不负责数据的同步!

  • 场景: 进程 A 正在写 "Hello",只写了 "Hel...",CPU 时间片到了切换给进程 B。进程 B 读取数据,读到了残缺的 "Hel..."。
  • 后果: 数据错乱(竞态条件, Race Condition)。

解决方案: 程序员必须自己使用辅助工具来保证读写顺序,通常配合 信号量 (Semaphore / PV操作)互斥锁 (Mutex) 一起使用。

口诀:共享存储快如闪电,但要配合 PV 防乱战。

5. 两种分类 (考研/教材常见分类)

在操作系统教材(如王道)中,共享存储通常分为两类:

  1. 基于数据结构的共享 (低级方式):
    • 比如共享一个数组、一个结构体。
    • 限制多,灵活性差,只能存放特定格式的数据。
  2. 基于存储区的共享 (高级方式):
    • 操作系统只给你划一块“空地”(比如 4KB),你想存字符串、图片还是二进制流都可以。
    • 这是现代操作系统(如 Linux 的 shmget)主要采用的方式。

2.1.5.2 管道通信 (用户态到内核态的转变)

这种方式最大的特点是:进程间不直接读写对方的内存,而是通过操作系统提供的原语(Primitives)来进行数据交换。

以下是关于消息传递的详细解析:

一、 核心机制:发送与接收

在消息传递系统中,进程主要通过两个核心原语来操作:

  1. send(destination, message):发送一条消息给目标。
  2. 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)

消息在传递过程中,通常暂存在操作系统的内核空间里。这个存储区域(队列)的大小决定了系统的行为:

  1. 零容量 (Zero capacity): 链路中不能存消息。发送方必须等接收方准备好,两者必须同时在线(握手/Rendezvous)。
  2. 有限容量 (Bounded capacity): 队列能存 n 条消息。如果队列满了,发送方必须阻塞(等待腾出空间)。
  3. 无限容量 (Unbounded capacity): 理论上可以无限存,发送方永远不需要等待。

五、 优缺点总结

  • 优点:
    • 安全: 进程间内存隔离,不会出现“野指针”乱改别人数据的情况。
    • 分布式友好: 这种模式天然适合网络通信(微内核架构、分布式系统常用)。
    • 同步简单: 接收原语本身就自带了“等待”功能,不需要像共享内存那样额外写复杂的信号量锁。
  • 缺点:
    • 开销大 (Overhead): 每一条消息都需要从用户态拷贝到内核态,再拷贝回用户态。也就是“中间商赚差价”,速度比共享内存慢。

2.1.5.3 管道通信

管道通信 (Pipe Communication) 是操作系统中最古老、也是最基础的一种进程间通信 (IPC) 方式。

如果说“共享存储”是两个房间开了一扇窗户,那么“管道”就是连接两个房间的一根水管

以下是关于管道通信的核心考点和原理图解:


1. 核心定义

管道本质上是一个打开的文件,但它不在磁盘上,而是在内核内存中维护的一个固定大小的缓冲区(通常是一个内存页,如 4KB)。

  • 像流水一样: 数据只能像水流一样,从一端流进去,从另一端流出来。
  • 读后即焚: 管道里的数据一旦被读取,就消失了(不可重复读)。
2. 四大核心特点 (考试必背)
1. 半双工通信 (Half-duplex)
  • 单行道: 数据同一时刻只能朝一个方向流动(A 发 B 收,或者 B 发 A 收)。
  • 想双向怎么办? 如果需要 A 和 B 互相聊天,必须建立两个管道
  1. 互斥与同步 (自动处理)

这是管道和共享存储最大的区别:操作系统帮你管秩序

  • 互斥: 当一个进程正在对管道进行读/写操作时,另一个进程不能同时操作。
  • 同步 (阻塞机制):
    • 写满了: 如果管道(缓冲区)被写满了,写进程(Writer)会被阻塞(去睡觉),直到有空间。
    • 读空了: 如果管道里没数据了,读进程(Reader)会被阻塞(去睡觉),直到有新数据来。
  1. 以“字节流”形式传输
  • 管道不认识任何数据结构(没有结构体、整数的概念),它只把数据看作一连串的字节 (Byte Stream)
  • 发送方和接收方必须事先约定好数据的格式,否则读出来就是乱码。
4. 固定容量
  • 管道是有大小限制的。如果写得太快读得太慢,管道满了就会阻塞写进程。

3. 原理图解 (Mermaid)

graph LR subgraph UserSpace [用户空间] ProcA["进程 A (Writer)"] ProcB["进程 B (Reader)"] end subgraph KernelSpace [内核空间] PipeBuffer["==== 管道缓冲区 (Pipe) ==== <br> FIFO 队列 <br> (限制大小, 如 4KB)"] end ProcA -- "1. write(fd[1])<br>数据写入" --> PipeBuffer PipeBuffer -- "2. read(fd[0])<br>数据流出" --> ProcB style PipeBuffer fill:#e1f5fe,stroke:#01579b,stroke-width:2px;

4. 管道的分类:匿名管道 vs 命名管道

考试中经常考察这两者的区别:

特性 匿名管道 (Anonymous Pipe) 命名管道 (Named Pipe / FIFO)
存在形式 只存在于内存中,没有文件名 在文件系统中有一个文件名 (如 my_pipe)
使用限制 只能用于有亲缘关系的进程 (父子、兄弟) 可以用于任意两个进程 (哪怕毫无关系)
生命周期 进程结束,管道就消失 即使进程结束,文件还在 (除非手动删除)
经典命令 Linux 中的竖线 ` <br> (例:ps

5. 管道 vs 共享存储 (对比总结)

维度 管道通信 (Pipe) 共享存储 (Shared Memory)
传输效率 (数据需要在用户态/内核态之间拷贝) (直接读写内存,零拷贝)
同步机制 OS 自动处理 (写满/读空会自动阻塞) 需手动处理 (必须配合信号量/PV操作)
数据流向 单向 (半双工) 双向 (只要能访问内存就能读写)
适用场景 传输少量数据,简单指令 传输大量数据,频繁交互

2.1.5.6 信号

一、 什么是信号?

  • 本质: 信号是一种模拟中断机制,因此也被称为“软件中断”
  • 特点:
    1. 异步性 (Asynchronous): 进程永远不知道信号什么时候会来。它正在专心跑代码,信号突然就来了,它必须马上停下手头的工作去响应。
    2. 信息量小: 信号本身只是一个整数(比如 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 了(信来了),进程也会假装没看见,暂时不处理,直到解除屏蔽。
  • 当操作系统把一个进程从内核态切换到用户态时(如系统调用返回时),会检查该进程是否有未被阻塞的待处理信号。”

二、 信号是从哪里来的?

信号的来源通常有三种:

  1. 用户操作 (终端输入):
    • 你在终端按下 Ctrl+C,就会产生一个 SIGINT 信号,强制终止当前进程。
    • 按下 Ctrl+Z,产生 SIGSTOP,暂停进程。
  2. 硬件异常 (内核检测):
    • 代码里写了“除以 0”,CPU 报错,内核发送 SIGFPE(浮点错误)。
    • 访问了非法内存(野指针),内核发送 SIGSEGV(段错误)。
  3. 软件/其他进程:
    • 在命令行输入 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.线程的基本概念

一、 核心定义(金科玉律)

在引入线程的操作系统中,有两个最基本的定义,请务必背下来:

  1. 进程 (Process):资源分配的基本单位。
    • (它负责占有内存、文件句柄等资源)。
  2. 线程 (Thread):处理机调度(CPU 执行)的基本单位。
    • (它负责在 CPU 上真正跑代码)。

一句话概括: 进程是“房东”,提供了场地和工具;线程是“租客/工人”,负责在里面干活。一个进程里至少要有一个线程(主线程)。


二、 通俗类比:工厂与工人

为了讲清楚它们的关系,我们继续用“工厂”做比喻:

  • 进程 = 工厂的车间
    • 它拥有独立的厂房(内存空间)、机器(I/O设备)和原材料(数据)。
    • 建一个车间很麻烦,需要申请地皮、盖房子(资源分配开销大)。
  • 线程 = 车间里的工人
    • 共享资源: 同一个车间里的工人,共享车间里的机器、原材料和墙上的规章制度(共享代码段、数据段、文件)。
    • 独立任务: 每个工人有自己的工位、自己的干活进度(PC 指针)和自己的临时草稿纸(栈空间)。
    • 并发: 一个车间里可以有多个工人同时干活, collaborative 完成一个大订单。

三、 为什么要引入线程?(优点)

既然已经有进程了,为什么还要搞个线程出来?主要是为了“快”“省”

  1. 开销小 (轻量级):
    • 创建/撤销快: 创建一个线程只需要分配很少的栈空间,不需要重新分配内存页表(因为共用进程的)。
    • 切换快: 线程切换时,不需要切换内存地址空间(页表不变),这大大减少了 CPU 的负担。
  2. 并发性高:
    • 在多核 CPU 上,一个进程的多个线程可以同时在不同的核上跑,真正的并行计算。
  3. 通信容易:
    • 进程间通信 (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.补充

  1. 进程实体(进程映像)由三部分组成:PCB(大脑) + 程序段(身体) + 数据段(物资)

  2. 操作系统对进程进行隔离,一个进程绝对不能 直接访问另一个进程的私有地址空间,否则就不安全了(就像你不能直接闯进邻居家拿东西)。

  3. 进程与程序的根本区别是静态动态特点

  4. 进程 $\neq$ 程序。进程是程序的一次执行,而不是程序本身。一个程序可以对应多个进程(比如你开了三个 QQ)。

  5. 在很多语境下(尤其是 Linux),线程被称为“轻量级进程 (LWP)”,所以说它是特殊的进程是可以接受的。

  6. 进程的执行是间断性的(走走停停),不是连续的。

  7. 由于异步性,如果你不加控制(同步),每次跑出来的结果可能都不一样,这就是不可再现性

    • 想要 CPU 但没给它 $\rightarrow$ 就绪态 (Ready)(排队等着)。
    • 想要 I/O 资源/事件 但还没来 $\rightarrow$ 阻塞态 (Blocked)(因缺少条件而无法运行)。
    • 题目说的是“申请处理器得不到满足”,说明它万事俱备只欠 CPU,所以应该是 就绪态
  8. 进程是通过调度获得 CPU 的

  9. 死锁的时候所有进程处于阻塞态

  10. 并发进程是走走停停的。一个进程什么时候跑、跑多久、什么时候停,完全取决于操作系统(调度程序)的心情(策略)。

    • 如果是先来先服务 (FCFS),谁先来谁跑得快。
    • 如果是优先级调度,重要的进程跑得快。
    • 如果是时间片轮转,大家轮流跑,速度相对均匀。
    • 进程自己(选项 B)无法控制自己的执行速度,它只能被动接受 CPU 的分配。
  11. 考点: 进程创建 vs 进程调度

    • 进程创建原语 (Creation): 负责把进程“生”出来。包括:填户口(申请 PCB A)、分房子(分配内存 B)、去排队(放入就绪队列 D)。此时进程处于 就绪态
    • 进程调度/分派 (Scheduling/Dispatching): 负责让孩子“去上课”。只有调度程序才能决定把 CPU(课堂)分配给哪个进程。这不是创建阶段做的事。
  12. 一个进程 = 一个执行流(在单线程语境下)。在任意一个物理时刻,它只能执行一条指令,也就是运行一个程序。它不可能“分身”同时跑两个程序(除非是多进程并发,那是多个进程,不是一个进程)。

  13. 用户登录 (I): 无论是终端登录还是图形界面登录,系统都要创建一个“Shell”进程或桌面环境进程来为你服务。

    高级调度 (作业调度) (II): 作业调度(High-Level Scheduling)的任务就是从后备队列中挑选作业,为其创建 PCB、分配内存,将其变成进程。所以必然涉及创建。

    响应用户请求 (III): 比如打印服务。当用户请求打印时,操作系统可能会创建一个专门的打印进程(或线程)来处理这个请求。

    用户打开程序 (IV): 双击图标,最直观的创建进程。

  14. 调度类型 别名 发生场所 频率 核心任务 状态转换
    高级调度 作业调度 外存 $\rightarrow$ 内存 最低 (几分钟一次) 接纳:从后备队列挑作业,建 PCB,给内存。 无 $\rightarrow$ 创建 $\rightarrow$ 就绪
    中级调度 内存调度 外存 $\leftrightarrow$ 内存 中等 交换 (Swapping):内存不够时把进程踢到硬盘(挂起),够了再拉回来。 挂起 $\leftrightarrow$ 就绪
    低级调度 进程调度 内存 $\rightarrow$ CPU 最高 (毫秒级) 执行:决定就绪队列里谁上 CPU 跑。 就绪 $\rightarrow$ 运行
  15. PCB (Process Control Block) 是进程存在的唯一标志。它是操作系统感知进程、控制进程和管理进程的核心数据结构。

  16. 系统守护进程(Daemon)一直存在于系统中,直到被操作人员撤销

  17. 高地址

    • 栈 (Stack): 局部变量、函数参数 (向下增长 $\downarrow$)
    • ... (空洞) ...
    • 堆 (Heap): malloc/new 出来的 (向上增长 $\uparrow$)
    • 数据段 (Data): 全局变量、静态变量
    • 正文段 (Text): 代码指令、常量 (只读)

    低地址

  18. A (主动): 运行 $\rightarrow$ 阻塞。这是进程自己执行了系统调用(如 scanf, read, wait),请求等待某个事件。这是代码逻辑决定的,是主动行为。

    B (被动): 运行 $\rightarrow$ 就绪。这是时间片用完或被更高优先级抢占。这是操作系统(调度器)决定的,进程自己通常不想停。

    C (被动): 就绪 $\rightarrow$ 运行。这是调度器选中了你,把 CPU 给你。进程自己不能决定什么时候上台。

    D (被动): 阻塞 $\rightarrow$ 就绪。这是事件发生了(如硬盘读完了,中断来了),操作系统把你唤醒。这取决于外部硬件或事件,不是进程决定的。

  19. 操作系统内核对每种信号都有默认处理程序,绝大多数信号的默认动作是终止进程,少部分是忽略或暂停。

  20. 操作系统内核可以给进程发送信号,比如 CPU 运算除以零,内核会发送 SIGFPE;非法访问内存,内核发送 SIGSEGV

  21. 线程共享进程的地址空间

  22. 安全性低: 因为同一进程内的所有线程共享地址空间

    后果: 一个线程写崩了内存(比如野指针),或者一个线程崩溃(SegFault),往往会导致整个进程的所有线程一起挂掉。线程之间没有防火墙,缺乏隔离性,所以安全性不如独立的进程。

  23. 无约束是错的。线程之间有同步约束(比如争抢锁),而且受限于 CPU 核心数。

  24. 原理: 键盘驱动运行在内核底层,通过中断处理输入。

    不合理性: 驱动程序不可能知道当前有多少个“正在运行的应用”,也不可能为系统中成百上千个进程每人分配一个“键盘监听线程”。

    实际做法: 驱动把按键放入一个公共缓冲区(消息队列),当前的焦点窗口所在的应用进程自己去读取消息。而不是驱动主动派线程去“喂”应用。

  25. 既然是用户级线程,内核根本不知道线程的存在,内核眼里只有进程,所以调度单位自然是进程。

  26. 用户级线程是以库函数的形式实现的(如早期的 pthreads),内核看来这就是普通的函数调用,所以只要有这个库,在任何 OS 上都能跑。

  27. 用户级线程: 整个进程只分到一个时间片。进程内的线程要在这个时间片里切来切去,每个线程分到的时间很少。

    内核级线程: 比如一个进程有 3 个线程,系统会把它当做 3 个独立的调度单位,该进程能分到 3 个时间片。所以效果完全不同。

  28. 跨进程调度必然涉及地址空间切换(切换页表、刷新 TLB),这属于特权操作,必须由内核参与!用户级线程库(运行在用户态)只能控制同一个进程内**的线程切换,手伸不到隔壁进程去。

  29. 内核级线程的程序实体可以在内核态运行

  30. 用户级线程 允许每个进程定制自己的调度算法

  31. 用户级线程 无论你内部有多少个用户线程,它们都只能在这个单独的核心上轮流跑,无法利用多核进行并行计算。想要多核并行,必须用内核级线程。

  32. 用户级线程的创建malloc 一个栈)、销毁调度(改 PC 指针)全部在用户空间完成。

    内核根本不知道这些线程的存在,更不会来干预。如果需要内核干预,那就变成内核级线程了。

  33. 线程库中线程的切换不会导致进程切换(只有一个CPU)

  34. 共享资源: 同一个进程内的所有线程都共享进程的地址空间,因此 代码段(A)、全局变量(C,位于数据段)都是共享的。同时,打开的文件描述符表(B)也是共享的,一个线程打开文件,另一个线程可以直接读写。

    私有资源: 线程为了能够独立执行函数调用,必须拥有自己独立的 栈 (Stack)。因此,栈指针 (Stack Pointer, SP) 必须是线程私有的(保存在 TCB 中)。如果共享栈指针,大家函数调用就乱套了。

  35. 线程是调度的单位,进程是资源分配的单位。

  36. 管道可以支持多个进程同时读或写(虽然通常是一对一使用,但技术上允许多对一或一对多,只是要注意原子性问题)。

  37. 时间片用完是将运行态变为就绪态,这叫超时重置,不叫唤醒(唤醒的前提是你得先睡着/阻塞)。

  38. 用户级线程 (ULT) 对内核是不可见的。内核不知道有这些线程,自然不会为它们建立 TCB。ULT 的 TCB 是由用户空间的线程库维护的。

  39. 72. 【2023 统考真题】

    题目: 下列操作完成时,导致 CPU 从内核态转为用户态的是 ( )。 A. 阻塞进程 B. 执行 CPU 调度 C. 唤醒进程 D. 执行系统调用

    答案:D

    解析:

    • A、B、C:这些都是操作系统内核内部的管理操作,执行过程中和执行后通常仍处于内核态,或者接着运行其他内核代码。
    • D (正确)系统调用 (System Call) 是用户程序请求内核服务。当系统调用执行完成并返回时,CPU 控制权会交还给用户程序,此时 CPU 状态会从 内核态 (Kernel Mode) 切换回 用户态 (User Mode)
posted @ 2025-12-18 22:14  belief73  阅读(2)  评论(0)    收藏  举报