Запуск bun install работает быстро, очень быстро. В среднем, он работает примерно в 7 раз быстрее, чем npm, в 4 раза быстрее, чем pnpm, и в 17 раз быстрее, чем yarn. Разница особенно заметна в проектах с большой кодовой базой. То, что раньше занимало минуты, теперь занимает (милли)секунды.

Таймлайн, показывающий переход от ожидания I/O к нагрузке на системные вызовы
Таймлайн, показывающий переход от ожидания I/O к нагрузке на системные вызовы

Это не просто подобранные для красоты бенчмарки. Bun быстрый, потому что он рассматривает установку пакетов как проблему системного программирования, а не JavaScript-проблему.

В этом посте мы исследуем, что это значит: от минимизации системных вызовов (syscalls) и кэширования манифестов в бинарном виде до оптимизации распаковки tarball, использования нативного копирования файлов ОС и масштабирования на ядра CPU.

Но чтобы понять, почему это важно, нам сначала нужно сделать небольшой шаг назад во времени.

На дворе 2009 год. Вы устанавливаете jQuery из .zip файла, у вашего iPhone 3GS 256 МБ RAM. GitHub всего год как существует, SSD стоят $700 за 256 ГБ. Жесткий диск вашего ноутбука со скоростью 5400 об/мин достигает максимум 100 МБ/с, а «широкополосный интернет» означает 10 Мбит/с (если вам повезло).

Но что более важно: только что запустился Node.js! [Ryan Dahl на сцене объясняет](https://www.youtube.com/watch?---
v=EeYvFl7li9E), почему серверы проводят большую часть времени в ожидании.

В 2009 году типичный поиск на диске занимает 10 мс, запрос к базе данных — 50–200 мс, а HTTP-запрос к внешнему API — 300+ мс. Во время каждой из этих операций традиционные серверы просто... ждали. Ваш сервер начинал читать файл и затем просто замирал на 10 мс.

Иллюстрация сервера 2009 года, ожидающего I/O
Иллюстрация сервера 2009 года, ожидающего I/O

Теперь умножьте это на тысячи одновременных подключений, каждое из которых выполняет несколько операций I/O. Серверы тратили ~95% своего времени на ожидание операций ввода-вывода.

Node.js понял, что event loop JavaScript (изначально разработанный для событий браузера) идеально подходит для серверного I/O. Когда код делает асинхронный запрос, ввод-вывод происходит в фоновом режиме, в то время как основной поток немедленно переходит к следующей задаче. После завершения callback ставится в очередь на выполнение.

Схема работы event loop и thread pool Node.js для fs.readFile
Схема работы event loop и thread pool Node.js для fs.readFile

Упрощенная иллюстрация того, как Node.js обрабатывает fs.readFile с помощью event loop и thread pool. Другие асинхронные источники и детали реализации опущены для ясности.

Event Loop JavaScript был отличным решением для мира, где ожидание данных было основным узким местом.

В течение следующих 15 лет архитектура Node формировала то, как мы создавали инструменты. Менеджеры пакетов унаследовали пул потоков (thread pool) Node, event loop, асинхронные паттерны — оптимизации, которые имели смысл, когда поиск на диске занимал 10 мс.

Но железо эволюционировало. Сейчас уже не 2009 год, мы на 16 лет в будущем, во что трудно поверить. MacBook M4 Max, на котором я пишу этот текст, в 2009 году вошел бы в число 50 самых быстрых суперкомпьютеров на Земле. Современные NVMe-накопители выдают до 7000 МБ/с, что в 70 раз быстрее, чем то, для чего проектировался Node.js! Медленные механические диски ушли в прошлое, скорости интернета позволяют транслировать видео в 4K, и даже у бюджетных смартфонов больше RAM, чем у топовых серверов в 2009 году.

Тем не менее, современные менеджеры пакетов все еще оптимизируют под проблемы прошлого десятилетия. В 2025 году настоящее узкое место — это не I/O, а системные вызовы (system calls).

Проблема System Calls

Каждый раз, когда вашей программе нужно, чтобы операционная система что-то сделала (прочитала файл, открыла сетевое соединение, выделила память), она делает системный вызов (system call). Каждый раз при выполнении системного вызова CPU должен выполнить переключение режима (mode switch).

Ваш CPU может запускать программы в двух режимах:

Пользовательский режим (user mode), в котором работает код вашего приложения. Программы в пользовательском режиме не могут напрямую обращаться к оборудованию вашего устройства, физическим адресам памяти и т.д. Эта изоляция предотвращает вмешательство программ друг в друга или крах системы.

Режим ядра (kernel mode), в котором работает ядро (kernel) операционной системы. Ядро — это основной компонент ОС, который управляет ресурсами, такими как планирование процессов для использования CPU, работа с памятью и оборудованием, например, дисками или сетевыми устройствами. Только ядро и драйверы устройств работают в kernel mode!

Когда вы хотите открыть файл (например, через fs.readFile()) в своей программе, CPU, работающий в пользовательском режиме, не может напрямую читать с диска. Сначала он должен переключиться в kernel mode.

Во время этого переключения режима CPU останавливает выполнение вашей программы → сохраняет всё её состояние → переключается в режим ядра → выполняет операцию → затем переключается обратно в пользовательский режим.

Схема переключения режимов CPU между пользовательским режимом и режимом ядра
Схема переключения режимов CPU между пользовательским режимом и режимом ядра

Однако это переключение режимов является дорогой операцией! Одно только это переключение обходится в 1000-1500 тактов CPU в виде чистых накладных расходов, еще до того, как будет выполнена какая-либо реальная работа.

Ваш CPU работает на тактовой частоте, которая тикает миллиарды раз в секунду. 3GHz процессор выполняет 3 миллиарда тактов в секунду. В течение каждого такта CPU может выполнять инструкции: складывать числа, перемещать данные, сравнивать значения и т.д. Каждый такт занимает 0,33 нс.

На 3GHz процессоре 1000-1500 тактов — это примерно 500 наносекунд. Это может показаться пренебрежимо малым, но современные SSD могут обрабатывать свыше 1 миллиона операций в секунду. Если каждой операции требуется системный вызов, вы тратите 1,5 миллиарда тактов в секунду только на переключение режимов.

Установка пакетов делает тысячи таких системных вызовов. Установка React и его зависимостей может вызвать 50 000+ системных вызовов: это секунды CPU времени, потраченные только на переключение режимов! Даже не на чтение файлов или установку пакетов, а на переключение между пользовательским режимом и режимом ядра.

Вот почему Bun рассматривает установку пакетов как проблему системного программирования (systems programming problem). Высокая скорость установки достигается за счет минимизации системных вызовов и использования всех доступных оптимизаций, специфичных для конкретной ОС.

Вы можете увидеть разницу, когда мы трассируем фактические системные вызовы, выполняемые каждым менеджером пакетов:

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 устанавливает пакеты гораздо быстрее, но он также делает гораздо меньше системных вызовов (system calls). Для простой установки yarn делает свыше 4 миллионов системных вызовов, npm — почти 1 миллион, pnpm — около 500 тысяч, а bun — 165 тысяч.

При цене в 1000-1500 тактов на вызов, 4 миллиона системных вызовов yarn означают, что она тратит миллиарды тактов 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 (fast userspace mutex) — это системный вызов Linux, используемый для синхронизации потоков. Потоки — это более мелкие единицы программы, которые выполняются одновременно и часто имеют общий доступ к памяти или ресурсам, поэтому они должны координироваться, чтобы избежать конфликтов.

Большую часть времени потоки координируются с помощью быстрых атомарных инструкций CPU в пользовательском режиме. Нет необходимости переключаться в режим ядра, поэтому это очень эффективно!

Но если поток пытается захватить блокировку (lock), которая уже занята, он делает системный вызов futex, чтобы попросить ядро перевести его в режим ожидания, пока блокировка не станет доступной. Высокое количество вызовов futex является индикатором того, что многие потоки ждут друг друга, вызывая задержки.

Так что же Bun делает по-другому?

Устранение накладных расходов JavaScript

npm, pnpm и yarn написаны на Node.js. В Node.js системные вызовы делаются не напрямую: когда вы вызываете fs.readFile(), вы на самом деле проходите через несколько слоёв, прежде чем достигнете ОС.

Node.js использует libuv, библиотеку на C, которая абстрагирует различия платформ и управляет асинхронным I/O через пул потоков (thread pool).

В результате, когда Node.js должен прочитать один файл, он запускает довольно сложный поток. Для простого fs.readFile('package.json', ...):

  1. JavaScript проверяет аргументы и конвертирует строки из UTF-16 в UTF-8 для C API libuv. Это ненадолго блокирует main thread ещё до того, как начнётся любой I/O.

  2. libuv ставит запрос в очередь для одного из 4 worker threads. Если все потоки заняты, ваш запрос ждёт.

  3. Worker thread забирает запрос, открывает file descriptor и делает фактический системный вызов read().

  4. Ядро переключается в kernel mode, получает данные с диска и возвращает их worker thread'у.

  5. Worker thread передаёт данные файла обратно в main thread через event loop, который в конечном итоге планирует и запускает ваш callback.

Каждый отдельный вызов fs.readFile() проходит через этот pipeline. Установка пакетов предполагает чтение тысяч файлов package.json: сканирование директорий, обработку metadata зависимостей и так далее. Каждый раз, когда потоки координируются (например, при доступе к очереди задач или сигнализации обратно в event loop), для управления locks или waits может использоваться системный вызов futex.

Накладные расходы на выполнение тысяч таких системных вызовов могут занимать больше времени, чем само фактическое перемещение данных!

Bun делает это по-другому. Bun написан на Zig — языке программирования, который компилируется в нативный код (native code) с прямым доступом к системным вызовам:

// 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 engine, thread pools, event loops или преобразования данных (marshaling) между разными слоями runtime. Просто нативный код, делающий прямые системные вызовы ядру.

Разница в производительности говорит сама за себя:

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

В этом benchmark Bun обрабатывает 146,057 файлов  package.json в секунду, в то время как Node.js v24.5.0 справляется с 66,576, а v22.18.0 — с 64,631. Это более чем в 2 раза быстрее!

0.019 мс на файл у Bun представляют собой фактическую стоимость I/O — то, сколько времени занимает чтение данных при использовании прямых системных вызовов без каких-либо накладных расходов runtime. Node.js тратит 0.065 мс на ту же операцию. Менеджеры пакетов, написанные на Node.js, «застряли» с абстракциями Node; они используют thread pool, нужен он им или нет. И они платят эту цену за каждую файловую операцию.

Менеджер пакетов Bun больше похож на нативное приложение, которое, как оказалось, понимает JavaScript-пакеты, а не на JavaScript-приложение, которое пытается заниматься системным программированием.

Хотя Bun не написан на Node.js, вы можете использовать bun install в любом Node.js-проекте, не меняя runtime. Менеджер пакетов Bun уважает вашу существующую настройку Node.js и инструменты — вы просто получаете более быструю установку!

Но на данном этапе мы даже еще не начали устанавливать пакеты. Давайте посмотрим на оптимизации, которые Bun применяет к самой установке.

Когда вы вводите bun install, Bun сначала выясняет, что вы просите его сделать. Он читает любые флаги, которые вы передали, и находит ваш package.json, чтобы прочитать ваши зависимости.

Асинхронное разрешение DNS

⚠️ Примечание: Эта оптимизация специфична для macOS

Работа с зависимостями (dependencies) подразумевает работу с сетевыми запросами, а сетевым запросам требуется DNS resolution для преобразования доменных имён , таких как registry.npmjs.org, в IP-адреса.

Пока Bun анализирует package.json, он уже начинает предварительно получать (prefetch) DNS-запросы. Это означает, что разрешение сетевых адресов начинается еще до того, как анализ зависимостей будет завершен.

Для менеджеров пакетов на базе Node.js один из способов сделать это — использовать dns.lookup(). Хотя со стороны JavaScript это выглядит как асинхронная операция (async), под капотом она реализована как блокирующий вызов (blocking call) getaddrinfo(), работающий в thread pool libuv. Он все равно блокирует поток, просто не main thread.

В качестве хорошей оптимизации Bun использует другой подход на macOS, делая это действительно асинхронным на системном уровне. Bun использует «скрытый» асинхронный DNS API от Apple (getaddrinfo_async_start()), который не является частью стандарта POSIX, но позволяет Bun делать DNS-запросы, которые выполняются полностью асинхронно с использованием mach ports — системы межпроцессного взаимодействия (inter-process communication system) Apple.

Пока разрешение DNS происходит в фоновом режиме, Bun может продолжать обрабатывать другие операции, такие как file I/O, network requests или dependency resolution, без какой-либо блокировки потоков. К тому времени, когда ему потребуется загрузить React, DNS-запрос уже будет выполнен.

Это небольшая оптимизация (и она не включена в бенчмарки), но она показывает внимание Bun к деталям: оптимизировать на каждом уровне!

Кэширование двоичного манифеста

Теперь, когда Bun установил соединение с npm registry, ему нужны package manifests.

Manifest — это 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+ более версий, почти идентичных
  }
}

Большинство менеджеров пакетов кэшируют манифесты как JSON-файлы в своих кеш директориях. Когда вы снова запускаете npm install, вместо загрузки манифеста они читают его из кеша.

Это все имеет смысл, но проблема в том, что при каждой установке (даже если она закэширована) им все равно нужно парсить JSON-файл. Это включает проверку синтаксиса, построение дерева объектов, управление сборщиком мусора и так далее. Много накладных расходов от парсинга.

И дело не только в накладных расходах от JSON парсинга. Посмотрим на lodash: строка "Lodash modular utilities." появляется в каждой отдельной версии — это 100+ раз. "MIT" появляется 100+ раз. "git+https://github.com/lodash/lodash.git" дублируется для каждой версии, URL "https://lodash.com/" появляется в каждой версии. В целом, много повторяющихся строк.

В памяти JavaScript создает отдельный string object для каждой строки. Это тратит память и замедляет сравнения. Каждый раз, когда менеджер пакетов проверяет, используют ли два пакета одну и ту же версию postcss, он сравнивает отдельные string objects, а не ссылается на одну и туже строку.

Bun хранит package manifests в двоичном формате. Когда Bun загружает информацию о пакете, он парсит JSON один раз и сохраняет его как двоичный файл (файлы .npm в ~/.bun/install/cache/). Эти двоичные файлы содержат всю информацию о пакете (версии, зависимости, чексуммы и т.д.), хранящуюся по определенным адресам памяти (byte offsets).

Когда Bun обращается к имени  lodash, это просто использует арифметичкский указатель: ]string_buffer + offset]. Никаких распределений, никакого парсинга, никакого обхода объекта, просто чтение байтов в известном месте.

// Пвсевдокод

// String buffer (all strings stored once)
string_buffer = "lodash\0MIT\0Lodash modular utilities.\0git+https://github.com/lodash/lodash.git\0https://lodash.com/\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 знает, что закэшированные данные актуальны, без парсинга ни одного байта.

Посмотрим на benchmarks:

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 install. Вот сколько накладных расходов может добавить JSON парсинг закэшированных файлов (среди других факторов).

Оптимизированное извлечение tarball

Теперь, когда Bun получил package manifests, ему нужно загрузить и распаковать сжатые tarballs из npm registry.

Tarballs — это сжатые архивные файлы (как .zip файлы), которые содержат весь фактический исходный код и файлы для каждого пакета.

Большинство менеджеров пакетов потоково передают данные tarball по мере их поступления и распаковывают их на лету. Когда вы извлекаете tarball, который передается потоково, типичный шаблон предполагает, что размер неизвестен, и выглядит примерно так:

let buffer = Buffer.alloc(64 * 1024); // Начинаем с 64KB
let offset = 0;

function onData(chunk) {
  while (moreDataToCome) {
    if (offset + chunk.length > buffer.length) {
      // buffer заполнен → выделяем больший
      const newBuffer = Buffer.alloc(buffer.length * 2);

      // копируем всё, что уже записали
      buffer.copy(newBuffer, 0, 0, offset);

      buffer = newBuffer;
    }

    // копируем новый chunk в buffer
    chunk.copy(buffer, offset);
    offset += chunk.length;
  }

  // ... decompress из buffer ...
}

Начните с маленького буфера и позвольте ему расти по мере поступления новых распакованных данных. Когда буфер заполняется, вы выделяете буфер большего размера, копируете все существующие данные в него и продолжаете.

Это кажется разумным, но создает узкое место производительности: вы в конечном итоге копируете одни и те же данные несколько раз, поскольку буфер постоянно перерастает свой текущий размер.

Диаграмма: Repeated buffer resizing and copying during streaming decompression
Диаграмма: Repeated buffer resizing and copying during streaming decompression

Когда у нас есть пакет размером 1 МБ:

  1. Начинаем с буфера в 64 КБ

  2. Заполняем → Выделяем 128 КБ → Копируем 64 КБ

  3. Заполняем → Выделяем 256 КБ → Копируем 128 КБ

  4. Заполняем → Выделяем 512 КБ → Копируем 256 КБ

  5. Заполняем → Выделяем 1 МБ → Копируем 512 КБ

Вы только что скопировали 960 КБ данных без необходимости! И это происходит для каждого отдельного пакета. Разделитель памяти должен найти непрерывное пространство для каждого нового буфера, в то время как старый буфер остается выделенным во время операции копирования. Для больших пакетов вы можете скопировать одни и те же байты 5-6 раз.

Bun использует другой подход, буферизируя весь tarball перед распаковкой. Вместо обработки данных по мере их поступления, Bun ждет, пока весь сжатый файл не будет загружен в память.

Теперь вы можете подумать: «Погодите, разве они не тратят впустую RAM, храня всё в памяти?» И для больших пакетов, таких как TypeScript (которые могут быть 50 МБ в сжатом виде), вы будете правы.

Но подавляющее большинство npm packages крошечные, большинство из них меньше 1 МБ. Для этих распространенных случаев buffering всего целиком устраняет все повторное копирование. Даже для тех больших пакетов временный всплеск памяти (temporary memory spike) обычно приемлем на современных системах, и избежание 5-6 копирований буфера с лихвой это компенсирует.

Как только Bun получает полный tarball в памяти, он может прочитать последние 4 байта формата gzip. Эти байты особенные, так как они хранят uncompressed size (несжатый размер) файла! Вместо того чтобы гадать, насколько большим будет распакованный файл, Bun может pre-allocate (предварительно выделить) память, чтобы полностью исключить изменение размеров буфера:

{
  // Последние 4 байта gzip-сжатого файла — это несжатый размер.
  if (tgz_bytes.len > 16) {
    // Если файл заявляет, что он больше 16 байт и меньше 64 МБ, мы предварительно выделим buffer.
    // Если он больше, мы сделаем это постепенно. Мы хотим избежать OOM (Out Of Memory).
    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) {
      // Ничего страшного, если это не удастся. Мы просто будем выделять по мере необходимости, и это вызовет ошибку, если у нас закончится память.
      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
Диаграмма: Preallocation using gzip ISIZE avoids buffer growth copies

Для фактической decompression Bun использует  [libdeflate](https://github.com/ebiggers/libdeflate). Это высокопроизводительная библиотека, которая распаковывает tarballs быстрее, чем стандартный zlib, используемый большинством менеджеров пакетов. Она оптимизирована специально для современных CPUs с SIMD инструкциями.

Оптимизированное извлечение tarball было бы сложно реализовать для менеджеров пакетов, написанных на Node.js. Вам потребовалось бы создать отдельную систему чтения, перейти к концу, прочитать 4 байта, распарсить их, закрыть поток, а затем начать все сначала с распаковки. APIs Node не созданы для этого патерна.

В Zig это довольно просто, переходите к концу и читаете последние четыре байта, вот и всё!

Теперь, когда у Bun есть все данные пакета, он сталкивается с другой проблемой: как эффективно хранить и получать доступ к тысячам (взаимозависимых) пакетов?

Удобная структура для кеширования данных

Работа с тысячами пакетов может быть непростой. Каждый пакет имеет зависимости, которые имеют свои собственные зависимости, создавая довольно сложный граф зависимостей.

Во время установки менеджеры пакетов должны обходить этот граф, чтобы проверить версии пакетов, разрешить любые конфликты и определить, какую версию установить. Им также необходимо поднимать  (hoist) зависимости, перемещая их на более высокие уровни, чтобы несколько пакетов могли ими совместно пользоваться.

Но то, как хранится этот граф зависимостей, сильно влияет на производительность Традиционные менеджеры пакетов хранят зависимости так:

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 каждый объект хранится в куче (heap memory). При доступе к packages["next"] CPU обращается к указателю, который указывает, где данные Next расположены в памяти. Эти данные затем содержат эти адреса просто в случайном порядке. Нет никакой гарантии локализации — объекты могут быть разбросаны по RAM, даже объекты, связанные друг с другом!

Это случайное рассеяние имеет значение из-за того, как современные CPUs фактически получают данные.

Современные CPUs невероятно быстры в обработке данных (миллиарды операций в секунду), но получение данных из RAM медленное. Чтобы преодолеть этот разрыв, CPUs имеют несколько уровней cache:

  1. L1 cache, маленькое хранилище, но очень быстрое (~4 CPU циклов)

  2. L2 cache, среднее хранилище, a чуть медленнее (~12 CPU циклов)

  3. L3 cache: 8-32MB хранилища, требует ~40 CPU циклов

  4. RAM: Много GB, требует ~300 циклов (медленно!)

Визуализация скорости CPU cache vs RAM. Кеш оптимизации имеют значение! pic.twitter.com/q2rkGqSUAG — Ben Dicken @BenjDickenn) Oct 18, 2024

«Проблема» в том, что кеши работают с строками кеша. Когда вы обращаетесь к памяти, CPU загружает не просто один байт: он загружает весь 64-байтовый чанк, в котором появляется этот байт. Он предполагает, что если вам нужен один байт, вам, вероятно, скоро понадобятся соседние байты (это называется spatial locality).

Диаграмма: Cache line showing 64-byte fetch granularity
Диаграмма: Cache line showing 64-byte fetch granularity

Эта оптимизация отлично работает для данных, хранящихся последовательно (sequentially), но она дает обратный эффект, когда ваши данные случайным образом разбросаны по памяти.

Когда CPU загружает packages["next"] по адресу 0x2000, он фактически загружает все байты в этой кеш строке. Но следующий пакет, packages["postcss"], находится по адресу 0x8000. Это совершенно другая кеш строка! Остальные 56 байт, которые CPU загрузил в кеш строку, полностью потрачены в пустую, это просто случайная память от того, что оказалось выделено рядом; может быть, удалена, может быть, части несвязанных объектов.

Диаграмма: Wasted cache line bytes due to non-local allocations
Диаграмма: Wasted cache line bytes due to non-local allocations

Но вы заплатили стоимость загрузки 64 байт, а использовали только 8...

К тому времени, когда вы обратились к 512 различным пакетам (32KB / 64 байта), вы уже заполнили весь свой L1 кеш. Теперь каждый новый доступ к пакету вытесняет ранее загруженную кеш строку, чтобы освободить место. Пакет, к которому вы только что обратились, скоро будет вытеснен, и та зависимость, которая понадобится через 10 микросекунд, уже исчезла. Коэффициент попаданий в кэш падает, и каждый доступ становится поездкой к RAM на ~300 циклов вместо попадания в L1 на 4 цикла, что далеко от оптимального решения.

Вложенная структура объектов создает так называемое "pointer chasing", распространенный анти-шаблон в системном программировании. CPU не может предсказать, куда загружать дальше, потому что каждый указатель может указывать куда угодно. Он просто не может знать, где находится следующие зависимости, пока не закончит загрузку объекта next.

При обходе зависимостей Next CPU должен выполнить несколько зависимые загрузки памяти:

  1. Загрузить указатель packages["next"] → Cache miss → RAM fetch (~300 cycles)

  2. Следовать по этому указателю для загрузки pointer next.dependencies → Еще один промах в кеше → RAM fetch (~300 циклов)

  3. Следовать по нему, чтобы найти "postcss" в хеш таблице → Кеш промах → RAM fetch (~300 cycles)

  4. Следовать по этому указателю для загрузки фактических строковых данных → Кеш промах → RAM fetch (~300 cycles)

Мы можем получить множество промахов в кеше, поскольку мы работаем с сотнями зависимостей, все разбросанные по памяти. Каждая загруженная нами кеш строка (64 байта) может содержать данные только для одного объекта. Со всеми этими объектами, разбросанными по гигабайтам RAM, рабочий пул легко превышает L1 cache (32 КБ), L2 (256 КБ) и даже L3 cache (8-32 МБ). К тому времени, когда нам снова понадобится объект, вероятно, он был вытеснен со всех уровней кеша.

Это ~1200 циклов (400 нс на 3GHz CPU) только для чтения одного имени зависимости! Для проекта с 1000 пакетов в среднем по 5 зависимостей каждый это 2 мс чистой задержкой памяти.

Bun использует Structure of Arrays (SoA). Вместо того чтобы каждый пакет хранил свой собственный массив зависимостей, 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 начинаются с позиции 0 в массиве dependencies, и их 2: { name_offset: 0, deps_offset: 0, deps_count: 2 }

  2. Переходим к dependencies[1], который сообщает нам, что имя postcss начинается с позиции 34 в string_buffer, а версия — с позиции 42: { name_offset: 34, version_offset: 42 }

  3. Переходим к позиции 34 в string_buffer и читаем postcss

  4. Переходим к позиции 42 в string_buffer и читаем "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 больших распределений, независимо от того, сколько у вас пакетов. Это совершенно разные от подхода на основе указателей, который требовал отдельного запроса к памяти для каждого объекта.

Оптимизированный формат файла блокировки

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 применяет подход Structure of Arrays к своему 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 также предварительно выделяет память на основе размера локфайла. Так же, как и с извлечением tarball, это позволяет избежать повторяющихся циклов изменения размера и копирования, которые создают узкие места производительности во время парсинга.

В качестве примечания: изначально Bun использовал бинарный формат lockfile (bun.lockb), чтобы полностью избежать накладных расходов от JSON парсинга, но бинарные файлы невозможно просматривать в pull requests, и их нельзя принимать при возникновении конфликтов.

Копирование файлов

После того как пакеты установлены и закэшированы в ~/.bun/install/cache/, Bun должен скопировать файлы в node_modules. Именно здесь мы видим наибольшее влияние на производительность Bun!

Традиционное копирование файлов обходит каждую директорию и копирует файлы по отдельности. Это требует нескольких системных вызовов на файл:

  • открытие исходного файла (open())

  • создание и открытие целевого файла (open())

  • повторяющееся чтение чанков из источника и запись их в назначение до завершения (read()/ write())

  • наконец, закрытие обоих файлов (close()).

Каждый из этих шагов требует дорогостоящего переключения режима между пользовательским режимом и ядром.

Для типичного React-приложения с тысячами файлов пакетов это создает сотни тысяч до миллионов системных вызовов! Это именно та проблема системного программирования, которую мы описали ранее: накладные расходы на все эти системные вызовы становятся дороже, чем фактическое перемещение данных.

Bun использует различные стратегии в зависимости от вашей операционной системы и файловой системы, используя все доступные оптимизации, специфичные для ОС. Bun поддерживает несколько бэкендов для копирования файлов, каждый с разными характеристиками производительности:

macOS

В macOS Bun использует нативный системный вызов Apple clonefile() с копирование при записи.

clonefile может к лонировать целые деревья директорий за один системный вызов. Этот системный вызов создает новые записи метаданные для директорий и файлов, которые ссылаются на те же физические блоки диска, что и исходные файлы. Вместо записи новых данных на диск файловая система просто создает новые «указатели» на существующие данные.

// Традиционный подход: миллионы системных вызовов
for (each file) {
  copy_file_traditionally(src, dst);  // 50+ системных вызовов на файл
}

// Подход Bun: ОДИН системный вызов
clonefile("/cache/react", "/node_modules/react", 0);

SSD хранит данные в блоках фиксированного размера. Когда вы обычно копируете файл (copy()), файловая система выделяет новые блоки и записывает дублированные данные. С клон файлом и исходный, и «скопированный» файл имеют метаданные, которые указывает на точно такие же физические блоки на вашем SSD.

Copy-on-write означает, что данные дублируются только при изменении. Это приводит к операции O(1) против O(n) при традиционном копировании.

Метаданные обоих файлов указывают на одни и те же блоки данных, пока вы не измените один из них.

Диаграмма: APFS clonefile copy-on-write метаданные, указывающие на те же блоки
Диаграмма: APFS clonefile copy-on-write метаданные, указывающие на те же блоки

Когда вы изменяете содержимое одного из файлов, файловая система автоматически выделяет новые блоки для отредактированных частей и обновляет metadata файла, чтобы они указывали на новые блоки.

Диаграмма: Copy-on-write after modification allocates new blocks
Диаграмма: Copy-on-write after modification allocates new blocks

Однако это редко случается, поскольку файлы node_modules обычно доступны только для чтения после установки; мы не изменяем модули активно из нашего кода.

Это делает copy-on-write чрезвычайно эффективным: несколько пакетов могут совместно использовать идентичные файлы зависимостей без использования дополнительного дискового пространства.

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

Когда клонирование файла не удается (из-за отсутствия поддержки файловой системы), Bun fallback переходит к clonefile_each_dir для клонирования каждой директории. Если это также не удается, Bun использует традиционный copyfile в качестве последнего запасного варианта.

Linux

В Linux нет clonefile(), но есть кое-что еще старше и мощнее: hardlinks (жесткие ссылки). Bun реализует цепочку fallback, которая пробует все менее оптимальные подходы, пока один не сработает:

1. Hardlinks

В Linux стратегия по умолчанию для Bun — это hardlinks. Hardlink вообще не создает новый файл, он только создает новое имя для существующего файла и ссылается на этот существующий файл.

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

Чтобы понять hardlinks, нужно понять inodes. Каждый файл в Linux имеет inode — это структура данных, которая содержит все metadata файла (права доступа, временные метки и т.д.). Имя файла — это просто указатель на inode:

Диаграмма: Linux inode с двумя записями директории (hardlink)
Диаграмма: Linux inode с двумя записями директории (hardlink)

Оба пути указывают на один и тот же inode. Если вы удалите один путь, другой останется. Однако, если вы измените один, изменения увидят оба (потому что это один и тот же файл!).

Диаграмма: Жестко связанные пути, ссылающиеся на один и тот же inode
Диаграмма: Жестко связанные пути, ссылающиеся на один и тот же inode

Это дает большой прирост производительности, потому что нет никакого перемещения данных. Создание hard link требует одного системного вызова, который выполняется за микросекунды, независимо от того, связываете ли вы файл размером 1 КБ или бандл размером 100 МБ. Гораздо эффективнее, чем традиционное копирование, которое должно читать и записывать каждый байт.

Они также чрезвычайно эффективный для дискового пространства, поскольку на диске всегда только одна копия фактических данных, независимо от того, сколько пакетов ссылается на одни и те же файлы зависимостей.

Однако у hardlinks есть ограничения. Они не могут пересекать границы файловых систем (например, ваш кеш находится в другом месте, чем ваш node_modules), некоторые файловые системы их не поддерживают, и определенные типы файлов или конфигурации прав могут привести к сбою создания hardlink.

Когда hardlinks невозможны, у Bun есть несколько запасных сценариев:

  1. ioctl_ficlone
    Он начинается с ioctl_ficlone, который включает copy-on-write в файловых системах, таких как Btrfs и XFS. Это очень похоже на систему copy-on-write в clonefile тем, что она также создает новые файловые ссылки, которые совместно используют одни и те же данные на диске. В отличие от hardlinks, это отдельные файлы; они просто находятся в общем хранилище до изменения.

  2. copy_file_range
    Если copy-on-write недоступен, Bun пытается по крайней мере сохранить копирование в kernel space и запасной вариант переходит к copy_file_range.

При традиционном копировании ядро читает с диска в буфер ядра, затем копирует эти данные в буфер вашей программы в пользовательское пространство. Позже, когда вы вызываете write(), оно копирует его обратно в буфер ядра перед записью на диск. Это четыре операции с памятью и несколько смены контекстов!

С copy_file_range ядро читает с диска в буфер ядра и записывает прямо на диск. Всего две операции и 0 смен контекста для перемещения данных.

  1. sendfile Если это недоступно, Bun использует sendfile. Это системный вызов, изначально спроектирован для сетевых передач, но он также эффективен для копирования данных прямо между двумя файлами на диске.

Эта команда также сохраняет данные в пространстве ядра: ядро читает данные из одного места (ссылка на открытый файл на диске, например, исходный файл в ~/.bun/install/cache/) и записывает их в другое место (например, целевой файл в node_modules), все в пространстве памяти ядра.

Этот процесс называется опирование с диска на диск, так как он перемещает данные между файлами, хранящимися на одном или разных дисках, без касания памяти вашей программы. Это более старый API, но более широко поддерживаемый, что делает его надежным запасным сценарием, когда новые системные вызовы недоступны, при этом все равно сокращение количества обращений к памяти.

  1. 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

Эти оптимизации копирования файлов решают основную проблему: system call overhead. Вместо использования универсального подхода (one-size-fits-all) Bun выбирает наиболее эффективное копирование файлов, специально tailored для вас.

Многоядерный параллелизм

Все упомянутые выше оптимизации отличны, но они направлены на снижение нагрузки на одно ядро процессора. Однако у современных ноутбуков есть 8, 16, даже 24 ядер!

У Node.js есть пул потоков, но вся реальная работа (например, выяснение того, какая версия React работает с какой версией webpack, построение графа зависимостей, решение о том, что установить) происходит в одном потоке и на одном ядре CPU. Когда npm работает на вашем M3 Max, одно ядро работает очень усердно, а остальные 15 простаивают.

Ядро процессора может независимо исполнять инструкции. Ранние компьютеры имели одно ядро, они могли делать только одну вещь за раз, но современные CPU помещают несколько ядер на один чип. 16-ядерный CPU может исполнять 16 различных потоков инструкций одновременно, а не просто очень быстро переключаться между ними.

Это еще одно фундаментальное узкое место для традиционных менеджеров пакетов: неважно, сколько у вас ядер, менеджер пакетов может использовать только одно ядро CPU.

Bun использует другой подход без блокировок, work-stealing (крадущей работой) архитектурой пула потоков.

Work-stealing означает, что бездействующие потоки могут «красть» (steal) ожидающие задачи (pending tasks) из очередей занятых потоков. Когда поток завершает свою работу, он проверяет свою локальную очередь, затем глобальную очередь, а затем крадет у других потоков. Ни один поток не простаивает, когда еще есть работа.

Вместо того чтобы ограничиваться event loop JavaScript, Bun порождает нативные потоки, которые могут полностью использовать каждое ядро CPU. Пул потоков автоматически масштабируется в соответствии с количеством ядер вашего устройства, позволяя Bun максимально параллелизовать части процесса установки, интенсивные по вводу-выводу. Один поток может распаковывать tarball next, другой — разрешать зависимости postcss, третий — применять patches к webpack и так далее.

Но многопоточность часто сопровождается накладными расходами на синхронизацию. Те сотни тысяч вызовов futex, которые делал npm, были просто потоками, постоянно ждущими друг друга. Каждый раз, когда поток хочет добавить задачу в общую очередь, он должен сначала заблокировать ее, блокируя все остальные потоки.

// Традиционный подход: Блокировки (Locks)
mutex.lock();                   // Thread 1 получает эксклюзивный доступ
queue.push(task);               // Только Thread 1 может работать
mutex.unlock();                 // Наконец-то releases lock
// Проблема: Потоки 2-8 заблокированы, ждут в очереди

Bun вместо этого использует структуры данных без блокировок. Они используют специальные инструкции CPU, называемые атомарные операции, которые позволяют потокам безопасно изменять общие данные без блокировок:

pub fn push(self: *Queue, batch: Batch) void {
  // Atomic compare-and-swap, происходит мгновенно
  _ = @cmpxchgStrong(usize, &self.state, state, new_state, .seq_cst, .seq_cst);
}

В более раннем benchmark мы видели, что Bun смог обработать 146 057 файлов package.json в секунду против 66 576 у Node.js. Это и есть влияние использования всех ядер вместо одного.

Bun также по-другому запускает сетевые операции. Традиционные менеджеры пакетов часто блокируются. При загрузке пакета CPU простаивает в ожидании сети.

Bun поддерживает пул из 64(!) одновременных HTTP-соединений (настраивается через BUN_CONFIG_MAX_HTTP_REQUESTS) на выделенных сетевых потоках. Сетевой поток работает независимо со своим собственным event loop, обрабатывая все загрузки, в то время как потоки CPU занимаются извлечением и обработкой. Ни один не ждет другого.

// 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 также дает каждому потоку свой собственный пул памяти. Проблема «традиционной» многопоточности заключается в том, что все потоки соревнуются за один и тот же memory аллокатор памяти. Это создает утверждение: если 16 потокам одновременно нужна память, им приходится ждать друг друга.

// Bun: каждый поток имеет свой собственный распределитель
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 придумал умный способ экономии места и ускорения работы с помощью hardlinks. Каждый усердно работал над решением проблем, с которыми разработчики действительно сталкивались в то время.

Но этого мира больше не существует. SSD стали в 70 раз быстрее, CPU имеют десятки ядер, а память стала дешевой. Реальное узкое место сместилось от скорости железа к программным абстракциям.

Подход Bun не был революционным, он просто был готов посмотреть на то, что на самом деле замедляет работу сегодня. Когда SSD могут обрабатывать миллион операций в секунду, зачем мириться с накладными расходами пула потоков? Когда вы в сотый раз читаете один и тот же манифест пакета, зачем снова парсите JSON? Когда файловая система поддерживает copy-on-write, зачем дублировать гигабайты данных?

Инструменты, которые определят следующее десятилетие производительности разработчиков, создаются прямо сейчас командами, которые понимают, что узкие места производительности сместились, когда хранилища стали быстрыми, а память — дешевой. Они не просто постепенно улучшают существующее; они переосмысливают то, что возможно.

Установка пакетов в 25 раз быстрее — это не «магия»: это то, что происходит, когда инструменты создаются для железа, которое у нас на самом деле есть.

Оригинал статьи: Behind The Scenes of Bun Install

Комментарии (7)


  1. mapnik
    12.09.2025 18:42

    Все эти прекрасные абзацы текста — ради констатации "Жаваскрипт не тормозит!"?


    1. strokoff Автор
      12.09.2025 18:42

      Скорее пояснение почему он тормозит и где конкретно, а также чем это вызвано. Конечно же в контексте работы пакетных менеджеров


  1. gmtd
    12.09.2025 18:42

    Node.js понял, что event loop JavaScript (изначально разработанный для событий браузера) идеально подходит для серверного I/O. Когда код делает асинхронный запрос, ввод-вывод происходит в фоновом режиме, в то время как основной поток немедленно переходит к следующей задаче


    Часто слышу этот довод от Node.js и Go программеров, но реальный пример из жизни никто не смог привести. Пример бэкенд API сервиса, который использует асинхронные запросы к БД чтобы что-то в это время еще поделать.


    1. GubkaBob
      12.09.2025 18:42

      К БД обращаться незачем, есть примеры попроще. Например, nginx с aio.

      Фраза "основной поток немедленно переходит к следующей задаче" вообще не означает "следующая задача в выполняемой функции". Это может быть задача http сервера "работай над следующим клиентским запросом пока".

      Хотя если интересно, могу и такой вариант предложить. Бэкэнду надо собрать и вернуть некий html. Кусочки которого, а также различные стили и картинки, разбросаны по куче локаций: файлы, архивы, URL-ы. Например, для простоты, мне нужно прочитать только файлы А и Б.
      И тут выбор - либо вы по очереди делаете синхронное чтение из А,потом из Б. Либо запрашиваете байты асинхронно из А, потом асинхронно из Б и ждете конца всех вызовов сразу. Вот это "потом асинхронно из Б" и есть та самая  "следующая задача, к которой немедленно переходит основной поток".


      1. gmtd
        12.09.2025 18:42

        Искусственные примеры придумать можно
        Я просил реальные


        Бэкенд API запрос в абсолютном большинстве случаев - синхронная операция
        А в остальных она решается другими механизмами (очереди сообщений и т.п.), а не js асинхронщиной


        1. sdramare
          12.09.2025 18:42

          Если на ваш api бэкэнд приходят два запроса одновременно, вы их последовательно обрабатывать будете?


          1. gmtd
            12.09.2025 18:42

            Я буду применять языки, в которых такие вопросы просто не возникают
            Например, PHP или Java