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.