2313 字
约 7 分钟
15
S06-A26 · 模块并行构建:为什么"开了并行还是 12 分钟"

一个 40 模块的工程开了 -T 4C(四倍核数并行),构建时间从 12 分钟变成 11 分钟。团队以为是"并行没效果",其实是三个模块"卡在同一个共享资源":同一个本地仓库锁、同一个端口、以及"后置的聚合模块"必须等全部完成。

模块并行的收益,取决于三件事:依赖图能不能排出宽度、有没有"全局串行点"、以及单个模块的耗时是否均匀.

"并行"这件事有个"会计口径"值得先建立:"总耗时 ≈ 关键路径 + 排队时间"——而"加核"只能压"排队时间",压不动"关键路径"。于是"12 分钟变 11 分钟"这类结果,其实在"提示你":你的时间已经"几乎全在关键路径上",而此时"任何"关于并行的调参"都只是"小数点后的优化"。

1. "并行度"实际由什么决定

因素 影响
依赖图宽度(同时可跑的模块数) 决定"能不能并行"
全局串行点(锁/端口/聚合模块) 决定"并行了多少"
最慢模块(关键路径) 决定"下限"

第三项是"硬下限":"一个模块要 4 分钟"时,整条链路"至少 4 分钟"——**并行只能把"其余 36 分钟"压到"4 分钟", 不可能更低.

"关键路径"可以"用日志时间戳算",而不必"读依赖图":

// 三步"算关键路径"(构建日志里就有)
1 每个模块记"开始/结束时间"(Maven/Gradle 都有时间戳)
2 按"结束时间"倒序,取"最后完成的 3 个"
3 看"它们之间是否互相依赖"——是,就是"链";否,就是"并行度不够"
// 判据:这几项"总耗时"占整体的比例 = 你的"收益上限"

2. "三个全局串行点"

串行点 表现
共享仓库/缓存锁 越并行越"排队"
固定端口(测试容器) 第二个实例"起不来"
聚合模块(-am/install) 尾部"全等"

第一项最隐蔽:"本地仓库"在并发写时会"加锁"——**于是"4 个线程"实际"排队装依赖".

"共享仓库锁"这条最"反直觉",因为"它看起来是读操作":

// 三种"并发装依赖"的冲突
1 两个模块"同时下载同一个依赖到本地仓库"→ 加锁,排队
2 一个模块"install 到本地仓库"、另一个"读"→ 读到"半成品"
3 并发"写同一个 pom/metadata"→ 文件损坏(偶发)
// 判据:并行后"下载阶段耗时反而上升" → 就是仓库锁

"聚合模块"是"结构性"的串行点,而它"常常被当成"正常结构":**"一个 -am/install 的父工程"最后"把全部模块"聚合起来"——于是"前面 39 个模块再并行,最后都要"等这个聚合"。而它的耗时"往往只有十几秒",却"把前面的并行收益"卡在"尾巴"上——解法是"把聚合拆成"独立的后置步骤",或"用 -DskipTests 让它更轻"。

3. "关键路径"怎么找

方法 说明
看"总耗时最长的模块" 最直接
看"依赖链最长的那条" 结构性
看"最后完成的三个模块" 实操(日志时间戳)

第三项是"最省事"的:"每个模块的开始/结束时间"排序,末尾那几个"就是关键路径"——而"给它们减负"比"给整体加核"有效.

"拆最慢模块"是最有效的动作,而它"不需要动依赖图":

// 三步"拆"(按"可独立"的部分切)
1 找到"它为什么慢":代码生成?聚合?测试?
2 把"生成/聚合"拆成"独立模块"(独立输入输出)
3 让"原模块"只依赖"拆出来的产物"
// 判据:拆完后"最慢模块"占比 < 30%,并行才有意义

4. "均匀度"的三种影响

情形 结果
均匀(都 30s) 并行接近线性
长尾(一个 4 分钟) 并行"被一个拖住"
双峰(两个都很长) 取决于"能否同时跑"

第二项在"真实工程"里最常见:"代码生成模块"或"聚合模块"天然重——于是"拆它"比"加核"有效.

"隐藏依赖"在"并行"时才暴露,而它有两个"高发位置":

// 两处"隐藏依赖"(串行时"恰好没事")
1 输出目录:多个模块写 target/generated-sources(互相覆盖)
2 端口/容器:测试用固定端口(第二个实例 bind 失败)
// 判据:并发跑三次,若有"偶发失败" → 先查这两处

"并发跑三次"这个自检很值:"偶发失败"比"稳定失败"更难定位,而"并发"恰好是"偶发的放大器"——"三次里失败一次"就说明"存在共享资源冲突",此时"加核"只会"让失败更频繁"。而"并行度调小到'不冲突'"这种"绕过去"的做法,是"把并行当成'运气'",而不是"基础设施"。

5. "并行的三个前提"

前提 检查
模块间"无隐藏依赖"(文件/端口) 看"并发失败日志"
每个模块"可独立构建" 单模块构建一次
输出"不写同一目录" 查"产物路径"

第一项是"并行的真正门槛":"两个模块都写 target/generated"——**于是"并行时互相覆盖",而"串行时恰好没事".

"并行度"不是"越大越好",它有"两个上限":

// 两个"上限"
1 依赖图宽度:同时可跑的模块数(可能只有 3)
2 机器核数:`-T 4C` 在"2 核 CI"上只会"互相抢"
// 判据:并行度 = min(宽度, 核数) —— 超过"宽度"只是"多排队"

6. 排错:五种"并行无效"

现象 成因 处理方向
加核没变快 关键路径 拆"最慢模块"
偶发"文件被占用" 共享输出目录 改"独立路径"
依赖"重复下载" 仓库锁 预下载/镜像
测试"端口冲突" 固定端口 随机端口
尾部"全等" 聚合模块 拆聚合/延后

7. 边界:什么情况"串行更快"

场景 为什么 做法
模块很少(< 5) 并行开销占比高 串行
单模块"占 80% 时间" 并行只压 20% 先拆模块
强"共享状态"(IDE/端口/锁) 并行=排队 串行
CI 机器"核少"(2 核) 并行度=1 串行

判断口径一句话:"最慢的那个模块,占了多少时间"。占 60% 以上,"并行"的收益上限就是 40%——此时该"拆它",而不是"加核";占 20% 以下,并行才真正"值得调".

8. 自测

  1. 模块并行的收益由哪三件事决定?为什么"最慢模块"是硬下限?
  2. 三个"全局串行点"分别是什么?哪一个"越并行越排队"?
  3. "看最后完成的三个模块"为什么"最省事"?它对应"关键路径"的哪一层?
  4. "双峰分布"为什么"取决于能否同时跑"?
  5. "两个模块都写 target/generated"会造成什么?为什么"串行时没事"?
  6. 判断"该并行还是该拆模块"的那个问题是什么?

补一句"决策"经验:"构建时间"的优化顺序应当是"拆关键路径 → 去串行点 → 提并行度 → 加机器"——而现实里"顺序几乎总是反的":先加机器,因为"加机器"只需要"改一个数字"。而"改一个数字"的收益,往往"连一次拆模块的十分之一"都不到;但"拆模块"要"改代码结构",于是"没人愿意先做"。

而"先做难的那一步"这件事,是"构建优化"里唯一"真能省时间"的部分。

而"真能省时间"的判断标准很朴素:"改完之后,最慢模块的占比降下来了没有"。

否则"加了多少核"都只是"安慰剂"。

而"安慰剂"在 CI 账单上是看得见的。

9. 一句话总结

并行的上限由"关键路径"决定,而不由"核数"决定:最慢模块占 60% 时,加核最多只能拿到剩下的 40%。所以"并行没效果"的正确诊断顺序是:先量"最慢模块占比" → 再看"全局串行点" → 最后才看"并行度";而"拆最慢的那个模块",几乎总比"再多给两核"有效。

S06-A26 · 模块并行构建:为什么"开了并行还是 12 分钟"
https://www.clxhxhhr.top/posts/6716/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。