{T}

CPU篇-进程与线程

章节导言

在前面几讲中,我们依次介绍了操作系统的存储管理(内存与外存)和输入输出设备管理。至此,冯·诺依曼体系结构的三大核心要素——CPU、存储、I/O——中,仅剩 CPU 的多任务调度尚未深入展开。

多任务的需求无处不在:边工作边听音乐、后台运行监控程序、网络服务器同时处理成千上万并发请求……这些场景都指向同一个核心问题:如何让有限的 CPU 资源服务于多个执行流?

本节将从物理层面的多任务实现出发,逐步深入到操作系统提供的三类执行体——进程、线程、协程——的设计动机、本质差异与架构权衡,并批判性地审视 UNIX fork 等历史设计决策。


核心概念与原理

多任务的物理基础

多任务的实现首先取决于硬件层面的能力。从物理维度看,存在两条路径:

图表渲染中…

多颗 CPU单颗 CPU 多核心 是物理并行的两种形态。桌面端(PC、手机、手表)受限于体积约束,主要采用多核方案;服务器领域则两者并用,以最大化单机计算力密度。

但物理并行并非多任务的必要条件。单核 CPU 同样可以实现多任务——其核心机制是分时系统(Time-Sharing System):将 CPU 时间切分为极小的时间片,每个时间片只运行一个软件,通过快速轮转制造"同时运行"的幻觉。

执行体:多任务的统一抽象

分时系统的实现依赖三个关键问题的解答:

  1. 任务是什么——如何抽象"任务"这一概念?
  2. 任务的状态包含什么——如何保存与恢复?
  3. 何时发生任务切换——调度的时机?

许式伟引入了 "执行体" 这一统一术语来统称进程、线程和协程。所谓执行体,是指 可被 CPU 赋予执行权的对象,它至少包含:

  • 下一个执行位置(获得执行权后的起始指令地址)
  • 其他运行状态(寄存器值、栈指针等)

执行体的上下文:寄存器即一切

从 CPU 的视角,执行程序依赖的内置存储只有两类:寄存器内存(RAM)。它们共同构成执行体的上下文。

寄存器层面:寄存器数量少且可枚举,通过寄存器名直接存取。任务切换时,需将当前执行体用到的所有寄存器值保存,并恢复目标执行体上一次的寄存器值,然后移交执行权。这样,每个执行体都"感觉"自己独占了 CPU。

内存层面:CPU 在实模式和保护模式下的内存访问机制截然不同:

模式内存访问方式多执行体隔离
实模式直接通过物理地址访问所有执行体共享同一地址空间,无隔离
保护模式通过地址映射表(页表)将虚拟地址转为物理地址不同任务可有独立地址空间,通过切换页表基址寄存器实现

因此,执行体的上下文本质上就是一堆寄存器的值。切换执行体,只需保存和恢复寄存器值——无论进程、线程还是协程,概莫能外。

图表渲染中…

进程、线程与协程的层次体系

三类执行体的层次关系

图表渲染中…
图表渲染中…

进程:安全隔离的边界

进程是操作系统从 安全角度 设定的隔离单位,不同进程之间遵循 最低授权原则(Principle of Least Privilege)。每个进程拥有独立的虚拟地址空间,通过各自的页表实现内存隔离。

进程创建方式上,UNIX 系与 Windows 系存在根本性的设计分歧:

维度UNIX (fork)Windows (CreateProcess)
语义先 clone 再分支,父子进程各干各的显式创建,参数明确
上下文继承糊里糊涂全部继承(文件句柄等)逐一明确指定哪些句柄需要继承
API 简洁性极简,无需传递大量参数复杂,参数众多
架构合理性糟糕——隔离单元不应藕断丝连清晰——隔离边界干净

许式伟对 UNIX fork 的批判极为尖锐:进程是操作系统最基本的隔离单元,我们怕的就是摘不清楚,但 fork 偏偏要藕断丝连。 这本质上是设计事故——fork 传递进程上下文的方式是彻头彻尾的过度设计。工程实践表明,除了接管子进程的标准输入输出,几乎从不通过向子进程传递文件句柄来通讯。

fork 的历史合理性在于:早期操作系统没有线程概念,进程同时承担了"需要父进程环境"这一线程级需求。但这不构成其设计合理性的辩护。

线程:进程内的并发

线程的出现源于同一软件内部的多任务需求——这些任务处于 相同的地址空间,彼此之间 相互信任,无需进程级的安全隔离。

线程的关键特征:

  • 共享所属进程的地址空间(代码段、数据段、堆)
  • 拥有独立的寄存器组和栈
  • 由操作系统内核调度,切换需进入内核态
  • 线程间通讯无需系统调用,直接通过共享内存

协程:用户态的轻量执行体

协程并非操作系统内核提供,而是在 用户态 实现的执行体,因此也被称为用户态线程。它的出现源于高性能网络服务器对线程模型的成本不满。


调度算法与成本分析

网络服务器的 IO 模型

理解协程的动机,必须先理解网络服务器的 IO 特征:

图表渲染中…

网络服务器充斥着大量并行 IO 请求。操作系统标准网络 IO 的成本构成如下:

图表渲染中…

一个关键澄清:系统调用本身并不慢。系统调用虽然比函数调用多做了一点事情(查询中断向量表、切换 CPU 执行权限等级),但 并没有发生调度行为,归根结底仍是一次函数调用的成本。从内核视角看,它更像一个多线程程序——每个系统调用是来自某个线程的函数调用。

图表渲染中…

IO 多路复用:epoll 与 IOCP

为改进网络服务器吞吐能力,主流方案采用 epoll(Linux)或 IOCP(Windows)机制:

图表渲染中…

epoll/IOCP 从系统调用次数和内存拷贝角度并未减少,真正有意义的是减少了线程数量。线程数量减少意味着调度开销、同步互斥开销和空间开销的全面降低。

线程的成本剖析

图表渲染中…

空间成本是第一根稻草:Linux 线程默认栈大小在数 MB 级别,1000 个线程即消耗 GB 级内存。虽然栈大小可配置,但出于执行安全性考虑,不能设得太小。

调度与同步成本是第二根稻草:单位成本看似可接受,但 IO 密集型场景下调度次数极多,网络服务器的共享状态必然伴随大量同步互斥操作,累积成本不可忽略。

调度算法对比

图表渲染中…
调度维度进程调度线程调度协程调度
调度执行者内核内核用户态运行时
调度策略CFS/优先级/RR同进程调度协作式为主,Go 辅以抢占
切换需进入内核
页表切换否(同进程线程)
寄存器保存/恢复全部全部部分 callee-saved
栈切换
典型切换耗时微秒级微秒级纳秒级
调度时机时钟中断/阻塞/显式同进程IO 阻塞/显式 yield

协程与 goroutine:从半吊子到完备方案

协程的设计动机

协程为两个核心目标而来:

  1. 回归同步 IO 的编程模式——异步 IO 的回调地狱让程序逻辑碎片化,可读性与可维护性急剧下降
  2. 降低执行体的空间成本和时间成本——线程太重,无法支撑高并发

半吊子协程库的通病

大部分协程(纤程)库只实现了协程的创建和执行权切换,缺失大量关键能力:

图表渲染中…

堆栈问题是协程实现的核心难题:堆栈太小则可能不够用(栈溢出),太大则空间成本过高(影响并发数)。理想情况下,堆栈大小需要 按需自动增长

因此,一个完备的协程库本质上就是 用户态的操作系统,而协程就是其中的"进程"。

Go 语言的 goroutine:完备协程的典范

世界上完备的协程实现有两个代表:Erlang(基于虚拟机)和 Go 语言(goroutine)。goroutine 的关键设计决策:

设计决策具体实现架构意义
堆栈初始 4K,按需自动增长分段栈/连续栈复制解决栈大小两难问题,极大降低空间成本
干掉 TLS(线程局部存储)不支持协程级局部存储执行体更精简,避免隐式状态依赖
提供完整的同步/互斥/通讯原语Mutex、WaitGroup、Channel 等不依赖操作系统线程级原语
包装几乎所有重要系统调用net 包、os 包等IO 操作自动触发协程调度,回归同步编程模型
图表渲染中…

并发模型对比

图表渲染中…
并发模型编程复杂度性能上限并发规模代表技术
同步 IO + 多线程受线程数限制千级传统 Java/Python 服务器
异步 IO + 回调高(回调地狱)万级Node.js、libevent
异步 IO + async/await万级Python asyncio、Rust tokio
协程/goroutine百万级Go、Erlang

设计原则与权衡(Trade-off 分析)

隔离性 vs 共享性

图表渲染中…
  • 进程:强隔离带来安全性,但进程间通讯(IPC)成本高,需系统调用介入
  • 线程:共享地址空间降低通讯成本,但引入竞态条件,需同步原语保护
  • 协程:共享一切且调度可控,通讯成本最低,但无隔离保护,一个协程崩溃可影响整个进程

切换成本 vs 功能完整性

执行体切换成本功能完整性适用场景
进程最高(页表 + 寄存器 + 内核态切换)完整(隔离、资源管理、安全)需要安全隔离的独立程序
线程中等(寄存器 + 内核态切换)较完整(内核调度、同步原语)桌面应用、通用并发
协程最低(寄存器 + 纯用户态)依赖运行时实现高并发网络服务器

编程模型 vs 性能

异步 IO 回调模型虽然性能优异,但 反人类的编程体验 严重制约了其工程实践价值。goroutine 的成功证明了一个关键原则:编程模型的人体工学(ergonomics)与运行时性能并非不可兼得,关键在于运行时层的抽象设计。

fork 的设计失误:便利性 vs 正确性

UNIX fork 的设计体现了"便利性优先于正确性"的决策失误。进程作为隔离单元,其创建接口应当 显式且精确 地定义父子进程间的资源继承关系,而非默认全量继承后再逐一关闭。Windows 的 CreateProcess 虽然参数繁多,但在架构合理性上远优于 fork。

这一教训具有普遍意义:隔离边界的接口设计,宁可繁琐也要精确,不可为便利而模糊边界。


实践案例与反模式

反模式 1:用线程模型构建高并发网络服务器

图表渲染中…

这是 C10K 问题的经典根源。当并发连接数达到万级时,线程模型的资源消耗和调度开销成为瓶颈。

反模式 2:半吊子协程库的陷阱

许多项目引入协程库只为了"轻量级线程"的噱头,却忽略了:

  • 协程内执行阻塞式系统调用(如同步 read/write)会 阻塞底层线程,导致同一线程上的所有协程无法运行
  • 缺少 IO 多路复用包装,协程退化为协作式线程,无法自动让出执行权
  • 缺少同步原语,开发者被迫混用线程级锁,导致死锁或性能退化

正确做法:要么使用完备的协程运行时(如 Go),要么老老实实用异步 IO + 回调/async-await。

实践案例:goroutine 的 M:N 调度模型

Go 运行时采用 M:N 调度模型——M 个 goroutine 映射到 N 个操作系统线程。其核心优势:

图表渲染中…
  • goroutine 因网络 IO 阻塞时,不阻塞底层线程,而是将该 goroutine 挂起,线程继续运行其他 goroutine
  • 当 IO 完成时,netpoller 通知调度器唤醒对应 goroutine
  • 开发者使用同步风格的代码,运行时自动实现异步 IO 的效果

反模式 3:fork 后不清理继承的文件句柄

c
// 反模式:fork 后未关闭不需要的文件描述符
pid = fork();
if (pid == 0) {
    // 子进程继承了父进程所有 fd
    // 但子进程只需要 stdin/stdout
    // 其他 fd(如监听 socket、日志文件)成为资源泄漏
    // 更危险的是:可能导致文件锁无法释放、端口无法复用
    do_child_work();
}

正确做法是 fork 后立即关闭所有不需要的 fd,或使用 close-on-exec 标记。但这恰恰证明了 fork 默认继承的设计是错误的——它把本应由创建者显式指定的责任,推给了子进程的开发者去"清理"。


小结与关键要点

关键要点

  1. 执行体的上下文本质是寄存器值的集合。无论进程、线程还是协程,切换执行体的核心操作都是保存和恢复寄存器值。进程切换额外需要切换页表基址寄存器(CR3),这是其成本高于线程切换的根本原因。

  2. 进程、线程、协程构成隔离性递减、轻量性递增的层次体系。进程是安全隔离边界,线程是进程内并发单位,协程是用户态轻量执行体。选择哪一层执行体,取决于对隔离性、并发规模和编程模型的权衡。

  3. UNIX fork 是架构设计失误的典型案例。进程作为隔离单元,其创建接口应当精确定义资源继承关系,而非默认全量继承。这一教训具有普遍意义:隔离边界的接口设计,便利性不能凌驾于正确性之上。

  4. 协程的完备性决定了其实用价值。半吊子协程库只实现了创建和切换,缺少调度、同步、IO 包装等关键能力。Go 语言的 goroutine 之所以成功,在于它构建了一个完备的"用户态操作系统"——4K 起步可增长栈、无 TLS、完整同步原语、全量系统调用包装。

  5. 编程模型的人体工学与运行时性能可以兼得。goroutine 证明了同步编程风格 + 高性能异步 IO 并非矛盾,关键在于运行时层的抽象——M:N 调度模型与 netpoller 的结合,让开发者以同步风格编写代码,运行时自动实现异步 IO 的效果。


交叉参考

  • [07 | 软件运行机制及内存管理]:保护模式下进程的独立地址空间与页表机制,是理解进程切换成本的基础
  • [08 | 操作系统内核与编程接口]:系统调用的实现机理(软中断、Ring 0/3 切换),是理解线程切换为何需进入内核态的前提
  • [12 | 进程内协同:同步、互斥与通讯]:执行体间的同步原语(锁、条件变量、channel)的详细讨论
  • [13 | 进程间的同步互斥、资源共享与通讯]:进程间通讯机制(IPC)的完整分析