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:351build_log.cc:60
追加式二进制 deps 日志 编译器发现的头文件依赖存成紧凑二进制,启动时一次读完 deps_log.h:29
字符串驻留 + 零拷贝 路径用自定义哈希表去重,StringPiece 借用而非拷贝 hash_map.h:42string_piece.h
re2c 生成词法器 manifest 与 depfile 用表驱动 DFA 扫描,而非手写 lexer.in.ccconfigure.py:513
关键路径优先调度 先调度最长路径上的边,缩短总墙钟时间 build.cc:472graph.h:402
restat 短路 命令重写了输出但内容没变时,剪掉下游重建 build.cc:955
延迟/省略工作 默认 -j 懒计算;退出时不析构主结构 ninja.cc:1711ninja.cc:1833

一个常见误解:Ninja 并没有用 mmap。它的”快文件 IO”是把整个 manifest 一次性读进 std::stringparser.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 / 编排 NinjaMainmainreal_main ninja.cc:90
词法 Lexer lexer.h:27
语法 ParserManifestParser parser.h:26manifest_parser.h:41
变量求值 EvalStringBindingEnvRule eval_env.h:35
构建图 NodeEdge graph.h:42graph.h:175
全局状态 StatePool state.h:95state.h:40
脏检查 DependencyScanImplicitDepLoader graph.h:333graph.h:286
调度 Plan build.h:42
执行 BuilderCommandRunner build.h:204build.h:154
子进程 SubprocessSubprocessSet subprocess.h:38
日志 BuildLogDepsLog build_log.h:45deps_log.h:68
磁盘抽象 DiskInterfaceRealDiskInterface 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、写日志、解锁下游

两个关键回路值得单独记住:

  1. 重建循环(cycle 1..100):Ninja 第一件事是检查 build.ninja 自己是否过时(比如 CMakeLists.txt 改了)。若是,先把它重建出来,然后 continue 用新 manifest 从头再来。这就是为什么 real_main 外层是个 for 循环(ninja.cc:1870)。

  2. 构建主循环(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:955Plan::CleanNode build.cc:272)。

5.3 依赖发现:Depfile 与 Deps Log

C/C++ 的头文件依赖在编译前是未知的——必须等编译器边编译边吐出来。Ninja 支持两种采集方式(依赖发现):

  • deps = gcc:读编译器生成的 .d depfile(Makefile 语法),解析后删掉临时文件
  • deps = msvc:解析 MSVC /showIncludes 打印到 stdout 的头文件行。

采集到的隐式依赖不会留在文本 depfile 里反复解析,而是写进紧凑的二进制 .ninja_depsDepsLog)。这个文件是 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 限制其内部并发;内置 console pool 深度为 1,用于需要独占终端、串行运行的命令(state.cc:64)。

POSIX 下子进程用 posix_spawn 起一个 /bin/sh -c <command>,stdout/stderr 合并到一根 pipe,SubprocessSetppoll/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、动态依赖

本站访问量

This site uses Just the Docs, a documentation theme for Jekyll.