На официальном сайте Deno (раздел Built for the real world) красуется любопытная табличка. Согласно их замерам, Deno уходит в отрыв от Node.js по HTTP аж в 1.65 раза:

Метрика

Deno 2.9

Bun 1.4

Node 26

Realworld HTTP (req/sec)

72 400

68 200

44 000

Hello-world HTTP (req/sec)

85 600

81 900

56 300

p99 latency (realworld)

1.87 ms

2.80 ms

3.76 ms

Независимые тесты (denosaurs/bench, pkgpulse 2026, jsmanifest 2026) показывают похожую картину: Deno стабильно быстрее на HTTP-нагрузке. Я решил копнуть глубже и понять, где Deno получает преимущество по сравнению с Node.js.

Часть 1. Проверяем гипотезу «tokio быстрее libuv»

Самая очевидная мысль: Deno написан на Rust и tokio, Node.js на C++ и libuv. Если один рантайм быстрее другого, значит, и весь сервер быстрее. Давайте проверим это, но с оговоркой: микробенчмарк сравнивает не полностью эквивалентные механизмы (uv_timer_t в libuv против tokio-тасков с sleep(0)yield_now() и вообще без await). Это не «event loop dispatch» в строгом смысле, а измерение скорости пробуждения одной задачи в каждом рантайме.

Как я тестировал

Я написал два автономных бинарника на Rust с одинаковой обвязкой: через FFI вызывает libuv 1.52.1, второй работает на tokio 1.53.

  • libuv_raw создаёт N таймеров (uv_timer_t) с задержкой 0 и запускает их через uv_run(UV_RUN_DEFAULT). В колбэке, через атомарный инкремент счётчика.

  • tokio_raw спавнит N задач в current_thread рантайме, собирает их через JoinSet и ждёт завершения. Каждая задача тоже делает атомарный инкремент.

Идея в том, чтобы измерить tokio и libuv, без JS, HTTP и V8. Сборка: cargo build --release (lto=fat, opt-level=3). Запускал в Docker на Debian 12. Чтобы исключить влияние park/unpark, я прогнал три варианта tokio-нагрузки:

Режим tokio

Что делает задача

park?

sleep(0)

sleep(Duration::ZERO).await; counter.inc()

да (timer wheel)

yield_now()

yield_now().await; counter.inc()

да (scheduler queue)

noop_ready

counter.inc() (вообще без await)

нет

Результаты

Прогнал оба бенчмарка с N = 10 000, 50 000 и 100 000 «задач». Вот что получилось (меньше — лучше, наносекунды на операцию):

N

libuv (ns/op)

tokio sleep(0)

tokio yield_now

tokio noop

10 000

220

550

371

330

50 000

313

506

424

321

100 000

410

528

446

333

Разница между libuv и tokio зависит от количества задач и она нестабильна: на N=10K libuv быстрее, на N=50K они практически равны, а на N=100K tokio чуть быстрее. Знак разницы меняется в зависимости от N, что указывает на погрешность/особенности нагрузки, а не на устойчивое преимущество одного event loop над другим. В нашем минимальном тесте мы не обнаружили разницы, которую можно было бы считать достаточной, чтобы объяснить 1,65-кратный разрыв в HTTP throughput. Это не означает, что event loop'ы одинаковы: в других сценариях результаты вполне могут отличаться. Но для нашего вопроса(может ли event loop сам по себе объяснить наблюдаемую разницу в HTTP-производительности) ответ, судя по этому тесту, скорее отрицательный.

Часть 2. Проверяем гипотезу «JS-таймеры»

Вторая гипотеза: разработчики Deno взяли и переписали стандартную библиотеку, включая таймеры, в «более асинхронном» стиле, который лучше ложится на Rust-рантайм.

Что я нашёл

Открываю libs/core/02_timers.js в репозитории Deno и lib/internal/timers.js в Node.js рядом. Видно следующее:

  • Похожие структуры данных: object-map timerListMap (по длительности задержки) + PriorityQueue поверх + linked list buckets.

  • processTimersnextExpiry = Infinity; while (list = timerListQueue.peek()) { if (list.expiry > now) return; listOnTimeout(list, now) } — алгоритмически практически один-в-один.

  • listOnTimeout — обход списка с проверкой diff < msecs, повторная вставка для _repeat таймеров, логика схожая там и там.

В Node.js linked list helpers живут в отдельном модуле internal/linkedlist.js и называются init/peek/remove/append/isEmpty (используются как L.append()L.peek() и т.д.), а в Deno они определены прямо в 02_timers.js под именами L_init/L_peek/L_remove/L_append/L_isEmpty. Но сам алгоритм linked list схожий — те же idleNext/idlePrev, та же логика «next = older, prev = newer».

Вывод

Похожие алгоритмы могут давать совершенно разные результаты на практике: V8, GC, представление объектов и границы runtime способны сделать две похожие JS-реализации существенно разными по фактической стоимости. Однако анализ исходного кода Deno (02_timers.js) и Node.js (internal/timers.js + internal/linkedlist.js) показывает, что реализации алгоритмически почти идентичны — структуры данных, логика в processTimers/listOnTimeout и linked list. Сходство делает гипотезу «Deno переписал таймеры под Rust-рантайм и сделал их быстрее» для меня маловероятной.

Часть 3. Главный фактор: архитектура HTTP-стека Deno

Если не event loop и не таймеры, то что остаётся? Смотрим на HTTP-стек целиком: Deno использует собственный Rust-стек HTTP/1.1 и другой путь интеграции с V8, тогда как Node.js использует связку node:http + llhttp + C++ binding. Это, судя по исходному коду и нашим экспериментам, один из наиболее вероятных факторов наблюдаемой разницы, а #[op2(fast)] и V8 Fast API — один из механизмов, который позволяет ему работать быстро.

Как устроен Deno

В Deno нативные операции (ops) объявляются с помощью #[op2], у них есть два режима:

  • Обычный режим: JS-вызов проходит через полный V8 dispatch — упаковка аргументов, проверка типов, вызов C++-функции, распаковка результата. На практике это слишком долго.

  • Быстрый режим (#[op2(fast)]): компилируется в V8 Fast API Call. V8 заранее знает сигнатуру функции и типы аргументов, поэтому вызов происходит напрямую — без упаковки и диспетчеризации. Это позволяет задействовать V8 Fast API.

Ключевой момент: Deno HTTP-стек активно использует макрос #[op2(fast)] для критических по производительности операций.

Как устроен Node.js

В Node.js ситуация сложнее. Там есть несколько механизмов для нативных вызовов:

  • V8 Fast API Call — есть, но используется не везде.

  • N-API / node-api — стабильный, но медленный слой с упаковкой аргументов и проверкой типов.

  • Internal bindings — то, что используется внутри ядра, но тоже с оверхедом.

Возьмём node_http_parser.cc — модуль, который отвечает за парсинг HTTP в Node.js (поверх библиотеки llhttp). Часть методов этого binding'а пока использует обычный V8 calling convention через FunctionCallbackInfo<Value>, тогда как для них в исходнике прямо оставлен TODO(@anonrig): Add V8 Fast API. Это делает скорость перехода JS/native одним из правдоподобных источников overhead в HTTP-цикле Node.js.

Что такое V8 Fast API Call

V8 Fast API, позволяет нативным функциям регистрироваться с фиксированной сигнатурой типов. Когда V8 встречает JS-вызов, который соответствует зарегистрированной Fast API сигнатуре, он может сгенерировать inline-инструкции прямо в JIT-скомпилированном коде, минуя общий dispatch. Несовместимые JS-вызовы уходят на slow path. Подробнее о том, как Node.js разработчики должны добавлять V8 Fast API в документации Node. Обычный путь (через FunctionCallbackInfo<Value>) выглядит так, что каждый вызов проходит через V8-диспетчер:

void HttpParse(const v8::FunctionCallbackInfo<v8::Value>& args) {
  v8::Local<v8::Value> buf = args[0];
  if (!buf->IsArrayBuffer()) { /* throw */ }
  auto* ab = buf.As<v8::ArrayBuffer>();
  // ... распаковка, работа, упаковка результата обратно в v8::Value
}

Fast API путь выглядит иначе, так как V8 знает сигнатуру, и для совместимого JS-вызова передаёт нативные типы напрямую:

void HttpParse(uint8_t* data, size_t length, FastApiCallbackOptions& opts) {
  // Никаких v8::Value unwrap/cast, V8 передаёт сырой указатель + длину
  // V8 может встроить этот вызов прямо в JIT-код JS-вызова
}

Почему это важно именно для HTTP

HTTP-запрос — это целая цепочка вызовов: прочитать сокет, распарсить заголовки, прочитать тело, сформировать ответ, записать в сокет. Если на каждом этапе мы идём через Fast API в одном runtime и через обычный V8 binding в другом, разница на один вызов всего доли микросекунды. Но когда таких вызовов десятки на каждый запрос, а самих запросов десятки тысяч в секунду, эти доли могут складываться в наблюдаемую разницу.

Собственный HTTP-стек на Rust

Это, по совокупности архитектурных признаков и моим экспериментам, один из наиболее вероятных факторов наблюдаемой разницы. В ext/http/http_next.rs видно, что HTTP/1.1 в Deno обслуживается через собственный Rust-стек. serve_http11_raw обрабатывает запросы через crate deno_http_h1 и собственные структуры на чистом Rust, без napi-style binding'ов, без node:http совместимости. Node.js идёт по другому пути: node:http → llhttp (C) → node_http_parser.cc (C++) → V8. Этот binding использует обычный V8 calling convention через v8::FunctionCallbackInfo<Value>. Сам Deno в release notes к 1.35 прямо заявляет двухкратное преимущество своего HTTP-стека. Это согласуется с тем, что собственный Rust-стек и V8 Fast API являются одними из основных факторов наблюдаемой разницы.

Вывод

Для прагматичного выбора стека: если у вас высоконагруженный прокси или простой API-шлюз, Deno (или Bun) могут дать заметный прирост производительности. Если же у вас сложная бизнес-логика с тяжёлым I/O, разница, скорее всего, нивелируется, поэтому выбор лучше делать на основе собственных бенчмарков. И ещё одна настоящая киллер-фича Deno — op-биндинги к Rust. Если вам нужно выжать из V8 максимум и перенести часть логики на системный уровень, Deno даёт удобный механизм для такого сценария.

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