进程、线程与上下文切换¶
磁盘上的可执行文件只是静态字节。只有当操作系统为它建立地址空间、执行现场和资源引用后,它才成为正在运行的进程。线程则表示进程内部的一条执行流。
为什么需要进程抽象¶
如果应用直接共享整台机器的寄存器和物理内存,暂停一个程序再运行另一个程序时,系统必须靠所有程序自觉保存状态,还无法阻止彼此覆盖数据。
进程把“运行一个程序所需的状态”封装成内核可管理对象:
进程不是 CPU 上一直存在的实体。它可能正在运行、排队等待 CPU、等待 I/O,甚至已经退出但仍保留少量退出信息。
程序、进程、线程¶
| 概念 | 核心含义 | 典型生命周期 |
|---|---|---|
| 程序 | 静态代码和数据 | 文件存在期间 |
| 进程 | 资源与隔离容器 | 创建到回收 |
| 线程 | 可被调度的执行流 | 创建到退出 |
同一个程序可以启动多个进程;同一进程可以包含多个线程。线程通常共享进程地址空间和打开文件,但拥有自己的:
- 程序计数器和通用寄存器。
- 栈。
- 调度状态。
- 线程局部存储。
- 与信号、调试相关的部分状态。
“共享地址空间”既是线程高效通信的原因,也是数据竞态出现的根源。
进程控制块与线程控制块¶
内核需要数据结构记录对象状态,教材常称进程控制块 PCB。真实系统可能把进程和线程信息拆在多个结构里,名称也不同。
概念上,调度一个线程至少需要知道:
- 当前状态与所在运行队列。
- 保存的寄存器和内核栈位置。
- 优先级、时间统计和 CPU 亲和性。
- 所属地址空间与进程资源。
- 等待的事件或阻塞队列。
内核对象本身不等于完整进程内容。用户代码、堆和用户栈仍位于该进程的虚拟地址空间中;控制块只保存管理和恢复所需的元数据与引用。
线程为什么比进程“轻”¶
创建同一进程内线程时,通常不用复制或新建完整地址空间和文件表。线程间通信也可以直接通过共享内存变量,不必跨进程建立额外通道。
但“轻量”不等于免费:
- 每个线程仍需要栈、内核对象和调度状态。
- 切换仍要保存寄存器并运行调度代码。
- 共享状态需要同步,锁竞争可能抵消并行收益。
- 线程过多会增加内存占用、运行队列长度和缓存压力。
用户级线程与内核级线程¶
用户级线程¶
用户运行库自行维护线程栈和就绪队列,切换可以不进入内核,因此非常快。问题是内核可能只看到一个执行实体:若它执行阻塞系统调用,整个进程中的用户线程都可能无法运行;也难以直接使用多个 CPU 核心。
内核级线程¶
每个线程对内核可见,可独立阻塞和调度到不同核心。代价是创建、切换和同步往往要经过内核机制。
多对多与现代运行时¶
协程、绿色线程和语言运行时常把大量用户任务复用到较少的内核线程上:
这种设计可以降低任务切换成本,但运行时必须处理阻塞调用、抢占、公平性、栈管理和与调试工具的集成。它没有消除调度,只是增加了一层调度。
什么是 CPU 上下文¶
上下文是恢复执行流所需的处理器和内核状态,通常包括:
- 程序计数器。
- 栈指针。
- 通用寄存器和状态寄存器。
- 必要的浮点、向量或扩展状态。
- 内核栈与线程控制信息。
- 若切换地址空间,还涉及页表根或地址空间标识。
并非每次都急切保存所有扩展寄存器。系统可能利用惰性策略或硬件支持减少不必要保存,但必须保证观察到的程序状态正确。
上下文切换的最小路径¶
假设线程 A 正在运行,时钟中断后调度器决定运行线程 B:
- CPU 进入内核入口并保存 A 的最小现场。
- 内核完成中断处理并进入调度逻辑。
- 把 A 的剩余可恢复状态记录到其内核栈或控制块。
- 更新 A 的运行状态和队列。
- 选择 B,切换到 B 的内核栈和地址空间上下文。
- 恢复 B 的寄存器。
- 从内核返回 B 之前暂停的位置。
如果 B 与 A 属于同一进程,可能无需切换地址空间;如果属于不同进程,页表上下文和 TLB 行为可能变化。
切换成本不只是保存寄存器¶
显式成本包括入口、调度、寄存器保存和恢复。隐式成本经常更大:
- 新线程的代码和数据不在当前核心缓存中。
- 分支预测历史不匹配。
- TLB 项可能失效或不再命中。
- 共享数据在核心间迁移,引发缓存一致性流量。
若一次切换直接耗时为 \(t_d\),随后缓存重新变热额外损失为 \(t_c\),则总影响更接近:
而不是只测入口到返回的那一小段时间。\(t_c\) 依赖工作集、核心拓扑和地址空间,不是固定常数。
最小实验:观察进程与线程身份¶
下面的 Python 程序创建两个线程。它们共享全局变量和进程号,但拥有不同线程标识:
import os
import threading
shared = []
def worker(name: str) -> None:
shared.append(name)
print("pid=", os.getpid(), "thread=", threading.get_ident(), "shared=", shared)
threads = [threading.Thread(target=worker, args=(str(i),)) for i in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
这个例子只展示共享关系,不保证两个 print 的先后顺序。若把 shared.append 换成依赖多步读写的不安全更新,就进入并发章节的问题。
多核、亲和性与负载均衡¶
多核系统中,每个核心通常有自己的运行队列或部分本地调度状态。局部队列减少全局锁竞争并保持缓存局部性,但会造成负载不均。调度器需要在“让任务留在热缓存核心”和“把任务迁移到空闲核心”之间权衡。
CPU 亲和性允许限制任务运行核心,适合某些实时、NUMA 或性能实验场景。错误绑定也可能让部分核心过载而其他核心空闲。
在 NUMA 机器上,内存访问成本与 CPU 和物理页所在节点有关。把线程迁移到远端节点,却不迁移其内存,可能降低性能。调度和内存放置因此不能完全独立优化。
进程隔离的边界¶
独立地址空间可以阻止普通指针直接访问其他进程,但进程仍可能通过以下方式交互:
- 继承或传递文件描述符。
- 共享内存映射。
- 管道、套接字和消息队列。
- 调试、信号或专用内核接口。
隔离并非“完全没有联系”,而是所有联系都应经过受控机制并接受权限检查。
历史与演进¶
早期系统常把进程作为唯一执行与资源单位。线程把资源所有权与执行流分开,使一个地址空间内可以并行处理多个工作。现代异步运行时又把更细粒度任务复用到线程上,以应对高并发 I/O 和大量短任务。
每次演进都在减少某类固定成本,却引入新的调度层和可见性问题。不存在“线程已经被协程淘汰”的简单结论:协程最终仍需内核线程使用 CPU,并通过系统调用等待内核事件。
自测¶
- 两个线程共享哪些资源,哪些状态必须各自拥有?
- 同进程线程切换为何可能不切换页表,却仍会影响缓存?
- 用户级线程库怎样避免一个阻塞调用冻结所有任务?
- 为什么把线程固定到 CPU 可能提高也可能降低性能?
- “进入内核”与“上下文切换”分别描述什么?
参考思路
线程共享地址空间和多数进程资源,各自保存执行现场、栈和调度状态。同进程切换仍改变工作集和预测历史。运行时可采用非阻塞 I/O、专用阻塞线程池或多对多映射。亲和性保留缓存局部性,却可能破坏负载均衡。进入内核是权限和控制流转换,上下文切换是更换执行主体。