到底什么是构建系统?
Source: Hacker News
大局观
在高层次上,构建系统是提供一种 定义 与 执行 一系列从 输入 数据到 输出 数据的 转换 的工具或库,这些转换通过 缓存 被 记忆化 并存放在 对象存储 中。
转换被称为 步骤 或 规则1,它们定义了如何执行一个 任务,该任务可以从零个或多个输入生成零个或多个输出。
规则通常是 缓存单元;即 缓存点 是规则的输出,而 缓存失效 必须在规则的输入上发生。
规则可以依赖先前的输出,形成一个称为 依赖图 的有向图。
形成循环的依赖图被称为 循环依赖,通常被禁止。2
仅被其他规则使用、但对最终用户并不 “有趣” 的输出称为 中间输出。
如果一个输出的某个依赖被修改,或者 传递性 地其某个依赖已过时,则该输出被视为 过时、脏 或 陈旧。陈旧的输出会使缓存失效,需要 重新构建。已缓存且未脏的输出是 最新 的。若规则的任何输出过时,则该规则也视为过时。如果规则没有输出,则它始终是过时的。
每一次调用构建工具都称为一次 构建。
- 当缓存为空且所有转换作为 批处理作业 执行时,称为 完整构建 或 清洁构建。
- 若缓存中的所有规则都是最新的,则缓存是 完整 的。
- 当缓存部分完整但有些输出过时需要重新构建时,称为 增量构建。
- 删除缓存的行为称为 清理。
如果所有可能的增量构建得到的结果与完整构建相同,则构建是 正确 或 可靠 的。3
如果每个规则在一次构建中最多只被重新运行一次,并且仅在为可靠性必须时才运行,则构建是 最小化(有时称为 最优)的(参见 Build Systems à la Carte、Pluto)。
为了使构建可靠,所有可能的缓存失效必须被 追踪 作为依赖。
没有缓存的构建系统被称为 任务运行器 或 批处理编译器。需要注意的是,任务运行器即使不支持缓存,通常仍会支持依赖。带缓存的构建系统可以通过只定义零输出的任务来模拟任务运行器,但它们通常并非为此用途设计。4
构建系统示例: make、docker build、rustc。
任务运行器示例: just、Shell 脚本、gcc。
指定依赖
构建可以是 进程间 的,即任务通常是一次 进程执行,带有输入文件和输出文件;也可以是 进程内 的,即任务通常是一次函数调用,带有参数和返回值。
为了追踪依赖,要么所有输入和输出必须在源码中提前 声明,要么能够从任务的执行中 推断 出它们。
能够追踪规则定义变化的构建系统被称为 自追踪。规则的过去版本称为它的 历史(参见 Build Systems à la Carte)。
从运行时行为中推断依赖的行为称为 追踪。
如果被追踪的规则依赖于尚未构建的依赖,构建系统可以选择报错、挂起 任务并在依赖构建完成后 恢复,或 中止 任务并在依赖构建完成后 重新启动(参见同上)。
进程间构建通常会声明其输入输出,而进程内构建则常常推断它们,但这并非定义所固有的。5
进程内构建示例: 电子表格、wild linker 与诸如 Python 的 functools.cache 之类的记忆化库。
应用式与单子式结构
如果所有输入、输出和规则都在事先声明,则构建图是 应用式 的,此时图是 静态已知 的。纯粹的应用式构建系统极少;几乎所有系统都有逃逸通道。
如果并非所有输出事先已知,或规则可以在运行时动态生成其他规则,则构建图是 单子式 的。事先未知的输入称为 动态依赖。动态依赖比完全单子式的构建系统更弱,因为它们能够表达的构建图更少(参见 能力‑可实现性权衡)。6
不需要声明构建规则的构建系统始终是单子式的。
单子式构建系统示例: Shake、ninja 的 dyndeps、Cargo 构建脚本。
应用式构建系统示例: make(在禁止递归 make的前提下)、Bazel(不包括原生规则),以及带记忆化的 map/reduce 库,例如此 Unison 程序。
早期截断
如果一个脏规则 R 的输出已过时,重新运行后产生的新输出与旧输出相同,则构建系统有机会避免运行依赖于 R 的后续规则。利用这一机会的行为称为 早期截断。
有关早期截断的更多信息,请参见 rustc‑dev‑guide。7
重建检测
在不可靠的构建系统中,系统可能无法准确 检测 是否需要重建。这类系统有时提供 强制重新运行 某个目标的方式:保留现有缓存但重新运行单个规则。对于进程间构建系统,这通常涉及 touch 一个文件以将其修改时间设为当前时间。
执行器
构建执行器 负责运行任务并按照尊重所有依赖的顺序调度它们,常使用诸如依赖深度或上一次运行耗时等启发式策略。
它还会检测规则的输入是否被修改,从而将规则标记为过时,这称为 重建检测。
在支持此功能的构建系统中,执行器可以重新启动或挂起任务,提供 进度报告,并有时允许 查询 依赖图。
偶尔,执行器会追踪任务使用的输入,以确保它们匹配声明的依赖,或自动将其加入内部依赖图。
进程间构建
在进程间构建的语境中,制品 是规则生成的输出文件。8
源文件 是特定于当前 项目(有时称为 仓库 或 工作区)的输入文件,与在多个项目之间复用的 系统依赖 相对。9