Rust Cargo 编译优化:利用 Link Wrapper + 文件锁实现「编译全并行,链接限并发」
七月 17, 2026 [rust, build] #rust #cargo #linker #flockRust Cargo 编译优化:利用 Link Wrapper + 文件锁实现「编译全并行,链接限并发」
背景
Rust 的 Cargo 在大型 Workspace 中通常能够很好地利用多核 CPU。
例如一台 32 核机器:
cargo
├── rustc crateA
├── rustc crateB
├── rustc crateC
├── rustc crateD
├── ...
前期编译阶段属于 CPU 密集型,大量 rustc 并行通常能够显著提升编译速度。
但是到了最后阶段:
crateA -> linker
crateB -> linker
crateC -> linker
Cargo 同样可能同时启动多个 Linker。
如果:
- Workspace 很大
- LTO 开启
- Debug 信息较多
- 可执行文件很多
那么每个 Linker 可能消耗数 GB 内存。
例如:
lld 4GB
lld 5GB
lld 4GB
----------------
13GB
很容易导致:
Out Of Memory
因此真正的问题其实不是:
Cargo 编译太快。
而是:
Cargo 对 Compile 和 Link 使用了相同的并发策略。
事实上,这两个阶段资源模型完全不同。
| 阶段 | 主要资源 |
|---|---|
| rustc 编译 | CPU |
| Link | 内存 |
理想情况下应该是:
Compile Jobs = 32
Link Jobs = 1
遗憾的是,目前 Cargo 并没有提供类似:
compile-jobs = 32
link-jobs = 1
这样的配置。
思路
Cargo 并不会自己完成链接。
调用关系如下:
Cargo
│
▼
rustc
│
▼
clang / lld / mold / ld
真正消耗大量内存的是:
clang
lld
mold
ld
因此最自然的方法就是:
不要修改 Cargo。
而是在 Linker 前面增加一个 Wrapper。
整体流程变成:
Cargo
│
├── rustc (32并发)
├── rustc (32并发)
├── rustc (32并发)
│
├── Link Wrapper
│ │
│ ├── 获取链接槽(slot)
│ ├── exec linker
│ └── 释放链接槽
│
└── Link Wrapper
这样:
- 编译仍然保持高并发
- Link 自动排队
最简单的实现:flock
如果希望:
max-link-jobs = 1
实际上只需要 Linux 的 flock。
例如:
#!/bin/bash
exec 9>/tmp/rust-link.lock
flock 9
exec clang "$@"
Cargo 配置:
[target.x86_64-unknown-linux-gnu]
linker="/usr/local/bin/clang-wrapper"
linker 可以写相对路径吗?
可以,不必是绝对路径。实测(cargo 1.96)规则如下:
含路径分隔符的相对路径:
[target.x86_64-unknown-linux-gnu]
linker = "tools/clang-wrapper.sh"
Cargo 按 config-relative 规则解析:
相对于 .cargo/config.toml 所在目录的父目录(即项目根),并在传给 rustc 前展开为绝对路径。
用 cargo build -v 可以看到:
-C linker=/path/to/project/tools/clang-wrapper.sh
即使在项目子目录里执行 cargo build,也能正常解析。
因此把 Wrapper 脚本放进仓库、随项目分发是完全可行的。
不含分隔符的裸名字:
linker = "clang-wrapper"
不做相对解析,只查 PATH。不在 PATH 里就会报:
error: linker `clang-wrapper` not found
两个注意点:
- 若通过环境变量
CARGO_TARGET_..._LINKER或--config KEY=VALUE传入,相对路径是相对当前工作目录解析的,不可靠,建议用绝对路径; - 脚本需有 shebang 且
chmod +x。
这样:
Wrapper1
↓
获得锁
↓
clang
----------------
Wrapper2
↓
等待
----------------
Wrapper3
↓
等待
任何时刻只有一个 Linker。
最大的优点是:
即使
kill -9,锁也会自动释放。
原因在于:
flock 属于 文件描述符锁(Open File Description Lock)。
锁绑定的是:
fd
而不是:
文件
当进程退出时:
exit
↓
close(fd)
↓
unlock
全部由内核自动完成,因此不会产生死锁。
为什么 flock 不支持多个并发?
flock 本质上是:
Mutex
只有:
锁
或
没锁
不能表示:
还有两个空位
因此:
max-link-jobs = 2
不能直接依赖 flock。
POSIX Semaphore 可以吗?
理论上可以。
例如:
sem_open()
count = 2
所有 Wrapper:
sem_wait()
↓
exec linker()
↓
sem_post()
这样最多允许两个 Linker。
但是:
POSIX Semaphore 有一个问题:
如果:
sem_wait()
↓
kill -9
由于:
sem_post()
永远不会执行。
于是:
Semaphore 的计数永久减少。
例如:
count=2
↓
kill
↓
count=1
↓
再 kill
↓
count=0
之后所有 Wrapper 都会永久等待。
因此:
普通 POSIX Semaphore 并不具备崩溃恢复(Robust)能力。
这一点和 flock 完全不同。
更推荐的方法:flock + 状态文件
我更喜欢的方案不是 Semaphore。
而是:
state.json
例如:
{
"slots": [
{
"pid": 1234,
"target": "server"
},
null,
{
"pid": 5678,
"target": "cli"
}
]
}
同时配合:
flock(state.lock)
保护状态修改。
整个流程如下。
Step 1:获得状态锁
flock(state.lock)
这里持锁时间非常短。
只是为了修改共享状态。
Step 2:读取当前状态
读取:
state.json
例如:
[
1234,
5678,
null
]
Step 3:清理失效 PID
逐个检查:
kill(pid, 0)
如果返回:
ESRCH
说明:
对应进程已经不存在。
例如:
1234
已经退出。
那么:
[
null,
5678,
null
]
自动释放这个 Slot。
因此:
即使:
kill -9
也能够恢复。
Step 4:寻找空 Slot
如果:
slot != null
全部占满:
释放 flock
↓
sleep
↓
重试
否则:
占用一个 Slot:
[
{
"pid":8888,
"target":"app"
},
{
"pid":5678
},
null
]
然后:
写回 state.json
Step 5:释放 flock
注意:
此时:
真正链接
还没有开始。
因此:
flock 并不会阻塞其它 Wrapper。
它只负责:
原子修改共享状态。
Step 6:执行真正 Linker
exec clang
或者:
exec mold
整个链接阶段:
没有任何锁。
因此:
多个 Linker 可以同时工作。
并发数量完全由:
Slot 数
决定。
Step 7:释放 Slot
链接结束:
再次:
flock
修改:
state.json
把自己的 Slot 置为:
null
随后退出。
为什么这种方案更适合工程实践?
相比 POSIX Semaphore,它有几个优势。
1. 自动恢复
程序异常退出:
kill -9
下一位 Wrapper:
kill(pid,0)
即可发现:
PID 已不存在
自动释放 Slot。
无需人工干预。
2. 状态可见
直接:
cat state.json
即可看到:
[
{
"pid":1001,
"target":"server"
},
{
"pid":1002,
"target":"cli"
}
]
调试非常方便。
3. 容易扩展
例如:
增加:
开始时间
RSS
目标名称
链接耗时
等待耗时
几乎不需要修改整体架构。
例如:
{
"pid":1234,
"target":"server",
"start":"18:22:11",
"rss":"3.2GB"
}
甚至可以提供:
cargo-link-wrapper status
输出:
Slot0
PID:1234
Target:server
RSS:3.2GB
Slot1
PID:5678
Target:cli
RSS:2.7GB
Waiting Queue
app
tests
examples
对于排查大型工程的编译瓶颈非常有帮助。
总结
对于 Rust 大型 Workspace,我认为最实用的方案如下:
- 如果只需要串行链接(
max-link-jobs = 1):直接使用flock包装 Linker,简单、稳定、无需额外状态。 - 如果需要限制为多个并发(如
max-link-jobs = 2或4):使用flock+ 状态文件(记录 PID 和 Slot),通过短时间持锁修改状态、长时间无锁执行 Linker,并在每次获取 Slot 时检查 PID 是否仍然存活,实现自动恢复。
这种设计兼顾了:
- 编译阶段保持最高并发;
- 链接阶段按内存容量限流;
- 崩溃自动恢复;
- 无需常驻守护进程;
- 状态透明、易于调试;
- 易于扩展为一个通用的
cargo-link-wrapper工具。
对于拥有几十甚至上百个 crate 的大型 Rust Workspace,这是一种简单、可靠且非常符合 Unix 哲学的工程实践。