Bun Install 比 npm 快 7 倍,Why?
运行 bun install 速度极快。平均而言,它比 npm 快约 7 倍,比 pnpm 快约 4 倍,比 yarn 快约 17 倍。在大型代码库中,这种差异尤为显著。原本需要数分钟的操作,如今只需 (毫)秒即可完成。
这些并非刻意挑选的基准测试。Bun之所以迅捷,在于它将包安装视为系统编程问题而非JavaScript问题。
本文将深入解析其实现原理:从最小化系统调用、二进制格式缓存清单文件,到优化tar包解压、利用操作系统原生文件复制机制,以及跨CPU核心扩展处理能力。
但要理解其意义,我们需先回溯至2009年。
2009年。你正从.zip文件安装jQuery,iPhone 3GS仅有256MB内存。GitHub刚满周岁,256GB固态硬盘售价700美元。笔记本电脑的5400转硬盘最高传输速率仅100MB/s,所谓“宽带”意味着10Mbps(运气好的话)。
但更重要的是:Node.js刚刚问世!Ryan Dahl正在台上阐述服务器为何耗费大量时间等待。
2009年,典型磁盘寻道耗时10毫秒,数据库查询需50-200毫秒,向外部API发起HTTP请求则超过300毫秒。在每次操作期间,传统服务器只能…等待。当服务器开始读取文件时,系统会直接冻结10毫秒。

现在想象成千上万的并发连接同时执行多项I/O操作。服务器约95%的时间都耗费在等待I/O响应上。
Node.js 发现 JavaScript 的事件循环(最初为浏览器事件设计)完美适用于服务器 I/O。当代码发起异步请求时,I/O 在后台执行,主线程立即转入下个任务。操作完成后,回调函数会被排入执行队列。

Node.js通过事件循环与线程池处理fs.readFile的简化示意图。为清晰起见,其他异步源及实现细节已省略。
在等待数据曾是主要瓶颈的时代,JavaScript 的事件循环堪称绝佳解决方案。
此后十五年间,Node 的架构深刻影响了工具开发模式。包管理器继承了 Node 的线程池、事件循环和异步模式——这些优化在磁盘寻道耗时 10 毫秒的年代具有现实意义。
但硬件已进化。我们不再身处2009年,而是迈入了难以置信的16年后的未来。此刻我撰写本文的M4 Max MacBook,在2009年足以跻身全球50强超级计算机之列。如今NVMe硬盘的传输速率高达7000MB/s,竟是Node.js设计基准的70倍!机械硬盘早已淘汰,网络流畅播放4K视频,就连低端智能手机的内存也超越了2009年高端服务器的配置。
然而当今的包管理器仍在为十年前的问题优化。2025年的真正瓶颈不再是I/O,而是系统调用。
系统调用的困境
每当程序需要操作系统执行操作(读取文件、建立网络连接、分配内存),就会发起系统调用。每次系统调用都会迫使CPU执行一次_模式切换_。
CPU 运行程序时存在两种模式:
用户模式:应用程序代码在此模式下运行。用户模式程序无法直接访问设备硬件、物理内存地址等资源。这种隔离机制可防止程序相互干扰或导致系统崩溃。内核模式:操作系统内核在此运行。内核是管理资源的核心组件,负责调度进程使用CPU、处理内存以及管理磁盘或网络设备等硬件。仅内核和设备驱动程序能在内核模式下运行!
当程序需要打开文件(例如调用fs.readFile())时,运行在用户模式的CPU无法直接读取磁盘数据,必须先切换至内核模式。
模式切换过程中,CPU将停止执行程序 → 保存所有运行状态 → 切换至内核模式 → 执行操作 → 切回用户模式。

然而这种模式切换代价高昂!仅切换过程本身就消耗1000-1500个CPU周期,这还是在实际工作开始前的纯开销。
CPU运行在每秒数十亿次计时的时钟上。3GHz处理器每秒完成30亿个周期。每个周期CPU可执行指令:加法运算、数据移动、比较操作等。每个周期耗时0.33纳秒。
在3GHz处理器上,1000-1500个周期约等于500纳秒。这看似微不足道,但现代固态硬盘每秒可处理超过百万次操作。若每次操作都需要系统调用,仅模式切换就将消耗15亿个周期。
包安装会触发数千次系统调用。安装React及其依赖项可能引发50,000+次系统调用:仅模式切换就耗费数秒CPU时间!这还不包括读取文件或安装包的过程,纯粹是用户模式与内核模式之间的切换。
正因如此,Bun将包安装视为系统编程问题。快速安装速度源于最小化系统调用,并充分利用所有操作系统特有的优化机制。
通过追踪各包管理器的实际系统调用,差异显而易见:
Benchmark 1: strace -c -f npm install
Time (mean ± σ): 37.245 s ± 2.134 s [User: 8.432 s, System: 4.821 s]
Range (min … max): 34.891 s … 41.203 s 10 runs
System calls: 996,978 total (108,775 errors)
Top syscalls: futex (663,158), write (109,412), epoll_pwait (54,496)
Benchmark 2: strace -c -f bun install
Time (mean ± σ): 5.612 s ± 0.287 s [User: 2.134 s, System: 1.892 s]
Range (min … max): 5.238 s … 6.102 s 10 runs
System calls: 165,743 total (3,131 errors)
Top syscalls: openat(45,348), futex (762), epoll_pwait2 (298)
Benchmark 3: strace -c -f yarn install
Time (mean ± σ): 94.156 s ± 3.821 s [User: 12.734 s, System: 7.234 s]
Range (min … max): 89.432 s … 98.912 s 10 runs
System calls: 4,046,507 total (420,131 errors)
Top syscalls: futex (2,499,660), epoll_pwait (326,351), write (287,543)
Benchmark 4: strace -c -f pnpm install
Time (mean ± σ): 24.521 s ± 1.287 s [User: 5.821 s, System: 3.912 s]
Range (min … max): 22.834 s … 26.743 s 10 runs
System calls: 456,930 total (32,351 errors)
Top syscalls: futex (116,577), openat(89,234), epoll_pwait (12,705)
Summary
'strace -c -f bun install' ran
4.37 ± 0.28 times faster than 'strace -c -f pnpm install'
6.64 ± 0.51 times faster than 'strace -c -f npm install'
16.78 ± 1.12 times faster than 'strace -c -f yarn install'
System Call Efficiency:
- bun: 165,743 syscalls (29.5k syscalls/s)
- pnpm: 456,930 syscalls (18.6k syscalls/s)
- npm: 996,978 syscalls (26.8k syscalls/s)
- yarn: 4,046,507 syscalls (43.0k syscalls/s)
可见 Bun 不仅安装速度更快,系统调用次数也少得多。在简单安装场景下,yarn 产生超 400 万次系统调用,npm 近 100 万次,pnpm 约 50 万次,而 bun 仅需 16.5 万次。
按每次调用消耗1000-1500个周期计算,yarn的400万次系统调用意味着仅模式切换就耗费数十亿CPU周期。在3GHz处理器上,这相当于数秒的纯开销!
关键不仅在于系统调用的数量。请看这些futex调用!bun仅发出762次futex调用(占总系统调用量的0.46%),而npm达663,158次(66.51%),yarn高达2,499,660次(61.76%),pnpm为116,577次(25.51%)。
futex(快速用户空间互斥量)是用于线程同步的Linux系统调用。线程作为程序中更小的并行执行单元,常需共享内存或资源访问权限,因此必须协调以避免冲突。
多数情况下,线程通过用户模式下的快速原子CPU指令进行协调。无需切换至内核模式,效率极高!
但若线程尝试获取已被占用的锁,就会发起futex系统调用,请求内核将其置于休眠状态直至锁释放。高频率的futex调用表明大量线程相互等待,导致延迟。
那么Bun在此有何不同?
消除 JavaScript 开销
npm、pnpm 和 yarn 均基于 Node.js 开发。在 Node.js 中,系统调用并非直接执行:当调用 fs.readFile() 时,实际需经过多层处理才能触达操作系统。
Node.js 采用 libuv 这一 C 库,该库通过线程池抽象平台差异并管理异步 I/O。
结果是当 Node.js 读取单个文件时,会触发相当复杂的管道流程。以简单的 fs.readFile(‘package.json’, ...) 为例:
- JavaScript 验证参数,并将字符串从 UTF-16 转换为 UTF-8 以适配 libuv 的 C 接口。此过程会在 I/O 开始前短暂阻塞主线程。
- libuv 将请求排入 4 个工作线程的队列。若所有线程均忙,请求将进入等待状态。
- 工作线程接管请求,打开文件描述符并执行实际的
read()系统调用。 - 内核切换至
内核模式,从磁盘读取数据并返回给工作线程。 - 工作线程通过事件循环将文件数据推送回主线程,最终调度并执行回调函数。
每次调用 fs.readFile() 都会经历这个流程。软件包安装涉及读取数千个 package.json 文件:扫描目录、处理依赖元数据等。每次线程协调(如访问任务队列或向事件循环发送信号时),都可能使用 futex 系统调用来管理锁或等待。
数千次系统调用的开销可能比实际数据传输耗时更长!
Bun 采用了不同方案。它使用 Zig 语言编写,该语言编译为原生代码并直接访问系统调用:
// Direct system call, no JavaScript overhead
var file = bun.sys.File.from(try bun.sys.openatA(
bun.FD.cwd(),
abs,
bun.O.RDONLY,
0,
).unwrap());
当 Bun 读取文件时:
- Zig 代码直接调用系统调用(如
openat()) - 内核立即执行系统调用并返回数据
仅此而已。没有 JavaScript 引擎、线程池、事件循环或不同运行时层之间的序列化处理。纯粹是原生代码直接向内核发出系统调用。
性能差异不言自明:
| Runtime | Version | Files/Second | Performance |
|---|---|---|---|
| Bun | v1.2.20 | 146,057 | |
| Node.js | v24.5.0 | 66,576 | 2.2x slower |
| Node.js | v22.18.0 | 64,631 | 2.3x slower |
本基准测试中,Bun每秒处理146,057个package.json文件,而Node.js v24.5.0仅处理66,576个,v22.18.0处理64,631个。速度提升超过2倍!
Bun每处理一个文件仅耗时0.019毫秒,这代表实际I/O成本——即直接系统调用时无运行时开销的数据读取耗时。Node.js执行相同操作需0.065毫秒。基于Node.js的包管理器受限于Node的抽象层,无论是否需要都必须使用线程池,且每次文件操作都要为此付出代价。
Bun的包管理器更像是一款理解JavaScript包的原生应用程序,而非试图进行系统编程的JavaScript应用程序。
尽管 Bun 并非用 Node.js 编写,您仍可在任何 Node.js 项目中直接使用 bun install 命令,无需切换运行时环境。Bun 的包管理器会充分兼容您现有的 Node.js 配置和工具链,让安装过程变得更快!
但此时我们尚未开始安装包。让我们看看 Bun 在实际安装过程中实施的优化措施。
当你输入bun install时,Bun首先解析你的指令意图。它会读取你传递的参数,并定位你的package.json文件以获取依赖项信息。
异步DNS解析
⚠️ 注意:此优化仅适用于 macOS
处理依赖关系意味着需要进行网络请求,而网络请求需要通过 DNS 解析将域名(如 registry.npmjs.org)转换为 IP 地址。
当 Bun 解析 package.json 时,它已开始预先获取 DNS 查询结果。这意味着网络解析在依赖分析完成之前就已启动。
对于基于 Node.js 的包管理器,一种实现方式是使用 dns.lookup()。虽然从 JavaScript 角度看它看似异步,但底层实际是通过 libuv 线程池调用阻塞式的 getaddrinfo()。它依然会阻塞线程,只是阻塞的是非主线程。
作为一项优化,Bun 在 macOS 上采用了不同策略,通过系统级实现真正异步处理。Bun利用苹果的“隐藏式”异步DNS API(getaddrinfo_async_start())——该API虽未纳入POSIX标准,但通过苹果进程间通信系统mach端口,使DNS请求能完全异步执行。
在DNS解析于后台进行时,Bun可继续处理文件I/O、网络请求或依赖解析等操作,且不会阻塞任何线程。待需要下载React时,DNS查询早已完成。
这虽是微小的优化(且未进行基准测试),却彰显了Bun对细节的执着:在每个层级都追求优化!
二进制清单缓存
当 Bun 与 npm 注册表建立连接后,便需要获取包清单文件。
清单是包含每个包所有版本、依赖关系和元数据的 JSON 文件。对于 React 这类拥有 100 多个版本的热门包,这些清单文件可能高达数兆字节!
典型清单文件结构如下:
{
"name": "lodash",
"versions": {
"4.17.20": {
"name": "lodash",
"version": "4.17.20",
"description": "Lodash modular utilities.",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/lodash/lodash.git"
},
"homepage": "https://lodash.com/"
},
"4.17.21": {
"name": "lodash",
"version": "4.17.21",
"description": "Lodash modular utilities.",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/lodash/lodash.git"
},
"homepage": "https://lodash.com/"
}
// ... 100+ more versions, nearly identical
}
}
大多数包管理器会将这些清单作为 JSON 文件缓存到其缓存目录中。当再次执行npm install时,它们不会重新下载清单文件,而是从缓存中读取。
这看似合理,但问题在于每次安装(即使存在缓存)仍需解析JSON文件。这包括语法验证、对象树构建、垃圾回收管理等操作,产生大量解析开销。
不仅是JSON解析开销。以lodash为例:字符串“Lodash modular utilities.”在每个版本中出现——超过100次。‘MIT’出现100多次。“git+https://github.com/lodash/lodash.git”在每个版本中重复出现,URL“https://lodash.com/”同样遍布所有版本。总之,大量字符串被重复使用。
在内存中,JavaScript 为每个字符串创建独立的字符串对象。这既浪费内存又降低比较效率。每次包管理器检查两个包是否使用相同版本的 postcss 时,它都在比较独立的字符串对象,而非指向相同的内存池字符串。
Bun 以二进制格式存储包清单。当 Bun 下载包信息时,仅解析一次 JSON 数据,将其存储为二进制文件(位于 ~/.bun/install/cache/ 目录下的 .npm 文件)。这些二进制文件包含所有包信息(版本、依赖项、校验和等),数据按特定字节偏移量存储。
当 Bun 访问名称 lodash 时,仅需执行指针运算:字符串缓冲区 + 偏移量。无需分配内存、无需解析、无需遍历对象,仅需读取已知位置的字节。
// Pseudocode
// String buffer (all strings stored once)
string_buffer = "lodashMITLodash modular utilities.git+https://github.com/lodash/lodash.githttps://lodash.com/4.17.204.17.21..."
^0 ^7 ^11 ^37 ^79 ^99 ^107
// Version entries (fixed-size structs)
versions = [
{ name_offset: 0, name_len: 6, version_offset: 99, version_len: 7, desc_offset: 11, desc_len: 26, license_offset: 7, license_len: 3, ... }, // 4.17.20
{ name_offset: 0, name_len: 6, version_offset: 107, version_len: 7, desc_offset: 11, desc_len: 26, license_offset: 7, license_len: 3, ... }, // 4.17.21
// ... 100+ more version structs
]
为检测包是否需要更新,Bun会存储响应的ETag并发送If-None-Match头部。当npm返回“304 Not Modified”时,Bun无需解析任何字节即可确认缓存数据为最新版本。
基准测试结果:
Benchmark 1: bun install # fresh
Time (mean ± σ): 230.2 ms ± 685.5 ms [User: 145.1 ms, System: 161.9 ms]
Range (min … max): 9.0 ms … 2181.0 ms 10 runs
Benchmark 2: bun install # cached
Time (mean ± σ): 9.1 ms ± 0.3 ms [User: 8.5 ms, System: 5.9 ms]
Range (min … max): 8.7 ms … 11.5 ms 10 runs
Benchmark 3: npm install # fresh
Time (mean ± σ): 1.786 s ± 4.407 s [User: 0.975 s, System: 0.484 s]
Range (min … max): 0.348 s … 14.328 s 10 runs
Benchmark 4: npm install # cached
Time (mean ± σ): 363.1 ms ± 21.6 ms [User: 276.3 ms, System: 63.0 ms]
Range (min … max): 344.7 ms … 412.0 ms 10 runs
Summary
bun install # cached ran
25.30 ± 75.33 times faster than bun install # fresh
39.90 ± 2.37 times faster than npm install # cached
196.26 ± 484.29 times faster than npm install # fresh
可见缓存版(!!)的npm install竟比全新安装的Bun更慢。这正是缓存文件的JSON解析开销(及其他因素)造成的性能损耗。
优化Tarball解压
当Bun获取到包清单后,需要从npm注册表下载并解压压缩的tarball文件。
tarball 是压缩存档文件(类似于 .zip 文件),包含每个软件包的所有实际源代码和文件。
大多数软件包管理器会流式传输 tarball 数据,并在传输过程中进行解压。当提取正在流式传输的 tarball 时,通常会假设文件大小未知,其典型模式如下:
let buffer = Buffer.alloc(64 * 1024); // Start with 64KB
let offset = 0;
function onData(chunk) {
while (moreDataToCome) {
if (offset + chunk.length > buffer.length) {
// buffer full → allocate bigger one
const newBuffer = Buffer.alloc(buffer.length * 2);
// copy everything we’ve already written
buffer.copy(newBuffer, 0, 0, offset);
buffer = newBuffer;
}
// copy new chunk into buffer
chunk.copy(buffer, offset);
offset += chunk.length;
}
// ... decompress from buffer ...
}
初始使用小缓冲区,随解压数据增加逐步扩展。当缓冲区填满时,分配更大缓冲区,将现有数据全部复制过去,然后继续处理。
这种方式看似合理,却会形成性能瓶颈:由于缓冲区反复超出当前容量,最终导致相同数据被多次复制。

以1MB数据包为例:
- 初始64KB缓冲区
- 填满 → 分配128KB → 复制64KB
- 填满 → 分配256KB → 复制128KB
- 填满 → 分配512KB → 复制256KB
- 填满 → 分配1MB → 复制512KB
这导致960KB数据被重复复制!且每个包都会发生此现象。内存分配器需为每个新缓冲区寻找连续空间,而旧缓冲区在复制过程中仍占用内存。对于大型包,相同字节可能被复制5-6次。
Bun 采用截然不同的方案:先缓冲整个 tarball 再解压。它不会即时处理接收到的数据,而是等待压缩文件完全下载至内存后再处理。
此刻你或许会质疑:“等等,把所有内容保存在内存里不是浪费 RAM 吗?” 对于 TypeScript 这样的大型包(压缩后可达 50MB),这种质疑确实有道理。
但绝大多数npm包体积极小,多数不足1MB。对于这类常见场景,缓存完整文件可彻底消除重复复制操作。即便是大型包,在现代系统中短暂的内存占用通常不成问题,而避免5-6次缓冲区复制带来的效率提升远超此代价。
当Bun将完整tar包加载至内存后,即可读取gzip格式的最后4字节。这4字节至关重要——它们存储着文件解压后的实际大小!Bun无需猜测解压文件体积,可直接预分配内存完全避免缓冲区动态调整:
{
// Last 4 bytes of a gzip-compressed file are the uncompressed size.
if (tgz_bytes.len > 16) {
// If the file claims to be larger than 16 bytes and smaller than 64 MB, we'll preallocate the buffer.
// If it's larger than that, we'll do it incrementally. We want to avoid OOMing.
const last_4_bytes: u32 = @bitCast(tgz_bytes[tgz_bytes.len - 4 ..][0..4].*);
if (last_4_bytes > 16 and last_4_bytes < 64 * 1024 * 1024) {
// It's okay if this fails. We will just allocate as we go and that will error if we run out of memory.
esimated_output_size = last_4_bytes;
if (zlib_pool.data.list.capacity == 0) {
zlib_pool.data.list.ensureTotalCapacityPrecise(zlib_pool.data.allocator, last_4_bytes) catch {};
} else {
zlib_pool.data.ensureUnusedCapacity(last_4_bytes) catch {};
}
}
}
}
这4字节向Bun传递“该gzip文件解压后精确为1,048,576字节”的信息,使其能预先精确分配内存。无需重复调整大小或复制数据,仅需一次内存分配。

实际解压缩时,Bun 使用 libdeflate。这是款高性能库,其解压tar包的速度远超多数包管理器使用的标准zlib。该库针对支持SIMD指令的现代CPU进行了专项优化。
对于用Node.js编写的包管理器而言,优化tar包解压本是难事。你需要创建独立读取流,跳转至末尾,读取4字节数据,解析后关闭流,再重复整个解压流程——Node的API本就不支持这种模式。
而在Zig中却简单得多:直接跳转至末尾读取最后四字节数据即可!
当 Bun 获取所有包数据后,又面临新挑战:如何高效存储并访问数千个(相互依赖的)包?
缓存友好型数据布局
处理数千个包可能很棘手。每个包都有依赖项,而这些依赖项又各自存在依赖关系,从而形成相当复杂的图结构。
在安装过程中,包管理器必须遍历这个图来检查包版本、解决冲突并确定要安装的版本。它们还需要通过将依赖项移至更高层级来实现“提升”,以便多个包共享这些依赖项。
但依赖图的存储方式对性能影响巨大。传统包管理器采用如下存储方式:
const packages = {
next: {
name: "next",
version: "15.5.0",
dependencies: {
"@swc/helpers": "0.5.15",
"postcss": "8.4.31",
"styled-jsx": "5.1.6",
},
},
postcss: {
name: "postcss",
version: "8.4.31",
dependencies: {
nanoid: "^3.3.6",
picocolors: "^1.0.0",
},
},
};
这种写法在 JavaScript 代码中看似简洁,却不符合现代 CPU 架构的特性。
在JavaScript中,每个对象都存储在堆内存中。访问packages[“next”]时,CPU需先获取指向Next数据内存位置的指针,该数据又包含指向依赖项存储位置的指针,而这些指针最终指向实际的依赖字符串。

核心问题在于JavaScript的内存分配机制。当你在不同时间创建对象时,JavaScript引擎会使用当时可用的任意内存区域:
// These objects are created at different moments during parsing
packages["react"] = { name: "react", ... } // Allocated at address 0x1000
packages["next"] = { name: "next", ... } // Allocated at address 0x2000
packages["postcss"] = { name: "postcss", ... } // Allocated at address 0x8000
// ... hundreds more packages
这些地址本质上是随机的。内存局部性无法得到保证——对象可能被分散存储在RAM各处,即使是相互关联的对象也不例外!
这种随机分散存储对现代CPU的数据读取机制至关重要。
现代CPU处理数据的速度极其快(每秒数十亿次操作),但从RAM中读取数据却很慢。为弥补这一差距,CPU配备了多级缓存:
- L1缓存:存储容量小,但速度极快(约4个CPU周期)
- L2缓存:存储容量中等,速度稍慢(约12个CPU周期)
- L3缓存:8-32MB存储容量,约需40个CPU周期
- RAM:数GB容量,约需300个周期(速度较慢!)
关键问题在于缓存以_缓存行_为单位工作。访问内存时,CPU不会只加载单个字节,而是加载包含该字节的整个64字节区块。其逻辑是:若需读取一个字节,通常很快会需要相邻字节(即空间局部性原理)。

这种优化对顺序存储的数据效果显著,但当数据在内存中随机分散时就会适得其反。
当CPU从地址0x2000加载packages[“next”]时,实际加载了该缓存行内的所有字节。但下一个包packages[“postcss”]位于地址0x8000,这属于完全不同的缓存行!缓存行中CPU加载的其余56字节完全被浪费了——它们只是附近随机分配的内存区域,可能是垃圾数据,也可能是无关对象的碎片。

你付出了加载64字节的代价,却只用了8字节…
当访问到第512个不同包(32KB / 64字节)时,整个L1缓存已被填满。此时每次新包访问都会驱逐先前加载的缓存行以腾出空间。你刚访问的包很快会被驱逐,而它需要在10微秒内检查的依赖项已不复存在。缓存命中率骤降,每次访问都变成约300个时钟周期的RAM往返,而非4个时钟周期的L1命中,远非最优状态。
对象的嵌套结构会引发系统编程中的常见反模式——“指针追逐”。由于每个指针都可能指向任意位置,CPU无法预测下一次加载目标。在完成next对象加载前,它根本无法确定next.dependencies的存储位置。
遍历Next的依赖项时,CPU需执行多次关联内存加载:
- 加载
packages[“next”]指针 → 缓存未命中 → RAM读取(约300个时钟周期) - 追踪该指针加载
next.dependencies指针 → 再次缓存未命中 → 内存读取(约300个时钟周期) - 继续追踪在哈希表中查找
“postcss”→ 缓存未命中 → 内存读取(约300个时钟周期) - 追踪该指针加载实际字符串数据 → 缓存未命中 → 内存读取(约300个时钟周期)
由于处理数百个分散在内存中的依赖项,可能导致大量缓存未命中。每次加载的缓存行(64字节)可能仅包含单个对象的数据。当所有对象分散在数GB的RAM中时,工作集很容易超过L1缓存(32KB)、L2缓存(256KB)甚至L3缓存(8-32MB)。当我们再次需要某个对象时,它很可能已被逐出所有缓存层级。
仅读取一个依赖项名称就耗费约1200个时钟周期(3GHz CPU下为400纳秒)!对于包含1000个包、每个包平均5个依赖项的项目,这意味着2毫秒的纯内存延迟。
Bun采用数组结构化存储方案。不同于每个包独立存储依赖数组的做法,Bun将所有依赖集中存放于共享大数组,所有包名存放于另一共享数组,依此类推:
// ❌ Traditional Array of Structures (AoS) - lots of pointers
packages = {
next: { dependencies: { "@swc/helpers": "0.5.15", "postcss": "8.4.31" } },
};
// ✅ Bun's Structure of Arrays (SoA) - cache friendly
packages = [
{
name: { off: 0, len: 4 },
version: { off: 5, len: 6 },
deps: { off: 0, len: 2 },
}, // next
];
dependencies = [
{ name: { off: 12, len: 13 }, version: { off: 26, len: 7 } }, // @swc/helpers@0.5.15
{ name: { off: 34, len: 7 }, version: { off: 42, len: 6 } }, // postcss@8.4.31
];
string_buffer = "next15.5.0@swc/helpers0.5.15postcss8.4.31";
Bun摒弃了每个包存储分散在内存中的数据指针的做法,转而采用大型连续缓冲区,包括:
packages存储轻量级结构体,通过偏移量指定包数据的存储位置dependencies集中存储所有包的实际依赖关系string_buffer将所有文本(名称、版本等)顺序存储为巨型字符串versions以紧凑结构体存储所有解析后的语义版本
现在访问 Next 的依赖关系只需进行算术运算:
packages[0]表明 Next 的依赖项在dependencies数组中从位置0开始,共有 2 个依赖项:{ name_offset: 0, deps_offset: 0, deps_count: 2 }- 跳转至
dependencies[1],得知 postcss 的名称起始于字符串string_buffer的第34位,版本号起始于第42位:{ name_offset: 34, version_offset: 42 } - 跳转至
string_buffer的第 34 位置读取postcss - 跳转至
string_buffer的第 42 位置读取“8.4.31” - …以此类推
此时访问packages[0]时,CPU不会仅加载这8字节数据:它会加载完整的64字节缓存行。由于每个包占用8字节,且64÷8=8,因此单次内存读取即可获取packages[0]至packages[7]。
因此当代码处理 react 依赖项时(packages[0] 及 packages[1] 至 packages[7] 已驻留在 L1 缓存中),无需额外内存读取即可直接访问。这就是顺序访问如此高效的原因:仅需一次内存访问即可获取 8 个包。
与前例中内存中大量分散的小块分配不同,现在无论有多少包,总共只有约6次大型分配。这与基于指针的方法截然不同——后者需要为每个对象单独进行内存读取。
优化锁定文件格式
Bun 还将数组结构化方法应用于其 bun.lock 锁定文件。
执行 bun install 时,Bun 必须解析现有锁定文件以确定已安装内容和需更新项。多数包管理器将锁定文件存储为嵌套 JSON(npm)或 YAML(pnpm、yarn)。当 npm 解析 package-lock.json 时,需处理深度嵌套的对象:
{
"dependencies": {
"next": {
"version": "15.5.0",
"requires": {
"@swc/helpers": "0.5.15",
"postcss": "8.4.31"
}
},
"postcss": {
"version": "8.4.31",
"requires": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0"
}
}
}
}
每个包都成为独立对象,并包含嵌套的依赖对象。JSON解析器需为每个对象分配内存、验证语法并构建复杂的嵌套树结构。对于拥有数千依赖项的项目,这将引发前文所述的指针追踪问题!
Bun采用数组结构化方案处理锁定文件,形成可读格式:
{
"lockfileVersion": 0,
"packages": {
"next": [
"next@npm:15.5.0",
{ "@swc/helpers": "0.5.15", "postcss": "8.4.31" },
"hash123"
],
"postcss": [
"postcss@npm:8.4.31",
{ "nanoid": "^3.3.6", "picocolors": "^1.0.0" },
"hash456"
]
}
}
此方案再次实现字符串去重,并以缓存友好布局存储依赖项。它们按依赖顺序而非字母顺序或嵌套层级存储。这意味着解析器能更高效地顺序读取内存,避免在对象间随机跳转。
不仅如此,Bun还会根据锁文件大小预分配内存。如同解压tarball时那样,这能避免解析过程中反复的调整大小和复制循环,从而消除性能瓶颈。
附注:Bun最初采用二进制锁文件格式(bun.lockb)以完全规避JSON解析开销,但二进制文件无法在拉取请求中审查,且发生冲突时无法合并。
文件复制
当包安装并缓存至~/.bun/install/cache/后,Bun必须将文件复制到node_modules目录。这正是Bun性能影响的主要环节!
传统文件复制会遍历每个目录逐个文件复制。这需要每个文件执行多次系统调用:
- 打开源文件(
open()) - 创建并打开目标文件(
open()) - 反复从源文件读取数据块并写入目标文件直至完成(
read()/write()) - 最后关闭两个文件(
close())。
每个步骤都需要耗费高昂代价在用户模式与内核模式间切换。
对于包含数千个包文件的典型 React 应用,这将产生数十万至数百万次系统调用!这正是我们之前描述的系统编程难题:所有系统调用的开销远高于实际数据传输成本。
Bun会根据操作系统和文件系统采用不同策略,充分利用所有操作系统特有的优化机制。Bun支持多种文件复制后端,各自具有不同的性能特征:
macOS
在macOS上,Bun使用苹果原生的clonefile()写时复制系统调用。
clonefile 能在单次系统调用中克隆整个目录树。该调用会创建新的目录和文件元数据条目,这些条目指向与原始文件相同的物理磁盘块。文件系统无需向磁盘写入新数据,只需创建指向现有数据的新“指针”即可。
// Traditional approach: millions of syscalls
for (each file) {
copy_file_traditionally(src, dst); // 50+ syscalls per file
}
// Bun's approach: ONE syscall
clonefile("/cache/react", "/node_modules/react", 0);
SSD 以固定大小的_数据块_存储信息。常规文件复制(copy())时,文件系统需分配新块并写入重复数据。而使用clonefile时,原始文件与“复制”文件的元数据均指向SSD上完全相同的物理块。
写时复制机制意味着数据仅在修改时才被复制。这使得操作复杂度从传统复制的O(n)降至O(1)。
在修改其中一个文件前,两者的元数据始终指向相同数据块。

当修改其中一个文件内容时,文件系统会自动为编辑部分分配新块,并更新文件元数据指向新块。

但这种情况很少发生,因为node_modules文件安装后通常为只读状态;我们不会在代码中主动修改模块。
这使得写时复制极其高效:多个包可共享相同的依赖文件,而无需额外占用磁盘空间。
Benchmark 1: bun install --backend=copyfile
Time (mean ± σ): 2.955 s ± 0.101 s [User: 0.190 s, System: 1.991 s]
Range (min … max): 2.825 s … 3.107 s 10 runs
Benchmark 2: bun install --backend=clonefile
Time (mean ± σ): 1.274 s ± 0.052 s [User: 0.140 s, System: 0.257 s]
Range (min … max): 1.184 s … 1.362 s 10 runs
Summary
bun install --backend=clonefile ran
2.32 ± 0.12 times faster than bun install --backend=copyfile
当 clonefile 因文件系统不支持而失败时,Bun 会回退至 clonefile_each_dir 进行目录级克隆。若该方法仍失败,Bun 将采用传统 copyfile 作为最终回退方案。
Linux
Linux 没有 clonefile() 函数,但它拥有更古老且更强大的功能:硬链接。Bun 实现了回退链机制,会尝试逐步降级的方案直至成功:
1. 硬链接
在 Linux 中,Bun 的默认策略是硬链接。硬链接根本不会创建新文件,它仅为现有文件创建新的_名称_,并引用该现有文件。
link("/cache/react/index.js", "/node_modules/react/index.js");
理解硬链接需先了解_inode_。Linux中每个文件都拥有inode——这种数据结构存储着文件的所有元数据(权限、时间戳等)。文件名本质上只是指向inode的指针:

两个路径指向同一inode。删除其中一个路径时,另一个仍保留。但修改其中一个时,两个路径都会显示变更(因为它们指向同一文件!)。

这种机制带来了显著的性能提升,因为完全无需移动数据。创建硬链接仅需一次系统调用,耗时以微秒计——无论链接1KB文件还是100MB文件包。这远比传统复制高效,后者需逐字节读写数据。
硬链接对磁盘空间也极其高效:无论多少包引用相同依赖文件,磁盘上实际数据仅存在一份副本
然而硬链接存在局限性:无法跨越文件系统边界(例如缓存与node_modules位于不同位置),部分文件系统不支持该功能,特定文件类型或权限配置可能导致创建失败。
当硬链接不可行时,Bun提供以下替代方案:
2. ioctl_ficlone
首先尝试ioctl_ficlone,该方法可在Btrfs和XFS等文件系统上启用写时复制机制。其原理与clonefile的写时复制系统相似,同样创建共享磁盘数据的新文件引用。不同于硬链接,这些是独立文件,仅在修改前共享存储空间。
3. copy_file_range
若写时复制不可用,Bun 至少会尝试将复制操作保留在内核空间,并回退至 copy_file_range。
传统复制方式中,内核会将数据从磁盘读入内核缓冲区,再复制到用户空间程序的缓冲区。后续调用 write() 时,又需将数据复制回内核缓冲区才能写入磁盘。这涉及四次内存操作和多次上下文切换!
而copy_file_range仅需两步操作:内核从磁盘读取数据至内核缓冲区后直接写入磁盘,数据传输过程中零上下文切换。
4. sendfile
若该功能不可用,Bun将使用sendfile。此系统调用虽最初为网络传输设计,但同样适用于磁盘文件间的直接数据复制。
该命令同样将数据保留在内核空间:内核从一个目标(指向磁盘上已打开文件的引用,例如~/.bun/install/cache/中的源文件)读取数据,并将其写入另一个目标(如node_modules中的目标文件),整个过程都在内核内存空间内完成。
该过程称为磁盘间复制,因其在同一磁盘或不同磁盘存储的文件间传输数据时完全绕开了程序内存。作为历史更悠久的 API,其兼容性更广,当新型系统调用不可用时可作为可靠的后备方案,同时仍能减少内存调用次数。
5. copyfile
作为最终手段,Bun采用传统文件复制方式——这与多数包管理器相同。该方法通过read()/write()循环从缓存读取数据并写入目标位置,为每个文件创建完全独立的副本。此过程涉及多次系统调用,恰恰是Bun试图最小化的环节。虽然效率最低,但兼容性最广。
Benchmark 1: bun install --backend=copyfile
Time (mean ± σ): 325.0 ms ± 7.7 ms [User: 38.4 ms, System: 295.0 ms]
Range (min … max): 314.2 ms … 340.0 ms 10 runs
Benchmark 2: bun install --backend=hardlink
Time (mean ± σ): 109.4 ms ± 5.1 ms [User: 32.0 ms, System: 86.8 ms]
Range (min … max): 102.8 ms … 119.0 ms 19 runs
Summary
bun install --backend=hardlink ran
2.97 ± 0.16 times faster than bun install --backend=copyfile
这些文件复制优化方案直击核心瓶颈:系统调用开销。Bun 摒弃千篇一律的方案,为用户量身定制最高效的文件复制机制。
多核并行处理
上述优化虽出色,但仅针对单核CPU负载优化。然而现代笔记本已配备8核、16核甚至24核处理器!
Node.js虽有线程池,但所有实际工作(如匹配React与webpack版本、构建依赖图、决定安装内容)仍仅在单线程单核上运行。当 npm 在你的 M3 Max 上运行时,一个核心全力运转,其余 15 个核心却处于闲置状态。
CPU 核心能够独立执行指令。早期计算机仅有一个核心,每次只能处理一项任务;而现代 CPU 将多个核心集成于单芯片。16 核 CPU 可同时执行 16 条不同的指令流,而非仅以极快速度在它们之间切换。
这是传统包管理器的又一根本性瓶颈:无论拥有多少个核心,包管理器只能使用单个CPU核心。
Bun采用无锁、工作窃取式线程池架构,实现了突破性变革。
工作窃取机制允许空闲线程从繁忙线程的队列中“窃取”待处理任务。当线程完成工作后,它会依次检查本地队列、全局队列,然后从其他线程窃取任务。只要还有工作可做,就不会有线程处于闲置状态。
Bun 突破了 JavaScript 事件循环的限制,通过生成原生线程充分利用每个 CPU 核心。线程池会自动扩展以匹配设备 CPU 的核心数量,使 Bun 能最大限度地并行化安装过程中的 I/O 密集型操作。一个线程可提取next的压缩包,另一个处理postcss依赖解析,第三个应用webpack补丁,以此类推。
但多线程常伴随同步开销。npm产生的数十万次futex调用,本质是线程间持续的互等。每次线程向共享队列添加任务时,都需先锁定队列,阻塞所有其他线程。
// Traditional approach: Locks
mutex.lock(); // Thread 1 gets exclusive access
queue.push(task); // Only Thread 1 can work
mutex.unlock(); // Finally releases lock
// Problem: Threads 2-8 blocked, waiting in line
Bun 则采用无锁数据结构。这类结构利用称为原子操作的特殊 CPU 指令,使线程无需锁定即可安全修改共享数据:
pub fn push(self: *Queue, batch: Batch) void {
// Atomic compare-and-swap, happens instantly
_ = @cmpxchgStrong(usize, &self.state, state, new_state, .seq_cst, .seq_cst);
}
在早期基准测试中,我们看到 Bun 每秒可处理 146,057 个 package.json 文件,而 Node.js 仅能处理 66,576 个。这正是利用所有核心而非单核心带来的性能提升。
Bun 的网络操作机制也与众不同。传统包管理器常会阻塞进程:下载包时,CPU 只能闲置等待网络响应。
Bun通过专用网络线程维护64个(!)并发HTTP连接池(可通过BUN_CONFIG_MAX_HTTP_REQUESTS配置)。网络线程独立运行并拥有专属事件循环,负责所有下载任务,而CPU线程则处理提取和解析工作。两者互不阻塞。
Bun还为每个线程分配独立内存池。“传统”多线程的缺陷在于所有线程共享同一内存分配器,导致资源争用:当16个线程同时需要内存时,彼此间将形成等待栈。
// Traditional: all threads share one allocator
Thread 1: "I need 1KB for package data" // Lock allocator
Thread 2: "I need 2KB for JSON parsing" // Wait...
Thread 3: "I need 512B for file paths" // Wait...
Thread 4: "I need 4KB for extraction" // Wait...
Bun则为每个线程预分配独立的大块内存区域,由线程自主管理。无需共享或等待,每个线程尽可能处理自有数据。
// Bun: each thread has its own allocator
Thread 1: Allocates from pool 1 // Instant
Thread 2: Allocates from pool 2 // Instant
Thread 3: Allocates from pool 3 // Instant
Thread 4: Allocates from pool 4 // Instant
结论
我们测评的包管理器并非设计缺陷,而是受限于时代背景的解决方案。
npm为我们提供了基础框架,yarn让工作区管理更轻松,pnpm则通过硬链接巧妙节省空间并提升速度。它们都致力于解决开发者当时面临的实际问题。
但那个时代已不复存在。SSD速度提升70倍,CPU拥有数十个核心,内存成本大幅降低。真正的瓶颈已从硬件速度转移到软件抽象层。
Buns的思路并非革命性创新,而是直面当今真正的性能瓶颈。当SSD能处理百万次每秒的操作时,何必忍受线程池开销?当你第百次读取相同包清单时,何必重复解析JSON?当文件系统支持写时复制时,何必复制数GB数据?
当下正在编写的工具将定义未来十年的开发者生产力——这些团队深刻理解:当存储加速、内存降价时,性能瓶颈已然转移。他们不仅在渐进优化现有方案,更在重构技术边界。
包安装速度提升25倍并非“魔法”:这是当工具真正适配现有硬件时必然实现的突破。
本文文字及图片出自 Behind The Scenes of Bun Install

> 我此刻用M4 Max MacBook撰写本文的这台设备,若放在2009年足以跻身全球前50强超级计算机之列。
我尝试验证了这一说法:2009年TOP500[0]排行榜中,需达到>75 TFlop/s的峰值性能才能进入前50名。M4 Max评测显示其FP32精度下为18.4 TFlop/s,但TOP500采用LINPACK测试,该测试使用FP64精度。
M2基准测试显示双精度运算效率为1:4,因此FP64精度下可能达到9 TFlop/s?这在2009年无法跻身TOP500榜单。
[0]: https://top500.org/lists/top500/list/2009/06/
> 现在乘以数千个并发连接,每个连接执行多次I/O操作。服务器约95%的时间都在等待I/O操作。
其实不然。某个特定执行线程可能耗费95%时间等待I/O,但服务器(承载数千连接的机器)的CPU利用率通常能轻松维持在70%-80%(因为超过该阈值尾部延迟将急剧恶化)。若服务器满负荷运行时CPU利用率仅5%,说明你未启动足够的并行进程,或未配置足够内存支持并行处理。
这虽是技术细节,但该帖子本就专注于技术细节,此类小失误会削弱读者对全文的信任。(作为Bun的粉丝,我必须指出这一点。)
我猜这是大型语言模型(LLM)的幻觉产物。结论部分尤其透露出LLM生成的痕迹:
> 我们测评的包管理器并非设计缺陷,而是为时代限制量身打造的解决方案。
> Bun的方法并非革命性创新,只是愿意直面当今真正的性能瓶颈。
> 安装包速度提升25倍并非“魔法”:当工具真正适配现有硬件时,自然会实现这种效能。
抱歉,这里的关键特征是什么?
不是_____,而是______。
一周前我参与的另一段讨论:
https://news.ycombinator.com/item?id=44786962#44788567
“优化tar包解压”这个概念让我有些困惑。它先说明其他包管理器需要反复将接收到的压缩数据复制到越来越大的缓冲区(完全没提解压后数据存放的缓冲区),接着却说:
> Bun采用不同方法,在解压前先缓冲整个tar包。
但似乎回避了它究竟如何与开篇“糟糕”示例实现差异(推测是在获取tarball时检查Content-Length头部,从而能确保获取的大小正确)。关于此处的说明仅有:
> Bun将完整tarball加载至内存后,即可读取gzip格式的最后4字节。
接着它解释了如何为解压数据预分配缓冲区,但我们从未在“糟糕”示例中看到这种缓冲区分配机制!
> 这些字节具有特殊意义,因为它们存储了文件的未压缩大小!Bun无需猜测未压缩文件的大小,即可预分配内存,从而完全避免缓冲区动态调整
推测其优势在于:低效的包管理器需同时扩展_两个_缓冲区,而bun至少预分配其中一个?
复杂主题,行文却简洁明快。向作者致敬。
另:欣喜于世间仍存在如此热忱之人,敢于挑战现状攻克艰深难题——那些我连思考的勇气都没有的领域。每月硬件升级而软件效率却持续下降实属异常。但愿人人(包括我自己)都能精进编写高效代码的技艺。
未曾料到竟是用Zig语言编写的。考虑到这门语言如此年轻,这个选择让我着迷。
看到它在实际生产环境中被应用真是太棒了。
Zig其实诞生于2016年——至今已有近十年历史。或许令人惊讶的是,我们很少在知名成熟项目中接触到这门语言,不像Rust、Go和C那样常见。
Zig仍处于0.x阶段,基础功能如I/O和内存分配仍在持续迭代。我虽乐于用它编程,但其稳定性远未达到支持大规模生产级应用的程度。
Rust于2015年发布1.0版本,其开发始于2006年Graydon Hoare的个人项目;这些时间节点与Zig的发展历程高度吻合。
公平地说,十年前的Zig与如今的Zig已是截然不同的语言。
这不幸成为基于Zig训练的人工智能面临的问题,使得某些人工智能辅助的Zig编程任务更具挑战性,例如问答和代码补全功能。令人遗憾的是,这种玻璃天花板现象已然在新型语言和框架中形成——虽非致命缺陷,却让任何Zig相关项目的交付周期突然蒙上阴影。不过……当招聘优秀程序员来处理冷门技术时,同样的问题也存在。
未来或许会针对新人和弱势群体制定策略(AEO?),比如由智能AI持续向文档和GitHub贡献海量示例,使其被训练集、实时工具调用或网络搜索所采纳。
该语言虽仍处于开发阶段,但其生态系统和工具链已相当成熟。
我不会说生态成熟。除了出色的C语言互操作性和流行的C/C++库封装外。
上周首次使用Bun,体验超棒!内置服务器和SQLite意味着除了Bun本身外无需任何依赖,这绝对是我最喜欢的开发方式。
尽管厌恶Node生态,我几乎所有开发都用原生JavaScript,早该试试Bun的。
我试用过几次Bun,操作体验相当顺手。
远胜Node。
然而…!
每次使用Bun总会遇到障碍,最终不得不回归Node。
起初是加密模块与Nodejs签名不兼容(现已修复),接着Playwright拒绝配合Bun运行(通过Crawlee)。
Playwright支持即将优化。我们正在重写node:http的客户端实现以通过Node测试套件,预计下周完成。
仅需将Bun作为包管理器使用即可,无需将其作为运行时环境。
确实如此!若不进行浏览器自动化,也可将其作为测试运行器/库使用。即使不用作运行时,Bun仍具备显著优势。
确定吗?
若存在带Node.js C++扩展的包,是否仍能正常工作?
为什么不行?npm install或bun install的最终结果都是将node_modules目录结构化为所需形态,而且我认为它能为需要node-gyp的包自动运行该工具。
Playwright的问题一年前就修复了。
遗憾的是Deno也不支持crawlee
我认为这正是阻碍“更优/更高效”工具普及的关键因素——即向后兼容性和即插即用能力。这恐怕印证了许多赫伦定律。
你该试试Deno,它的Node兼容性很出色
真的吗?几年前我试用时,Node API的覆盖率很差。当时想通过UDP传输数据,发现很多Node基础功能都缺失。
Deno的Node兼容性现在好多了。
虽然仍缺些小众功能,但他们主要针对大多数开发者(及其依赖项)实际使用的功能进行优化。
不过我注意到他们现在兼容库里已包含该功能,本地 REPL 环境中运行正常:https://docs.deno.com/api/node/dgram/
Storybook 对我来说也是关键。
不过Node现在不是自带服务器和SQLite了吗?或者如果你想要单依赖实现更多功能,Hono是个不错的选择。
Hono有多少依赖项?看起来约有26个。这些依赖项又包含多少依赖?
单一的静态zig可执行文件,与易受供应链攻击、且存在DOS时代以来最严重代码腐化的包管理依赖管道截然不同。
> 那Hono有多少依赖项?
零。
我猜你看到的是package.json里的`devDependencies`,但这些仅供项目构建者使用,普通使用者无需关心。
如此技术性的解释竟能写得如此通俗易懂,令人惊叹。文笔真棒。
说得对!
Lydia 擅长将复杂概念化繁为简。我读过她大部分著作,看过所有视频。她总倾注心血让知识鲜活呈现。强烈推荐她的文章和YouTube视频。
不过她最近更新变少了,可能是因为工作原因
我认为他们在“二进制清单缓存”章节里漏掉了“npm(缓存版)”的基准时间。我们有bun、bun(缓存版)、npm这三种版本。总结统计数据似乎也有误。
他们似乎未在每次新运行间清除缓存。这从下限时间范围与缓存均值时间一致即可看出。
这导致他们得出错误结论:bun新运行比npm缓存更快,但实际情况并非如此。
bun的安装速度很快,但我觉得它可能因为速度太快且并发性太高,导致npm有时会彻底混乱。
我用bun安装时,时不时会收到npm返回的500错误,却始终搞不清原因。
真希望行业标准是企业为自身需求托管专属注册表,这样我就能合理化相关成本投入,而非应对注册表时不时莫名其妙出故障的状况。
我负责Bun开发工作,也投入大量时间优化bun install流程。欢迎随时提问
据我所知,
bun install与npm install类似,都会将所有依赖以扁平结构安装到node_modules目录中。为何不选择pnpm这类更优方案?因为pnpm能避免误导入传递性依赖。或许多数人不在意,但我对此很在意。你可以使用
bun install --linker=isolated,我们可能在Bun v1.3中将其设为默认选项。主要缺点是应用加载会略微变慢,因为此时每个文件路径都变成了符号链接。https://bun.sh/docs/install/isolated
谨此向您、团队及社区致谢。Bun的使用体验令人愉悦。
这篇博文的行文风格深得我心。
几点补充:
– 感觉这篇博文若重新包装,能成为阐释_io_uring_重要性的绝佳范例。
– 不知Zig语言v0.15版本的io更新是否为Bun带来了超越现有性能的进一步提升。
我关注Bun已有近一年,原以为2025年将是它爆发之年。但它至今未获广泛采用着实令人意外。我扫描了GitHub前10万个仓库,发现2025年新增仓库中npm的普及率是Bun的35倍,pnpm也达到其11倍[0][1]。另一款新兴JavaScript运行时Deno同样未获广泛采用。
究其原因何在?是否因为运行时环境的兼容性要求远高于普通包管理器?
请试用过bun却未在个人或工作中采用的用户分享原因。
[0] https://aleyan.com/blog/2025-task-runners-census/#javascript…
[1] https://news.ycombinator.com/item?id=44559375
我真的很想喜欢Bun和Deno。我多次尝试使用这两者,但至今从未超过几千行代码就遇到致命缺陷。
Bun最近让我头疼的是流过早关闭的问题:
https://github.com/oven-sh/bun/issues/16037
Deno遇到的最大问题是内存泄漏:
https://github.com/denoland/deno/issues/24674
眼下感觉Node生态圈很可能在Bun/Deno真正起飞前就吸收它们的优点。
呃…好像是AI用户看到这条评论修复了你的Bun问题?或者它只是随机删了代码,我也不确定。
https://github.com/oven-sh/bun/commit/b474e3a1f63972979845a6…
bun团队用Discord启动Claude机器人,所以可能有人看到评论后让它这么做的。不过那次编辑看起来不太妥当
这是个由风投支持的新晋竞争者,试图挑战开源领域经实战检验的霸主地位。它有锁定用户的动机,本质上和node没什么区别。使用bun基本没有战略优势,它无法实现Node做不到的功能。目前尚未见到严肃开发者选用它,倒是见过不少不认真的用户在用。
这总结得很到位。它并未达到十倍优势的水平,不足以让人们冒险选择这家风投支持的公司带来的供应商锁定风险。Prisma和Next对我来说也是同样的问题。
我也非常好奇大家的看法。在我看来,Node作为项目散发着成熟、民主和社区驱动的气息,尤其是在几年前成功化解io.js分叉风波之后。显然bun和deno都不是社区驱动的民主项目,因为它们都获得了风投资金。
看看它们的问题追踪器吧,满屏都是崩溃报告——显然Zig语言安全性极差。我还是继续用Node。
正因如此,若非要从Bun和Deno中选Node替代品,我会选Deno。
Zig本身并非天生高风险。某些方面比Rust稍逊,但在其他几方面却更安全。
但该语言尚未达到1.0版本,许多实现安全Zig的策略尚未完全成熟。
然而,TigerBeetle是用Zig语言编写的,是一款极其健壮的软件。
我认为Bun的重点短期内可能更侧重功能对等性。
幸好libuv是用“安全”语言编写的。
npm如同雷区,每日有数千人穿行其间,因此你不太可能触雷。
bun则是崎岖小路,通行者寥寥,你很可能撞上颠簸。
存在`crash`标签,758个未解决问题。
Node 基于 C++ 实现,安全性同样存疑。但其经过更充分的测试。
我是 Bun 的头号拥趸。所有项目都尽可能使用它,一次性脚本也全用 Bun/TS 编写。不过确实遇到过几个问题,让我对将其投入生产环境有些顾虑。比如不久前遇到的问题:在Docker中运行简单的Express网关时会卡死,但切换回Node就正常了。一年前还有次Bun+Prisma网关缓慢内存泄漏直至崩溃的情况(事隔一年,相信他们已修复)。
尽管存在这些烦人问题,我仍认为Bun如此优秀,总体上仍能节省开发时间。它解决的转译、模块、工作区等痛点实在令人惊叹。不过我也能理解它为何至今未能逼近npm的地位。
Bun和Deno都没有杀手级功能。
当然,它们有些亮点值得引入Node,但不足以让人为之颠覆生态系统并承受兼容性问题。
bun test才是杀手级功能
我认为问题部分在于:许多变更都属于渐进式改进,因此很容易被NodeJS重新纳入。或者它们只是让Bun入门更轻松,却未能带来实质性的长期价值。例如评论中有人提到sqlite模块和HTTP服务器,但如今NodeJS也原生支持sqlite。若从事Web开发并编写服务器,我更倾向于使用Express或Fastify这类经过实战检验的框架,它们拥有更庞大的生态系统。
这确实是个酷炫的项目,我欣赏他们不依赖V8引擎的创新尝试,但仅凭这些渐进式改进很难说服开发者转变阵营。
我尝试用Bun运行项目——失败后便放弃了。此外,转向新生态系统必须有足够有力的理由。
Bun 存在一些粗糙之处(参见相关评论),因此切换显然存在成本——开发者需耗费时间处理 Node 不兼容问题。对我而言,安装包速度提升 7 倍意义不大,因此看不到切换的优势。
兼容性问题依然存在…我对Deno更为熟悉,过去几年频繁使用它,如今它几乎已成为我的默认Shell脚本工具。
不过在许多工作项目中,我需要访问MS-SQL,而Deno运行时不支持其套接字连接方式(或类似限制),这限制了我的工作能力。我猜想Bun在其他模块/工具方面也存在类似的兼容性问题。
要摆脱Node+npm的生态体系也极其困难。这个生态经过十余年积累和大量投入才形成,人们不会轻易放弃。
我特别喜欢用Deno编写shell脚本,因为它支持shebang标记、依赖引用,运行时会自动处理这些操作。无需额外执行“npm install”步骤,也不会让~/bin/目录充斥可能冲突的node_modules文件——所有依赖都从共享(可配置)位置加载。我猜Bun的工作原理应该类似。
话虽如此,工作中我需要配合现有的系统,或是被动接受他人选定的技术栈。技术替换并非总能随心所欲。
要击败现有技术,你必须做到两倍优秀。目前看来它仅提升了1.1倍效率(适用于中等规模项目),且作为开发中的工具存在预期中的缺陷,生态支持也存疑。这或许适用于业余项目或小型绿地项目,但我绝不会拿重要公司项目冒险尝试。
去年测试时表现已接近两倍效率。
https://dev.to/hamzakhan/rust-vs-go-vs-bun-vs-nodejs-the-ult…
> 性能提升超过1.1倍(适用于任何合理规模的项目)
特定微基准测试中的2倍提升,实际应用中并不会带来显著节省。我们不会在生产环境中用应用服务器来服务静态字符串。
去年试过——我花了几个小时和内置的sqlite驱动器较劲,发现它漏洞百出(静默错误),文档也极其匮乏。
Bun比pnpm新得多,从1.0版本来看pnpm领先了约6年。
我常为Node/TS项目编写一次性脚本,Bun刚火时就尝试过。但生态兼容性问题太多,之后就没再碰了。
说实话它没解决我的核心痛点,反而带来了“新生代”工具常见的各种问题。
> 这究竟为何?
LLM默认选择npm
难道不是因为npm作为Node默认包管理器已存在15年?
这没能阻止我切换到Bun,毕竟成本为零。
这写得真好,但我不太明白Linux的硬链接如何等同于MacOS的clonefile。如果理解正确,难道修改单个“副本”不会导致所有项目中的文件意外更新吗?
读得津津有味。这正是计算机科学原理在日常软件开发中至关重要的绝佳例证。
诸如大O表示法、时空局部性、算法复杂度、底层用户空间/内核空间概念、文件系统、写时复制等概念,全都是优质计算机科学课程的核心内容。而在这类底层软件包中,你将所有知识都运用得淋漓尽致。
这属于软件工程范畴而非计算机科学。
计算机科学研究计算及其理论(编程语言、算法、密码学、机器学习等)。
软件工程则是将工程原理应用于构建可扩展可靠软件的实践。
> Node.js 使用 libuv 库——这个 C 库通过线程池抽象平台差异并管理异步 I/O。
> Bun 的实现方式不同。它采用 Zig 语言编写,该语言编译为原生代码并直接访问系统调用:
猜猜看,C/C++ 同样能编译为原生代码。
我理解他们的观点且认同其合理性,Node.js本可实现类似方案却未采纳。
但请勿暗示其天生无能为力。npm采用该抽象层并非被迫之举,它本应从一开始就成为基于C/C++的Node.js扩展。
(若以上内容听起来像在为npm或Node辩护,实则不然。)
在我看来,其逻辑似乎是:
npm、pnpm和yarn用JS编写,因此必须依赖基于libuv的Node.js设施,而libuv在此场景下并非最优解。
而Bun采用Zig语言开发,无需依赖libuv,因此能实现独立运行。
显然,开发者完全可以编写C/C++原生模块实现Node.js包管理功能,但npm、pnpm和yarn并未选择这条路。
问题核心难道不是libuv是C语言实现,而是调用它的Node.js是JavaScript?这意味着每次libuv发起系统调用时都需切换运行模式?
完全不知道莉迪亚现在在Bun工作。她的技术写作绝对是顶尖水平
我不太理解为什么等待读取完整压缩文件再解压更有益。在下载完成前开始解压的优势,难道不远大于因向量调整大小而多复制几次内存的开销吗?
流式处理会阻碍许多优化,因为代码无法假设单次运行就能完成任务,因此必须频繁暂停/恢复、为更长的数据克隆冗余内容,并更谨慎地处理边界情况。
通常只有在处理数十兆字节以上数据时才值得采用,但绝大多数npm包远小于这个规模。所以能避免就尽量避免。
欣赏这种从第一性原理出发的包管理设计,将其视为系统级优化问题而非文件脚本。其架构类似数据库引擎——依赖感知任务调度、缓存局部性、系统调用开销——这些特性都已具备。
我有点好奇Deno在这方面表现如何…另外不确定具体安装哪些包。可能会创建一个Vite模板项目作为基准,配置React+TypeScript+MUI,毕竟这是工具链中比较典型的应用组合。或许还会尝试hono+zod+openapi。
出于个人好奇,在工作台式机上测试React应用。
npm i,1分20秒- 使用 deno.lock,无 node_modules,20秒- 清除npm ci(package-lock.json),无 node_modules,1分2秒(异常)看来如果Deno能像bun那样添加package-lock.json转换功能,安装流程就会变得高度统一了。这台机器的安全软件我无法控制,当时只是出于操作便利才这么做的。
希望有人能关注这个议题:https://github.com/denoland/deno/issues/25815
我认为Deno未被纳入基准测试,是因为其可比性远比表面看起来复杂。
Deno的依赖架构并非基于npm构建;那层兼容性封装实则是对核心的后期改造(若您想查阅源代码便能发现)。Deno的核心依赖管理架构采用基于URL的全新范式。虽然速度稍逊,但…它本质不同。这种设计能提升安全性,并支持诸如轻松托管自有安全注册表等创新功能。你无需依赖npm或jsr。这套方案非常出色,但与当前基准测试的对象截然不同。
尽管如此,在包含 package.json 的目录中运行 deno install 仍会解析并安装到 node_modules。该过程同样采用编译代码实现(类似 bun…),因此我只是好奇。
编辑:回复自己的帖子…当 deno.lock 存在时,
deno install --allow-scripts比 bun 慢约 1 秒。哇,真难以置信 yarn 竟如此缓慢,它曾经比 npm 快得多。在我任职的公司里,我们从 npm 换到 yarn,再到 pnpm,最后又回到了 npm。如今我尽量使用 Bun,但 Vercel 仍未将其原生集成到 Next 中。
为何放弃pnpm?
“…这是gzip格式的最后4个字节。这些字节很特殊,因为它们存储了文件的未压缩大小!”
这有什么意义?
我猜想,许多工具若能提前知道解压后的文件大小会更高效。
若假设存在单个GZIP“成员”,这完全符合GZIP规范:https://www.ietf.org/rfc/rfc1952.txt
> ISIZE(输入大小)
> 此字段包含原始(未压缩)输入数据的大小模2^32。
因此存在两大限制:
1. 数据必须是单个GZIP成员(我猜这意味着整个文件夹的内容)
2. 数据大小需小于2^32字节。
嗯,我明白了。
只是好奇GZIP为何采用这种规范。
因为这支持流式压缩。
啊,有道理。
谢谢!
我认为这是因为它能在牺牲流式解压效率的前提下,实现高效的流式压缩。
gzip.py [1]
—
def _read_eof(self):
# 已读至文件末尾,需回卷以重新读取
# 包含CRC校验和文件大小的8字节数据。
# 验证计算出的CRC值与未压缩数据大小
# 是否与存储值匹配。注意存储的大小
# 为真实文件大小模2×32后的值。
—
[1]: https://stackoverflow.com/a/1704576
我也非常喜欢Bun,但在Windows 10的WSL1环境下(我更倾向于WSL1而非WSL2)难以使其正常运行。例如:
为何你更偏好WSL1而非WSL2?
最显著的例子是跨操作系统边界的文件系统调用在WSL1中明显更快。我个人更倾向WSL2,但会尽量避免使用/mnt/c/路径,更绝不会跨边界运行数据库(如sqlite),否则你会后悔的。
WSL1就是更快,没有奇怪的网络问题,而且我能在Windows和Linux两边无缝编辑Linux文件。
Python有uv,JS有bun,Ruby或PHP呢?使用这些语言的开发者对当前主流依赖管理器的速度满意吗?
你理解错了。Python有nix,JS有nix,ruby和php也有nix 😀
这更接近pnpm实现加速的方式。我知道最近有'rv',但还没试过。
你是说Nix包管理器?我以前用过NixOS,但因环境变量问题陷入无休止的混乱而放弃了。
没错,就是Nix包管理器。或者devenv——它实现了我描述功能的精简版,类似mise但基于Nix驱动。
PHP即将迎来Mago(用Rust编写)。
仓库:https://github.com/carthage-software/mago
9个月前公告:
https://www.reddit.com/r/PHP/comments/1h9zh83/announcing_mag…
目前主要功能有三项:格式化、代码检查和修复代码检查问题。
希望他们能添加类似Composer的包管理功能。
它相当新颖,但在Ruby中存在`rv`工具,其设计显然受到`uv`的启发:https://github.com/spinel-coop/rv。
>由 Spinel 呈现
>Spinel.coop 是 Ruby 开源维护者组成的集体,致力于打造 rv 等新一代开发者工具,并为来自 Rails、Hotwire、Bundler、RubyGems、rbenv 等核心团队的维护者提供定额无限访问权限。
Bundler 在 Ruby 端通常相当高效。它还能复用特定 Ruby 版本的依赖项,避免每个项目都重复下载存储依赖项导致的 node_folder 文件堆积。当项目已有 90% 依赖项时,仅需下载并安装/编译剩余 10% 的依赖项,效率提升堪称天壤之别。
PHP有Composer,而且极其优秀!
PHP更接近原始C语言,默认不支持线程,因此Composer应该不会遭遇bun和npm之间因线程同步与事件循环差异引发的问题。
但Node默认也不支持线程吧?难道说npm本身是多线程的?
Bun这个名字念起来真带劲。
> 然而这种模式切换代价高昂!仅切换本身就消耗1000-1500个CPU周期纯开销,这还不包括实际工作。…
> 在3GHz处理器上,1000-1500个周期约等于500纳秒。这看似微不足道,但现代SSD每秒可处理超过百万次操作。若每次操作都需要系统调用,仅模式切换就意味着每秒消耗15亿个周期。
> 软件包安装会触发数千次系统调用。安装React及其依赖项可能引发5万次以上的系统调用:仅模式切换就耗费数秒CPU时间!这还不包括读取文件或安装包的时间,纯粹是用户模式与内核模式之间的切换。
我是否遗漏了什么,还是这个说法有误?他们声称每次系统调用耗时500纳秒,5万次调用应为500纳秒×50000=25毫秒。这与“仅模式切换就损失数秒CPU时间”相差甚远,对吧?
继续往下看。后续基准测试中,Yarn执行了400万次系统调用。
虽然总耗时仍只有约2秒,但这点值得注意。
好奇Yarn为何比npm慢这么多?有没有反驳这篇文章的观点?
文章写得不错,但读起来很像AI生成的。
macOS也有硬链接功能,为何不用?
还有人看到这个词首先联想到https://xkcd.com/1682而不是面包吗?
这些方案固然不错,但节点模块的安装时间从未成为我参与的任何项目的关键阻碍。相较于人力(完成变更的能力与时间)和基础设施(持续集成/部署/成本),这根本不值一提。缩短20秒的依赖项安装时间根本不是决定成败的关键因素。
这足以让人分心。若能将耗时从15秒以上缩短至几秒甚至更短,就值得去做。
你多久会完整安装一次依赖?对我而言,即使在超大型单仓库中,使用npm/pnpm/yarn重新运行也最多只需1-2秒。实在难以想象需要频繁进行完整安装的情境。
我发现这很大程度上取决于驱动器速度,因此在组装新电脑时,我倾向于尽可能采用当前世代的高速驱动器,有时也会进行中期升级。考虑到我经常在不同项目间进行咨询工作,我经常需要在由pnpm管理的单一仓库、由yarn管理的另一个仓库等环境中配置和安装各种工具… 因此痛点相当真实,不过最快的驱动器同样重要甚至更关键,尤其在构建步骤中。
处理合并/拉取请求时,我常在完整安装和构建前执行清理步骤(删除node_modules和临时文件)以确保功能正常。我知道并非所有人都如此细致,但这种情况可能每天发生多次… 自动化(通常通过Docker实现)能通过CI/CD环境大幅提升测试效率,但我也讨厌等待流程耗时过长…很容易因此分心脱离任务。我整天设置各种闹钟/计时器,只为确保不缺席会议。我不想只是想看一眼Hacker News,结果一转眼就过了几小时。没错,这是我的问题…但其他人也有同感。
所以重申:若能把耗时15秒以上的工作压缩至15秒内完成,我绝对支持。当初从eslint转用Rome/Biome也是出于类似考量——我会选择更高效的工具,以降低分心后难以回正轨的风险。
我也刚在主工作单仓库里测试了bun和yarn的安装速度。bun耗时12秒,yarn耗时15秒。这点差距根本不值一提。
没错,我发现硬盘速度同样影响显著。第五代PCIe硬盘表现惊人…即使和优秀的第四代硬盘相比,Rust编译速度的差异也相当惊人。
看到Web开发圈终于有人重视性能,且真正理解计算机运作原理,我感到无比欣慰。
实在受够了那种“管它占用100MiB内存呢,反正用着顺手”的态度。