编译编排、缓存与增量编译
前置知识
- ›本系列第 1–4 篇文章
- ›了解缓存策略与依赖图
- ›熟悉并发编程(线程池、互斥锁、原子操作)
编译编排、缓存与增量编译
前四篇文章逐一剖析了 Zig 编译器的各个阶段:解析、AstGen、Sema、代码生成和链接。但这一切由谁来驱动?当源文件发生变化时,编译器如何判断哪些函数需要重新分析?缓存机制是如何工作的?自举流程又是如何将这一切整合在一起,从一个 WebAssembly blob 构建出完整编译器的?
本文作为系列终篇,聚焦于编排层——Compilation.update() 循环、任务队列系统、三种缓存模式、InternPool 的依赖跟踪,以及 PerThread 线程模型。
Compilation.update() 循环
一切从 Compilation.update() 开始。buildOutputType() 构造完 Compilation 后会调用它,长期运行的编译服务器也会在每次编辑循环中调用它。
函数的执行流程取决于缓存模式,但整体结构如下:
flowchart TD
START["update()"] --> CLEAR["Clear misc failures"]
CLEAR --> CACHE{"Cache mode?"}
CACHE -->|"whole"| CHECK["Check cache manifest"]
CHECK -->|"hit"| DONE["Return (cache hit)"]
CHECK -->|"miss"| WORK
CACHE -->|"none"| WORK["Create temp directory"]
CACHE -->|"incremental"| WORK2["Detect changed files"]
WORK --> QUEUE["Queue AstGen, C compilation,\nSema, codegen, linking jobs"]
WORK2 --> QUEUE
QUEUE --> PROCESS["Process work queues\n(thread pool)"]
PROCESS --> LINK["Wait for link tasks"]
LINK --> FINALIZE["Finalize output"]
FINALIZE --> END["Write cache manifest"]
在第 2884 行,函数根据 comp.cache_use 进行分支:
whole:在编译开始前检查完整的缓存清单。如果所有输入都匹配,则跳过整个编译过程。这是一次性构建的默认模式。none:完全不使用缓存,用于特殊场景。incremental:按声明粒度跟踪变更,文件修改后只对受影响的部分进行定向重分析。
在 whole 模式下,第 2898–2960 行的缓存检查会根据所有输入(源文件、编译标志、目标三元组等)计算清单,并检查是否存在缓存输出。这样只需一次文件系统检查,就能跳过整个编译流程。
分阶段任务队列与优先级
Compilation 维护着一个任务队列数组,其大小在编译时根据任务阶段数确定:
work_queues: [len: { ... }]std.Deque(Job),
第 119–127 行的长度计算逻辑会遍历所有 Job.Tag 变体,找出最大阶段编号。Job union 定义了所有任务类型:
const Job = union(enum) {
codegen_func: struct { func: InternPool.Index, air: Air },
link_nav: InternPool.Nav.Index,
link_type: InternPool.Index,
analyze_comptime_unit: InternPool.AnalUnit,
analyze_func: InternPool.Index,
analyze_mod: *Package.Module,
resolve_type_fully: InternPool.Index,
windows_import_lib: usize,
};
stage() 函数负责分配优先级:
fn stage(tag: Tag) usize {
return switch (tag) {
.resolve_type_fully, .analyze_func, .codegen_func => 0,
else => 1,
};
}
阶段 0 的任务(类型解析、函数分析、代码生成)优先于阶段 1 的任务(模块分析、link_nav 等)处理。这样的设计确保了 Sema 分析完一个函数后,代码生成线程能立即获得工作,从而最大化并行度。
flowchart LR
subgraph "Stage 0 (High Priority)"
RF["resolve_type_fully"]
AF["analyze_func"]
CF["codegen_func"]
end
subgraph "Stage 1 (Normal Priority)"
AM["analyze_mod"]
LN["link_nav"]
LT["link_type"]
ACU["analyze_comptime_unit"]
end
AF -->|"produces"| CF
CF -->|"produces"| LN
processOneJob 函数负责分发每种任务类型。正如第 4 篇所介绍的,codegen_func 任务通过创建 SharedMir 来处理,既可以派生线程执行,也可以内联运行代码生成。
提示:第 1012 行的编译期断言(
assert(stage(.resolve_type_fully) <= stage(.codegen_func)))在结构层面保证了类型解析必须在代码生成使用这些类型之前完成——这不只是运行时的期望,而是硬性约束。
三种缓存模式:none、whole 与 incremental
三种缓存模式代表着本质不同的策略:
| 模式 | 使用场景 | 粒度 | 失效机制 |
|---|---|---|---|
none |
特殊场景 | — | 全量重建 |
whole |
默认一次性构建 | 整个编译 | 任意输入变化即失效 |
incremental |
Watch 模式 / 服务器 | 单个声明 | 依赖跟踪式失效 |
whole 缓存是最简单也最常见的方式。它将所有输入(源文件、编译标志、目标平台)哈希为一份缓存清单,若清单匹配则直接使用缓存二进制文件。这提供了"无变化、无需重建"的快速路径。
incremental 缓存则复杂得多。它以 AnalUnit 为粒度跟踪依赖——涵盖单个函数、comptime 块和类型解析。当源文件发生变化时,只有实际发生变更的声明(通过源码哈希对比检测)会被重新分析,且只有依赖这些声明的单元才会被标记为失效。
增量模式依赖 InternPool 的依赖跟踪基础设施,接下来我们深入了解。
InternPool 中的依赖跟踪
InternPool 在第 34–65 行维护着八个依赖哈希表:
src_hash_deps: AutoArrayHashMap(TrackedInst.Index, DepEntry.Index),
nav_val_deps: AutoArrayHashMap(Nav.Index, DepEntry.Index),
nav_ty_deps: AutoArrayHashMap(Nav.Index, DepEntry.Index),
interned_deps: AutoArrayHashMap(Index, DepEntry.Index),
zon_file_deps: AutoArrayHashMap(FileIndex, DepEntry.Index),
embed_file_deps: AutoArrayHashMap(Zcu.EmbedFile.Index, DepEntry.Index),
namespace_deps: AutoArrayHashMap(TrackedInst.Index, DepEntry.Index),
namespace_name_deps: AutoArrayHashMap(NamespaceNameKey, DepEntry.Index),
graph TD
subgraph "Dependency Sources (Dependees)"
SH["src_hash_deps\n(ZIR instruction hashes)"]
NV["nav_val_deps\n(Nav values)"]
NT["nav_ty_deps\n(Nav types)"]
ID["interned_deps\n(runtime funcs, containers)"]
NS["namespace_deps\n(all names in scope)"]
NN["namespace_name_deps\n(specific name existence)"]
end
subgraph "Dependency Consumers (Dependers)"
AU["AnalUnit\n(func, comptime, nav_val, etc.)"]
end
SH -->|"invalidates"| AU
NV -->|"invalidates"| AU
NT -->|"invalidates"| AU
ID -->|"invalidates"| AU
NS -->|"invalidates"| AU
NN -->|"invalidates"| AU
每个哈希表将一个被依赖项(可能发生变化的事物)映射到一个 DepEntry 链表。每个 DepEntry 记录了依赖该项的 AnalUnit,以及指向两个链表中下一个条目的指针(一个用于"该被依赖项的所有依赖者",另一个用于"某依赖者的所有依赖项")。
TrackedInst 为 ZIR 指令提供跨更新的稳定引用。在增量更新时,ZIR 指令索引可能发生偏移,TrackedInst 负责将旧索引映射到新索引。若映射失败(指令已被删除),MaybeLost 包装器会使用哨兵值 .lost 表示。
失效流程如下:
- 源文件变化 → AstGen 生成新的 ZIR
- 比较
TrackedInst的源码哈希:新旧对比 - 哈希变化触发
src_hash_deps查找 - 每个受影响的
AnalUnit被标记为需要重新分析 - 重新分析可能改变 Nav 的值或类型,进而通过
nav_val_deps和nav_ty_deps触发级联失效
第 76 行的 first_dependency 映射提供了反向查找:给定一个 AnalUnit,找出它的所有依赖项,以便在该单元重新分析时将这些依赖关系清除。
PerThread 线程模型
对 Zcu 和 InternPool 的线程安全访问通过 PerThread 来管理:
zcu: *Zcu,
tid: Id, // dense, per-thread unique index
pub fn activate(zcu: *Zcu, tid: Id) Zcu.PerThread {
zcu.intern_pool.activate();
return .{ .zcu = zcu, .tid = tid };
}
pub fn deactivate(pt: Zcu.PerThread) void {
pt.zcu.intern_pool.deactivate();
}
activate/deactivate 模式是一种轻量级的作用域守卫。activate() 递增 InternPool 的活跃线程计数,deactivate() 则递减。这不会产生任何锁操作——InternPool 的分片设计允许多个线程同时 intern 值而不产生竞争,前提是它们使用不同的分片(由 tid 选择)。
sequenceDiagram
participant T1 as Thread 1
participant T2 as Thread 2
participant IP as InternPool
participant S1 as Shard[tid=0]
participant S2 as Shard[tid=1]
T1->>IP: activate(tid=0)
T2->>IP: activate(tid=1)
T1->>S1: intern type (no contention)
T2->>S2: intern type (no contention)
T1->>IP: deactivate()
T2->>IP: deactivate()
Compilation 本身有一个 mutex 用于保护共享可变状态,例如错误列表、失败对象表和任务队列。在单线程构建中,这个互斥锁会退化为无操作结构体。
线程模型为最常见的场景而设计:Sema 在一个线程上运行,代码生成在另一个线程上并行进行。InternPool 的每线程 Local 存储使得 Sema 可以 intern 新类型,而不会阻塞代码生成的读取操作。依赖跟踪数据只在两次更新之间的单线程失效阶段才会被修改。
提示:
IdBacking类型为u7,最多支持 128 个线程。tid通过tid_shift_*字段嵌入 InternPool 索引的高位,因此每个索引都隐式记录了创建它的线程。
多阶段自举
让我们把整个流程串联起来。第 1 篇简要介绍了三阶段自举,现在我们理解了整个编译流水线,可以更深刻地体会这一设计的精妙之处。
bootstrap.c 负责编排整个链条:
阶段 1 — zig1(bootstrap 环境):
// 1. Build wasm2c from C
cc -o zig-wasm2c stage1/wasm2c.c
// 2. Convert zig1.wasm to C
./zig-wasm2c stage1/zig1.wasm zig1.c
// 3. Compile zig1 from C
cc -o zig1 zig1.c stage1/wasi.c
随后,bootstrap.c 在第 145 行写入 pub const dev = .core; 到 config.zig,为 zig2 做好准备。
阶段 2 — zig2(core 环境):
// 4. zig1 compiles the compiler to C
./zig1 lib build-exe -ofmt=c -target host ...
// 5. System C compiler builds zig2
cc -o zig2 zig2.c compiler_rt.c
阶段 3 — zig3(完整环境):
zig2 作为普通 Zig 编译器运行(dev = .core 支持所有后端和链接器),并使用 dev = .full 构建最终的阶段 3 编译器。
src/dev.zig 中的功能门控系统使每个阶段都能正常运作:
sequenceDiagram
participant B as bootstrap (zig1)
participant C as core (zig2)
participant F as full (zig3)
Note over B: 6 features enabled
Note over B: C backend only
Note over B: No networking, no fmt
B->>C: Compile with -ofmt=c
Note over C: ~35 features enabled
Note over C: All backends + linkers
Note over C: No fmt, fetch, init
C->>F: Compile with all features
Note over F: All features enabled
Note over F: Complete compiler
第 58–67 行的 bootstrap 环境仅支持:build_exe_command、build_obj_command、ast_gen、sema、c_backend 和 c_linker。其余一切——x86_64 代码生成、ELF 链接、LLVM、增量编译——都在编译期被死代码消除。最终生成的 zig1.c 足够精简,任何 C 编译器都能快速编译它。
第 68–130 行的 core 环境启用了绝大多数编译功能,但有意排除了网络支持(network_listen)、fmt 命令等非必要功能。第 126 行的注释解释了原因:"Avoid dragging networking into zig2.c because it adds dependencies on some linker symbols that are annoying to satisfy while bootstrapping."
三阶段设计从根本上解决了自举的先有鸡还是先有蛋问题:编译 Zig 编译器需要一个 Zig 编译器。通过以 WebAssembly 作为可移植的 stage0,以 C 作为通用自举语言进行转换,并在每个阶段逐步启用功能,Zig 实现了一个完全自托管的编译器——只需一个 C 编译器,就能在任意平台上从源码构建。
结语
历经五篇文章,我们完整地走过了 Zig 编译器的全貌——从代码仓库结构到最终的二进制输出。这套架构体现了几个鲜明的设计理念:
-
扁平数据结构无处不在。 AST、ZIR、AIR 和 InternPool 全部使用整数索引而非指针。这带来了出色的缓存局部性和近乎零代价的序列化。
-
comptime 驱动的配置。
dev.zig的功能门控、importBackend()的 comptime 分发,以及AnyMirunion,都充分运用了 Zig 的 comptime 能力,在编译期完成死代码消除。 -
从底层支持增量编译。
AnalUnit/Nav/TrackedInst/ 依赖跟踪基础设施并非事后补丁——它们深度织入了核心数据结构。 -
前端作为库共享。 将词法分析器、解析器和 AstGen 放在
lib/std/zig/中,确保了语言语法有唯一的权威来源,由编译器、格式化工具和语言服务器共同使用。 -
通过渐进式能力实现自托管。 结合
dev.zig功能门控的三阶段自举,是解决自托管自举问题的优雅方案。
Zig 编译器是一个以 0.16.0-dev 为目标的活跃项目,这些结构还将持续演进。但核心架构——IR 链条、InternPool、分阶段任务系统和自举策略——已构成一个既有原则又务实的坚实基础。深入理解它,无论是参与贡献、围绕 Zig 构建工具链,还是纯粹欣赏现代系统编程领域最具雄心的编译器项目之一,你都会站在一个更高的起点上。