我有一个由 20 个测试文件组成的测试套件,运行一次需要 4 秒多。我只加了一个参数 --parallel,耗时就降到了 1 秒。接着我尝试进一步压榨性能,并找到了这个参数停止生效的临界点------事实证明,这取决于测试实际执行的工作类型,而不是文件的数量。
Bun 1.4 于 2026 年 8 月 20 日发布,带来了 bun test --parallel,它会将测试文件分发到多个 worker 进程中并行执行,而不是在单个进程里逐个运行。我在一台 4 核 Intel Xeon 虚拟机上(KVM,4 个逻辑 CPU,无 cgroup 限制)运行 Bun 1.4.2,我想弄清楚这个参数到底能带来多少收益、它的上限在哪里,以及文档中"隐含 --isolate"这句话在实际中意味着什么。
我写了 20 个测试文件,每个文件两个测试,其中每个测试都会先 await 一个 100ms 的 setTimeout 再执行断言------用来模拟拖慢大量真实集成测试的 API 调用和防抖逻辑。每组运行三次,最快与最慢之间的差距在 15ms 以内:
| 模式 | 墙钟耗时 |
|---|---|
bun test(串行) |
4.04s |
--parallel=1 |
4.06s |
--parallel=2 |
2.05s |
--parallel=4(默认值,= CPU 核心数) |
1.03s |
--parallel=8 |
0.63s |
不带参数时,--parallel 默认使用 CPU 核心数,在这台机器上是 4。仅此一项就把套件从 4.04s 降到了 1.03s,提速 3.9 倍,这与 4 个 worker 分摊 20 个文件的情况几乎完全吻合。让我惊讶的是,在这台只有 4 个逻辑核心的机器上,--parallel=8 仍然在继续提升,降到了 0.63s。我稍后会解释原因,因为这是本文最重要的一部分。
另一面的读数:CPU 密集型测试会在核心数处撞墙
一个 await setTimeout 的测试在等待时其实并不占用 CPU,它只是占着一个坑位。于是我构建了第二个套件,结构相同(20 个文件、每个文件 2 个测试),但这次每个测试运行真实计算:一个筛选到 2000 万的质数筛(prime sieve),它占用真正的 CPU 时间,没有任何等待。
| 模式 | 墙钟耗时 |
|---|---|
bun test(串行) |
6.1s |
--parallel=1 |
6.2s |
--parallel=2 |
3.3s |
--parallel=4 |
1.8s |
--parallel=6 |
1.9s |
--parallel=8 |
1.8s |
这个数字才是反对"头条结论"的证据。扩展到 4 个 worker 时,缩放几乎呈线性,与 4 个物理核心匹配,然后就不再提升了。增加到 6 个或 8 个 worker 没有任何收益,在某些轮次中甚至比 4 个稍慢------大概是进程争夺相同核心时产生了上下文切换开销。--parallel 调度的是操作系统进程,它并不能凭空变出更多 CPU。对于真正受 CPU 限制的套件,关键数字不是你有多少个文件,而是 nproc。
把两张表放在一起,真正的规律比任何一张单独的表都更简单:对于大部分时间在等待的测试,--parallel 可以扩展到超过你的核心数;而对于大部分时间在计算的测试,它做不到。大多数真实套件是两者的混合,所以诚实的建议是:对自己的套件做基准测试,而不是假设它符合某一条曲线。
"隐含 --isolate"到底隔离了什么
文档说 --parallel 隐含 --isolate,并将其描述为"即使两个文件落在同一个 worker 上,每个文件也会获得全新的全局对象"。我想确切知道它重置了什么,因为这种措辞没有说清是按文件隔离,还是更粗或更细的粒度。
我写了一个带有顶层计数器的模块和四个导入它并打印所见值的测试文件:
bash
// shared.ts
let count = 0;
export function bump() { count++; return count; }
在普通 bun test 下,整个运行共享一个进程和一个模块缓存,因此计数器会按文件实际运行的顺序跨文件递增:
bash
file-4 sees counter = 1
file-1 sees counter = 2
file-2 sees counter = 3
file-3 sees counter = 4
在 --parallel 下,每个文件看到的都是 1:
bash
file-1 sees counter = 1 worker = 1
file-2 sees counter = 1 worker = 1
file-3 sees counter = 1 worker = 1
file-4 sees counter = 1 worker = 1
四个文件都在同一个 worker 进程中运行(相同 PID),所以这与操作系统进程隔离无关------而是在单个进程内,每个文件拥有全新的 JS 全局对象,与文档描述完全一致。在 --parallel 上加上 --no-isolate,泄漏立刻回来了:又变成了 1, 2, 3, 4,与串行相同。
那一行文档摘要没有说清楚的部分是:隔离是按文件,而不是按测试。同一个文件内的三个测试,在 --parallel 下运行时,计数器会依次看到 1, 2, 3,之间没有重置。如果你的测试依赖模块级设置在每一个 测试(而不仅是每个文件)都重新初始化,那么 --isolate 无法提供这一点,无论是否并行。
Bun 还会把 BUN_TEST_WORKER_ID 和 JEST_WORKER_ID 设置为 worker 的从 1 开始的索引,我在上面的测试中打印两者确认过------如果你在移植一个以该变量作为数据库名或端口键的 Jest 配置,这一点值得了解。
--bail 停止的是尚未启动的文件,而不是正在运行的文件
文档描述得很精确:"协调器按文件粒度处理 --bail:一旦达到失败阈值,它就不再启动新文件,但已在运行的文件会继续跑完。"我针对 20 个文件测试了这一点,其中第一个文件有一个耗时 300ms 的失败测试。
不加 --bail,在 --parallel 下,无论是否失败,20 个文件全部运行:
bash
19 pass
1 fail
Ran 20 tests across 20 files. [1.53s]
加上 --bail 后:
bash
Bailed out after 1 failure
3 pass
1 fail
Ran 4 tests across 4 files. [320.00ms]
运行了 4 个文件,而不是 1 个。这与失败发生时已有 4 个 worker 正在执行相匹配;其余 16 个文件从未启动。这在 CI 中能节省大量时间(这里从 1.53s 降到 0.32s),但这也意味着在 --parallel 下使用 --bail,你无法得知那 16 个未触碰的文件中是否还有其他失败------包括那些与导致首次失败的问题毫无关系的失败。
覆盖率合并正确、非法输入被干净拒绝、单个坏文件不会拖垮整个运行
我还检查了另外三件事,简要说明:
覆盖率合并
我把一个模块的三个函数拆分到三个测试文件中,每个文件覆盖一个函数,并比较了 --coverage 输出。串行与 --parallel --coverage 报告的数值完全相同------函数覆盖率 75%、行覆盖率 60%、未覆盖行范围一致------因此文档所称的跨 worker 覆盖率合并完全成立。
非法输入在任何测试运行前就被拒绝
--parallel=0、--parallel=-1 和 --parallel=abc 都产生了同样简洁的错误信息和退出码 1,且没有执行任何内容:
bash
error: --parallel expects a positive integer, received "0"
一个文件的崩溃不会拖垮整个运行
我有一个测试文件在模块加载时抛错,与文件内任何测试无关。在 --parallel 下,该批次中的另外三个文件仍然运行并通过,崩溃被报告为独立的 "error",而不是与断言失败混在一起:
bash
bun test v1.4.2 (744846f84) 4x PARALLEL
tests-crash/cr2.test.ts:
# Unhandled error between tests
-------------------------------
error: boom at module load time
-------------------------------
3 pass
1 fail
1 error
Ran 4 tests across 4 files. [11.00ms]
横幅中那行 4x PARALLEL 也是从 CI 日志确认实际运行了多少 worker 的最简单方式,无需向自己的测试代码添加任何内容。
我在这条路上犯过的错
我第一次构建"CPU 密集型"套件时,用 while (Date.now() - start < 100) {} 来模拟真实工作,期望它的表现和质数筛套件一样。结果并非如此:在一台 4 核机器上,--parallel=8 一直持续变快,远超真实 CPU 工作应该达到的平台期,形状与 IO 密集型套件相同。我花了一段时间假设 Bun 在调度上做了什么聪明的事,才想明白真正的原因:一个检查墙钟时间的自旋循环,只需在截止时间之后被调度一次,就会注意到时间已过并退出。它表现得像 sleep 而不是计算,因为操作系统想抢占它多久就多久,循环根本不在意------只要它下次被调度检查时真实时间已经流逝即可。这就是我重建 CPU 密集型套件时改用真正的质数筛、完全不检查时钟的原因,也就是上面表格中的版本。
自己动手跑一遍
这是生成第一张表的精简版脚本。需要 Bun 1.4 或更高版本。
bash
curl -fsSL https://bun.sh/install | bash
mkdir bun-parallel-demo && cd bun-parallel-demo
for i in $(seq -w 1 20); do
cat > "t$i.test.ts" <<EOF
import { test, expect } from "bun:test";
test("io-$i", async () => {
await new Promise((r) => setTimeout(r, 100));
expect(1 + 1).toBe(2);
});
EOF
done
bun test # 串行基线
bun test --parallel # 默认使用你的 CPU 核心数
bun test --parallel=8 # 超出核心数继续压榨,因为这些测试只是在等待
把生成的测试主体替换为真实计算(一个质数筛、一次排序,任何不带定时器的东西),就能看到平台期而非持续扩展,并与你自己机器上的 nproc 对比。
该怎么用
在为现有套件开启 --parallel 之前,先弄清楚你的套件到底属于哪种类型。如果你的大部分测试都在等待定时器、socket 或数据库往返,把 --parallel 提高到超过核心数就是免费的速度提升,值得立刻尝试。如果你的测试在进程内做真实计算,那么超过 nproc 就没有任何收益,应该把 --parallel 设为该数值,而不是猜一个更大的数。无论哪种情况,在依赖 --isolate 保证正确性之前,先检查你的测试在模块作用域到底共享了什么:它重置的是文件之间的状态,而不是同一文件内测试之间的状态,而这个缺口正是测试套件变得不稳定的常见温床。
相关阅读(延伸外链)
以下为推荐的相关技术教程,来自致知笔记: