进程、线程与纤程
Processes, Threads & Fibers
并发编程依赖操作系统提供的服务:内核态与用户态、中断、系统调用、抢占式多任务。进程只是为线程提供运行环境(虚拟地址空间与资源),线程才是操作系统的基本执行单位(调用栈 + 寄存器)。线程由内核抢占调度;纤程 (Fiber) 由程序协作调度,适合实现引擎任务系统;用户级线程与协程完全在用户空间切换上下文,更轻量。
内核、中断与系统调用(卷 I 4.4.1–4.4.3)
- 内核 (kernel) 是操作系统最底层的核心,内核与驱动运行在内核态(也叫保护模式、特权模式),其他程序(含操作系统的非内核部分)运行在受限的用户态。很多系统实现多级保护环:例如内核在 ring 0、驱动 ring 1、有 I/O 权限的可信程序 ring 2、其余程序 ring 3;环数和分配因 CPU、操作系统而异。
- 内核态可以执行特权指令,如 x86 的
wrmsr(写特定模型寄存器)、cli(关中断),用来控制虚拟内存映射、屏蔽中断等;内核把自己的代码和数据放在用户程序不可写的受保护页中。 - 中断:键盘、外设、定时器等事件触发中断请求 (IRQ),操作系统暂停当前工作,调用中断服务例程 (ISR),尽快处理后返回。硬件中断由 CPU 引脚上的电压触发,可能发生在任何时刻;软件中断由中断指令或 CPU 检测到的错误条件触发,后者称为陷阱 (trap) 或「异常」(不要和语言层面的异常处理混淆)。例如除以零会触发软件中断,操作系统通常让程序崩溃并生成 core dump,附加的调试器则可以截获并中断进调试器。
- 系统调用 (kernel call / system call):用户程序请求内核代为执行特权操作(映射物理页、访问原始网络套接字等)。多数系统通过软件中断实现(MIPS 用
syscall指令):参数放到约定位置,发出带编号的中断指令,CPU 提升特权、保存调用者状态、调用对应 ISR,完成后恢复。这也是一种上下文切换。实际中程序调用内核 API 函数,由它封装参数并触发中断,所以系统调用看起来像普通函数调用。
抢占式多任务(卷 I 4.4.4)
- 早期电脑一次只跑一个程序(如 Apple II 程序都是全屏)。大型机上的多道程序 (multiprogramming) 让一个程序在另一个等待外设时运行。
- 经典 Mac OS 和 Windows NT/95 之前的 Windows 用协作式多任务:每个程序定期主动让出 CPU,即时分复用 (TDM)、时间多线程 (TMT),俗称时间片。问题是一个不让出 CPU 的「流氓」程序会霸占全部时间。
- PDP-6 Monitor 和 Multics 引入抢占式多任务,后被 UNIX 系列和后来的 Mac OS、Windows 采用:由操作系统响应定时硬件中断进行上下文切换,每个程序得到稳定的时间片(称为 quantum)。
- 多核机器上依然需要抢占:线程数通常多于核数,例如 100 个线程、4 个核,每核要在 25 个线程间轮转。
进程(卷 I 4.4.5)
进程是操作系统管理一个正在运行的可执行文件(Windows 的 .exe、Linux 的 .elf)实例的方式,程序退出、被杀或崩溃时进程即被销毁;同一程序可同时有多个进程实例。书中以 UNIX 风格(Linux、BSD、macOS)API 为主说明,Windows 或主机系统差异大时另行注明。内核调度所有进程的所有线程,线程多于核时用抢占式多任务轮转。
| 进程包含 | 说明 |
|---|---|
| PID | 系统内唯一的进程 ID |
| 权限 | 属于哪个用户、哪个用户组 |
| 父进程引用 | 若有 |
| 虚拟内存空间 | 进程对物理内存的「视图」 |
| 环境变量、打开的文件句柄、当前工作目录 | |
| 进程间同步与通信资源 | 消息队列、管道、信号量 |
| 一个或多个线程 | 默认一个 |
- 线程才是基本执行单位:进程只提供环境(虚拟内存映射与所有线程共享的资源);线程在核上运行时总是处于恰好一个进程的上下文中。
- 虚拟内存映射:每个进程有自己的虚拟页表,因此进程间不会互相破坏内存(除非显式共享页)。映射中通常有:从可执行文件读入的 text/data/BSS 段(机器码可重定位,由操作系统修正地址)、共享库视图、每个线程的调用栈、堆、可能的共享内存页,以及进程不可访问的内核空间。程序启动时内核创建默认线程,为其栈分配页,初始化 SP/BP,从
main()(Windows 下WinMain())开始执行。堆的物理页按需分配和归还。地址空间布局见 C++ 内存布局与对齐,页表与 TLB 见 计算机硬件与内存层次。 - 共享库:Windows 的 DLL,PS4 的 PRX(名字来自 PS3 的 PPU Relocatable Executable)。首次需要时载入物理内存并映射,函数与全局变量地址被修补进程序;第二个进程使用时直接映射已载入的物理页,省内存、启动更快。理论上更新库能让所有程序受益,实际上常导致兼容问题(「DLL 地狱」),Windows 用清单 (manifest) 机制缓解。
- 内核页:地址空间分为用户空间和内核空间。32 位 Windows 中用户空间是低 2 GiB(0x0–0x7FFFFFFF),内核占高 2 GiB;64 位 Windows 用户空间为 8 TiB(0x0–0x7FF'FFFFFFFF),0xFFFF0800'00000000 以上 248 TiB 留给内核(不是全部都用上)。注意:书中 64 位 Windows 用户空间 8 TiB 的数字只适用于较早版本;Microsoft Learn「Virtual Address Spaces」(Windows 驱动文档) 给出当前 64 位进程用户空间为 128 TiB(0x000'00000000–0x7FFF'FFFFFFFF)。32 位 Windows 可用
BCDEdit /set increaseuserva把用户空间扩到最多 3 GiB。用户空间用每进程独立的页表,内核空间用所有进程共享的页表。用户进程访问内核页会触发页错误并崩溃;系统调用切换到特权模式后内核才能访问。Meltdown 和 Spectre 漏洞分别利用乱序执行和推测执行诱使 CPU 访问受保护的页。32 位 Windows 的典型布局:低地址是 text/data/BSS,其上是堆,再上是共享内存页,调用栈在用户空间高端,高 2 GiB 是内核页。 - 各区域实际地址不可预测,部分因为段大小不同,部分因为地址空间布局随机化 (ASLR)。
线程(卷 I 4.4.6)
线程包含:进程内唯一(但不一定全系统唯一)的线程 ID (TID);调用栈;所有用户态可见的寄存器值(含 IP、BP、SP;不含某些特权态寄存器);线程局部存储 (TLS)。线程提供执行一条指令流所需的最少资源:调用栈加一组寄存器。
- 线程库:各操作系统都有线程系统调用;可移植的有 POSIX
pthread(IEEE 1003.1c)和 C11/C++11 标准线程库;PS4 SDK 的sce前缀线程函数几乎直接对应 pthread。基本操作:创建、终止、请求另一线程退出、睡眠、让出 (yield)、等待汇合 (join)。 - 创建与终止:主线程用
pthread_create()、CreateThread()或std::thread创建新线程。线程可以从入口函数返回(主线程从main()返回会结束整个进程)、调用pthread_exit()、被其他线程请求取消(可能延迟响应或忽略,可取消性在创建时决定),或因进程结束被强制终止(主线程从main()返回、任一线程调用exit()、或外部杀掉进程)。 - Join:书中例子把 1000 个计算分 4 份交给 4 个线程,主线程
pthread_join等全部完成后再对结果做 SHA-1 校验。原书问题:书中代码写pthread_join(&tid[i], nullptr),但 POSIX 原型是int pthread_join(pthread_t thread, void **retval),应传值tid[i];入口函数void Compute(void*)也应为void* Compute(void*)才符合pthread_create的要求(见 man7.org 的 pthread_join(3)、pthread_create(3))。
等待某个未来事件时有三种选择:
| 方式 | 做法 | 取舍 |
|---|---|---|
| 轮询 (poll) | 紧循环检查条件,即自旋等待/忙等待 | 简单,但白烧 CPU |
| 阻塞 (block) | 调用阻塞函数,条件不满足时内核让线程睡眠并记入等待表,条件满足时唤醒 | 适合长时间等待;fopen()(可能耗时成百上千周期)、Sleep()/usleep()/std::this_thread::sleep_until()、pthread_join()、等待互斥锁都会阻塞(Linux open() 有 O_NONBLOCK 选项支持异步 I/O)。条件已满足时阻塞函数立即返回。原书问题:书中列出的 pthread_sleep() 与 pthread_mutex_wait() 并非 POSIX 函数,加锁应为 pthread_mutex_lock(),睡眠用 nanosleep()/sleep()。最终调用阻塞函数的用户函数也算阻塞函数,应在文档中注明 |
| 边轮询边让出 (yield) | 循环中调用 pthread_yield()、Sleep(0) 或 SwitchToThread() 让出剩余时间片 | 比纯忙等待更省周期和电,但仍是内核调用,代价不小。更轻量的是 x86 SSE2 的 _mm_pause(),书中说它等流水线排空、约暂停 40 周期,可降低忙等待功耗。对照:Intel《64 and IA-32 Architectures Optimization Reference Manual》「Pause Latency in Skylake Client Microarchitecture」一节称 PAUSE 延迟在旧核约 10 周期,Skylake 起约 140 周期,所以按 PAUSE 次数计时的自旋循环在新 CPU 上等得长得多。另,pthread_yield() 并非 POSIX 标准函数,glibc 2.34 起已废弃,标准做法是 sched_yield()(man7.org pthread_yield(3));书中写的 pthread_yield(nullptr) 也与其无参原型不符 |
- 上下文切换:内核线程有三种状态:运行 (Running)、可运行 (Runnable)、阻塞 (Blocked)。状态转换就是上下文切换,总在特权模式下发生:抢占的定时中断(运行↔可运行)、显式阻塞调用(→阻塞)、等待条件满足(阻塞→可运行)。
stateDiagram-v2 Runnable --> Running: 调度 Running --> Runnable: 时间片用完 Running --> Blocked: 睡眠/阻塞 Blocked --> Runnable: 唤醒
上图是内核的线程状态机。离开运行态时,CPU 寄存器存入内核为该线程保留的内存块,回到运行态时恢复。调用栈本身无需保存:它已在进程虚拟内存中,保存 SP/BP 就「免费」保存了栈。如果前后线程属于不同进程,还要切换页表指针(存于特权寄存器)并冲刷 TLB,所以进程间切换比同进程线程间切换更贵。
- 优先级与亲和性:设计初衷是大多数线程用默认优先级公平分享,偶尔有高优先级线程变为可运行时立即运行、尽快结束后把控制交还。Windows 有 6 个优先级类、每类 7 级,组合出 32 个基础优先级。最简单的规则是只要有更高优先级的可运行线程,低优先级线程就不被调度;这会导致饥饿 (starvation),有些系统为此加入例外规则给饥饿线程一点时间。亲和性 (affinity) 请求内核把线程锁定在某个核,或优先使用某些核。
- TLS:每个线程有私有内存块,例如存放线程私有的内存分配器。TLS 块实际上通常对进程内所有线程可见,不像内核页那样受保护;操作系统把它们映射到不同地址,并提供系统调用让线程取得自己 TLS 块的地址。
- 调试:Visual Studio 的 Threads 窗口列出所有线程,双击激活其执行上下文后,可在 Call Stack 与 Watch 窗口查看,即使线程处于可运行或阻塞态。
纤程(卷 I 4.4.7)
- 实现游戏引擎任务系统时(书中第 8.6.4 节),我们可能希望任务能显式把 CPU 让给其他任务,而不担心被抢占「抽走地毯」,也就是要协作式多任务。纤程就是操作系统提供的这种机制:和线程一样有调用栈和寄存器状态,但从不由内核直接调度,而是在某个线程内部由纤程彼此协作调度。书中以 Windows 纤程为例,PS5 SDK 也有非常相似的 API。
- API:线程调用
ConvertThreadToFiber()引导出第一个纤程;CreateFiber()以入口函数创建新纤程;SwitchToFiber()切换;DeleteFiber()销毁。 - 状态:只有活跃 (Active) 与非活跃 (Inactive) 两种(Windows 称活跃纤程为线程的「selected」纤程),只能通过
SwitchToFiber()切换。活跃纤程是否真在核上执行取决于其所在线程的状态。 - 坑:纤程没有阻塞态,等待条件时只能忙等待或
SwitchToFiber让给别的纤程。在纤程里调用阻塞的系统调用通常是大忌,会让整个线程睡眠,连调度其他纤程都做不到。 - 迁移:纤程只能经过非活跃态在线程间迁移。例:线程 A 中的纤程 F 调用
SwitchToFiber(G)后变为非活跃;线程 B 中的纤程 H 调用SwitchToFiber(F),F 就迁移到了 B。 - 调试:纤程由操作系统提供,调试器应能看到它们,例如 PS4 上 SN Systems 的 VS 插件把纤程显示在 Threads 窗口。作者建议在投入纤程设计前先确认目标平台调试器对纤程的支持,否则可能是一票否决项。
用户级线程与协程(卷 I 4.4.8)
- 书中说线程和纤程由内核提供,操作它们多半要进内核,较「重」。对照:对 Windows 纤程而言这一点不完全成立——WG21 论文 P0866R0 (2019) 称 Windows Fibers 是「用户态协作式线程设施」,Microsoft Learn 的 SwitchToFiber 文档也只描述为保存/恢复纤程状态;纤程切换本身不需要进入内核,代价主要在其他 API 与调试支持上。用户级线程完全在用户空间实现,内核一无所知:每个线程只是一个记录 ID、名字和执行上下文(寄存器与调用栈)的数据结构,运行在操作系统提供的真实线程或纤程之上。关键是用巧妙的汇编实现上下文切换(本质上就是交换寄存器),其余只是数据管理。
- C/C++ 支持不佳:POSIX 曾提供
ucontext.h,现已废弃;Boost 提供可移植的用户级线程库。 - 协程 (Coroutine) 是子例程的推广:子例程只能返回调用者,协程还能让出 (yield) 给另一个协程,执行上下文保留在内存中,下次被让给时从原处继续。子例程是层级调用,协程是对称调用;A、B 互相无限让出也不会让栈无限加深,因为各有私有栈,更像线程间切换,但因在用户空间所以很高效。书中伪代码:生产者协程填满队列后
YieldToCoroutine(Consume),消费者清空队列后让回生产者。Ruby、Lua、Go 原生提供协程;C++ 可用 Boost(代码库庞大),或参考 Malte Skarupke 的博客自己实现。(补充,非书中内容:C++20 起语言本身提供无栈协程co_await/co_yield/co_return,与书中所说的有栈用户级线程式协程不同。) - 「内核线程」的两种含义:(1) Linux 中指内核自己使用、只在特权模式运行的线程,相对地所有在用户态运行的线程都叫用户线程;(2) 指任何由内核知晓并调度的线程,此时「用户线程」只指完全由用户程序管理的控制流(如协程)。按定义 2,纤程介于两者之间:内核知道它并为它维护独立调用栈,但不调度它。
- 延伸阅读(卷 I 4.4.9):pthread 与 Windows 线程 API 文档、Nikita Ishkov「A Complete Guide to Linux Process Scheduling」、Rob Pike 关于 goroutine 的演讲。
与其他来源的关系
UE5 任务系统 的官方文档说 5.5 起放弃「等待时执行其他任务」的忙等待,改为唤醒额外线程(超额订阅);GEA 这里则从操作系统角度说明纤程为何适合任务系统、以及纤程中阻塞调用的危险。两者都指向同一点:任务系统中的「等待」必须小心设计,具体做法不同。
出处
- Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 4 章 4.4 节