Перейти к содержанию

Что такое Node.js: архитектура среды выполнения, V8 и libuv

Источник: theNodeBook — What Node.js Is

Node.js — это среда выполнения JavaScript для серверных программ, CLI‑утилит, сборочных инструментов и локальной автоматизации. JavaScript в ней исполняется движком V8. Системные API предоставляет Node core. За event loop, таймеры, интеграцию с I/O ОС и worker pool для файловой системы, DNS, crypto и zlib отвечает libuv.

Именно эта граница runtime — практичный ответ на вопрос «что такое Node.js». V8 выполняет JavaScript. libuv отслеживает асинхронную работу и готовность операций. C++‑биндинги связывают JavaScript API с нативным кодом. Стандартная библиотека Node включает модули вроде node:fs, node:net, node:http, node:crypto, node:timers.

Главный поток исполняет JavaScript по одному стеку вызовов за раз. I/O в это время может продолжаться вне этого стека: в ОС, в дескрипторах libuv или в worker pool libuv. Завершенные операции возвращаются в JavaScript через event loop. Поэтому процесс Node может держать тысячи сокетов открытыми, хотя в конкретный момент выполняется только один JS‑колбэк.

Что такое Node.js на практике

У Node.js есть четыре основные поверхности:

  1. V8. Парсит, компилирует, оптимизирует и выполняет JavaScript, а также управляет JS‑heap.
  2. libuv. Запускает event loop, отслеживает handles/requests, дает кроссплатформенные I/O‑примитивы и владеет worker pool для некоторых нативных операций.
  3. Node core API. Экспортирует модули node:fs, node:http, node:net, node:crypto, node:stream, node:process.
  4. Экосистема пакетов. Добавляет userland‑модули поверх runtime через npm‑совместимые менеджеры.

Из этого напрямую следует модель конкурентности: ваш JavaScript работает в одном главном потоке, а медленная нативная работа может идти параллельно. Когда результат готов, Node планирует соответствующий JS‑колбэк или реакцию промиса.

Логика приложения на JavaScript обычно идет в одном главном потоке, и это упрощает жизнь по сравнению с классическим многопоточным доступом. Но конкурентный доступ к состоянию все равно остается: асинхронные колбэки, несколько процессов Node, worker_threads, нативные аддоны и разделяемая память (SharedArrayBuffer) могут приводить к гонкам. Проектируйте состояние как иммутабельное, используйте атомарные обновления и синхронизацию там, где это необходимо.


Архитектура runtime Node.js

Почему блокирующий I/O плохо масштабировался

Чтобы понять, почему Node.js устроен так, нужно вернуться к веб‑ландшафту 2009 года. Классическая модель веб‑сервера выглядела так:

  1. Приходит пользовательский запрос.
  2. Сервер выделяет поток (или процесс) под этот запрос.
  3. Ваш код работает в этом потоке.
  4. Если нужен медленный шаг (БД, файл, внешний API), поток блокируется и ждет.
  5. После завершения медленной операции поток продолжает выполнение и только потом освобождается.

Модель проста и линейна, но имеет критичный недостаток.

Это описание классического подхода. Современные серверы и runtime развились: появились гибридные event‑driven/threaded‑архитектуры и более эффективные механизмы работы с потоками.

Представьте кофейню, где на каждого клиента выделяется отдельный бариста «под ключ». Пока один бариста делает ваш напиток, он не может обслуживать других. Чем больше клиентов — тем больше нужно бариста и оборудования.

Ровно так работала модель «поток на запрос». Потоки дороги: они потребляют память (стек) и CPU (переключение контекста). Отсюда и известная проблема C10k — как обработать 10 000 одновременных соединений на одной машине.

«Ага‑момент»

Райан Даль увидел, что в веб‑приложениях чаще всего тормозит I/O, а CPU в это время простаивает. Он посмотрел на подход Nginx: событийная неблокирующая архитектура.

Вместо «один бариста на клиента» — один быстрый координатор (event loop), который:

  1. Принимает задачу и передает медленную работу в ОС/ядро.
  2. Сразу переходит к следующей задаче, не ожидая результата.

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

JavaScript отлично подошел к этой модели: в браузере он уже событийный (button.addEventListener(...)). Даль взял V8 из Chrome и соединил его с C‑библиотекой асинхронного I/O (дальше это направление оформилось в libuv) — так и появился серверный JS‑runtime с неблокирующей архитектурой.

Ранние прототипы Node использовали libev/libeio для интеграции с ядром ОС. Позже проект перешел на libuv — кроссплатформенную C‑библиотеку, которая объединила event loop, async filesystem, thread pool и платформенные абстракции.

Эта история важна, потому что зашита в ДНК Node: не блокировать главный поток. Поэтому асинхронные API, промисы и async/await — не косметика, а базовый способ работы runtime.

Исторически fs.readFile часто использовали с колбэками. В современном коде обычно выбирают fs.promises + async/await или потоковые API, когда это уместно.

V8, libuv и биндинги Node core

Команда node my_app.js запускает не «голый интерпретатор», а многослойную систему.

Любая схема внутренностей Node упрощена: детали зависят от платформы и API. Например, сеть в основном опирается на механизмы готовности ОС, часть FS/DNS‑операций выполняется через worker pool libuv, а на разных платформах используются epoll, kqueue, IOCP, io_uring и т.д.

V8: движок JavaScript

V8 — open‑source движок JavaScript/WebAssembly на C++, тот же, что в Chrome.

Он не просто «читает JS по строкам». V8 компилирует код в машинные инструкции (JIT):

  • сначала Ignition быстро превращает код в байткод;
  • затем profiler выявляет «горячие» функции;
  • TurboFan строит оптимизированный машинный код на основе предположений;
  • если предположения не подтверждаются, происходит деоптимизация.

Главный вывод: именно V8 делает JavaScript достаточно быстрым для серверных задач.

Но V8 сам по себе не знает о файловой системе, сети и таймерах. Для этого ему нужен слой Node + libuv.

libuv

libuv — C‑библиотека, которая дает Node асинхронный event‑driven фундамент.

Ключевые обязанности:

  1. Event loop. Центральный цикл обработки событий.
  2. Асинхронный I/O. Кроссплатформенная абстракция над epoll/kqueue/IOCP.
  3. Thread pool. Пул потоков для задач, которые нельзя эффективно сделать чисто неблокирующими средствами ОС.

Отсюда частое недопонимание: «Node же однопоточный».
Пояснение: пользовательский JavaScript в основном действительно однопоточный, но под капотом для части задач используются рабочие потоки.

По умолчанию worker pool состоит из 4 потоков (размер настраивается через UV_THREADPOOL_SIZE до создания пула; верхняя граница порядка 1024).

Сетевой сокетный I/O обычно обслуживается неблокирующими механизмами ОС и не использует worker pool libuv. Но есть исключения: например, dns.lookup (через getaddrinfo) и многие файловые операции идут через пул потоков.

Когда worker‑поток завершает работу, он сообщает результат в event loop, и Node выполняет соответствующий JS‑колбэк.

C++‑биндинги и Node core API: мост

Когда вы пишете const fs = require('fs'), вы получаете API, связанный с нативным слоем.

Вызов fs.readFile('/path/to/file', callback) проходит примерно так:

  1. Вызывается JS‑функция readFile из core‑модуля fs.
  2. Она переходит в C++ через биндинги.
  3. C++‑слой формирует запрос и передает его в libuv.
  4. libuv отправляет работу в worker pool (для соответствующих операций).
  5. JS‑код продолжает выполняться сразу.
  6. По завершении worker сообщает результат в libuv.
  7. libuv ставит callback/результат в очередь event loop.
  8. На одном из следующих тиков event loop запускает ваш исходный JS‑колбэк.

Именно этот мост превращает низкоуровневые механизмы в удобные JavaScript‑модули Node.

Мини‑эксперимент: блокирующий vs неблокирующий подход

Теория полезна, но наглядный пример запоминается лучше.

Сделаем два простых сервера:

  1. Python/Flask: на каждый запрос sleep(2) (блокирующая операция).
  2. Node.js: на каждый запрос setTimeout(..., 2000) перед ответом (неблокирующая операция).

Затем отправим 10 одновременных запросов.

Для Python используется встроенный dev‑сервер Flask (однопоточный), чтобы контраст был максимально заметен. В production обычно используют WSGI/ASGI‑серверы (gunicorn, uWSGI, uvicorn) с другой моделью конкурентности.

Блокирующий сервер (Python/Flask)

Установка:

1
pip install Flask

blocking_server.py:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import time
from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello_world():
    print("Request received. Starting 2-second 'work'...")
    time.sleep(2)  # This BLOCKS the entire process!
    print("Work finished. Sending response.")
    return "<p>Hello from the blocking server!</p>"

if __name__ == '__main__':
    # Flask's dev server is single-threaded by default
    app.run(port=5000, threaded=False)

Запуск:

1
python blocking_server.py

Неблокирующий сервер (Node.js)

non_blocking_server.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// @noErrors
import http from 'http';

const server = http.createServer((req, res) => {
    // @log: Request received. Scheduling 2-second 'work'...
    console.log(
        "Request received. Scheduling 2-second 'work'..."
    );

    // This is NON-BLOCKING. It schedules the work and returns immediately.
    setTimeout(() => {
        // @log: Work finished. Sending response.
        console.log('Work finished. Sending response.');
        res.writeHead(200, { 'Content-Type': 'text/html' });
        res.end(
            '<p>Hello from the non-blocking server!</p>'
        );
    }, 2000);
});

server.listen(5001, () => {
    // @log: Server running on http://localhost:5001/
    console.log('Server running on http://localhost:5001/');
});

setTimeout здесь демонстрирует «ожидание без блокировки главного потока». В реальном приложении 10 одновременных запросов к БД все равно создадут 10 соединений и займут память под буферы. Сила Node не в «бесплатном I/O», а в том, что главный поток не простаивает, пока I/O выполняется.

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

Запуск:

1
node non_blocking_server.js

Тест

Откройте новый терминал и запустите 10 запросов почти одновременно.

Для блокирующего сервера:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
time ( \
  curl http://localhost:5000/ & \
  curl http://localhost:5000/ & \
  curl http://localhost:5000/ & \
  curl http://localhost:5000/ & \
  curl http://localhost:5000/ & \
  curl http://localhost:5000/ & \
  curl http://localhost:5000/ & \
  curl http://localhost:5000/ & \
  curl http://localhost:5000/ & \
  curl http://localhost:5000/ \
)

Ожидаемый вывод:

1
2
3
4
5
Request received. Starting 2-second 'work'...
Work finished. Sending response.
Request received. Starting 2-second 'work'...
Work finished. Sending response.
... (и так 10 раз) ...

Запросы обрабатываются строго последовательно. Время — примерно 20 секунд.

Для неблокирующего сервера:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
time ( \
  curl http://localhost:5001/ & \
  curl http://localhost:5001/ & \
  curl http://localhost:5001/ & \
  curl http://localhost:5001/ & \
  curl http://localhost:5001/ & \
  curl http://localhost:5001/ & \
  curl http://localhost:5001/ & \
  curl http://localhost:5001/ & \
  curl http://localhost:5001/ & \
  curl http://localhost:5001/ \
)

Ожидаемый вывод:

1
2
3
4
5
6
7
8
9
Request received. Scheduling 2-second 'work'...
Request received. Scheduling 2-second 'work'...
Request received. Scheduling 2-second 'work'...
... (почти мгновенно 10 раз) ...

Work finished. Sending response.
Work finished. Sending response.
Work finished. Sending response.
... (примерно через 2 секунды) ...

Сервер принимает все 10 запросов сразу, планирует таймеры и почти одновременно отдает ответы примерно через 2 секунды. Это и есть ключевая интуиция производительности Node.js.


Runtime Node.js и экосистема npm

О Node.js нельзя говорить в отрыве от экосистемы. Runtime — это фундамент, а экосистема пакетов дает скорость разработки.

Если Node.js — двигатель, то npm — фабрика деталей.

npm: крупнейшая экосистема пакетов

В начале у JavaScript‑разработчиков не было единого удобного способа делиться серверным кодом. В 2010 году Айзек Шлютер создал npm (Node Package Manager), а с 2011 npm стал поставляться вместе с Node.js.

Это резко упростило повторное использование кода:

  • полезная функция — npm publish;
  • нужен фреймворк — npm install express.

Появилась культура маленьких модулей («делай одну вещь и делай ее хорошо»).

Плюсы и минусы:

  • Плюс: сложные системы можно быстро собирать из готовых блоков.
  • Минус: node_modules легко разрастается до тысяч транзитивных зависимостей.

Сегодня npm‑реестр — крупнейший в мире: all-the-packages.

Параллельно развивались yarn и pnpm, подталкивая экосистему к лучшей производительности, безопасности и предсказуемому dependency resolution.

День, когда left-pad «сломал интернет»

22 марта 2016 года разработчик Азер Коджулу удалил из npm свои пакеты, включая маленький left-pad, от которого транзитивно зависели тысячи проектов (в том числе инструменты вокруг Babel).

Последствия были мгновенными:

  • падали CI/CD пайплайны;
  • установка зависимостей ломалась с 404 Not Found: left-pad;
  • стало очевидно, насколько хрупкой бывает цепочка поставки open source.

После инцидента пакет восстановили, но уроки остались:

  1. Зависимости — это ответственность и риск.
  2. Транзитивные зависимости так же важны, как прямые.
  3. Нужны lockfile‑подходы и аудит (package-lock.json, yarn.lock, npm audit).

Node везде, а не только на серверах

Сегодня Node.js — это не только backend API.

  • Сборка и фронтенд: Webpack, esbuild, Vite, TypeScript compiler.
  • Десктоп: Electron, Tauri.
  • CLI‑инструменты: от scaffolders до cloud‑утилит.
  • Serverless/edge: удобный запуск небольших событийных функций.

Быстрый старт Node хорошо подходит serverless‑сценариям, но cold start может быть заметным при большом дереве зависимостей и тяжелой инициализации. Также edge runtime (изоляты V8) и классические serverless‑контейнеры — разные среды с разными ограничениями API, памяти и CPU.

Проверки runtime в продакшене

Если сервис ведет себя нестабильно, проверьте:

  • Не блокирует ли JS‑код главный поток между задачами.
  • Учтена ли нагрузка worker pool для FS/DNS/crypto/compression.
  • Отделены ли сетевые задержки от задержек бизнес‑логики.
  • Нет ли синхронной загрузки модулей на latency‑критичных путях.
  • Не перегружает ли проект дерево зависимостей и install‑скрипты.
  • Есть ли диагностика по active handles, росту heap и event loop delay.

V8 исполняет JavaScript. libuv управляет ожиданием и событиями. Node core превращает нативные возможности в JavaScript API. Эта граница проходит через все темы платформы: модули, потоки, файловую систему, процессы и сеть.

Связанное чтение

Комментарии