跳到正文

编译任务图与模块编译 ​

背景 ​

C++20 模块终结了独立编译:在 main.cpp 编译 import math; 之前,math 的接口单元必须先编译为 PCM,一整条导入链也必须自底向上依次编译。构建系统通过扫描全部文件并按序编译来处理这张图,而语言服务器做不到:

  • 必须在毫秒级响应:只编译当前打开文件所需的模块,而非整个项目的模块图。
  • 修改会级联传播:保存模块接口会使上层所有依赖它的 PCM 过期,无论相关构建是否正在进行。
  • 请求会重叠:多个文件需要同一个 PCM 时,只能构建一次;不再有任何请求需要的构建应当停止。
  • 正在输入的代码可能存在错误:循环导入必须作为错误报告,而不能导致死锁。

clangd 的模块支持多年来一直缺乏专门的设计——其官方总结指出“需要专门的设计和新的基础设施”(clangd#1293)——而且与构建系统共用 PCM 会造成文件锁冲突(clangd#2292)。clice 在同一张编译任务图上构建所有高开销产物:惰性构造、按需编译、协作式取消、沿依赖链失效。模块是这张图最核心的使用方,也是贯穿本文的示例。

设计 ​

节点 ​

任务图只管理节点、边、关注度(interest)、尝试(attempt)和失效,完全不关心节点具体构建什么。节点的种类赋予其含义:

  • PCM:某条构建命令下的一个模块;其边为它导入的模块,以及其构建目标读取的预编译头文件(PCH)。
  • PCH:某条编译命令下的一份 Preamble 内容——Preamble 与编译命令相同的文件共享同一个节点和同一次构建。
  • AST:已打开文档的一次编译,其世代(generation)代表文档的新鲜度。
  • Unit:为生成索引或运行 Lint 而编译的翻译单元。

谁等待节点,谁就携带构建该节点的方法。 请求或正在等待依赖项的构建会把目标节点的构建任务一起传入;等待者发现节点需要构建且当前没有构建在运行时,就用自己携带的任务启动一次构建。所有等待者传入的任务都是等价的,因此运行谁的那份并无区别;构建所需的输入也只存续到不再有人等待为止——不会为可能永远不会启动的构建预先暂存任何状态。

惰性构造 ​

节点在请求首次到达时创建,每次构建都会重新解析其依赖,因此打开文件时只会扫描并构建它实际触及的模块。尚无任何单元提供的导入会成为占位节点,从不执行构建,仅作为被依赖对象;当提供方出现时,占位节点失效,依赖它的节点随之改用真实模块重新构建。

关注度 ​

关注度统计当前有多少活跃请求需要某个节点:请求在等待期间持有关注度,完成或取消时释放。后台构建一旦有前台请求加入就会升级为前台构建,它所等待的依赖构建也会随之升级。正在等待依赖项的构建同样持有对依赖项的关注度,因此被其他模块需要的模块在最初触发它的请求离开后仍会继续构建。关注度归零时,构建会在短暂延迟后取消——之所以延迟,是因为被新编辑取代的旧请求刚释放关注度,接替它的新请求紧接着就会重新获取。

构建尝试 ​

每次构建都是一次尝试,并发请求会汇入当前正在运行的尝试。尝试以成功、失败(编译报错或存在循环依赖)或过期(被之后的文件变更作废,或不再有人需要)结束。失败状态不会固化:节点保持脏标记,用户修复代码后的下一次请求会重新检查它——只要失败构建的输入未变,检查就会直接返回失败而不重复运行;一旦任一输入改变或有导入方文件保存,就会重新构建。

干净节点的产物仍可能在任务图不知情时消失或过期——PCH 被缓存淘汰、AST 被驱逐,或磁盘文件在事件送达前已发生变化——因此构建任务还附带一项检查,在等待者到达干净节点时调用:产物仍然有效;产物已消失但构建输入未变,此时仅重新构建当前节点;或输入已变,此时使当前节点及其所有依赖方全部失效。

取消与世代 ​

取消是协作式的提示信号:构建在编译过程中的声明边界处检查停止标志,提前收尾后仍会汇报明确的结果。运行中的构建绝不会在中途被强行销毁,因此构建末尾“先写出产物、再注册记录”的步骤绝不会只做一半。

每个节点都维护一个世代号,每次失效传播到达该节点时递增。构建在启动时记录世代号,结束时再次比对:若两者不同,说明其读取过的内容在此期间发生了变化,所得结果已过期。主进程是单线程的,仅凭世代号即可完成所需的全部竞态检测。

边与级联 ​

节点的边由其源文件声明的依赖以及上一次成功尝试所等待过的节点共同构成。模块的导入扫描会在构建前先行声明其导入边,即便构建失败这些边也会保留,否则修复被导入模块时将无法通知到因它而失败的导入方模块。保存模块文件时,任务图沿反向边向上遍历:上层所有节点都标为脏,已经读取过变更内容的运行中尝试会被判为过期。在等待某个依赖项之前,任务图会沿着正在进行的构建链检查,一旦发现链条回到了当前等待者便立即报错返回:循环依赖按错误处理。

解析导入 ​

启动扫描会建立模块名到声明文件的注册表(参见依赖扫描);多个单元声明同一模块名时,导入会解析到目录距离最近的一个,并在导入方给出其余候选的警告。构建自身的导入扫描会按该单元的编译命令运行预处理器,跳过不可能导入任何模块的单元,并在编译命令与相关文件未变时复用扫描结果——对于已打开的文件,只要预处理指令所在行未变即可复用,因此在其他位置输入代码不会触发扫描。扫描直接作用于正在编辑的缓冲区,因此新输入但尚未保存的 import 也能在语法解析用到之前提前构建好 PCM。

构建系统可能以多种方式编译同一个模块——按导入方的不同编译标志各编一次,或按不同构建目录各编一次——而 Clang 只允许语言选项相匹配的编译读取对应的 PCM。因此,导入操作会选取与导入方编译相匹配的模块构建:优先在与导入方同属一个编译数据库的条目下查找(与真实构建一致),其中又优先采用该模块自身的编译命令。

PCM 缓存 ​

每个模块都会按编译命令分别记录上一次构建的信息:构建键、读取过的文件以及结束状态。构建键按配置标识一次构建——涵盖模块本身、编译器、编译标志以及所导入模块的构建键——因此它依赖链下方任意位置的编译命令变动都会改变它的构建键;文件内容变动则通过共享的文件检查机制核对构建读取过的文件来捕获(参见增量编译)。每次构建都会在 clice 独立的缓存目录中写入新文件,因此重新构建绝不会覆盖其他编译正在读取的 PCM,也不与构建系统共享任何文件。成功构建的记录会随索引一同持久化,使服务器重启后即可热启动。

常见问题 ​

  • 为什么使用关注度而不是任务队列? 队列无法表达“某次构建已不再有人需要”。关注度让取消操作跟随实际需求,而非依赖超时。
  • 为什么采用协作式取消? 在中途直接销毁构建会让每个挂起点都变成退出点,而构建收尾阶段的步骤绝不能被拆断。停止标志让构建在已知的安全点退出,并始终汇报结果。
  • 为什么由等待者携带构建任务? 构建所需的某些输入只有发起请求的一方才掌握——如缓冲区文本、某次 Lint 运行的参数。若把它们存放在项目状态里留给下一次构建使用,就不得不额外规定何时保留、何时丢弃,还会让它们的生命周期超出所属请求。
  • 为什么使用独立的 PCM 目录? 与构建系统共用 PCM 会锁住文件,妨碍构建系统写入。独立目录的代价只是额外的磁盘空间以及每个模块的首次构建开销。

已知限制 ​

  • 从未保存到磁盘的模块无法构建:磁盘上没有可供编译的文件。一旦保存,对应的占位节点就会失效,各导入方随之基于该模块重新构建。
  • 任务图保存在内存中。 服务器重启后,会根据持久化的构建记录和模块注册表惰性重建任务图。