Node.js Internals

libuv Deep Dive: Внутренности асинхронного движка

Кажется, что libuv - это знакомая территория? В исходниках Node.js 90% кода - это обёртки вокруг libuv. Понимание uv_loop_t, ref/unref, platform-specific I/O - это разница между "использую Node.js" и "понимаю, как он работает".

  • **Production: процесс не завершается после deploy**: Новый код добавил fs.watch(), но забыл .close(). process._getActiveHandles() показывает 500+ FSWatcher handles. Memory leak 2GB за сутки. wtfnode выдаёт stacktrace - фикс за 5 минут.
  • **High-throughput API: bottleneck в thread pool**: Сервис обрабатывает 10k req/sec, каждый делает bcrypt.hash() (CPU-bound, идёт в thread pool). UV_THREADPOOL_SIZE=4 - катастрофа. Переход на worker_threads pool - latency упала с 500мс до 50мс.
  • **Кроссплатформенный native addon**: C++ код работает на Linux (epoll), но крашится на macOS (kqueue). Чтение libuv source выявляет: edge-triggered семантика разная. Понимание platform-io спасает неделю отладки.

Loop Internals: uv_loop_t и режимы выполнения

Структура **uv_loop_t** - это сердце libuv. Она содержит min-heap таймеров, очередь pending callbacks, file descriptor для epoll/kqueue, счётчик активных handles. Каждый Node.js процесс имеет один default loop, но можно создать кастомный.

**uv_run()** принимает три режима: **UV_RUN_DEFAULT** (работает, пока есть активные handles), **UV_RUN_ONCE** (одна итерация, полезно для embedding), **UV_RUN_NOWAIT** (poll без блокировки, для интеграции с другими event loops).

**Embedding libuv** в свой event loop: применяется UV_RUN_NOWAIT в комбинации с собственным polling механизмом. Например, GUI-приложение может чередовать uv_run(UV_RUN_NOWAIT) с обработкой UI-событий.

**UV_RUN_DEFAULT** - стандартный режим Node.js. Цикл продолжается, пока `uv_loop_alive()` возвращает true (есть активные handles или pending requests). Когда все handles закрыты, event loop завершается.

**UV_RUN_ONCE** полезен для embedding: вызывается вручную из главного цикла приложения, передавая контроль libuv на одну итерацию, затем управление возвращается к собственной логике (например, обработка UI в Qt/GTK).

**uv_backend_timeout()** вычисляет, сколько миллисекунд можно блокироваться в epoll_wait/kevent. Если есть pending callbacks или setImmediate, timeout = 0 (non-blocking poll). Если есть таймер через 500мс, timeout = 500мс.

В чём разница между UV_RUN_ONCE и UV_RUN_NOWAIT?

Жизненный цикл handles и requests: ref/unref, uv_close()

**Handle** живёт до явного вызова `.close()`. **Request** автоматически уничтожается после callback. Утечка памяти в Node.js - это почти всегда незакрытые handles: сокеты, таймеры, fs watchers.

**ref/unref** контролирует, должен ли handle держать event loop живым. По умолчанию handle ref=1 (event loop не завершится). `.unref()` переводит в ref=0 (event loop может завершиться, даже если handle активен).

**uv_loop_alive()** возвращает true, если `active_handles > 0 || active_reqs > 0 || closing_handles > 0`. Unref'd handles не учитываются в active_handles. Поэтому процесс может завершиться, даже если есть unref'd таймер.

**Четыре типа специальных handles**: **idle** (вызывается каждую итерацию loop, если есть другая работа), **prepare** (перед poll phase), **check** (после poll, используется для setImmediate), **async** (thread-safe wakeup).

**uv_close() - асинхронная операция!** Вызов `uv_close(handle, close_cb)` не уничтожает handle сразу. Сначала он помечается как closing, затем в close callbacks phase вызывается `close_cb`, и только потом память освобождается.

**Callback hell** в libuv: чтобы корректно закрыть handle, нужно вызвать `uv_close()` и дождаться `close_cb`. На 10 handles получается 10 вложенных callbacks. Решение: счётчик pending closes + единый cleanup callback.

Что произойдёт при вызове setInterval().unref(), если больше нет других active handles?

Кроссплатформенные абстракции: epoll, kqueue, IOCP

**epoll (Linux)** - edge-triggered механизм для мониторинга file descriptors. Fd добавляется в epoll через `epoll_ctl(EPOLL_CTL_ADD)`, затем `epoll_wait()` блокируется до события (readable/writable/error).

**kqueue (macOS/BSD)** - более универсальный аналог epoll. Поддерживает не только сокеты, но и file system events (EVFILT_VNODE), signals (EVFILT_SIGNAL), timers (EVFILT_TIMER). libuv использует только EVFILT_READ/WRITE для совместимости.

**IOCP (Windows)** - completion-based модель: ожидание не на готовности ("socket ready to read"), а на уведомлении "операция завершена". Фундаментально другой подход. libuv эмулирует readiness-модель поверх IOCP.

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

**Почему файлы используют thread pool?** epoll и kqueue работают только с non-blocking I/O (сокеты, pipes). Обычные файлы не поддерживают non-blocking режим в POSIX - `read()` всегда блокирующий, даже с O_NONBLOCK.

**IOCP на Windows** поддерживает истинный асинхронный file I/O! `ReadFile()` с OVERLAPPED структурой не блокирует. Но libuv всё равно использует thread pool для совместимости с POSIX API (Node.js должен работать одинаково на всех ОС).

**Thundering herd** в старых версиях Linux (до epoll). С `select()`/`poll()` все потоки просыпались при событии на shared socket. epoll решает это через EPOLLEXCLUSIVE (Linux 4.5+), libuv использует этот флаг для load balancing.

Почему Windows поддерживает асинхронный file I/O (IOCP), а Linux нет (требует thread pool)?

Thread Pool Tuning: UV_THREADPOOL_SIZE, профилирование, альтернативы

**UV_THREADPOOL_SIZE** (по умолчанию 4) контролирует количество потоков для блокирующих операций: fs, dns.lookup, crypto, zlib. Максимум 1024, но оптимально 2-4x количества CPU cores.

**Когда увеличивать?** Если приложение делает много параллельных операций, требующих thread pool (например, image processing с sharp, архивирование, хеширование паролей), стандартные 4 потока - bottleneck.

**КРИТИЧНО**: UV_THREADPOOL_SIZE должен задаваться перед запуском процесса (env переменная). Установка через `process.env.UV_THREADPOOL_SIZE = '16'` в коде НЕ РАБОТАЕТ - libuv инициализирует thread pool до выполнения JavaScript.

**Когда НЕ увеличивать**: если bottleneck в CPU (CPU-bound задачи вроде image processing), больше потоков не поможет. Context switching убьёт производительность. Используйте worker_threads для CPU-bound задач.

**Alternative: worker_threads** для CPU-intensive задач. Thread pool libuv предназначен для I/O-blocking операций (fs, dns), не для CPU-bound. worker_threads даёт полный контроль над потоками и V8 isolates.

Приложение обрабатывает 100 uploaded изображений параллельно (sharp library, CPU-bound). Какое решение оптимально?

Отладка libuv: утечки handles, uv_print_all_handles(), dtrace

**Утечка handles** - главная причина memory leaks в Node.js. Забыли вызвать `.close()` на сокете? Handle живёт вечно, держит ссылку на callback с closure, closure держит десятки мегабайт данных.

**process._getActiveHandles()** (unofficial API) возвращает массив всех активных handles. Полезно для диагностики "почему процесс не завершается".

**uv_print_all_handles()** (C API) выводит список всех handles с типом и адресом. Для Node.js нужен native addon. Альтернатива: wtfnode module (npm install wtfnode).

**wtfnode** - npm-модуль для автоматической диагностики активных handles. Показывает stacktrace, где handle был создан.

**uv_print_active_handles()** (C API) выводит только active handles (ref=1). uv_print_all_handles() выводит все, включая unref'd.

**DTrace/SystemTap probes** в libuv (Linux/macOS). Можно трейсить: создание handles, вызовы uv_run, I/O операции. Требует компиляцию Node.js с флагом --with-dtrace.

**async_hooks для продвинутой диагностики**: отслеживание жизненного цикла всех async операций (fs, timers, promises).

Если увеличить UV_THREADPOOL_SIZE до 128, все async операции будут выполняться быстрее

Thread pool предназначен только для I/O-blocking операций (fs, dns.lookup, crypto, zlib). CPU-bound задачи (image processing, heavy computations) должны выполняться в worker_threads. Слишком большой thread pool убивает производительность через context switching.

Thread pool libuv - это не универсальный пул потоков для любых задач. Он используется исключительно для блокирующих системных вызовов (read, write, getaddrinfo), где поток большую часть времени спит, ожидая I/O. Если загрузить thread pool CPU-bound задачами (например, 128 параллельных crypto.pbkdf2), context switching между потоками убьёт CPU cache locality, и производительность упадёт. Оптимально: UV_THREADPOOL_SIZE = 2-4x CPU cores для I/O, worker_threads pool = CPU cores для CPU-bound.

Node.js процесс не завершается после вызова process.exit(). Первый шаг диагностики?

Итоги

  • **uv_loop_t** - центральная структура: min-heap таймеров, fd для epoll/kqueue, очередь requests. Режимы uv_run: DEFAULT (до завершения), ONCE (одна итерация), NOWAIT (non-blocking poll для embedding).
  • **ref/unref** контролирует, держит ли handle event loop alive. uv_close() - асинхронная операция, callback вызывается в close phase. Утечки handles диагностируются через process._getActiveHandles() или wtfnode.
  • **Кроссплатформенные абстракции**: epoll (Linux), kqueue (macOS), IOCP (Windows). epoll/kqueue - edge-triggered, только сокеты. IOCP - completion-based, все I/O. Обычные файлы используют thread pool, т.к. POSIX read() всегда блокирующий.
  • **UV_THREADPOOL_SIZE** для I/O-blocking операций (fs, dns, crypto). Оптимально 2-4x CPU cores. CPU-bound задачи (image processing) - в worker_threads, не в thread pool. DTrace/async_hooks для advanced диагностики.

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

Продвинутое понимание libuv открывает двери к профессиональной работе с Node.js:

  • Event Loop Internals — uv_run() реализует 7 фаз event loop. Понимание uv_backend_timeout() объясняет, почему setTimeout(0) и setImmediate могут выполняться в разном порядке.
  • Worker Threads — worker_threads создают отдельный uv_loop_t для каждого worker. Межпотоковая коммуникация через uv_async_t. Альтернатива thread pool для CPU-bound задач.
  • Native Addons (N-API) — Native addons напрямую используют libuv API: uv_queue_work для thread pool, uv_async_send для thread-safe callbacks. Понимание handles/requests критично для C++ bindings.

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

  • Как реализовать graceful shutdown при 1000+ активных WebSocket соединений (handles)? Нужно ли вызывать uv_close для каждого, или можно убить процесс силой?
  • Почему io_uring (новый Linux API для async I/O) может заменить thread pool для файлов, но epoll/kqueue не могут? Что фундаментально отличает io_uring от epoll?
  • Если создать 10 worker_threads, каждый с UV_THREADPOOL_SIZE=8, сколько всего потоков будет в процессе? Учитывайте: главный поток, thread pool каждого worker, V8 background threads.

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

  • os-04-scheduling
libuv Deep Dive: Внутренности асинхронного движка

0

1

Войти