Ninja:构建很快,为什么增量结果仍然可能是错的
Ninja 最危险的误解是“它快,所以换成 Ninja 就能修好构建”。Ninja 只擅长快速读取一个已经生成好的依赖图,判断哪些输出过期,再执行对应命令。图里漏了一条边,它会非常快地给出错误增量结果;命令把输出写错目录,它也不会替上层工程模型纠正。
因此,Ninja 的工程价值不是替代 CMake、Meson 或语言包管理器,而是成为低层、确定、可诊断的执行器。大型项目通常由上层生成器维护 build.ninja。手写只适合学习模型、小型引导工程或开发生成器,不能让成千上万条编译边成为人工维护资产。
先确认 Ninja 由谁提供、由谁调用
Ninja 官方仓库发布单文件二进制,操作系统包管理器和 IDE 工具链也可能自带副本。先检查终端实际入口:
Get-Command ninja | Format-List Source
ninja --version
ninja -hcommand -v ninja
ninja --version
ninja -h如果项目由 CMake 生成,继续记录 CMake 和 generator:
cmake --version
cmake -S . -B build -G Ninja
cmake --build build --verbose如果项目由 Meson 生成:
meson --version
meson setup build
meson compile -C build -v直接运行 ninja -C build 适合诊断执行层,但团队权威入口通常仍应是 cmake --build 或 meson compile,因为上层工具还承担重生成、配置选择和跨平台封装。
安装包、源码和当前 release 需要核对时,从 Ninja 官方仓库及其 Releases进入。不要把某台 IDE 内置版本误写为整个团队的版本基线。
用十几行文件看懂执行模型
创建一个可删除目录,准备两个输入文件:
ninja-lab/
├─ build.ninja
├─ concat.py
├─ one.txt
└─ two.txtbuild.ninja 写成:
rule concat
command = python concat.py $out $in
description = CONCAT $out
build result.txt: concat one.txt two.txt | concat.py
default result.txt再准备最小脚本:
# concat.py
from pathlib import Path
import sys
output = Path(sys.argv[1])
inputs = [Path(path) for path in sys.argv[2:]]
output.write_text("".join(path.read_text() for path in inputs))执行:
ninja -v
ninja -n
ninja -d explain第一次运行会生成 result.txt。第二次没有输入变化时应显示 no work to do。修改 one.txt 后只执行一次 concat。-v 展开真实命令,-n 只显示计划而不执行,-d explain 解释 target 为什么被认为 dirty。
这个实验已经包含 Ninja 的核心对象:rule 是命令模板,build 是一条带输入和输出的 edge,default 指定无参数时构建的 target。Ninja 构建的是文件之间的有向无环图,不理解“模块”“库的公开接口”或“业务服务”。这些语义应由生成器转换成文件依赖边。
显式、隐式和仅排序依赖不能混用
Ninja 一条 build edge 可以同时拥有三类输入:
build app: link main.o util.o | version.txt || prepare-directorymain.o util.o 是显式输入,通常出现在 $in。version.txt 是隐式输入,会影响 dirty 判断,但不自动进入 $in。prepare-directory 是 order-only 依赖,只保证执行顺序,其时间戳变化不应让 app 重建。
目录创建若写成普通输入,目录 mtime 变化可能触发无意义重建;真实内容依赖若错写成 order-only,输入变化又不会重建。排查增量错误时,不只看“有没有这条依赖”,还要看依赖类型是否表达了正确语义。
depfile 补齐编译器运行时发现的头文件
C/C++ 编译前无法仅靠生成器静态列出所有传递头文件。编译器可在执行时写出 Makefile 风格 depfile,Ninja 读取后把隐式头文件依赖记录到依赖数据库:
rule cc
command = cc -MMD -MF $out.d -c $in -o $out
depfile = $out.d
deps = gcc
build main.o: cc main.c第一次编译后,.ninja_deps 保存发现的依赖。修改一个被 include 的头文件,再运行:
ninja -d explain main.o
ninja -t deps main.o预期能看到头文件导致 main.o 变脏。若没有重建,检查 depfile 是否生成、路径拼写是否一致、编译器工作目录与 Ninja 路径是否匹配,以及输出是否真的由该 edge 声明。
MSVC 常通过 /showIncludes 输出依赖,Ninja 的 deps = msvc 负责解析。非英文工具链可能需要正确的 msvc_deps_prefix;优先让 CMake 等生成器处理这类平台细节,避免每个项目维护脆弱的本地化前缀。
depfile 的完整语义和路径约束可在 Ninja Manual 的 depfile 说明中核对。绝对路径与相对路径混用时,逻辑上同一文件可能成为图里的两个节点,这是跨目录增量失真的高发根因。
dyndep 处理构建过程中才完整出现的边
有些语言和生成流程在扫描源文件后才知道真正的输入输出,例如 Fortran module。dyndep 允许一个扫描步骤生成动态依赖文件,Ninja 在执行相关 edge 前加载这些边:
rule scan
command = scanner $in -o $out
rule compile
command = compiler -c $in -o $out
build modules.dd: scan sources.list
build object.o: compile source.f90 || modules.dd
dyndep = modules.dddyndep 文件必须与主图中的 edge 一一对应,格式或对应关系错误会直接失败。它不是通用“运行时随便加任务”的接口,更不能替代上层生成器的项目分析。规范和示例应查 Ninja Manual 的 Dynamic Dependencies。
restat 解决命令运行但输出没变的问题
代码生成器可能每次都运行,但生成内容没有变化。如果输出 mtime 被无条件更新,下游会被级联重建。restat = 1 让 Ninja 在命令完成后重新检查输出;若输出没有实质变化,下游可保持干净:
rule generate
command = generator $in $out
restat = 1
build generated.h: generate schema.idlrestat 不是掩盖错误时间戳的开关。生成器仍应采用“内容变化才替换文件”的原子写入策略,并声明全部输出和 byproduct。若命令悄悄写了未声明文件,Ninja 无法建立正确依赖图。
并行度不是越大越快
Ninja 默认积极并行,但链接、大型代码生成、内存密集型编译或许可证受限工具可能不能同时开满。全局可用 -j 控制 job 数,局部应由生成文件声明 pool:
pool link_pool
depth = 2
rule link
command = c++ $in -o $out
pool = link_pool运行时还可限制负载:
ninja -j 8 -l 6-j 设得过高时,常见现象不是线性加速,而是内存耗尽、swap 抖动、磁盘队列上升或链接器被系统终止。团队应按 runner CPU、内存、磁盘和单任务峰值建立并行基线。pool 应由 CMake/Meson 或专门生成器维护,不能在生成后的 build.ninja 上手改,因为下一次 configure 会覆盖。
日志让 Ninja 记住命令和依赖历史
构建目录通常有两个关键文件:
.ninja_log 记录输出的命令执行时间和命令哈希,帮助判断命令变化。.ninja_deps 保存 depfile 或 MSVC include 扫描得到的依赖。
它们是可再生执行状态,不是源码资产。损坏时可在确认 build tree 可重建后清理,但不要把“删日志”当作日常排障第一步。先保留副本、运行 ninja -d explain 和 ninja -t deps,否则会丢失根因证据。
构建目录跨机器搬运风险很高:日志和生成文件可能保存绝对路径、工具链位置与环境探测结果。CI 缓存完整 Ninja build tree 时,cache key 至少要包含操作系统、架构、编译器、生成器、配置和关键依赖;更稳妥的基线是从上层描述重新生成,再只复用受控编译缓存。
用内置工具回答“为什么”和“将执行什么”
先从 target 和命令入手:
ninja -C build -t targets all
ninja -C build -t query app
ninja -C build -t commands apptargets 列出图中的 target,query 显示某个节点的输入输出关系,commands 展开构建它将执行的命令。需要查看图时:
ninja -C build -t graph app > graph.dot
dot -Tsvg graph.dot -o graph.svg大型图会非常庞大,应针对具体 target 生成,不要把全仓库图当作日常报告。检查依赖数据库:
ninja -C build -t deps app
ninja -C build -d explain app分析耗时:
ninja -C build -d stats app
ninja -C build -t browse appbrowse 依赖 Python 并启动本地浏览服务,受控环境要确认端口和访问边界。可用 tool 及参数以 ninja -t list 和 Ninja Manual为准,不要把第三方速查表当成版本事实。
不要直接修生成后的 build.ninja
当 build.ninja 顶部标明由 CMake、Meson、GN 或其他工具生成时,真正的修复入口在上层描述。手改生成文件有三个后果:
下一次重生成覆盖修改。本地临时成功,CI 仍按上层事实失败。代码评审看不到真正工程模型的变化。
先找生成器规则和重生成入口:
ninja -C build -t query build.ninja
ninja -C build -d explainCMake 项目修改 CMakeLists.txt、preset 或 toolchain;Meson 项目修改 meson.build、option 或 cross file。只有开发自有生成器时,才把 build.ninja 格式作为输出协议维护,并为生成器添加快照测试、最小构建和增量测试。
与 CMake 和 Meson 衔接时守住层级
CMake 选择 Ninja generator:
cmake -S . -B build -G Ninja
cmake --build build --parallel
ctest --test-dir build --output-on-failureCMake 负责 target、toolchain、package、测试和安装模型;Ninja 负责执行生成后的 edge。需要详细命令时使用 cmake --build build --verbose,需要解释 dirty 原因时进入 build directory 运行 ninja -d explain。
Meson 默认常以 Ninja 为 backend:
meson setup build
meson compile -C build
meson test -C build --print-errorlogs配置变化通过 meson configure 和 meson setup --reconfigure 处理,不在 build.ninja 里打补丁。无论上层是谁,团队脚本都应优先调用上层稳定入口,并把直接 Ninja 命令定位为诊断工具。
增量构建错了,按四种现象取证
输入变了却没有重建
运行:
ninja -C build -t query <target>
ninja -C build -t deps <target>
ninja -C build -d explain <target>检查输入是否出现在显式边、隐式边或 depfile 数据中。常见根因是生成器漏边、depfile 没产生、路径规范化不一致或生成代码未声明 byproduct。修复上层模型后删除可再生 build tree 做一次干净验证。
什么都没改却总是重建
先看 -d explain,再比较命令是否每次变化、输入输出时间戳是否倒退、生成器是否无条件重写文件。常见根因包括嵌入随机值或当前时间、路径顺序不稳定、生成器每次触碰输出、系统时间不同步。restat 只能减少内容未变的级联重建,不能解决非确定输出。
串行成功、并行失败
ninja -C build -j 1 -v
ninja -C build -j 8 -v若只有并行失败,优先怀疑缺少依赖边、多个命令写同一输出、临时文件名冲突或工具本身不支持并发。把 -j 1 固化为团队方案只是隐藏图错误;应补边、隔离输出或给确实受限的规则配置 pool。
干净构建成功、增量构建失败
这通常说明声明的输入输出不完整。保存两次命令和 -d explain 结果,检查代码生成、头文件依赖和重生成规则。自动化门禁应同时测试 clean build 和“只改一个代表性输入”的 incremental build。
清理要区分 target、生成状态和源码
查看将删除什么:
ninja -C build -t clean -n执行清理:
ninja -C build -t clean生成器规则默认可能不在普通 clean 范围内;-g 等选项的具体含义应以当前 ninja -t clean -h 为准。最彻底的清理是删除明确的 out-of-source build directory,但必须先确认目录可再生且路径正确,不能对工作区根目录执行模糊递归删除。
清理 .ninja_log 或 .ninja_deps 前先保存诊断证据。若只为验证干净构建,使用新的 build directory 比在故障现场反复删除更可靠。
代理、权限和凭证不应进入命令行
Ninja 自身通常不下载依赖,但它执行的命令可能调用包管理器、代码生成服务、远端编译器或签名工具。风险集中在生成图和日志:命令行可能被 -v、.ninja_log、CI 日志或进程列表观察到。
凭证应通过短期秘密文件、受控凭证助手或进程环境注入,并确保生成器不会把值展开写进 build.ninja。代理和企业 CA 由实际下载工具配置;不要为了让构建通过而关闭 TLS 校验。分享 build.ninja、verbose 命令和 graph 前,要清理内部仓库地址、用户目录、工具链路径和 token。
构建目录的写权限也要受控。多个 job 共享同一 build directory、不同用户交替执行或容器挂载 UID 不一致,会产生无法覆盖输出、日志损坏和偶发竞态。一个 build tree 应对应一套配置、一个工具链和清晰的所有者。
CI 既要测速度,也要测正确性
一条可信的 Ninja 链路至少包含:
ninja --version
cmake --preset ci
cmake --build --preset ci --verbose
ctest --preset ci再增加代表性增量验证:先构建,修改或 touch 一个受控输入,运行 ninja -d explain,确认只重建预期 target。周期性运行空缓存 clean build,避免热 build tree 掩盖缺失依赖。
建议保留总耗时、关键路径耗时、cache 命中率、峰值内存、失败命令和退出码。不要只优化 ninja 自身启动时间;真正瓶颈常在编译器、链接器、代码生成、I/O 或远端依赖。
升级和回滚要由生成器组合负责
升级 Ninja 前记录:
ninja --version
ninja -C build -t targets all > targets.before.txt
ninja -C build -t commands app > commands.before.txt使用新 Ninja 和全新 build directory 重新生成,比较 clean build、增量 build、并行 build、测试和产物。Ninja 版本不能脱离 CMake/Meson 版本和生成图语法单独评估;一套组合通过后再进入团队镜像或工具链包。
回滚应恢复 Ninja、上层生成器、preset/toolchain 与构建镜像的兼容组合,然后重建 build tree。仅替换 ninja.exe 并继续使用新版本生成的状态文件,无法证明回滚完整。
快速执行会放大错误依赖图
Ninja 不会推断生成器漏掉的业务关系。团队必须把 clean、incremental、parallel 三种验证都纳入构建契约;只有 clean build 通过,不能证明依赖图正确。
时间戳仍是核心判断依据
网络文件系统、容器挂载、双系统工作区和时钟漂移可能让 mtime 顺序异常。发现输出比输入“来自未来”时,先检查文件系统和时钟,再讨论缓存。对内容寻址要求更高的场景,需要编译缓存或远端执行系统补充,Ninja 本身不是内容寻址构建系统。
并行资源需要容量模型
CPU 核数不是唯一并行依据。一个链接 job 可能消耗数 GB 内存,代码生成器可能占用许可证,磁盘可能先饱和。用监控数据确定 -j 和 pool depth,并为 OOM、超时和磁盘队列建立判断阈值。
生成文件也是敏感资产
build.ninja 可能暴露源码目录、内部 SDK、编译宏、私有 endpoint 和安全开关。它通常不应提交,也不应无审查上传工单。日志保留要兼顾排障价值和数据边界。
构建目录不是跨环境制品
把整个 build tree 从开发机复制到 CI,可能携带绝对路径、错误 ABI 和旧依赖状态。需要交付的是可验证产物、安装树和构建证据;需要加速的是受控缓存,而不是未经建模的工作目录快照。
生成器所有权必须明确
build.ninja 出错时,负责修复的是 CMake/Meson 描述 owner 或自研生成器 owner,而不是让业务开发者维护生成文件补丁。团队要记录生成入口、支持组合、升级负责人和故障升级路径。
Ninja 命令来源、版本以及上层生成器组合可查询。大型项目的 build.ninja 由 CMake、Meson 或专用生成器维护。rule、build edge、显式/隐式/order-only 依赖的语义没有混淆。
C/C++ 头文件依赖通过 depfile 或 MSVC 依赖扫描进入图。动态依赖只在确有需要时使用 dyndep,并由生成器验证格式。代码生成器声明全部输入、输出和 byproduct,内容未变时避免触碰输出。
并行度与 pool 依据 CPU、内存、I/O 和许可证容量设定。query、commands、deps、graph 和 -d explain 能支撑故障取证。clean、incremental、parallel 三类构建都进入 CI 验证。
构建目录不跨不兼容平台、工具链和配置复用。verbose 日志、生成文件和图在共享前会清理路径、私有地址和凭证。升级与回滚按 Ninja、生成器、toolchain 的完整组合执行。
行为不符合预期时查哪一层
Ninja 的语法、dirty 判断、depfile、dyndep、pool、restat 和 tool 行为应从 Ninja Manual核对,再与 ninja --version、-d explain、-t query 和真实生成文件对照。二进制来源与版本变化从 官方 Releases核对。
如果错误来自 target、toolchain、依赖发现或安装模型,应回到 CMake/Meson 等上层描述;如果真实编译或链接命令失败,应继续下钻到编译器、链接器和 SDK。只有先确定失败发生在哪一层,Ninja 的速度才会成为效率,而不是更快地产生错误答案。
