make 并行构建偶发失败 flaky 速查
更新时间:2026-10-24。FAQ 速查页,面向用
make -j并行构建的同学。并行构建偶发失败(flaky build)指同一份代码、同一条命令,make -j1串行永远通过、make -j8并行却隔三差五翻车,报错位置还每次不一样。这不是编译器抽风,而是 Makefile 里没写全的依赖关系被并行度放大成了真实竞态:串行时恰好「先干的活替后干的活铺了路」,并行时两个活同时跑就撞车。它和 make 构建卡死无响应速查 同属调度层疾病,但一个是「挂住不动」,一个是「时好时坏」。本篇给出一套以「串行复现对照」为核心的定位法。
本文要回答的问题
- 为什么
make -j1永远成功、make -j8却随机失败? - 「依赖声明不全」在并行下具体是怎么炸的?
- 同一个输出文件被两个目标写会发生什么?为什么有时候还不报错?
- 怎么用对照实验把「偶发」变成「必现」来定位?
.NOTPARALLEL和order-only依赖各管什么场景?
一、报错原文与典型现象
典型现场(工程路径以 /proj/demo 占位):同一 commit,串行必过、并行偶挂,且每次挂在不同地方:
$ make clean && make -j1
g++ -O2 -c src/parser.cpp -o build/parser.o
g++ build/parser.o build/gen/grammar.o -o bin/app # 成功
$ make clean && make -j8
g++ -O2 -c src/parser.cpp -o build/parser.o
g++: error: build/gen/grammar.cpp: No such file or directory
make: *** [Makefile:31: build/gen/grammar.o] Error 1另一种高发长相是输出文件被撞烂,报的错误五花八门:
ar: lib/libcore.a: file format not recognized
# 或
/usr/bin/ld: build/util.o: file not recognized: file truncated读懂现场抓住两件东西:失败是否可串行复现(make clean && make -j1 连跑三次全过,基本锁定并行竞态)与失败点是否漂移(每次挂在不同文件,是竞态的典型指纹;固定挂在同一处更可能是真 bug)。竞态病的本质是「图上少画了一条依赖边」,make 按错误图调度,碰巧对了千百次,错一次就炸。
二、病因处置速查表
| 报错现象 / 场景 | 可能原因 | 快速验证 | 处置 |
|---|---|---|---|
| -j1 必过、-jN 偶挂且挂点漂移 | 依赖声明不全,被并行放大 | 串行连跑三次全过即可初步锁定 | 用下述二分法找出缺失的依赖边并补上 |
| 报「生成的头文件/源码 No such file」 | 生成物与消费方之间没声明依赖 | 看报错文件是否由自定义命令生成 | 给消费方显式依赖生成物;生成规则用真目标而非伪目标 |
| 静态库/输出文件损坏、format not recognized | 两个目标同时写同一文件 | make -n 找所有写该文件的规则 | 同一输出只允许一个规则产生;多产物用模式规则分组 |
| 偶发报「目录不存在」mkdir 失败 | 多个规则各自 mkdir 同一目录撞车 | 报错处是 mkdir 或输出到不存在的目录 | 目录用 order-only 依赖:| build/ 配一条 mkdir 规则 |
| 改了文件却不重编或过度重编 | 时间戳精度与 NFS/时钟偏差 | stat 看 mtime;跨机挂载时高发 | 依赖关系写全;共享存储场景避免依赖秒级以下时间戳 |
| 顶层 -j 与子 make 打架、jobserver 警告 | 递归 make 没用 $(MAKE) 传递 jobserver | 出现 jobserver unavailable 警告 | 递归调用一律写 $(MAKE) 而不是裸 make |
加了 .NOTPARALLEL 就好了 | 实锤竞态但没找到病根 | 加后并行失效、串行变慢 | .NOTPARALLEL 只当创可贴定位对照,不作正式修复 |
| 多产物规则(如 yacc 出 .c 和 .h)偶发缺失 | 普通多目标规则被 make 拆开调度 | 一条命令产多个文件、各自有规则 | GNU make 4.3+ 用分组目标 &:;老版本用 stamp 文件中转 |
三、排查决策图
下面这张图按「竞态发生在哪两个节点之间」分岔:红是报错入口与命中的根因,蓝是排查动作,绿是验证通过,橙是修复动作,紫是防复发沉淀。
读这张图抓一条主线:先做一次串行对照确认是竞态(串行连过三次、并行拉满能复现),再按失败点形态分流——指向生成物就补依赖边、输出文件损坏就查重复写入、目录或时间戳问题就改 order-only 依赖。修完后把 -j 拉满连跑二十次是验收标准,跑三次不算数。
四、逐条排查展开
依赖声明不全是第一大病根,占并行 flaky 的八成。 最经典的形态是代码生成:
# 错误:grammar.o 依赖 grammar.cpp,但没人声明它依赖 gen_parser 这一步
build/gen/grammar.o: build/gen/grammar.cpp
g++ -c $< -o $@
build/gen/grammar.cpp: tools/gen.py
python3 tools/gen.py > $@串行时 make 恰好先跑生成规则,一切正常;并行时 grammar.o 的编译和 grammar.cpp 的生成同时启动,编译器打开一个不存在的文件直接报错。修法就是把依赖边画全:凡是「A 的产物是 B 的输入」,图上必须有 A→B 的边,没有例外。
同一输出被两个目标写是第二大病根。 典型是静态库:两个规则都 ar rcs libcore.a xxx.o,并行时两个 ar 进程交叉写同一个文件,产物损坏报 file format not recognized。规则:一个输出文件只允许由一条规则产生;一条命令产多个文件(yacc 同时出 .c 和 .h)时,GNU make 4.3 起用分组目标:
parser.c parser.h &: parser.y
yacc -d parser.y老版本 make 没有 &:,用 stamp 文件中转:parser.stamp 规则真正执行命令并 touch,.c/.h 都依赖这个 stamp。
mkdir 竞态与 order-only 依赖。 多条规则各自写着 mkdir -p build && ...,并行时两个 mkdir 撞车虽然多数情况下 -p 容忍,但与「目录创建到一半被另一进程看到」的组合仍会偶发失败。标准解法是把目录做成 order-only 依赖:
build/parser.o: src/parser.cpp | build
...
build:
mkdir -p build竖线后的 order-only 依赖保证目录先存在,且目录 mtime 变化不会触发无意义重编。
串行复现对照法是把「偶发」变「必现」的核心武器。 步骤:① make clean && make -j1 连跑三次全过,确认串行干净;② make clean && make -j$(nproc) 反复跑直到抓到失败;③ 拿到失败点后二分:把疑似缺失依赖的规则的依赖临时删掉/加上,观察失败率变化;④ 还可以用 --shuffle(GNU make 4.4+)让 make 随机打乱同层调度顺序,不用真并行也能把隐藏的顺序假设逼出来——make --shuffle -j1 是低配机器上复现竞态的神器。
递归 make 的 jobserver 打架。 顶层 make -j8、规则里写裸 make -C subdir,子 make 拿不到 jobserver 令牌,会打出 -j8 forced in submake: resetting jobserver mode 或 jobserver unavailable 警告,实际并发失控,既放大竞态又可能在低内存机器上触发 make 编译内存超限速查 里的 OOM。修法:递归调用一律写 $(MAKE),让它继承 jobserver 管道。
.NOTPARALLEL 是对照工具不是修复方案。 加上它并行失效、问题消失,证明确实是竞态——但它把构建打回串行,CI 时长翻倍。正确用法是定位期临时加、找到病根补齐依赖后删掉。
五、规范做法与预防
- 依赖图评审纪律:新增自定义命令/代码生成规则时,review 必查「产物被谁消费、依赖边画全没有」;多产物命令必须用
&:或 stamp。 - CI 保留一条并行拉满的分支:日常 CI 用高并发构建,flaky 会在 CI 上先暴露而不是在开发者机器上;同时保留
--shuffle夜间任务专门抖竞态。 - 输出归属唯一写进 Makefile 规范:一个文件一条规则;静态库统一由一条归档规则产出。
- 目录统一 order-only:所有「输出到某目录」的规则挂
| dir,杜绝散落各处的mkdir -p。 - 递归一律
$(MAKE):写进模板和 review 清单,顺带保住 jobserver 与内存预算。
这一篇在知识体系里的位置
本篇是 Makefile/CMake 排错速查簇的调度竞态篇,管「串行必过、并行偶挂」这一类病。配置层的缓存病看本批的 CMakeCache 陈旧缓存速查,configure 语法层看 CMakeLists 语法错误速查;调度层另一个极端「挂住不动」看 make 构建卡死无响应速查;并行放大后的资源病看 make 编译内存超限速查;编译链接期其余岔路见 fatal error 头文件找不到速查、undefined reference 链接错误速查、multiple definition 重复定义速查、make 编译器内部崩溃速查、构建 Permission denied 速查、make: No rule to make target 速查、make 构建输出乱码速查、Makefile missing separator 速查、CMake 版本不兼容速查、CMake 找不到编译器速查、CMake 找不到依赖包速查、CMake FetchContent 拉取失败速查、error: cannot convert 类型不匹配速查。「串行对照 + 依赖边画全」这对方法对一切构建系统的 flaky 通用(Bazel、ninja 同理会报 missing dependency)。
一句话总结
make 并行构建偶发失败先做串行对照——make -j1 连跑三次全过即锁定竞态,再按失败点形态分流:指向生成物就补齐「产物到消费方」的依赖边,输出文件损坏就查同一文件被两条规则写、多产物命令改 &: 分组目标,目录问题统一改 order-only 依赖;递归 make 一律 $(MAKE) 保 jobserver,.NOTPARALLEL 只当对照不作修复;验收标准是 -j 拉满连跑二十次全绿,长期靠依赖图评审与 CI 高并发分支把 flaky 拦在合并前。