中EN
概念页

进程、线程与纤程

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 这里则从操作系统角度说明纤程为何适合任务系统、以及纤程中阻塞调用的危险。两者都指向同一点:任务系统中的「等待」必须小心设计,具体做法不同。

  1. Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
  2. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 4 章 4.4 节

相关页面

游戏开发并发与并行基础Concurrency & Parallelism FundamentalsGEA 区分两个正交的概念:并发 (Concurrency) 是软件层面多个控制流读写共享数据,核心难题是数据竞争;并行 (Parallelism) 是硬件层面两个以上部件同时工作。硬件并行又分为提升单线程速度的隐式并行(流水线、超标量、VLIW,即指令级并行 ILP)和为同时运行多条指令流而设计的显式并行(超线程、多核、多处理器、集群/云)。游戏开发计算机硬件与内存层次Computer Hardware & Memory Hierarchy从冯·诺依曼结构讲起,依次是 CPU 的组成(ALU、VPU、寄存器、控制单元、时钟)、总线与字长、机器语言、汇编和寻址方式,再到内存体系:内存映射、虚拟内存和 TLB,以及为了缩小“内存鸿沟”而设计的寄存器文件、多级缓存(缓存行、标签、组相联、替换和写策略、一致性协议)和 NUMA(PS3 的 SPU 本地存储、PS2 的 scratchpad)。对游戏程序员最重要的结论是:数据要连续、紧凑、顺序访问;热循环的代码要小。游戏开发C++ 内存布局与对齐C++ Memory Layout & Alignment理解 C/C++ 程序在内存中的样子,是写高性能引擎代码的前提。主要内容包括:声明与定义、内部与外部链接;可执行映像的几个段(text、data、BSS、rodata)以及栈和堆;结构体成员的自然对齐和填充;继承和虚函数如何改变类的布局(vpointer 和 vtable)。GEA 也简要回顾了面向对象:多重继承容易产生致命菱形,所以通常只允许 mix-in;组合往往比继承更好。游戏开发 · UE5任务系统Tasks SystemUE::Tasks 是 UE5 的任务级并行库,是对 TaskGraph 的改进,与 TaskGraph 共用同一套调度器和工作线程。它支持启动任务、等待或取结果、声明前置依赖、嵌套任务、管道 (Pipe) 和任务事件。官方建议用依赖关系把工作连成图,而不是阻塞等待;5.5 起用「超额订阅」取代了容易死锁的忙等待。游戏开发游戏循环与更新方法Game Loop & Update Method游戏循环 (Game Loop) 让游戏时间的推进与玩家输入和处理器速度脱钩:每一轮非阻塞地处理输入、更新状态、渲染,并跟踪真实时间来控制节奏;最常推荐的做法是“固定更新步长、可变渲染”。更新方法 (Update Method) 是它的搭档:世界维护一组对象,每个对象实现一个模拟“一帧”行为的 update(),每帧依次调用。两者和组件模式一起常被视为游戏引擎的核心。游戏开发线程同步原语Thread Synchronization Primitives并发编程的中心问题是数据竞争 (Data Race):多个控制流交错地读改写同一共享对象。解决思路是让同一对象上的关键操作具备原子性(看起来瞬间完成,从而被串行化)。最简单可靠的工具是操作系统提供的线程同步原语:互斥锁、临界区、条件变量、信号量、Windows 事件。它们好用但昂贵(需要内核调用,上下文切换可超过 1000 个时钟周期)。游戏开发基于锁的并发问题Problems with Lock-Based Concurrency即使所有共享数据操作都用锁保护好了,基于锁的并发仍有四类问题:死锁 (Deadlock)、活锁 (Livelock)、饥饿 (Starvation)、优先级反转 (Priority Inversion)。哲学家就餐问题集中体现了它们。GEA 由此总结出经验法则:全局排序、事务式算法、尽量减少争用;并反对把所有接口都做成「100% 线程安全」,主张正视并发、少用锁。图形学GPGPU 编程入门Introduction to GPGPU ProgrammingGEA 把 GPGPU 讲成「SIMD 向量化推到极致」:GPU 结合 SIMD(向量化 ALU)与 MIMD(一种抢占式多线程),即 NVIDIA 所说的 SIMT。适合 GPU 的任务必须是数据并行的——每个元素的计算不依赖其他元素的结果。写法与 CPU 向量化相反:不把循环硬编码成每次 4/8/16 个,而是保留单通道的循环体,抽成计算核函数 (compute kernel),由 GPU 替我们「跑循环」。一个「GPU 线程」其实就是一条 SIMD 通道。游戏开发自定义内存分配器Custom Memory Allocators内存从两方面影响性能:动态分配(malloc / 全局 new)很慢,以及访问模式主导现代 CPU 的性能。GEA 的经验法则是「尽量少做堆分配,绝不在紧密循环里做堆分配」,并介绍游戏引擎常用的自定义分配器:栈分配器(含双端栈)、池分配器、对齐分配、单帧与双缓冲分配器;以及如何用栈/池避免碎片,或用碎片整理加指针重定位(智能指针、句柄)解决碎片。游戏开发引擎文件系统与异步文件读写Engine File System & Asynchronous File IO资源管理器大量依赖文件系统。引擎通常把操作系统的文件 API 包装成自己的 API:一是跨平台、屏蔽硬件差异;二是操作系统缺少引擎需要的功能(如流式加载),主机还要统一访问记忆棒、硬盘、蓝光盘、网络文件系统等介质。引擎文件系统 API 通常涵盖:文件名与路径操作、打开/关闭/读写文件、扫描目录、处理异步 I/O 请求(流式加载)。游戏开发作业系统Job System多核硬件上,游戏循环的工作要分解成可并行执行的子任务。GEA 依次比较「每子系统一个线程」「分散/汇集 (scatter/gather)」和通用的作业系统 (job system),并给出典型作业 API、基于线程池的简单实现及其局限、用协程或纤程让作业可以睡眠、作业计数器、作业同步原语与可视化剖析工具,最后介绍 Naughty Dog 基于纤程的作业系统。(卷 I 8.6 节)游戏开发人机接口设备Human Interface Devices人机接口设备 (HID) 是玩家向游戏提供输入、游戏向玩家提供反馈的硬件:手柄、键鼠、摇杆、Wii 遥控器、方向盘、跳舞毯、吉他等。GEA 从三个层面介绍它们:游戏如何与设备通信(轮询、中断、无线协议),设备提供哪几类输入,以及设备能输出哪些反馈。引擎如何加工这些原始输入见 引擎输入系统。(卷 I 9.1–9.4 节)游戏开发游戏对象并发更新Concurrent Game Object Updates游戏对象模型要和几乎必然是多线程的底层子系统打交道,所以对象代码必须异步思考:耗时操作用非阻塞请求尽早发起、尽晚取结果,必要时容忍一帧延迟。作业之间的依赖决定了并行度 (DOP),每个依赖都引入同步点,"优化并发设计的秘诀是延迟"。对象模型本身也能并行:Naughty Dog 在把引擎从 PS3 移植到 PS4(《最后生还者 重制版》)时,让每个桶内的对象更新成为作业;桶间访问无需加锁,桶内读取用对象快照,桶内修改尽量避免,或加锁、或放进请求队列、或交给下一桶中的同步作业(GEA4 卷 II 17.7)。游戏开发游戏脚本系统Game Scripting脚本语言是主要用来让用户控制和定制软件行为的编程语言;在游戏引擎中,它是高层、相对易用、能方便访问引擎大多数常用功能的语言,程序员和非程序员都能用它开发新游戏或给现有游戏做 mod。GEA 先区分数据定义语言和运行时脚本语言,梳理语言分类与游戏脚本的典型特征(解释执行、轻量、快速迭代、方便),介绍 QuakeC、UnrealScript、Lua、Python、Pawn,列出从"脚本回调"到"脚本驱动整个游戏"的六种架构,再以 Naughty Dog 的 DCScript 为例讲虚拟机与原生函数接口,最后是对象引用、事件收发、面向对象脚本、脚本状态机和协作式多线程脚本(GEA4 卷 II 17.游戏开发多线程渲染与任务系统Multithreaded Rendering & Task SystemsCPU 时钟频率约在 2003 年停在 3.4 GHz 附近后,性能来自多核。应用阶段的优化一是让代码与内存访问更缓存友好,二是利用多核:多处理器流水线(时间并行,提高吞吐但增加延迟)、并行处理(空间并行,延迟更低)以及两者结合的任务系统;图形 API 用命令缓冲让多个线程并行录制绘制命令,新一代 API(DirectX 12、Vulkan、Metal)把更多责任交给应用以降低驱动开销。

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原