Bun

Bun 安装的幕后花絮


Lydia Hallie · 2025 年 9 月 10 日

运行 bun install 非常快,极快。平均而言,它比 npm 快约 7 倍,比 pnpm 快约 4 倍,比 yarn 快约 17 倍。在大型代码库中,这种差异尤其明显。曾经需要几分钟才能完成的操作,现在只需(毫)秒即可完成。

Timeline showing shift from I/O wait to syscall overhead

这并非只是经过精心挑选的基准测试。Bun 之所以快速,是因为它**将包安装视为一个系统编程问题**,而不是一个 JavaScript 问题。

在本篇文章中,我们将探讨这意味着什么:从最小化系统调用和将清单缓存为二进制格式,到优化 tarball 提取、利用操作系统原生的文件复制以及跨 CPU 核心进行扩展。

但要理解为什么这很重要,我们首先需要回顾一下过去。


那是 2009 年。你正在从 .zip 文件安装 jQuery,你的 iPhone 3GS 只有 256MB RAM。GitHub 才刚刚成立一年,SSD 价格高达 700 美元才能买到 256GB。你的笔记本电脑上的 5400RPM 硬盘最大速度为 100MB/s,“宽带”意味着 10 Mbps(如果你运气好的话)。

但更重要的是:Node.js 刚刚发布!Ryan Dahl 正在台上解释为什么服务器大部分时间都在等待。

在 2009 年,典型的磁盘寻道需要 10 毫秒,数据库查询需要 50-200 毫秒,而向外部 API 发起 HTTP 请求需要 300 毫秒以上。在每次这些事务处理期间,传统服务器只是……等待。你的服务器会开始读取文件,然后冻结 10 毫秒。

2009 server waiting on I/O illustration

现在乘以数千个并发连接,每个连接都执行多个 I/O 操作。服务器大约 95% 的时间都在等待 I/O 操作。


Node.js 认为 JavaScript 的事件循环(最初是为浏览器事件设计的)非常适合服务器 I/O。当代码发起异步请求时,I/O 会在后台进行,而主线程会立即转到下一个任务。完成后,回调会被排队等待执行。

Node.js event loop and thread pool flow for fs.readFile
Node.js 如何处理 fs.readFile 与事件循环和线程池的简化图示。为清晰起见,省略了其他异步源和实现细节。

JavaScript 的事件循环为数据等待是主要瓶颈的世界提供了一个很好的解决方案。

在接下来的 15 年里,Node 的架构塑造了我们构建工具的方式。包管理器继承了 Node 的线程池、事件循环、异步模式;这些优化在磁盘寻道需要 10 毫秒时是有意义的。

但是硬件进化了。现在已经是 2025 年了,不是 2009 年了,尽管这很难相信。我用来写这篇文章的 M4 Max MacBook 的性能在 2009 年足以跻身全球最快的 50 台超级计算机之列。今天的 NVMe 驱动器速度可达 7000 MB/s,比 Node.js 设计时的速度快 70 倍!缓慢的机械硬盘已不复存在,互联网速度可以播放 4K 视频,即使是低端智能手机的 RAM 也比 2009 年高端服务器的 RAM 多。

然而,今天的包管理器仍在优化过去十年的问题。在 2025 年,真正的瓶颈已不再是 I/O。**而是系统调用**。

系统调用的问题

每当你的程序想让操作系统执行某项操作(读取文件、打开网络连接、分配内存)时,它都会进行一次系统调用。每次进行系统调用时,CPU 都必须执行一次**模式切换**。

你的 CPU 可以有两种模式运行

  • 用户模式 (user mode),你的应用程序代码在此模式下运行。用户模式下的程序无法直接访问设备的硬件、物理内存地址等。这种隔离可以防止程序相互干扰或导致系统崩溃。
  • 内核模式 (kernel mode),操作系统内核在此模式下运行。内核是操作系统的核心组件,负责管理资源,例如调度进程使用 CPU、处理内存以及磁盘或网络设备等硬件。只有内核和设备驱动程序在内核模式下运行!

当你想在程序中打开一个文件(例如 fs.readFile())时,在用户模式下运行的 CPU 无法直接从磁盘读取。它首先必须切换到内核模式

在此模式切换期间,CPU 会停止执行你的程序 → 保存其所有状态 → 切换到内核模式 → 执行操作 → 然后切换回用户模式。

CPU mode switches between user and kernel mode

然而,这种模式切换成本很高!仅此切换本身就会产生 1000-1500 个 CPU 周期作为纯粹的开销,在实际工作发生之前。

你的 CPU 以每秒数十亿次的时钟进行操作。一台 3GHz 的处理器每秒完成 30 亿个周期。在每个周期中,CPU 都可以执行指令:加法、数据移动、比较等。每个周期需要 0.33 纳秒。

在 3GHz 的处理器上,1000-1500 个周期大约是 500 纳秒。这听起来可能微不足道,但现代 SSD 每秒可以处理超过一百万次操作。如果每次操作都需要系统调用,你每秒将花费 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 仅 165k 次。

每次调用 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', ...)

  1. JavaScript 会验证参数,并将字符串从 UTF-16 转换为 UTF-8 以供 libuv 的 C API 使用。这会在任何 I/O 开始之前短暂阻塞主线程。
  2. libuv 将请求排队到 4 个工作线程之一。如果所有线程都忙,你的请求就会等待。
  3. 一个工作线程会获取请求,打开文件描述符,并进行实际的 read() 系统调用。
  4. 内核切换到内核模式,从磁盘获取数据,并将其返回给工作线程。
  5. 工作线程通过事件循环将文件数据推回主线程,主线程最终安排并运行你的回调。

每一个 fs.readFile() 调用都经过这个管道。包安装涉及读取*数千*个 package.json 文件:扫描目录、处理依赖元数据等等。每次线程协调(例如,访问任务队列或向事件循环发出信号)时,都可能使用 futex 系统调用来管理锁或等待。

进行数千次系统调用的开销可能比实际数据传输本身花费的时间还要长!


Bun 的做法不同。**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 读取文件时

  1. Zig 代码直接调用系统调用(例如 openat())。
  2. 内核立即执行系统调用并返回数据。

就是这样。没有 JavaScript 引擎,没有线程池,没有事件循环,也没有在不同运行时层之间的封送处理。只是原生代码直接向内核进行系统调用。

性能差异不言而喻。

运行时版本每秒文件数性能
Bunv1.2.20146,057
Node.jsv24.5.066,576慢 2.2 倍
Node.jsv22.18.064,631慢 2.3 倍

在此基准测试中,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 使用 Apple 的“隐藏”异步 DNS API (getaddrinfo_async_start()),它不是 POSIX 标准的一部分,但它允许 Bun 进行完全异步的 DNS 请求,使用 mach ports,这是 Apple 的进程间通信系统。

当 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.node.org.cn/"
    },
    "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.node.org.cn/"
    }
    // ... 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.node.org.cn/" 出现在每个版本中。总的来说,重复的字符串很多。

在内存中,JavaScript 为每个字符串创建一个单独的字符串对象。这浪费了内存,并且使比较变慢。每次包管理器检查两个包是否使用相同版本的 postcss 时,它都会比较不同的字符串对象,而不是指向同一个字符串。


Bun 将包清单存储为二进制格式。 当 Bun 下载包信息时,它只解析一次 JSON,并将其存储为二进制文件(位于 ~/.bun/install/cache/ 中的 .npm 文件)。这些二进制文件包含所有包信息(版本、依赖项、校验和等),存储在特定的字节偏移量处。

当 Bun 访问名称 lodash 时,它只是指针算术:string_buffer + offset。没有分配,没有解析,没有对象遍历,只是从已知位置读取字节。

// Pseudocode

// String buffer (all strings stored once)
string_buffer = "lodash\0MIT\0Lodash modular utilities.\0git+https://github.com/lodash/lodash.git\0https://lodash.node.org.cn/\04.17.20\04.17.21\0..."
                 ^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 ...
}

从一个小缓冲区开始,随着更多解压数据的到来而增长。当缓冲区填满时,你分配一个更大的缓冲区,将所有现有数据复制过去,然后继续。

这似乎很合理,但它造成了性能瓶颈:随着缓冲区反复超出其当前大小,你最终会多次复制相同的数据。

Repeated buffer resizing and copying during streaming decompression

当我们有一个 1MB 的包时

  1. 从 64KB 缓冲区开始
  2. 填满 → 分配 128KB → 复制 64KB
  3. 填满 → 分配 256KB → 复制 128KB
  4. 填满 → 分配 512KB → 复制 256KB
  5. 填满 → 分配 1MB → 复制 512KB

你不必要地复制了 960KB 的数据!而且这种情况发生在每个包上。内存分配器必须为每个新缓冲区找到连续的空间,而旧缓冲区在复制操作期间保持分配状态。对于大型包,你可能会复制相同的字节 5-6 次。


Bun 采取了不同的方法,即**在解压缩之前缓冲整个 tarball**。Bun 不是在数据到达时处理数据,而是等待整个压缩文件下载到内存中。

现在你可能会想,“等等,他们难道没有浪费 RAM 将所有东西都保存在内存中吗?”对于像 TypeScript 这样大的包(压缩后可能高达 50MB),你可能会认为这是有道理的。

但绝大多数 npm 包都很小,大多数都在 1MB 以下。对于这些常见情况,缓冲整个文件消除了所有重复的复制。即使对于那些较大的包,内存的临时峰值在现代系统上通常也是可以接受的,并且避免 5-6 次缓冲区复制的收益远远超过了它。

一旦 Bun 将完整的 tarball 放入内存中,它就可以读取 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 字节”,因此它可以预先分配正好这么多的内存。没有重复的大小调整或数据复制;只有一个内存分配。

Preallocation using gzip ISIZE avoids buffer growth copies

为了实际进行解压缩,Bun 使用 libdeflate。这是一个高性能库,其解压缩 tarball 的速度比大多数包管理器使用的标准 zlib 更快。它专门针对支持 SIMD 指令的现代 CPU 进行了优化。

对于用 Node.js 编写的包管理器来说,优化 tarball 提取将非常困难。你需要创建一个单独的读取流,定位到末尾,读取 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 的数据在内存中的位置。然后,这些数据又包含一个指向其依赖项所在位置的指针,而该指针又包含指向实际依赖项字符串的更多指针。

Pointer chasing through scattered JS objects in memory

关键问题在于 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 字节块。它会认为,如果你需要一个字节,你很可能很快就需要附近的字节(这称为空间局部性)。

Cache line showing 64-byte fetch granularity

这种优化对于顺序存储的数据效果很好,但当你的数据随机分散在内存中时,它就会适得其反。

当 CPU 在地址 0x2000 加载 packages["next"] 时,它实际上加载了该缓存行的所有字节。但是下一个包 packages["postcss"] 在地址 0x8000。这是一个完全不同的缓存行!CPU 在缓存行中加载的其他 56 个字节完全被浪费了,它们只是随机内存,是从附近任何碰巧分配的区域中获取的;可能是垃圾,可能是无关对象的片段。

Wasted cache line bytes due to non-local allocations

但是你却付出了加载 64 字节但只使用了 8 字节的代价……

当你访问 512 个不同的包(32KB / 64 字节)时,你就已经填满了整个 L1 缓存。现在,每次访问新包都会驱逐先前加载的缓存行以腾出空间。你刚刚访问的包很快就会被驱逐,而它在 10 微秒内需要检查的依赖项已经消失。缓存命中率下降,每次访问都变成了一次约 300 个周期的 RAM 行程,而不是一次 4 个周期的 L1 命中,远非最优。

对象的嵌套结构产生了所谓的“指针追逐”,这是系统编程中常见的反模式。CPU 无法预测接下来要加载什么,因为每个指针都可能指向任何地方。在加载完 next 对象之前,它根本不知道 next.dependencies 存在于何处。

在遍历 Next 的依赖项时,CPU 必须执行多个依赖内存加载:

  1. 加载 packages["next"] 指针 → 缓存未命中 → RAM 获取(约 300 个周期)
  2. 跟随该指针加载 next.dependencies 指针 → 另一个缓存未命中 → RAM 获取(约 300 个周期)
  3. 跟随该指针查找哈希表中的 "postcss" → 缓存未命中 → RAM 获取(约 300 个周期)
  4. 跟随该指针加载实际的字符串数据 → 缓存未命中 → RAM 获取(约 300 个周期)

由于我们处理的是数百个分散在内存中的依赖项,因此我们可能会遇到许多缓存未命中。我们加载的每个缓存行(64 字节)可能只包含一个对象的数据。所有这些对象都分布在 GB 级别的 RAM 中,工作集很容易超出 L1 缓存(32KB)、L2(256KB)甚至 L3 缓存(8-32MB)。当我们再次需要一个对象时,它很可能已经被从所有缓存级别中驱逐了。

这只是读取一个依赖项名称就需要约 1200 个周期(在 3GHz CPU 上为 400ns)!对于一个拥有 1000 个包平均每个包有 5 个依赖项的项目,这意味着 2ms 的纯内存延迟。


Bun 使用数组结构 (Structure of Arrays)。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 = "next\015.5.0\0@swc/helpers\00.5.15\0postcss\08.4.31\0";

而不是让每个包存储指向其分散在内存中的数据的指针,Bun 只使用大的连续缓冲区,包括:

  • packages 存储轻量级结构体,使用偏移量指定此包数据的位置。
  • dependencies 将所有包的实际依赖关系存储在一个地方。
  • string_buffer 按顺序在一个巨大的字符串中存储所有文本(名称、版本等)。
  • versions 将所有解析的语义版本存储为紧凑的结构体。

现在,访问 Next 的依赖项就变成了算术运算:

  1. packages[0] 告诉我们 Next 的依赖项从 dependencies 数组的索引 0 开始,并且有 2 个依赖项:{ name_offset: 0, deps_offset: 0, deps_count: 2 }
  2. 转到 dependencies[1],它告诉我们 postcss 的名称在 string_buffer 的索引 34 处,版本在索引 42 处:{ name_offset: 34, version_offset: 42 }
  3. 转到 string_buffer 的索引 34 处并读取 postcss
  4. 转到 string_buffer 的索引 42 处并读取 "8.4.31"
  5. ……等等。

现在当你访问 packages[0] 时,CPU 不仅加载那 8 个字节:它会加载整个 64 字节的缓存行。由于每个包是 8 字节,而 64 ÷ 8 = 8,所以你在一次内存访问中就能获取 packages[0]packages[7]

因此,当你的代码处理 react 依赖项时(packages[0]packages[1]packages[7] 已经位于你的 L1 缓存中,可以立即访问,无需额外的内存获取。这就是顺序访问如此之快的原因:你只需访问一次内存即可获得 8 个包。

与我们在上一个示例中看到的、在内存中分散的许多小型分配不同,我们现在总共只有大约 6 次大的分配,无论你有多少个包。这与基于指针的方法完全不同,后者需要为每个对象进行单独的内存获取。

优化的 Lockfile 格式

Bun 也将数组结构方法应用于其 bun.lock lockfile。

当你运行 bun install 时,Bun 必须解析现有的 lockfile 以确定已安装的内容和需要更新的内容。大多数包管理器将 lockfile 存储为嵌套的 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 将数组结构方法应用于其 lockfile,采用人类可读的格式。

{
  "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 还根据 lockfile 的大小预分配内存。就像 tarball 提取一样,这避免了重复的调整大小和复制周期,这些周期会在解析期间造成性能瓶颈。

顺便说一句:Bun 最初使用二进制 lockfile 格式(bun.lockb)来完全避免 JSON 解析开销,但二进制文件在拉取请求中无法审查,并且在发生冲突时无法合并。

文件复制

在包安装并缓存到 ~/.bun/install/cache/ 后,Bun 必须将文件复制到 node_modules。这是 Bun 性能影响最大的地方!

传统的文件复制会遍历每个目录并单独复制文件。这需要为每个文件执行多个系统调用:

  1. 打开源文件(open()
  2. 创建并打开目标文件(open()
  3. 重复从源读取块并将其写入目标,直到完成(read()/ write()
  4. 最后,关闭两个文件 close()

这些步骤中的每一步都需要用户模式和内核之间的昂贵模式切换。

对于一个典型的 React 应用,包含数千个包文件,这会产生数十万到数百万次系统调用! 这正是我们之前描述的系统编程问题:进行所有这些系统调用的开销比实际移动数据更昂贵。

Bun 根据你的操作系统和文件系统采用不同的策略,利用所有可用的 OS 特定优化。Bun 支持多种文件复制后端,每种都有不同的性能特征:

macOS

在 macOS 上,Bun 使用 Apple 的原生 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(1) 操作,而传统复制是 O(n)

两个文件的元数据都指向相同的数据直到你修改其中一个

APFS clonefile copy-on-write metadata pointing to same blocks

当你修改文件内容时,文件系统会自动为编辑过的部分分配新块,并更新文件元数据以指向新块。

Copy-on-write after modification allocates new blocks

然而,这种情况很少发生,因为 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 实现了一个回退链,它会尝试越来越不优化的方法,直到找到一个有效的:

在 Linux 上,Bun 的默认策略是硬链接。硬链接根本不创建新文件,它只为现有文件创建一个新名称,并引用该现有文件。

link("/cache/react/index.js", "/node_modules/react/index.js");

要理解硬链接,你需要了解inode。Linux 上的每个文件都有一个 inode,它是一个数据结构,包含文件的所有元数据(权限、时间戳等)。文件名只是指向 inode 的指针。

Linux inode with two directory entries (hardlink)

两个路径都指向同一个 inode。如果删除一个路径,另一个仍然存在。但是,如果修改其中一个,两个都会看到更改(因为它们是同一个文件!)。

Hardlinked paths referencing the same 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 个 CPU 核心!

Node.js 有一个线程池,但所有实际工作(例如,找出哪个版本的 React 与哪个版本的 webpack 兼容,构建依赖关系图,决定安装什么)都在一个线程和一个 CPU 核心上进行。当 npm 在你的 M3 Max 上运行时,一个核心非常繁忙,而其他 15 个核心则处于空闲状态。

CPU 核心可以独立执行指令。早期计算机只有一个核心,一次只能做一件事,但现代 CPU 将多个核心集成到单个芯片上。16 核 CPU 可以同时执行 16 个不同的指令流,而不仅仅是快速地在它们之间切换。

这是传统包管理器的又一个基本瓶颈:无论你有多少个核心,包管理器都只能使用一个 CPU 核心。


Bun 采用了一种不同的方法,即无锁、工作窃取的线程池架构。

工作窃取意味着空闲线程可以从繁忙线程的队列中“窃取”待处理任务。当一个线程完成其工作时,它会检查其本地队列,然后是全局队列,然后从其他线程窃取。只要还有工作要做,就没有线程会空闲。

Bun 不受限于 JavaScript 的事件循环,而是生成可以充分利用所有 CPU 核心的原生线程。线程池会自动扩展以匹配你设备的 CPU 核心数,从而使 Bun 能够最大化并行处理安装过程中 I/O 密集的部分。一个线程可以解压 next 的 tarball,另一个线程可以解析 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 有几十个核心,内存也便宜了。真正的瓶颈已从硬件速度转移到软件抽象。

Bun 的方法并非革命性,它只是愿意审视今天真正拖慢速度的东西。当 SSD 每秒可以处理一百万次操作时,为何还要接受线程池开销?当你第一百次读取相同的包清单时,为何还要再次解析 JSON?当文件系统支持写入时复制时,为何还要复制数 GB 的数据?

那些将定义未来十年开发者生产力的工具正在被那些了解存储加速和内存廉价化之后性能瓶颈转移的团队编写。他们不只是渐进式改进现有技术;他们正在重新思考什么才是可能的。

将包安装速度提高 25 倍并非“魔法”:当工具是为我们实际拥有的硬件而构建时,这就是会发生的事情。

→ 在 bun.com 体验为 2025 年而构建的软件。