子进程、并行调度与 Dyndep
构建执行流程 里 CommandRunner 把”启动/回收一条命令”抽象掉了。本文钻进这层抽象底下:命令到底怎么被起成子进程、怎么并行、怎么和外层 make 协调,以及 dyndep 这种”边构建边发现依赖”的机制。
1. Subprocess:一个异步子进程
struct Subprocess(subprocess.h:38)封装单个异步子进程,它完全被动——靠调用方在 fd 就绪时通知它。公有接口只有四个:Finish()、Done()、GetOutput()、析构(subprocess.h:43)。
POSIX 关键私有成员(subprocess.h:70):
std::string buf_; // 累积子进程的合并输出
int fd_; // 非 console: 该子进程专属 pipe 读端;console: -1
pid_t pid_; // 子进程 PID,回收后置 -1
bool use_console_;
1.1 Start:posix_spawn + /bin/sh
尽管头文件注释提到 fork/exec,POSIX 下实际用 posix_spawn(subprocess-posix.cc:56):
- 建 pipe:非 console 模式
pipe(output_pipe),读端给fd_,写端作为合并的 stdout+stderr 交给子进程(subprocess-posix.cc:58)。 - 重定向:通过
posix_spawn_file_actions,把 stdin 接/dev/null,dup2写端到 fd 1 和 2(subprocess-posix.cc:113)。 - 独立进程组:非 console 时加
POSIX_SPAWN_SETPGROUP(subprocess-posix.cc:103),让子进程进自己的进程组,使 Ctrl-C 不会直接打到它——而是由 Ninja 统一处理。 - 执行:固定
/bin/sh -c <command>(subprocess-posix.cc:132),所以命令字符串就是一段 shell。
console 模式不建 pipe、不进独立进程组——它直接共享 Ninja 的终端(典型如交互式命令、需要彩色输出的命令),靠 SIGCHLD + waitpid(WNOHANG) 检测结束。
1.2 输出收集与完成判定
OnPipeReady(subprocess-posix.cc:150)用 4KB 栈缓冲 read 追加到 buf_,读到 EOF 就关 fd 并置 fd_ = -1。Finish(subprocess-posix.cc:178)阻塞 waitpid 拿退出码;ParseExitStatus(subprocess-posix.cc:186)把被 SIGINT/SIGTERM/SIGHUP 杀死映射成 ExitInterrupted。Done()(subprocess-posix.cc:214):console 进程退出即完成,普通进程要消费完输出且 pipe 关闭才算完成。
2. SubprocessSet:一组子进程 + 信号
SubprocessSet(subprocess.h:101)围着一组子进程跑 ppoll/pselect 循环。running_ 是在跑的、finished_ 是已完成待回收的队列。WorkResult 枚举按优先级递增排序:NoWork < JobserverTokenAvailable < SubprocFinished < Interrupted(subprocess.h:111),同时发生时取最高。
2.1 信号处理
构造函数(subprocess-posix.cc:252)阻塞 INT/TERM/HUP/CHLD,并装上只设标志位的处理器(interrupted_、s_sigchld_received)。这些处理器只在 ppoll/pselect 等待期间生效——关键在于 ppoll 的最后一个参数传 old_mask_(subprocess-posix.cc:353),在等待期间原子地解除信号阻塞。这是经典的”自管道/信号安全等待”模式,避免信号在检查和等待之间丢失。
2.2 DoWork
DoWork(ppoll 路径 subprocess-posix.cc:322):为每个有效 fd 建 pollfd,可选追加 jobserver fd,ppoll 等待。返回后先查 console 子进程是否结束,处理待决中断(中断优先于 fd 事件以尽快退出),再遍历有 IO 事件的子进程调 OnPipeReady,完成的移入 finished_。NextFinished(subprocess-posix.cc:476)出队,Clear(subprocess-posix.cc:484)用 kill(-pid_, ...)(负 pid = 杀整个进程组)终止所有子进程。
Windows 实现在 subprocess-win32.cc:用
CreateProcess(不预置cmd /c,以支持超长命令行,subprocess-win32.cc:111)+ 命名管道 + IO completion port(GetQueuedCompletionStatus,subprocess-win32.cc:260),用SetConsoleCtrlHandler处理 Ctrl-C。
3. 并行模型:-j、-l 与 console pool
RealCommandRunner::CanRunMore(real_command_runner.cc:60)把三重约束揉在一起:
size_t subproc_number = subprocs_.running_.size() + subprocs_.finished_.size();
int64_t capacity = config_.parallelism - subproc_number; // -j 上限
if (jobserver_) capacity = INT_MAX; // jobserver 模式:交给 token 限流
if (config_.max_load_average > 0.0f) { // -l 负载上限
int load_capacity = config_.max_load_average - GetLoadAverage();
if (load_capacity < capacity) capacity = load_capacity;
}
if (capacity < 0) capacity = 0;
if (capacity == 0 && subprocs_.running_.empty()) capacity = 1; // 保证有进度
-j N:默认 = 核数 + 2(GuessParallelism,ninja.cc:256)。-l N:GetLoadAverage(util.cc:981 POSIX 用getloadavg)超过阈值就不再起新命令。- console pool:
kConsolePool深度为 1(state.cc:65),保证同时只有一条 console 命令在跑——它要独占终端。pool 的延迟/放出机制见 构建图模型 §5。
4. Jobserver:和外层 make 协调并发
当 Ninja 被一个更大的 make 构建调用(例如 make 里某个目标是 ninja ...),如果 make 和 ninja 各自按 -j N 起进程,总并发会爆炸。GNU make 的 jobserver 协议用一个跨进程共享的 token 池解决:谁要起一个 job 就先从池里取一个 token,完成后归还。
Jobserver(jobserver.h:23)实现了这个协议的客户端。一个硬约束(jobserver.h:185):所有取到的 token 都必须归还,即使 ninja 提前退出。
- 解析 MAKEFLAGS:
ParseMakeFlagsValue(jobserver.cc:69)从环境变量识别--jobserver-auth=,支持 POSIX FIFO(fifo:PATH)和 Windows 信号量两种模式。 - 取/还 token:POSIX 实现
PosixJobserverClient(jobserver-posix.cc)——TryAcquire首次返回一个 implicit slot(不读 pipe),之后read一字节算一个 token(jobserver-posix.cc:47);Release把收到的那个确切字节写回 pipe(jobserver-posix.cc:63,符合规范)。 - 集成进 Plan:每条边带一个
Jobserver::Slot job_slot_(graph.h:225)。Plan::FindWork(build.cc:162)取到就绪边后先TryAcquire,拿不到 token 就返回 nullptr(不开工,build.cc:170);Plan::EdgeFinished(build.cc:212)归还 token。 - 等待 token:jobserver 模式下
CanRunMore返回INT_MAX,并发完全由 token 限。WaitForCommandOrJobserverToken(real_command_runner.cc:103)把 FIFO 读端加入ppoll监控集,token 可用时返回jobserver_token_available,主循环据此continue回去重试FindWork(build.cc:806)。
5. Dyndep:构建期才知道的依赖
绝大多数依赖在解析 manifest 时就确定了。但有一类场景,连”有哪些输入/输出”都要等前一个构建步骤跑完才知道——经典例子是 Fortran 模块:编译某个 .f90 之前,无法静态知道它 use 了哪些模块、又会生成哪些 .mod。dyndep 机制处理这种动态依赖。
用法上,一条边声明 dyndep = some_file,some_file 本身是个构建产物,内容是一小段 Ninja 语法描述该边的额外隐式输入/输出。等 some_file 被构建出来后,Ninja 再解析它、把发现的依赖并入构建图。
5.1 数据结构与解析
Dyndeps(dyndep.h:30)保存单条边的动态信息:restat_、implicit_inputs_、implicit_outputs_。DyndepFile(dyndep.h:42)是 map<Edge*, Dyndeps>。
DyndepParser(dyndep_parser.cc:33)复用 Manifest 解析 的 Parser 基类,但语法严格受限:必须以 ninja_dyndep_version = 1 开头(dyndep_parser.cc:77,只接受 1.0),build 行只允许隐式输出/输入(|)和 restat 绑定,禁止显式输入、order-only 和额外显式输出(dyndep_parser.cc:128)。
5.2 加载与回填
DyndepLoader::LoadDyndeps(dyndep.cc:30)解析 dyndep 文件,对每条引用它的边调 UpdateEdge(dyndep.cc:81):
restat_为真则给边加restat=1绑定;- 追加 implicit outputs 到
outputs_、增implicit_outs_,并设各新输出的 in_edge(重复生产仍报 “multiple rules generate”); - 追加 implicit inputs——插入位置同样在
inputs_.end() - order_only_deps_(dyndep.cc:106),维持 构建图模型 §2.1 的三段不变量。
5.3 两个触发时机
Node::dyndep_pending_(graph.h:151)标记”期待 dyndep 但未加载”。加载在两处触发:
- 图构建期(graph.cc:138):脏检查
RecomputeNodeDirty中,若边的 dyndep 节点已就绪(无 in_edge 或其已 outputs_ready),立即LoadDyndeps。 - 构建运行期(build.cc:234):
Plan::NodeFinished中,若刚完成的节点dyndep_pending(),调builder_->LoadDyndeps加载这个”刚生成好的” dyndep 文件。
后者随后调 Plan::DyndepsLoaded(build.cc:327),它会重算受影响边的脏状态、把新可达的子图通过 AddSubTarget 加进计划、并对相关边调 EdgeMaybeReady 检查是否就绪——于是动态发现的依赖被无缝并入正在进行的构建。
6. 小结:行号速查
| 主题 | 位置 |
|---|---|
Subprocess(POSIX 成员) | subprocess.h:70 |
Start(posix_spawn / 进程组) | subprocess-posix.cc:56 |
SubprocessSet::DoWork(ppoll) | subprocess-posix.cc:322 |
| 信号安全等待(old_mask) | subprocess-posix.cc:353 |
CanRunMore(-j/-l/jobserver) | real_command_runner.cc:60 |
| console pool 深度 1 | state.cc:65 |
| Jobserver 协议 | jobserver.h:23 |
| token 取/还(FindWork/EdgeFinished) | build.cc:170 / build.cc:212 |
Dyndeps / DyndepFile | dyndep.h:30 / dyndep.h:42 |
UpdateEdge(回填) | dyndep.cc:81 |
| 两个触发点 | graph.cc:138 / build.cc:234 |
DyndepsLoaded | build.cc:327 |
至此,从 build.ninja 文本到并行执行命令的完整链路就走完了。回到 架构概述 可重温全景,或从 目录 跳转任意章节。