Node.js Internals

libuv: Асинхронный движок Node.js

Node.js считается однопоточным? `htop` во время работы сервера показывает 5+ потоков. Один - это JavaScript. Остальные - libuv thread pool, тихо обрабатывающий файлы, DNS и криптографию.

  • **Production-сервер с высокой нагрузкой на файлы**: Стандартные 4 потока thread pool - bottleneck. Увеличиваете UV_THREADPOOL_SIZE=16, throughput вырастает в 3 раза.
  • **Медленный старт Node.js приложения**: Загрузка зависимостей делает тысячи `fs.readFile()` операций. Все они конкурируют за 4 потока thread pool. Решение: preload модулей или увеличить thread pool.
  • **UV_DEBUG для отладки native addon**: C++ addon периодически крашится. Включение UV_DEBUG=uv_async показывает race condition в uv_async_send() - забыт mutex перед доступом к shared data.

Intro

Когда говорят, что **Node.js однопоточный**, они лгут. Точнее, упрощают настолько, что это превращается в ложь.

Node.js использует **libuv** - библиотеку на C, которая скрывает внутри **thread pool** из 4 потоков (по умолчанию). Пока JavaScript-код выполняется в одном потоке, libuv тихо распределяет блокирующие операции по рабочим потокам.

Вот почему `fs.readFileSync()` блокирует весь сервер, а `fs.readFile()` - нет. Синхронная версия выполняется в главном потоке JavaScript. Асинхронная - отправляет задачу в thread pool libuv, освобождая event loop для других задач.

**libuv** - это кроссплатформенная C-библиотека для асинхронного I/O. Она даёт Node.js доступ к операциям файловой системы, сети, таймерам и IPC (inter-process communication) через единый интерфейс, скрывая различия между Linux (epoll), macOS (kqueue), Windows (IOCP).

Без libuv Node.js не был бы кроссплатформенным. На каждой ОС свой API для асинхронного I/O: Linux использует `epoll`, macOS - `kqueue`, Windows - `IOCP` (I/O Completion Ports). libuv абстрагирует эти различия.

Зачем Node.js вообще C-библиотека? JavaScript не может напрямую общаться с ядром ОС. Нужен мост между V8 (JavaScript engine) и системными вызовами. libuv - это мост.

Почему `fs.readFile()` не блокирует event loop, а `fs.readFileSync()` блокирует?

Architecture

Архитектура libuv строится на двух ключевых абстракциях: **handles** (долгоживущие объекты вроде TCP-сокетов, таймеров) и **requests** (одноразовые операции вроде записи файла, DNS-запроса).

**Handle** - это объект, который существует длительное время и может генерировать события. Примеры: `uv_tcp_t` (TCP-соединение), `uv_timer_t` (таймер), `uv_fs_event_t` (отслеживание изменений файла).

**Request** - это одноразовая операция. Код создаёт request, libuv выполняет его (возможно, в thread pool), вызывает callback, и request уничтожается. Примеры: `uv_fs_t` (чтение файла), `uv_getaddrinfo_t` (DNS-резолвинг), `uv_write_t` (запись в socket).

**Event Loop в libuv** проходит через 7 фаз: timers → pending callbacks → idle/prepare → poll → check → close callbacks → repeat. Node.js добавляет свои микротаски (Promise callbacks) между фазами.

Ключевое отличие: handle живёт, пока его явно не закрыть (`.close()`). Request живёт только до завершения операции. Утечка памяти в Node.js часто связана с незакрытыми handles - сервер продолжает держать сокеты, таймеры, file watchers.

В чём разница между handle и request в libuv?

Thread Pool

Вот момент истины: **Node.js НЕ однопоточный**. libuv создаёт thread pool из 4 потоков (по умолчанию), где выполняются блокирующие операции.

Какие операции используют thread pool? Три категории:

1. **File System** - все операции `fs` (кроме `fs.watch`): `readFile`, `writeFile`, `stat`, `readdir`. Чтение файлов - блокирующая операция, не поддерживаемая epoll/kqueue.

2. **DNS** - `dns.lookup()` (но не `dns.resolve()`!). `lookup` вызывает блокирующий системный вызов `getaddrinfo()`, требующий thread pool. `resolve` использует c-ares (асинхронная библиотека), работает в event loop.

3. **Crypto** - `crypto.pbkdf2()`, `crypto.scrypt()`, `crypto.randomBytes()` (если без callback). Эти операции CPU-интенсивны, блокируют event loop, поэтому идут в thread pool.

**UV_THREADPOOL_SIZE** - переменная окружения для настройки размера thread pool (по умолчани 4). Максимум 1024. Увеличивается, если приложение активно использует `fs` или `crypto`.

**Когда увеличивать UV_THREADPOOL_SIZE?** Если приложение делает много параллельных операций `fs` (например, обрабатывает uploaded файлы) или `crypto` (хеширование паролей), стандартные 4 потока станут bottleneck.

Какая операция НЕ использует thread pool libuv?

I/O Polling

Главная задача libuv - абстрагировать различия в асинхронном I/O между операционными системами. Linux использует **epoll**, macOS - **kqueue**, Windows - **IOCP** (I/O Completion Ports). libuv скрывает эти детали за единым API.

**epoll (Linux)** - механизм для мониторинга множества file descriptors (сокеты, pipes, но НЕ файлы). Работает в режиме edge-triggered: уведомляет только при изменении состояния (новые данные в сокете).

**kqueue (macOS/BSD)** - аналог epoll, но с поддержкой большего количества типов событий: изменения файлов, signals, timers. Также edge-triggered.

**IOCP (Windows)** - принципиально другая модель: completion-based вместо readiness-based. Код не спрашивает "готов ли socket к чтению?", а получает уведомление "операция чтения завершена".

**Poll Phase** - фаза event loop, где libuv вызывает OS polling API (epoll_wait/kevent/GetQueuedCompletionStatus). Здесь event loop может блокироваться, ожидая I/O событий, но с timeout (следующий таймер или setImmediate).

Почему файлы не используют epoll/kqueue? Эти механизмы работают только с network I/O и pipes. Чтение обычных файлов всегда блокирующее в POSIX API, поэтому libuv использует thread pool.

**Edge-triggered vs Level-triggered**. epoll и kqueue работают в edge-triggered режиме: уведомляют только при появлении новых данных. Если данные не прочитаны все за один раз, следующее уведомление не придёт, пока не появятся ещё данные.

**IOCP на Windows** - особая песня. Completion-based модель означает: отправляется запрос на чтение, и OS уведомляет, когда чтение завершено (не когда данные готовы к чтению). libuv эмулирует readiness-модель поверх IOCP для совместимости.

Почему network I/O (HTTP-запросы) не блокирует event loop, а file I/O (fs.readFile) использует thread pool?

Timers

Таймеры в libuv реализованы через **min-heap** (бинарная куча). Каждый `setTimeout`/`setInterval` добавляет узел в кучу, где ключ - время срабатывания. Корень кучи - ближайший таймер.

Почему min-heap? Потому что нам нужна O(1) сложность для поиска ближайшего таймера (корень кучи), O(log n) для добавления нового таймера, и O(log n) для удаления сработавшего таймера.

Event loop проверяет таймеры в **timers phase**: сравнивает текущее время с корнем min-heap. Если время пришло, вызывает callback и удаляет таймер из кучи. Повторяет, пока корень кучи > текущее время.

**Monotonic Time** - libuv использует монотонное время (не wall clock time), которое не меняется при переводе системных часов. Функция `uv_now()` возвращает миллисекунды с момента запуска event loop.

**Точность таймеров** зависит от загрузки системы. Если event loop занят (например, выполняет долгий синхронный код), таймеры сработают с задержкой. setTimeout(100) гарантирует "не раньше 100мс", но не "ровно через 100мс".

**Почему монотонное время?** Сценарий: установлен `setTimeout(1000)`, и пользователь перевёл системные часы на час назад. Если бы libuv использовал wall clock time, таймер сработал бы через 1 час + 1 секунду. С монотонным временем - ровно через 1 секунду, независимо от изменений часов.

Почему libuv использует min-heap для таймеров вместо массива или linked list?

Async Handles

**Async handles** (`uv_async_t`) - это механизм для thread-safe коммуникации между потоками. Они позволяют рабочему потоку (из thread pool или `worker_threads`) безопасно вызвать callback в главном потоке event loop.

Почему нужен специальный механизм? Event loop работает в одном потоке. Если рабочий поток напрямую вызовет JavaScript callback, возникнет race condition (два потока одновременно изменяют V8 heap).

**uv_async_send()** - атомарная операция: рабочий поток вызывает её, libuv помечает async handle как "pending", и event loop вызовет callback в следующей итерации (в safe context).

**Coalescing** - если вызвать `uv_async_send()` несколько раз до того, как event loop обработает handle, callback выполнится только один раз. Async handles не гарантируют "один send = один callback", только "хотя бы один callback".

Внутри Node.js bindings: при вызове `parentPort.postMessage()` C++ код создаёт `uv_async_t` handle и вызывает `uv_async_send()`. Event loop главного потока получает уведомление и вызывает JavaScript callback ('message' event).

**Почему coalescing?** Производительность. Если рабочий поток отправляет тысячи событий в секунду, event loop не должен обрабатывать каждое отдельно - это убьёт throughput. Coalescing позволяет batch processing.

**Native addons** активно используют `uv_async_t`. Если C++ addon выполняет блокирующую работу в отдельном потоке (например, обработка видео), он вызывает `uv_async_send()` для уведомления JavaScript о результате.

Node.js полностью однопоточный, весь код выполняется в одном потоке

JavaScript-код выполняется в одном потоке, но libuv использует thread pool (4+ потоков) для блокирующих операций (fs, dns.lookup, crypto)

Путаница возникает из-за упрощённых объяснений "Node.js однопоточный". На самом деле libuv скрывает многопоточность: JavaScript работает в одном потоке, но параллельно выполняются файловые операции, DNS-запросы, криптография. UV_THREADPOOL_SIZE контролирует количество этих скрытых потоков.

Что произойдёт, если рабочий поток вызовет uv_async_send() 1000 раз подряд?

Ключевые идеи

  • **libuv - кроссплатформенный слой** между Node.js и OS (epoll/kqueue/IOCP). Абстрагирует различия в асинхронном I/O.
  • **Thread pool (4 потока)** выполняет блокирующие операции: fs, dns.lookup, crypto. Network I/O идёт через event loop (epoll/kqueue). UV_THREADPOOL_SIZE настраивается через env переменную.
  • **Таймеры реализованы через min-heap** с монотонным временем. setTimeout(0) не гарантирует немедленное выполнение - зависит от фазы event loop.
  • **uv_async_t** обеспечивает thread-safe коммуникацию между потоками. worker_threads использует это под капотом. Coalescing объединяет множественные send в один callback.

Связанные темы

libuv - фундамент для понимания всей асинхронной архитектуры Node.js:

  • Event Loop — libuv реализует event loop с 7 фазами (timers, poll, check, etc.). Понимание libuv объясняет, почему setTimeout и setImmediate могут выполняться в разном порядке.
  • Worker Threads — worker_threads использует uv_async_t для межпотоковой коммуникации. Каждый worker имеет свой event loop (отдельный uv_loop_t).
  • Async Patterns — Все async паттерны в Node.js (callbacks, promises, async/await) построены поверх libuv primitives. fs.readFile возвращает Promise, но под капотом - uv_fs_t request.

Вопросы для размышления

  • Если приложение делает много DNS-запросов, что лучше использовать - dns.lookup() или dns.resolve4()? Почему?
  • Можно ли увеличить UV_THREADPOOL_SIZE до 1000, и будет ли это хорошей идеей для production?
  • Почему libuv не может использовать epoll для чтения обычных файлов (не pipes, не sockets)? Что блокирует создание truly асинхронного file I/O в POSIX?

Связанные уроки

  • os-04-scheduling
libuv: Асинхронный движок Node.js

0

1

Войти