模块编译
背景
C++20 引入了模块(Modules),改变了 C++ 自诞生以来的独立编译模型。传统的 C++ 编译中,每个源文件独立编译为目标文件,文件之间通过头文件共享声明。模块打破了这种独立性:
// math.cppm — 模块接口单元
export module math;
export int add(int a, int b) { return a + b; }
// main.cpp — 导入模块
import math;
int main() { return add(1, 2); }编译 main.cpp 之前,必须先编译 math.cppm 的模块接口单元,产出预编译模块文件(PCM)。如果项目中有多层模块依赖——A 导入 B,B 导入 C——编译顺序必须是 C → B → A。这构成了一个有向无环图(DAG),节点是模块文件,边是 import 关系。
构建系统(CMake、Ninja 等)天然适合处理这种 DAG 调度:扫描所有文件、构建完整依赖图、按拓扑序编译。但语言服务器面临的挑战不同:
实时性。用户打开一个导入了模块的文件后,期望在数百毫秒内获得编辑反馈。等待整个模块图编译完成是不可接受的。语言服务器需要一种惰性的、按需的编译策略——只编译当前文件实际需要的那些模块。
文件变更的级联影响。当用户修改一个模块接口文件并保存时,所有直接或间接依赖它的模块的 PCM 都已过时。语言服务器需要检测这种情况,取消正在进行的编译,标记受影响的模块为脏,在下次需要时重新编译。
并发与取消。多个文件可能同时需要同一个模块的 PCM。语言服务器需要避免重复编译,让后到达的请求等待先发起的编译完成。同时,当用户关闭了触发编译的文件、不再需要某个模块时,正在进行的编译应该能被取消以释放资源。
临时的循环依赖。虽然 C++ 模块不允许循环 import,但用户在编辑过程中可能临时引入循环。语言服务器需要检测并报错,而不是死锁。
在 clangd 中,C++20 模块支持长期处于实验阶段,缺乏专门的设计。clangd/clangd#1293 是 clangd 项目方对模块支持问题的总结——结论是"这不是能靠修 bug 解决的问题,需要全新的设计和基础设施",并且当时"没有人有计划或时间来做这件事"。多年过去,社区中相关问题持续出现:clangd/clangd#2569 列举了模块中缺失的关键功能(重命名、查找引用等);clangd/clangd#2292 和 clangd/clangd#2497 反映了 clangd 与构建系统共用 PCM 文件导致的文件锁定冲突——clangd 打开 PCM 文件后,构建系统无法覆写更新。
这些问题的共同根源是缺乏一个为语言服务器场景专门设计的模块编译调度系统。clice 通过 CompileGraph 解决这一问题——一个基于引用计数的编译 DAG,支持惰性构建、按需编译、实时取消和依赖级联。
设计
CompileGraph 是一个运行时构建的编译依赖图。每个节点(CompileUnit)代表一个模块文件,边代表 import 依赖关系。
惰性构建
与构建系统在编译前扫描所有文件并构建完整 DAG 不同,CompileGraph 是惰性构建的——节点只在第一次被编译请求触及时才解析其依赖并加入图中。用户打开一个文件时,只有该文件实际需要的模块链被扫描和编译,而不是整个项目的模块图。
CompileGraph 有两种编译入口:
- 编译模块:编译指定模块及其所有传递依赖,产出 PCM 文件。用于模块接口单元自身的编译。
- 编译依赖:编译指定文件的所有模块依赖,但不编译文件本身。用于普通源文件——它们不属于模块 DAG,但可能通过
import使用模块。
引用计数
引用计数(interest counting)是 CompileGraph 的核心调度机制,追踪"当前有多少活跃请求关心某个编译单元",回答两个问题:是否需要启动编译?是否可以取消编译?
当某个文件需要一个模块时,该模块的引用计数加一(acquire);当请求结束或被取消时,引用计数减一(release)。引用计数归零意味着当前没有任何活跃请求关心这个模块。
引用计数的管理通过 RAII 守卫完成:编译请求创建守卫时自动 acquire,守卫析构时自动 release。这与协程取消的语义天然契合——取消导致协程帧销毁,析构器释放引用计数,不需要额外的清理代码。
引用计数有两个层级:请求级和编译任务级。请求级引用表示"这个请求在等待某个模块的 PCM";编译任务级引用表示"这个模块的编译任务正在等待其直接依赖完成"。后者确保了一个模块在被其他模块的编译任务依赖时不会因为请求取消而被过早终止。
编译轮次
每次编译尝试构成一个编译轮次(Round)。等待者通过完成事件得知某轮编译结束,通过结果决定下一步动作。一个轮次有三种可能的结果:
- 成功(Success):PCM 产出成功,清除脏标志
- 失败(Failed):编译出错(依赖失败、循环依赖等),保留脏标志
- 过时(Stale):编译被取消(文件在编译期间被修改,或引用计数归零),等待者自动驱动新一轮
失败不是粘性的——保留脏标志意味着用户修复错误后,下次请求会自然触发重试,不需要手动重启服务器。
脏状态与世代计数器
CompileUnit 使用脏标志表示需要编译。世代计数器是一个单调递增的值,在每次文件更新时递增,用于检测异步竞态:编译任务启动时记录当前世代,完成时比较——如果不一致,说明编译期间文件被修改过,结果已过时,这轮编译的结果为 Stale。
依赖关系
每个 CompileUnit 维护正向依赖(它导入的模块)和反向依赖(导入它的模块)。正向依赖通过惰性解析获得;解析时同时填充反向依赖。反向依赖是文件变更时级联通知的基础——从被修改的模块出发,沿反向依赖边找到所有受影响的模块。
模块名到文件的映射由 DependencyGraph 维护(见依赖扫描)——启动时的快速扫描会发现所有模块声明,建立模块名 → 文件路径的注册表。CompileGraph 使用这个注册表将 import 语句解析为具体的文件路径。
实现
编译流程
一个模块编译请求的完整流程:
请求进入
│
├─ RAII 守卫对目标模块 acquire
│
├─ 目标模块不脏? ──→ 直接返回(PCM 已可用)
│
├─ 没有正在进行的编译? ──→ 启动编译任务
│ │
│ ├─ 惰性解析依赖(扫描 import 声明)
│ ├─ 检测自环
│ ├─ 对直接依赖 acquire
│ ├─ 并行等待所有依赖编译完成
│ ├─ 分派到工作进程(产出 PCM)
│ └─ 检查世代计数器 ──→ 不一致则结果为 Stale
│
├─ 等待编译轮次完成
│
└─ 根据结果:Success 返回 / Failed 报错 / Stale 重试惰性解析使用 Clang 预处理器进行精确扫描,与启动阶段依赖扫描使用的快速词法扫描不同。快速扫描不展开宏和条件编译,适合构建全局的包含关系概览;精确扫描展开所有预处理指令,得到该文件在当前编译命令下实际的模块依赖列表。解析结果被缓存,后续编译复用缓存,直到文件更新时重置。
模块实现单元(没有
export的module X;)隐式依赖对应的模块接口单元。精确扫描会检测这种情况并自动添加依赖。
延迟零引用取消
当引用计数降为零时,不立即取消编译,而是延迟一个事件循环 tick 后再检查——如果引用计数仍为零,才真正取消。
这是为了处理编译请求被取代的场景。当用户在编译期间编辑了文件,新的编译请求取代旧的:旧请求释放引用(计数瞬间降为零),紧接着新请求在同一 tick 内建立引用(计数回升)。立即取消会导致共享依赖的编译被不必要地终止和重启。延迟一个 tick 让这种引用交接平滑完成,避免了对正在进行的共享编译的干扰。
级联更新
当模块文件被保存(didSave)时,CompileGraph 执行级联更新:
- 重置被修改文件的已解析标志——下次编译时重新扫描依赖,因为文件可能增删了
import - 清除旧的正向依赖边
- 标记为脏,递增世代计数器
- 沿反向依赖边遍历所有传递依赖方,对每个受影响的模块:取消其编译轮次、标记为脏、递增世代
- 返回所有被标记为脏的文件列表,调用方据此清除对应的 PCM 缓存
级联更新不修改引用计数——现有的等待者保持它们的引用。它们在观察到编译结果为 Stale 后,会自动驱动新一轮编译。
循环依赖检测
在等待某个依赖的编译完成之前,CompileGraph 检查是否存在等待环:从目标节点出发,沿依赖链搜索,只跟随正在编译中的节点,检查是否会回到当前等待者。如果检测到环,立即返回失败,避免死锁。
RAII 守卫与结构化并发
协程取消在 kotatsu 中的语义是销毁协程帧——挂起点之后的代码不会执行,只有已构造对象的析构器保证执行。CompileGraph 利用这一点,将所有清理逻辑放在两层 RAII 守卫中:
- RefGuard(请求级):持有请求对模块的根引用。请求完成或被取消时析构,释放引用计数。
- UnitGuard(编译轮次级):管理一轮编译的所有状态。析构时发布结果、清除编译中标志、释放所有已获取的依赖引用、触发完成事件通知等待者。
所有编译任务通过 kota::task_group 管理,提供结构化并发保证:关闭时先取消所有任务,再等待它们的帧退出,确保没有悬空的编译任务。
PCM 缓存
PCM 文件采用内容寻址的路径命名——由模块名和编译参数的哈希值决定文件名,存储在专用的缓存目录中。这与构建系统的产物完全隔离,避免了文件锁定冲突。
PCM 缓存使用两层新旧检测:先比较依赖文件的修改时间(mtime),时间变化时再比对内容哈希。只有依赖文件的内容实际发生变化时才重新编译,避免"touch 但未修改"导致的不必要重编译。缓存元数据持久化到磁盘上的 cache.json,服务器重启后可以恢复。
与编译流程的集成
CompileGraph 在服务器启动阶段初始化。Compiler 在每次编译文件之前,通过 CompileGraph 确保所有模块依赖就绪——这是编译准备阶段的第一步,在 PCH 构建之前执行。
对于用户在编辑器中新增的 import 语句(尚未保存,因此 CompileGraph 不知道),Compiler 会额外扫描缓冲区内容,识别出新增的模块依赖并尝试构建对应的 PCM。这是一个补偿机制,覆盖了用户编辑但未保存时的体验。
FAQ
为什么用引用计数而不是任务队列? 任务队列无法表达"这个编译不再被任何人需要"的语义。当用户关闭了触发编译的文件时,继续编译浪费资源。引用计数精确追踪需求,使取消决策有据可依——不是基于超时或启发式判断,而是基于"当前还有没有请求在等这个结果"。
为什么延迟一个 tick 而不是立即取消? 在事件循环模型中,一个操作的多个步骤在同一 tick 内完成。当编译请求被新版本取代时,旧引用先释放、新引用随后建立,中间存在瞬间的零引用。立即取消会导致共享依赖被不必要地终止——延迟一个 tick 确保同一 tick 内的引用交接不触发取消。
为什么用世代计数器而不是锁? 主进程是单线程事件循环,没有数据竞争。需要检测的"竞态"来自异步操作的时序——"编译期间文件是否被更新过"。世代计数器用最小的开销回答这个问题,不需要引入锁。
为什么惰性解析依赖? 依赖解析需要对每个模块文件运行 Clang 预处理器(精确扫描),成本不低。如果启动时解析所有模块的依赖,会增加启动时间。惰性解析确保只扫描实际需要编译的模块——用户打开一个文件不会触发整个模块图的扫描。
失败为什么不是粘性的? 用户正在编辑代码,语法错误是常态。如果把失败标记为持久状态,用户修复错误后仍然无法获得正确结果,必须重启服务器。保留脏标志让下次请求自然触发重试。
为什么 PCM 存储在独立的缓存目录? clangd 共用构建系统的 PCM 文件会导致文件锁定冲突——语言服务器读取 PCM 时,构建系统无法覆写更新(见 clangd/clangd#2292)。clice 使用内容寻址的独立缓存,避免了这个问题。代价是磁盘空间的额外占用和首次编译的额外时间。
已知局限
编译任务的内存积累。每轮编译在 task_group 中创建一个任务。已完成的任务帧在 task_group 析构时才回收,而非完成时立即回收。长期运行的服务器中,已完成的任务帧会持续积累。
依赖解析的确定性。解析依赖和实际编译之间存在时间窗口。如果文件在此期间被修改,解析出的依赖可能与编译时的实际依赖不一致。世代计数器可以检测到这种情况并触发重试,但会产生额外的编译开销。
未保存文件的模块依赖覆盖有限。CompileGraph 基于磁盘文件构建依赖关系。用户在编辑器中新增
import但未保存时,Compiler 通过缓冲区扫描来补偿,但如果导入的模块本身还未被 CompileGraph 管理(例如一个尚未保存的新模块文件),对应的 PCM 无法被构建。用户需要先保存模块接口文件,再保存导入方文件。