Article
操作系统-CH22-线程
操作系统-CH22-线程,待补充摘要。
BOK 操作系统课程:线程专题全景学习指南
一、 课程整体知识图谱概览
线程(Thread)是在进程(Process)基础之上对执行单位进行更进一步划分的基本单位。
┌── 1. 线程的基本概念 (Basic Concepts of Threads)
├── 2. 线程的状态与转换 (Thread States & Transitions)
线程专题 (Thread) ───────├── 3. 线程管理中的数据结构 (TCB & Stack Mechanism)
├── 4. 线程的控制 (Thread Control: Creation & Termination)
└── 5. 线程的实现方式与多线程模型 (ULT, KST, and Models)
二、 线程的基本概念 (Basic Concepts)
1. 进程与线程的本质区别
在引入线程之前,进程是操作系统中拥有资源和独立调度的基本单位。
- 单线程进程:一个进程只有一个执行点,即只有一个逻辑上的程序计数器(Program Counter, PC),用来存放并指向当前要执行的指令。其执行流程是一条单向的线性轨迹。
- 多线程进程(Multi-threaded Process):一个进程内部拥有多个执行点。这意味着进程内部有多个逻辑上的程序计数器,每个计数器都独立用于取指令和执行,使得进程内部可以并行/并发地执行多个不同的任务。
2. 线程的定义与特征
- 定义:线程是进程中的一个实体,是系统独立调度和分派的基本单位。它自身基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器、一组寄存器和栈)。
- 资源归属:分配资源的基本单位仍然是进程。线程完全依附于进程,并共享其所属进程的地址空间,从而能够直接访问该进程内相同的数据和资源。
3. 内存布局对比:单线程 vs 多线程地址空间
根据 PPT 第 1-3 页的地址空间分布图,单线程和多线程进程在内存布局上存在重大差异:
单线程进程的地址空间 (Single-threaded Address Space)
+-----------------------------------+ 16KB (高地址)
| 用户栈 (Stack) | (向低地址增长)
+-----------------------------------+
| 空闲区 |
+-----------------------------------+ 2KB
| 堆 (Heap) | (向高地址增长)
+-----------------------------------+ 1KB
| 程序代码与数据 |
+-----------------------------------+ 0KB (低地址)
多线程进程的地址空间 (Multi-threaded Address Space)
+-----------------------------------+ 16KB (高地址)
| 用户栈 1 (Stack 1) | (线程 1 专有,向低地址增长)
+-----------------------------------+
| 用户栈 2 (Stack 2) | (线程 2 专有,向低地址增长)
+-----------------------------------+
| 空闲区 |
+-----------------------------------+ 2KB
| 共享堆 (Shared Heap) | (所有线程共享,向高地址增长)
+-----------------------------------+ 1KB
| 共享程序代码与全局数据 | (所有线程共享)
+-----------------------------------+ 0KB (低地址)
关键特性解析:
- 每个线程拥有自己独立的栈(Stack):栈用于存储函数调用的返回地址、局部变量以及部分上下文信息。
- 堆(Heap)是共享的:所有线程均可访问同一个堆区(通过
malloc或new动态分配的内存)。 - 线程间无保护措施(重要考点):
- 线程之间没有内存页表级别的写保护保护机制。
- 每个线程的堆栈可以被同进程内的其他线程读写甚至清除。
- 一个线程打开的文件描述符,其他线程也可以直接进行读写。
- 设计初衷:线程被视为“进程内部的协作单位”,属于“自己人”,因此为了追求极高的通信效率,放弃了线程间的硬性内存隔离。
三、 线程的状态与转换 (Thread States & Transitions)
线程的状态模型完全套用了进程的经典三态模型:
调度 (Schedule)
┌───────────────────> 执行态 (Running)
│ │
│ │ 时间片完 (Time Slice Out)
就绪态 (Ready) <───────────────────────┤ / 被抢占 (Preempted)
^ │
│ │ I/O 请求 (I/O Request)
│ v
└─────────────────── 阻塞态 (Blocked)
I/O 完成 (I/O Complete)
1. 三种基本状态的严格定义
- 就绪态(Ready):线程已具备了运行的所有条件,只差 CPU 资源,一旦获得处理机便可立即执行。
- 执行态(Running):线程已获得 CPU 资源,其代码正在处理机上运行。
- 阻塞态(Blocked):线程在执行中因等待某事件(如 I/O 完成、申请锁、等待信号量等)而受阻,处于暂停状态,即使分配 CPU 也无法运行。
2. 状态转换细节解析
- 执行 阻塞:这是由线程主动发起的系统调用(如 I/O 请求)引起的,是主动行为。
- 执行 就绪:这是由于时间片耗尽或高优先级线程抢占(Preemption)引起的,是被动行为。
- 阻塞 就绪:等待的外部事件完成(如 I/O 硬件中断通知内核完成读写),由内核将其从阻塞队列移入就绪队列,是被动接收通知行为。
四、 线程管理中的数据结构 (Thread Control Block)
1. 线程控制块 TCB (Thread Control Block)
为了便于系统描述和管理线程,操作系统为每个线程定义了一个专用数据结构——TCB。它是线程存在的唯一标志,类比于进程的 PCB。
TCB 中的核心信息:
| 字段名称 | 英文术语 | 详细描述与作用 |
|---|---|---|
| 线程标识符 | Thread ID (TID) | 为每个线程赋予一个在所属进程内(或系统内)唯一的标识符。 |
| 寄存器上下文 | Processor Registers | 保存 CPU 寄存器的快照,包括:1. 程序计数器(PC):下一条要执行的指令地址。2. 程序状态字(PSW):保留条件码、CPU 运行模式等。3. 通用寄存器:保存计算中间值。 |
| 线程执行状态 | Thread State | 记录线程当前处于何种状态(Ready, Running, Blocked)。 |
| 优先级 | Priority | 描述该线程被调度的优先程度。 |
| 线程专有存储区 | Thread-Local Storage | 用于在线程切换时,存放现场保护信息和线程独享的局部静态数据。 |
| 信号屏蔽 | Signal Mask | 声明该线程目前选择屏蔽/不响应的信号。 |
| 堆栈指针 | Stack Pointers (SP) | 必须设置两个堆栈指针:1. 指向该线程在用户空间的用户栈(User Stack)。2. 指向该线程在内核空间的内核栈(Kernel Stack)。 |
2. 核心机制深度解析:用户栈与内核栈的切换过程
每个线程在运行时,都同时持有用户栈和内核栈。这是保障系统并行性和内核隔离性的底层基石。
为什么线程必须同时拥有两个栈?
- 用户栈 (User Stack):在用户态下运行,用于存储用户程序的普通函数调用参数、局部变量和返回地址。
- 内核栈 (Kernel Stack):在内核态下运行。当线程通过系统调用陷入内核或遭遇硬件中断时,内核代码在执行其特定函数调用时也需要栈空间。
系统调用引发的栈切换流程图解:
[用户态 (User Mode)] [内核态 (Kernel Mode)]
┌───────────────────────┐ ┌───────────────────────┐
| 执行用户程序 | | 系统调用处理逻辑 |
| PC -> 用户代码 | | PC -> 内核服务代码 |
| SP -> 用户栈 (User SP) | | SP -> 内核栈 (Kernel SP)|
└──────────┬────────────┘ └───────────▲───────────┘
│ │
│ 1. 触发系统调用 (e.g., Trap) │ 3. 执行内核代码
▼ │
┌──────────────────────────────────────────────────────┴───────────┐
| CPU 硬件 & 内核入口自动处理逻辑 |
| a. 提升 CPU 特权级 (User -> Kernel) |
| b. 将当前的用户栈地址 (User SP) 和 PSW 等寄存器压入该线程的内核栈保存 |
| c. 将 SP 寄存器的值强行修改为该线程的【内核栈基地址】 |
└──────────────────────────────────────────────────────────────────┘
详细切换步骤(100% 还原系统运行机理):
- 用户态正常运行:此时 CPU 处于用户态,
SP寄存器指向该线程的用户栈。 - 触发系统调用/陷入内核:执行到如
read()等系统调用,产生软中断(Trap)。CPU 硬件自动进行变态(模式切换,Mode Switch),从用户态切换到内核态。 - 保护用户态现场与栈指针保存:
- 陷入内核的第一步,CPU 必须将当前的用户栈地址(User SP)压入该线程对应的内核栈中保存。
- 同时保存当前用户态的
PC(返回地址)和PSW等关键寄存器到内核栈中。
- 重置堆栈指针寄存器:
- 将
SP寄存器的内容强行修改为该线程对应的内核栈地址。 - 此后,在内核态中执行的一切函数调用,其局部变量和返回地址全部保存在内核栈中。
- 将
- 系统调用结束并返回:
- 内核代码执行完毕后,调用中断返回指令(如
iret)。 - 从内核栈中弹出之前保存的
User SP(用户栈地址)和PC。 - 将弹出值恢复到
SP和PC寄存器,CPU 恢复为用户态,继续从系统调用下一条指令处执行用户栈代码。
- 内核代码执行完毕后,调用中断返回指令(如
3. 区分内核栈和用户栈的核心深层原因
这是为了实现内核与用户态的严格隔离(Security & Isolation):
- 防止恶意篡改:内核代码运行权限极高。如果内核函数调用的返回地址和局部变量依然存在用户空间的“用户栈”上,那么由于同一进程内的其他线程可以随意读写该用户栈,恶意线程就可以在后台修改该用户栈上的返回地址,从而劫持内核执行流,破坏整个操作系统。
- 数据安全:内核栈位于受保护的内核地址空间中,用户态下的任何代码(包括同进程的其他线程)都绝无可能访问到内核栈,从而确保了内核上下文的安全。
五、 线程的控制 (Thread Control)
| 控制动作 | 机制与生命周期细节 |
|---|---|
| 线程的创建*(Thread Creation)* | 1. 进程在启动时,OS 会默认创建并运行一个初始化主线程。 2. 该主线程调用线程创建函数(如 pthread_create),传入目标函数地址和参数。3. 操作系统或线程库为其分配 TCB、分配独立的用户栈与内核栈,并返回一个唯一的线程标识符(TID)。 |
| 线程的终止*(Thread Termination)* | 1. 线程可以通过调用终止函数(如 pthread_exit)主动退出,也可以被其他线程异常杀死。2. 资源延迟释放(极其重要):在大多数 OS 中,线程被终止后不会立刻释放其占有的所有资源(如栈内存、TCB 等)。此时它处于类似于进程中“僵尸态”的状态。 3. 回收机制:只有当进程中的其他线程执行了**分离函数(如 pthread_join 或 pthread_detach)**后,被终止线程才与资源彻底分离,资源才能被重新利用。4. 在尚未执行分离回收前,已被终止但尚未释放资源的线程,仍可以被需要的线程恢复运行。 |
六、 线程的实现方式与多线程模型 (Implementation & Models)
线程的实现方式决定了“用户级线程”如何向“内核级线程/操作系统”进行映射。这是理解多线程高并发底层性能的关键。
1. 用户级线程 ULT (User-Level Thread)
- 定义:整个线程管理和实现全部放在用户空间中。通过线程库(Thread Library)*在应用层模拟线程调度。对于操作系统内核而言,它*完全感知不到 ULT 的存在,内核眼中该进程仍然是一个普通的“单线程进程”。
+-------------------------+ 用户空间 (User Space)
| [ULT 1] [ULT 2] [ULT 3] | <- 线程库进行管理与调度
+─────────────┬───────────+
================│====================================
+─────────────v───────────+ 内核空间 (Kernel Space)
| [ 进程 (Process) ] | <- 内核眼中只有这一个调度实体
+-------------------------+
优势:
- 无需模式切换(变态):线程的切换、创建和撤销全部在用户空间完成,无需通过系统调用陷入内核,避免了高昂的 CPU 模式切换开销。
- 极高的调度灵活性:每个进程都可以自主选择最适合自身业务逻辑的专用调度算法(如时间片轮转、优先级、协程协作式调度),而无需受限于操作系统的调度器。
- 超强的平台跨越性:由于管理代码显式存在于应用程序本身中,ULT 甚至可以在完全不支持线程机制的极早期或极精简的操作系统平台上运行。
劣势:
- 无法发挥多核处理器的优势:内核只将 CPU 分配给进程,同一时刻一个进程只能在一个 CPU 上运行。因此即使是多核系统,该进程内部的多个用户级线程也无法真正并行,只能并发地轮流执行。
- 一阻塞全阻塞(致命缺陷):如果进程中的某个线程发起了系统调用(如调用了阻塞式的 I/O 系统调用
read),内核在收到请求后,会误以为是“整个进程”被阻塞了。内核会将该进程的状态修改为阻塞态(修改 PCB),从而导致该进程内的所有其他无辜线程全部被强行阻塞。
2. 内核支持线程 KST (Kernel-Supported Thread)
- 定义:在内核的支持下运行的线程。操作系统可以直接感知并管理它。每个线程的 TCB 都存放在内核空间中。
+-------------------------+ 用户空间 (User Space)
| [ULT 1] [ULT 2] [ULT 3] |
+─────┬──────┬──────┬─────+
│ │ │ (一对一绑定映射)
=====================================================
+─────v──────v──────v─────+ 内核空间 (Kernel Space)
| [KLT 1] [KLT 2] [KLT 3] | <- 内核感知 TCB,直接调度线程
+-------------------------+
优势:
- 多处理器真正并行调度:由于调度的基本单元变成了内核级线程(KLT),在多处理机系统中,内核可以同时将该进程下的不同线程分配给不同的 CPU,实现真正的并行计算。
- 非阻塞并发:当进程中的一个线程因 I/O 系统调用被阻塞时,内核可以敏锐地识别到。它会仅仅阻塞这一个线程,并立刻调度该进程内的其他就绪线程,或者调度其他进程的线程上处理器运行。
劣势:
- 开销巨大:线程的每一次创建、撤销和上下文切换,全部需要调用内核服务,导致 CPU 在用户态和内核态之间频繁切换,开销极其高昂。
- 占用系统宝贵内核空间:由于 TCB 必须存放在操作系统的内核空间中,线程数量增多会耗费大量的内核内存。
3. 多线程映射模型对比
现代操作系统(如 Linux, Windows 等)通过不同的映射关系来结合 ULT 和 KST 的优势:
| 模型名称 | 映射结构 | 结构图 | 优点 | 缺点 |
|---|---|---|---|---|
| 多对一模型*(Many-to-One)* | 多个用户级线程映射到一个内核级线程上。 | 线程管理在用户空间进行,效率高,模式切换开销极小。 | 一个线程被阻塞,会导致该进程下的所有线程全部被阻塞。无法利用多核。 | |
| 一对一模型*(One-to-One)* | 每个用户级线程都映射到一个专有的内核级线程上。 | 高并发性。一个线程阻塞不影响其他线程,可真正利用多 CPU 并行。 | 每次创建线程都需要在内核中创建对应的 KST,开销大,内存占用高。 | |
| 多对多模型*(Many-to-Many)* | 个用户级线程映射到 个内核级线程上(通常 )。 | 结合了两者的优势:1. 可以像一对一模型那样实现多 CPU 并行执行。2. 限制了内核级线程的无节制增长,减少了管理开销。 | 实现极其复杂。需要用户空间的线程库与操作系统的内核调度器高度默契协作。 |
七、 针对手写笔记(疑问与深度反思)的专家级解答
❓ 疑问一(针对手写笔记一 image_953258.jpg):
“有独立的栈,但是堆是共享的(这是因为堆主(存)的都是全局的静态变量吗?)”
💡 专家深度纠错与权威解答:
这是一个在计算机基础和 C/C++ 内存布局中非常经典的认知误区。
- 错误纠正:堆(Heap)中绝对不存储“全局变量”和“静态变量”。
- 内存布局的真实真相: 在操作系统和编译器为进程划分的虚拟地址空间中,不同属性的数据存放在完全不同的内存段中:
- 堆(Heap):堆是用来存储程序在运行期间,由程序员手动申请和释放的动态内存(例如在 C 语言中通过
malloc()/calloc(),在 C++ / Java 中通过new申请的空间)。 - 数据段(Data Segment & BSS Segment):全局变量(Global Variables)和静态变量(Static Variables)存放于此。
.data段:存放已初始化的全局变量和静态变量。.bss段:存放未初始化的全局变量和静态变量。
- 堆(Heap):堆是用来存储程序在运行期间,由程序员手动申请和释放的动态内存(例如在 C 语言中通过
+-----------------------------------+ 高地址
| 用户栈 (Stack) | -> 存放:函数参数、局部变量、返回地址
+-----------------------------------+
| 空闲区 |
+-----------------------------------+
| 堆 (Heap) | -> 存放:程序员手动动态申请的内存 (malloc / new)
+-----------------------------------+
| 数据段 (.data / .bss Segment) | -> 存放:全局变量 (Global) 与 静态变量 (Static)
+-----------------------------------+
| 代码段 (.init / .text) | -> 存放:机器指令、只读常量
+-----------------------------------+ 低地址
为什么堆和全局/静态变量对线程来说都是“共享的”?
- 因为地址空间共享(无内存页隔离):对于一个进程来说,除了各个线程自己占用的那部分局部“栈(Stack)”空间外,整个进程的其余虚拟地址空间(包括堆区、数据段和代码段)全部都是透明且共享的。
- 任何一个线程,只要能拿到指向堆中某块动态内存的指针,或者直接声明全局变量的名,就可以跨线程进行读写。操作系统没有在进程内部做硬性地址隔离。
❓ 疑问二(针对手写笔记二 ):
“多对一模型,我能理解为用户级线程有内部需求(比如调度算法)所以才不完全暴露给内核吗?”
💡 专家高度肯定与深度解析:
你的这个理解非常深刻,切中了现代高级并发编程模型(如 Go 协程、Java 虚拟线程)的核心设计哲学! 虽然在历史发展上,最开始出现 ULT(多对一)是由于早期的操作系统内核根本不支持多线程,开发者被迫在应用层模拟。但在现代高并发架构设计中,你提到的“因为内部调度需求而不完全暴露给内核”成为了最核心、最主动的设计考量。
深度解析:为什么现代高级语言依然青睐“多对一/多对多(协程)”而不完全暴露给内核?
1. 业务逻辑调度与内核通用调度的本质冲突(你的核心观点)
- 内核调度器是“通用且被动的”:操作系统的内核级线程(KLT)调度器必须兼顾系统里所有的进程(游戏、浏览器、后台服务等),它采用的是强行剥夺、公平时间片的粗暴调度。
- 应用层调度器是“精细且主动的”:某些特定应用(如高性能 Web 服务器)内部有极强的业务特征(如 99% 的时间都在等网络数据,只有 1% 的时间在做极快的数据解析)。如果直接用内核 KLT 调度,频繁的变态切换会消耗 80% 的 CPU 性能。
- 通过“多对一(或多对多)”,应用程序可以在用户态实现极度轻量级的协作式调度(Cooperative Scheduling)。比如 Go 语言的 Goroutine(M:N 协程),其调度切换成本只有几十个时钟周期,而内核级线程切换则需要几千个时钟周期。
2. “不完全暴露给内核”换来的极致性能
- 超轻量级的上下文:一个内核级线程(KLT)的内核栈和 TCB 至少占用几 KB 到几 MB 的系统内存;而一个用户级协程(ULT)只需要几百个字节(保存几个寄存器即可)。
- 高并发的实现:如果把几万个用户级任务全部 1:1 暴露给内核创建 KLT,操作系统内核会由于 TCB 爆满和频繁切换直接崩溃;而通过“不完全暴露”,将几万个用户级线程映射到几个内核级线程上,可以在保障内核稳定的同时实现单机百万级并发。
总结:你的直觉完全正确。多对一/多对多模型中,将线程控制权保留在用户空间,正是为了满足应用层对极致轻量、自定义调度算法、高吞吐量的“内部需求”。