Skip to content

编译任务图与模块编译

背景

C++20 引入了模块,改变了 C++ 自诞生以来一直使用的独立编译模型。传统 C++ 中,每个源文件独立编译为目标文件,文件之间通过头文件共享声明。模块打破了这种独立性:

cpp
// math.cppm — module interface unit
export module math;
export int add(int a, int b) { return a + b; }

// main.cpp — imports the module
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 团队对模块支持问题的总结——结论是“这些不只是修复缺陷就能解决的问题,还需要专门的设计和新的基础设施”,并且当时“近期没有人计划或有余力处理这件事”。多年后,相关问题仍不断出现:clangd/clangd#2569 列举了模块支持中缺失的关键功能(重命名、查找引用等);clangd/clangd#2292clangd/clangd#2497 报告了 clangd 与构建系统共用 PCM 文件导致的文件锁冲突——clangd 一直保持 PCM 文件打开,从而阻止构建系统覆写该文件。

这些问题的共同根源是缺少专为语言服务器场景设计的编译调度系统。clice 通过一个通用的编译任务图来解决这些问题:每一种代价高昂的构建产物——模块 PCM、Preamble PCH、文档 AST、一次性批处理运行——都是同一个图中的节点,并支持惰性构造、按需编译、实时协作式取消和依赖级联。模块对该图的运用最为全面,本页将以模块为贯穿全文的示例。

设计

一个图,多个节点族

任务图本身刻意保持轻量:它只了解节点、边、关注(interest)、轮次和失效,并不关心节点构建的内容。节点的具体含义由其定义:

  • PCM 族:每个模块一个节点,边是 import 关系。所有模块相关策略都在这里。
  • PCH 族:每个 Preamble 内容键一个节点——Preamble 相同的文件共享一个节点和一次构建。
  • AST 族:每个打开的文档一个节点,生成要发布的功能结果。
  • TU-run 族:后台索引和批量 Lint 共享的一次性翻译单元运行。

本节其余部分描述图的通用契约;模块特有的行为会明确指出。

惰性构建

与编译前扫描所有文件并构建完整 DAG 的构建系统不同,该图采用惰性构建——只有当编译请求首次到达某个节点时,才会创建该节点;该节点的每一构建轮次都会重新解析其依赖。当用户打开文件时,只会扫描和编译该文件实际需要的模块链,而非整个项目的模块图。

有两个模块入口点:

  • 编译模块:编译指定模块及其所有传递依赖,生成 PCM 文件。用于模块接口单元本身。
  • 编译依赖:编译指定文件的所有模块依赖,但不编译文件本身。用于普通源文件——它们不属于模块 DAG,但可以通过 import 使用模块。

如果某个 import 的提供方尚无法解析,图不会因此失败:它会成为一个 占位节点(placeholder node),该节点永远不会运行,只会被依赖。当提供方随后出现时——例如文件被创建,或重新加载编译数据库后发现了它——图会更新相应的边并使依赖方失效,随后依赖方会基于真实模块重新构建。

关注计数

关注计数是图的核心调度机制,用于追踪“当前有多少活跃请求关注某个节点”。它回答两个问题:是否应该启动编译?是否可以取消编译?

当请求需要某个节点时,会增加对该节点的关注;当请求完成或被取消时,则会释放这份关注。关注计数降至零,意味着当前没有任何活跃请求关注该产物。

关注分为两个层级:请求级和依赖级。请求级关注表示“该请求正在等待产物”;依赖级关注表示“该节点的构建正在等待其直接依赖完成”。后者可确保当另一个模块的构建依赖某个模块时,即使原始请求已被取消,该模块也不会被过早取消。

编译轮次

每次构建尝试构成一个轮次。针对同一节点的并发请求会加入正在运行的轮次,而不是各自发起新轮次;等待者通过轮次结果获知其完成情况,并决定下一步操作。一个轮次有三种可能的结果:

  • 成功:产物构建完成,脏标志清除。
  • 失败:发生编译错误(依赖失败、检测到循环等),脏标志保留。
  • 过时:该轮次已被取代(编译期间文件被修改,或关注计数降至零),等待者会自动发起新一轮。

失败状态不会固化——保留脏标志意味着用户修复错误后,下一个请求会自然触发重试,无需重启服务器。

协作式取消

取消仅具有提示作用,绝不会进行破坏性终止。关注计数降至零(或轮次被取代)时会置位停止标志;正在运行的构建会在编译过程中的声明级检查点检测该标志并提前收尾,而该轮次会向等待者发布实际结果。正在运行的轮次绝不会在中途被拆除——这意味着构建结束时的提交序列(先写入产物,再注册其元数据)一旦开始,就一定会执行完毕,因此取消绝不会留下注册不完整的产物。

脏状态与世代计数器

每个节点都使用脏标志表示需要重新构建。世代计数器是一个单调递增的值,每次文件更新时都会递增,用于检测异步竞态:构建开始时记录当前世代,完成时再进行比较——如果不一致,则表示文件在编译期间被修改,该结果已经过时。

依赖关系

模块节点维护正向依赖(它们导入的模块)和反向依赖(导入它们的模块)。正向依赖通过惰性解析获得;反向依赖则在解析时回填。反向依赖是文件变更时进行级联通知的基础——从被修改的模块出发,沿反向边即可找到所有受影响的模块。

模块名到文件的映射由 DependencyGraph 维护(见依赖扫描)——启动时的快速扫描会发现所有模块声明,并建立模块名 → 文件路径注册表。PCM 族使用该注册表将 import 语句解析为具体的文件路径。

实现

编译流程

模块编译请求的完整流程:

Request enters

  ├─ Acquire interest on the target module's node

  ├─ Target not dirty? ──→ Return immediately (PCM available)

  ├─ No round in progress? ──→ Start a build round
  │                             │
  │                             ├─ Lazily resolve dependencies (scan import declarations)
  │                             ├─ Check for self-cycle
  │                             ├─ Acquire interest on direct dependencies
  │                             ├─ Wait for each dependency to compile
  │                             ├─ Dispatch to worker process (produce PCM)
  │                             └─ Check generation counter ──→ Mismatch = Stale

  ├─ Join the round and wait for its outcome

  └─ Based on result: Success → return / Failed → error / Stale → retry

惰性解析使用 Clang 预处理器进行精确扫描,这与启动阶段的依赖扫描所采用的基于词法分析器的快速扫描不同。快速扫描不会展开宏,也不会对条件编译指令求值,适合构建包含关系的全局概览;精确扫描则会展开所有预处理指令,以获取文件在当前编译命令下的实际模块依赖。解析不会缓存,而是在每轮构建开始时运行,因此编译命令或导入列表发生变化时,下一轮总能发现。

模块实现单元(含有 module X; 但不含 export)隐式依赖对应的模块接口单元。精确扫描会检测这种情况并自动添加该依赖。

引用归零后的延迟取消

当引用计数降为零时,不会立即请求取消,而会短暂延后检查——届时如果引用计数仍为零,便执行协作式取消。

这种延迟机制用于处理编译请求被取代的情况。用户在编译期间编辑时,新请求会取代旧请求:旧请求释放其引用(引用会短暂归零),随后新请求重新取得引用。立即取消会导致共享依赖项的构建在没有必要的情况下中止;延迟机制则可让这类交接顺利完成。

级联更新

保存模块文件(didSave)时,该图会执行级联更新:

  1. 将已保存文件对应的节点标记为脏,递增其世代计数器,并取代正在进行的轮次
  2. 沿反向边遍历所有传递依赖者——既包括先前轮次记录的持久边,也包括仍在进行的轮次所产生的候选边;对每个受影响的模块:取代其正在运行的轮次,将其标记为脏,并将世代值加一
  3. 使受影响的缓存 PCM 失效,确保不会使用过时产物

正向边保持不变:已保存文件的下一轮会重新扫描其 import 声明并替换这些边,因此编辑中移除的导入要到此次重新扫描后才不再触发级联。级联更新不会修改引用计数——现有等待者保留各自的引用。等待者一旦观察到 Stale 结果,就会自动发起新一轮。

环检测

等待某个依赖项编译完成前,该图会检查等待环:它从目标节点出发,沿依赖链搜索,只跟随当前正在构建的节点,检查该链是否会回到当前等待者。如果检测到环,则立即返回失败,以避免死锁。

结构化并发

所有轮次都以任务形式在该图拥有的任务组中运行,从而提供结构化并发保证:关闭时会停止接收新任务、执行协作式取消并等待帧展开完成,确保不留下悬空的构建任务。轮次帧完成后会立即回收。

PCM 缓存

PCM 文件根据配置而非内容命名:模块名加上编译器版本、工作目录、源文件路径和前端相关选项的哈希值,并存储在专用缓存目录中。这与构建系统产物完全隔离,可避免文件锁冲突。内容变化由下文介绍的依赖快照捕获,而不是通过文件名检测。

缓存的 PCM 通过其依赖快照进行校验:将每个依赖项的标识和记录的内容版本与共享文件表进行核对(stat 快速路径、内容哈希确认——参见增量编译)。只有依赖项的内容确实发生变化时才会重新编译,避免因“只 touch 而不修改”造成不必要的重建。缓存元数据与索引一同持久化,并在服务器重启时恢复;Clang 自身的 PCM 校验仍作为最后一道保障。

与编译流程的集成

在分派文档 AST 或交互式构建前,其依赖准备阶段会获取所需的 PCM 节点——这是编译准备的第一步,在构建 PCH 前执行。导入扫描使用请求的实际缓冲区文本(包括为头文件上下文合成的后缀),因此,即使用户输入的 import m; 尚未保存,其所需的 PCM 仍会在解析需要前构建完成。

常见问题

  • 为什么用需求计数而不是任务队列? 任务队列无法表达“这个编译已无人需要”。当用户关闭触发编译的文件时,继续编译会浪费资源。需求计数可以精确追踪需求,让取消决策有据可依——依据的不是超时或启发式判断,而是是否仍有请求在等待结果。

  • 为什么延后零需求取消,而不是立即取消? 在事件循环模型中,一个操作的多个步骤会在同一 tick 内完成。当一个编译请求被新请求取代时,旧需求会先解除,新需求随后建立,两者之间会短暂出现零需求状态。立即取消会导致共享依赖项不必要地停止;延后检查可确保需求交接不会触发取消。

  • 为什么用协作式取消而不是销毁构建任务? 在构建中途销毁协程帧会让每个 await 点都可能成为退出点,而构建末尾的提交序列(写入产物、注册元数据)绝不能中途断开。提示性停止标志让构建能够在明确定义的检查点退出,并始终发布实际结果,代价是取消会略有延迟。

  • 为什么用世代计数器而不是锁? 主进程采用单线程事件循环,不存在数据竞争。需要检测的“竞态”来自异步时序——“文件是否在编译期间更新过?”世代计数器能以极小的开销回答这个问题,无须引入锁。

  • 为什么惰性解析依赖? 依赖解析需要对每个模块文件运行 Clang 预处理器(精确扫描),成本不低。如果在启动时解析所有模块依赖,就会延长启动时间。惰性解析确保只扫描编译实际需要的模块——打开一个文件不会触发对整个模块图的扫描。

  • 为什么失败状态不会持续生效? 用户正在编辑代码,语法错误是常态。如果将失败标记为持久状态,用户修复错误后必须重启服务器才能获得正确结果。保留脏标志可让下一次请求自然触发重试。

  • 为什么把 PCM 存储在独立的缓存目录中? clangd 与构建系统共享 PCM 文件,这会导致文件锁冲突——语言服务器保持 PCM 文件处于打开状态,使构建系统无法覆写它(见 clangd/clangd#2292)。clice 将自己的 PCM 存放在独立的缓存目录中,从而避免这个问题。代价是占用更多磁盘空间,首次编译也需要更长时间。

已知限制

  • 依赖解析的确定性。 依赖解析与实际编译之间存在一个时间窗口。如果文件在此期间被修改,解析出的依赖可能与编译时的实际依赖不一致。世代计数器会检测这种情况并触发重试,代价是增加编译开销。

  • 新建的模块提供方。 如果某个 import 的提供方模块从未保存到磁盘,该 import 就无法构建——因为没有可供编译的文件。提供方保存后,会通过占位节点机制被发现;先前无法解析的 import 链要等到其依赖方下次被编辑或重新打开时才会恢复,不会自行恢复。

  • 图状态存储在内存中。 节点和边的状态无法跨服务器重启保留;下次运行时,会根据持久化缓存和依赖注册表惰性重建图。