Ninja 架构概述
1. Ninja 是什么
Ninja 是一个专注于速度的小型构建系统。它的角色介于”构建工具”和”构建后端”之间:你通常不直接手写它的输入文件 build.ninja,而是用 CMake、GN、Meson 等生成器生成它,然后由 Ninja 负责真正地、尽可能快地执行增量构建。
官方手册(doc/manual.asciidoc:12-24)把这个定位讲得很清楚:
“Its distinguishing goal is to be fast. … Where other build systems are high-level languages, Ninja aims to be an assembler.”
一句话总结 Ninja 的设计取舍(doc/manual.asciidoc:67):
“when convenience and speed are in conflict, prefer speed.”(便利与速度冲突时,选速度。)
这条原则贯穿了整个代码库——从内存里的构建图布局,到磁盘上的二进制日志格式,再到 real_main 里”宁可 exit() 也不做析构清理”的细节。
2. 为什么快:把”做决策”上移
构建系统慢,通常慢在做决策:判断平台、展开条件、计算规则、扫描整棵依赖树。Ninja 的核心思路是把这些慢操作全部交给生成器一次性算好,自己只保留一份”扁平、无分支”的描述文件。手册的非目标列表(doc/manual.asciidoc:82)直说:”Making decisions is slow.”
落到代码里,”快”由一组具体设计共同支撑:
| 设计 | 机制 | 代码位置 |
|---|---|---|
| 命令哈希脏检查 | 不只看 mtime,命令行变了也要重建(改编译选项必触发) | graph.cc:351、build_log.cc:60 |
| 追加式二进制 deps 日志 | 编译器发现的头文件依赖存成紧凑二进制,启动时一次读完 | deps_log.h:29 |
| 字符串驻留 + 零拷贝 | 路径用自定义哈希表去重,StringPiece 借用而非拷贝 | hash_map.h:42、string_piece.h |
| re2c 生成词法器 | manifest 与 depfile 用表驱动 DFA 扫描,而非手写 | lexer.in.cc、configure.py:513 |
| 关键路径优先调度 | 先调度最长路径上的边,缩短总墙钟时间 | build.cc:472、graph.h:402 |
restat 短路 | 命令重写了输出但内容没变时,剪掉下游重建 | build.cc:955 |
| 延迟/省略工作 | 默认 -j 懒计算;退出时不析构主结构 | ninja.cc:1711、ninja.cc:1833 |
一个常见误解:Ninja 并没有用 mmap。它的”快文件 IO”是把整个 manifest 一次性读进
std::string(parser.cc:27)再用StringPiece零拷贝扫描,二进制日志也是启动时顺序fread全部读入内存。
3. 整体架构
Ninja 整个 CLI 都在一个文件 ninja.cc 里(约 1900 行),但内部清晰地分成若干层。数据流是:manifest 文本 → 词法/语法分析 → State(构建图)→ 脏检查 → Plan(调度)→ Builder/CommandRunner → 子进程 → 日志,所有磁盘访问都经过 DiskInterface 这层抽象。
┌────────────────────────────────────────────────────────────┐
│ CLI / 编排层 (ninja.cc) │
│ main → real_main → ReadFlags → 加载 manifest → RunBuild │
│ NinjaMain 持有所有核心数据结构 + 一堆子命令工具 (-t graph...) │
├────────────────────────────────────────────────────────────┤
│ 解析层 (parsing) │
│ Lexer (re2c) → Parser → ManifestParser │
│ EvalString / BindingEnv / Rule 变量求值与作用域 │
├────────────────────────────────────────────────────────────┤
│ 构建图模型 (graph model) │
│ State: 路径→Node 哈希表、edges_、pools_、根作用域 bindings_ │
│ Node (文件) ── in_edge/out_edges ── Edge (一条构建命令) │
├────────────────────────────────────────────────────────────┤
│ 脏检查 (dirty analysis) │
│ DependencyScan: mtime 比较 + 命令哈希 + restat │
│ ImplicitDepLoader: 从 depfile / deps log 回填隐式依赖 │
├────────────────────────────────────────────────────────────┤
│ 调度与执行 (build / exec) │
│ Plan: want_ 集合 + ready_ 优先队列 (关键路径权重) │
│ Builder: 主循环;CommandRunner: 启动/回收命令 │
│ Pool: 并发度限制 (console pool / 自定义 pool) │
├────────────────────────────────────────────────────────────┤
│ 平台层 (platform) │
│ Subprocess / SubprocessSet (posix_spawn + ppoll/pselect) │
│ Jobserver (GNU make 协议) DiskInterface (stat/read/write) │
├────────────────────────────────────────────────────────────┤
│ 持久化 (logs) │
│ BuildLog (.ninja_log: 命令哈希+时间) │
│ DepsLog (.ninja_deps: 二进制隐式依赖) │
└────────────────────────────────────────────────────────────┘
各层的核心类与所在文件(详见 代码结构分析):
| 层 | 关键类 | 文件 |
|---|---|---|
| CLI / 编排 | NinjaMain、main、real_main | ninja.cc:90 |
| 词法 | Lexer | lexer.h:27 |
| 语法 | Parser、ManifestParser | parser.h:26、manifest_parser.h:41 |
| 变量求值 | EvalString、BindingEnv、Rule | eval_env.h:35 |
| 构建图 | Node、Edge | graph.h:42、graph.h:175 |
| 全局状态 | State、Pool | state.h:95、state.h:40 |
| 脏检查 | DependencyScan、ImplicitDepLoader | graph.h:333、graph.h:286 |
| 调度 | Plan | build.h:42 |
| 执行 | Builder、CommandRunner | build.h:204、build.h:154 |
| 子进程 | Subprocess、SubprocessSet | subprocess.h:38 |
| 日志 | BuildLog、DepsLog | build_log.h:45、deps_log.h:68 |
| 磁盘抽象 | DiskInterface、RealDiskInterface | disk_interface.h:45 |
4. 一次构建的完整生命周期
下面把 main() 到命令执行完整串一遍,每一步都给出代码锚点。详见 构建执行流程。
main (ninja.cc:1924)
└─ real_main (ninja.cc:1832)
├─ ReadFlags 解析 -j/-k/-l/-n/-t/-C ... (ninja.cc:1730)
├─ Status::factory 创建进度输出 (ninja.cc:1846)
└─ for cycle in 1..100: (ninja.cc:1870) ← 重建循环
├─ ManifestParser.Load(build.ninja) (ninja.cc:1877) ← 解析 → State
├─ OpenBuildLog / OpenDepsLog (ninja.cc:1890) ← 读日志
├─ RebuildManifest (ninja.cc:1897) ← 若 build.ninja 自身需重生成则 continue
└─ RunBuild (ninja.cc:1911)
├─ CollectTargetsFromArgs (ninja.cc:1636)
├─ Builder.AddTarget (build.cc:668)
│ ├─ scan_.RecomputeDirty (graph.cc:49) ← 脏检查
│ └─ plan_.AddTarget (build.cc:95) ← 填 want_ 集合
└─ Builder.Build (build.cc:699) ← 主循环
├─ plan_.PrepareQueue (build.cc:588) ← 关键路径 + 初始就绪边
└─ while more_to_do:
├─ CanRunMore (real_command_runner.cc:60) ← -j/-l/池 限流
├─ plan_.FindWork (build.cc:162) ← 取最高优先级就绪边
├─ StartEdge → StartCommand (build.cc:841) ← 启动子进程
├─ WaitForCommand... (real_command_runner.cc:103) ← 回收完成的子进程
└─ FinishCommand (build.cc:894) ← 抽取 deps、restat、写日志、解锁下游
两个关键回路值得单独记住:
-
重建循环(cycle 1..100):Ninja 第一件事是检查
build.ninja自己是否过时(比如CMakeLists.txt改了)。若是,先把它重建出来,然后continue用新 manifest 从头再来。这就是为什么real_main外层是个for循环(ninja.cc:1870)。 -
构建主循环(while more_to_do):典型的”取就绪边 → 启动 → 等完成 → 解锁下游”事件循环。完成一条边后,
Plan::EdgeFinished → NodeFinished → EdgeMaybeReady会把新就绪的下游边推进ready_队列(build.cc:202)。
5. 关键子系统
5.1 构建图:Node 与 Edge
整张构建图就是两种对象互相指:Node 是文件,Edge 是一条把若干输入变成若干输出的命令(构建图模型)。
foo.c ──┐ ┌── 每个 Node 只有一条 in_edge (谁生产它)
bar.h ──┼──► [Edge: cc] ──► foo.o Node 可有多条 out_edges (谁消费它)
(implicit)┘ └── Edge 的 inputs_ 按 显式→隐式→order-only 分段存储
关键不变量(在 state.cc:135 强制):一个输出文件只能由一条边生产,否则报 “multiple rules generate”。路径通过 State 的哈希表去重驻留,同一路径全程只有一个 Node 对象(state.cc:95)。
5.2 增量构建:脏检查 + 命令哈希
判断”要不要重建”靠两条线(增量构建):
- mtime 比较:输出比任一输入旧 → 脏(graph.cc:335)。
- 命令哈希:即使文件没动,只要命令行变了(比如改了
-O2),也要重建(graph.cc:351)。哈希存在.ninja_log里,用rapidhash计算。
restat = 1 是一个重要优化:有些命令即使重新运行,输出内容其实没变(典型如 touch 戳记文件、配置头)。restat 会在命令跑完后重新 stat,发现 mtime 没变就把”干净”状态沿图向下传播,剪掉本不必要的下游重建(build.cc:955、Plan::CleanNode build.cc:272)。
5.3 依赖发现:Depfile 与 Deps Log
C/C++ 的头文件依赖在编译前是未知的——必须等编译器边编译边吐出来。Ninja 支持两种采集方式(依赖发现):
deps = gcc:读编译器生成的.ddepfile(Makefile 语法),解析后删掉临时文件。deps = msvc:解析 MSVC/showIncludes打印到 stdout 的头文件行。
采集到的隐式依赖不会留在文本 depfile 里反复解析,而是写进紧凑的二进制 .ninja_deps(DepsLog)。这个文件是 Ninja 增量构建快的关键之一:手册提到 Chrome 的依赖文本累计达 90MB,用字符串驻留压成约 2MB 唯一字符串(deps_log.h:40)。下次构建时一次性读入,按 id 索引。
5.4 并行调度与子进程
Ninja 默认并行构建(子进程与并行调度)。并发度同时受三重约束:
-j N:最大并发命令数(默认 = 核数+2)。-l N:系统负载上限(max_load_average - GetLoadAverage())。- Pool:边可归属某个 pool,pool 的
depth限制其内部并发;内置consolepool 深度为 1,用于需要独占终端、串行运行的命令(state.cc:64)。
POSIX 下子进程用 posix_spawn 起一个 /bin/sh -c <command>,stdout/stderr 合并到一根 pipe,SubprocessSet 用 ppoll/pselect 同时等待所有子进程的 IO 和中断信号(subprocess-posix.cc:56)。Ninja 还能作为 GNU make 的 jobserver 客户端,与父级 make 共享一个全局 token 池来协调跨进程并发(jobserver.h)。
5.5 动态依赖 Dyndep
少数场景下,连”有哪些输入/输出”都要等前一个构建步骤跑完才知道(典型如 Fortran 模块:编译 .f90 后才知道它产生/依赖哪些 .mod)。dyndep 机制允许一条边引用一个在构建过程中生成的 dyndep 文件,运行时再把发现的隐式输入/输出并入构建图(dyndep.cc:30)。
6. 系列文档索引
| 文档 | 内容 |
|---|---|
| 代码结构分析 | 目录结构、configure.py/CMake/bootstrap 自举、re2c 词法器生成、测试体系 |
| 构建图模型 | Node/Edge/State/Pool、显式/隐式/order-only 依赖、字符串驻留与单生产者不变量 |
| Manifest 解析 | Lexer、Parser、EvalString/BindingEnv 变量求值、作用域链与 $in/$out 合成 |
| 构建执行流程 | Plan 调度、Builder 主循环、CommandRunner、命令完成回路 |
| 增量构建:脏检查、Build Log 与 Restat | mtime 脏检查、命令哈希、.ninja_log 格式、restat 短路、disk_interface |
| 依赖发现:Depfile 与 Deps Log | Depfile 解析、.ninja_deps 二进制格式、隐式依赖回填、MSVC /showIncludes |
| 子进程、并行调度与 Dyndep | subprocess/SubprocessSet、-j/-l/console pool、jobserver、动态依赖 |