Rust Cargo 编译优化:利用 Link Wrapper + 文件锁实现「编译全并行,链接限并发」

七月 17, 2026 [rust, build] #rust #cargo #linker #flock

Rust 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。

如果:

那么每个 Linker 可能消耗数 GB 内存。

例如:

lld      4GB
lld      5GB
lld      4GB
----------------
13GB

很容易导致:

Out Of Memory

因此真正的问题其实不是:

Cargo 编译太快。

而是:

Cargo 对 CompileLink 使用了相同的并发策略。

事实上,这两个阶段资源模型完全不同。

阶段主要资源
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

这样:


最简单的实现: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

两个注意点:

这样:

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,我认为最实用的方案如下:

这种设计兼顾了:

对于拥有几十甚至上百个 crate 的大型 Rust Workspace,这是一种简单、可靠且非常符合 Unix 哲学的工程实践。