Node.js против Java, Go и Python
Разберите, почему Node эффективен на I/O, где заканчивается преимущество Event Loop и какие модели используют Java, Go и Python.
Временная шкала
Разбираем: Node.js против Java, Go и Python
Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.
Node похож не на «одного медленного работника», а на диспетчера, который не стоит рядом с каждым ожидающим заказом. Пока база данных, сеть или диск выполняют работу, главный JavaScript-поток может обслуживать другие готовые события. Это экономит потоки именно на ожидании I/O, но не превращает тяжёлый JavaScript в параллельный.
Один Node-процесс содержит V8 isolate с главным JavaScript Event Loop, служебные потоки V8 и возможности libuv. Сокеты обычно наблюдаются механизмами готовности ОС, часть fs/crypto/DNS выполняется в ограниченном libuv pool, а CPU-bound JavaScript можно вынести в Worker Threads или процессы. Поэтому фраза «Node однопоточный» описывает выполнение JavaScript в одном isolate, а не весь процесс.
Термины этого эксперимента
Сначала поймите слова — затем порядок выполнения.
Concurrency
Несколько задач находятся в работе и продвигаются с перекрытием по времени; это ещё не означает одновременное выполнение инструкций.
Parallelism
Инструкции действительно выполняются одновременно на нескольких ядрах или аппаратных потоках.
I/O-bound
Нагрузка, где большая часть времени уходит на ожидание сети, диска, базы данных или другого внешнего ресурса.
CPU-bound
Нагрузка, ограниченная вычислениями: сериализацией, компрессией, изображениями, криптографией или большими циклами.
V8 isolate
Изолированная среда V8 со своим heap и JavaScript execution state. Worker Thread создаёт отдельный isolate.
Throughput
Количество успешно обработанных операций за единицу времени; не то же самое, что latency одного запроса.
Что происходит по шагам
Каждый шаг соответствует наблюдаемому состоянию runtime.
- 01Классифицируйте работу
Отделите короткий JavaScript от сетевого ожидания, нативной операции и тяжёлого CPU-кода.
- 02Node регистрирует I/O
Главный поток передаёт ожидание ОС/libuv и возвращается к готовым callbacks.
- 03Готовность возвращает continuation
Когда операция готова, callback или Promise continuation ждёт свободного JavaScript-стека.
- 04CPU меняет картину
Долгий синхронный JavaScript занимает isolate и задерживает все его соединения.
- 05Выбирается граница параллелизма
Worker Threads подходят для CPU и общей памяти, процессы — для изоляции, очередь jobs — для распределённой работы.
- 06Модель проверяется метриками
Сравнивайте throughput, p95/p99 latency, Event Loop delay, CPU, память и поведение под перегрузкой.
Где результат требует оговорки
Эти детали объясняют, почему похожий код иногда даёт другой trace.
Java — не только тяжёлый thread-per-request
Java поддерживает platform threads, неблокирующий NIO/reactive-подход и virtual threads. Virtual thread при blocking I/O может освободить carrier OS thread; это масштабирует привычный синхронный стиль, но не ускоряет CPU-задачу.
Go — не «по ОС-потоку на goroutine»
Лёгкие goroutines мультиплексируются scheduler-ом Go на набор OS threads. Параллелизм регулируется runtime и доступными ядрами, а блокирующие операции интегрируются с scheduler-ом.
Python зависит от реализации и сборки
asyncio также использует Event Loop для I/O. Обычная CPython-сборка сериализует большую часть Python bytecode через GIL, но multiprocessing, native extensions и опциональная free-threaded сборка меняют границы параллелизма.
Node эффективен не для всего
Много ожидающих сокетов — сильный сценарий. Большая синхронная JSON-сериализация или расчёт на каждом запросе может превратить один isolate в bottleneck.
Архитектура важнее языка в вакууме
Connection pool, backpressure, алгоритм, batching, кэш, число процессов и лимиты часто сильнее влияют на результат, чем название runtime.
Сначала разберитесь, какие части Node участвуют в выполнении.
Затем уберите служебные детали и рассмотрите только главную идею.
После этого сопоставьте модель с кодом, который создаёт live trace.
Минимальная модель без служебного кода
const waits = Array.from({ length: 24 }, (_, index) =>
delay(25 + (index % 4) * 10)
);
// Один Event Loop координирует все pending waits:
await Promise.all(waits);
// Но этот CPU-код занимает главный JavaScript isolate:
heavyCpuWork(180);Полный код, который выполняет сценарий
Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.
Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.
import { performance } from 'node:perf_hooks';
const sleep = (ms) =>
new Promise((resolve) => setTimeout(resolve, ms));
function blockMainThread(durationMs) {
const startedAt = performance.now();
let iterations = 0;
// Намеренная блокировка для учебного сценария. В production так делать нельзя.
while (performance.now() - startedAt < durationMs) {
iterations += Math.sqrt((iterations % 10_000) + 1);
}
return Math.round(iterations);
}
async function runtimeModelsComparison(emit) {
const taskCount = 24;
const startedAt = performance.now();
emit(
'main-js',
'schedule',
`Регистрируем ${taskCount} I/O-подобных ожиданий без ${taskCount} JavaScript-потоков`,
);
const waits = Array.from({ length: taskCount }, (_, index) =>
sleep(25 + (index % 4) * 10).then(() => index),
);
emit(
'main-js',
'sync',
`Все ожидания зарегистрированы за ${(
performance.now() - startedAt
).toFixed(1)} мс; стек снова свободен`,
);
const results = await Promise.all(waits);
emit(
'event-loop',
'result',
`${results.length} continuations выполнены тем же Event Loop после готовности таймеров`,
);
const timerStartedAt = performance.now();
const delayedTimer = new Promise((resolve) => {
setTimeout(() => {
emit(
'event-loop',
'warning',
`Таймер после CPU-блокировки вошёл в стек через ${Math.round(
performance.now() - timerStartedAt,
)} мс`,
);
resolve();
}, 20);
});
emit(
'main-js',
'warning',
'Теперь 180 мс CPU-bound JavaScript блокируют один главный isolate',
);
blockMainThread(180);
await delayedTimer;
emit(
'architecture',
'result',
'Вывод: дешёвое ожидание I/O даёт throughput, но CPU-параллелизм требует Worker, процессов или другого runtime-подхода',
);
}Именно вызовы emit(...) превращаются в строки live trace. await и Promise удерживают HTTP-поток открытым до завершения сценария.
Популярные заблуждения
Миф слева, корректная модель справа.
Node.js полностью однопоточный.
Обычно один поток исполняет JavaScript конкретного isolate, но процесс использует служебные потоки, libuv pool и при необходимости Worker Threads.
Асинхронность автоматически использует все CPU-ядра.
Она позволяет не блокировать поток ожиданием. CPU-параллелизм требует нескольких isolates/процессов или нативного параллельного API.
Java всегда создаёт дорогой OS thread на запрос.
Это лишь одна модель. Есть NIO, reactive frameworks и virtual threads, которые мультиплексируются JVM.
У Python всегда и при любых условиях нет параллельных threads.
Это слишком широкое утверждение: важны CPython/GIL, free-threaded build, native code и выбранная модель multiprocessing/asyncio.
Высокий throughput означает низкую latency.
Система может обрабатывать много запросов в секунду и одновременно иметь плохие хвостовые p95/p99 задержки.
Ответьте своими словами
Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.
- Почему десять тысяч открытых сокетов и десять тысяч активных CPU-расчётов — принципиально разные нагрузки?
- Когда вы выберете Worker Thread, а когда отдельный процесс или BullMQ Worker?
- Какие метрики нужны, чтобы доказать преимущество модели, а не повторить рекламный тезис?