Node.js Internals

Event Loop: Сердце Node.js

Почему Netflix может стримить видео миллионам пользователей одновременно на серверах Node.js? Почему Discord обрабатывает миллиарды сообщений в день с минимальной задержкой? Секрет не в мощности серверов, а в понимании Event Loop - механизма, который делает Node.js одним из самых эффективных решений для I/O-интенсивных приложений.

  • **LinkedIn** мигрировал с Ruby on Rails на Node.js и сократил число серверов с 30 до 3, обрабатывая тот же трафик. Причина: Event Loop эффективно использует один поток вместо создания нового потока на каждое соединение.
  • **PayPal** после перехода на Node.js удвоил requests/sec при уменьшении response time на 35%. Event Loop позволил обрабатывать API-вызовы к банкам параллельно, вместо последовательного ожидания каждого ответа.
  • **Walmart** обрабатывает 500 миллионов pageviews/месяц на Node.js, экономя миллионы на инфраструктуре. Event Loop позволяет одному серверу держать сотни тысяч WebSocket-соединений для real-time обновлений корзины.

Loop Overview

Аналогия: официант в ресторане. Вместо того, чтобы стоять у каждого стола и ждать заказа, он постоянно обходит все столы: принял заказ здесь, принёс блюдо туда, забрал счёт там. Один человек обслуживает десятки столов одновременно. Именно так работает Node.js.

**Event Loop** - это бесконечный цикл, который проверяет очереди задач и выполняет их одну за другой. Node.js использует единственный поток (single-threaded), но благодаря асинхронной модели обрабатывает тысячи соединений параллельно. Секрет в том, что операции ввода-вывода (I/O) выполняются в фоне операционной системой или библиотекой libuv, а JavaScript-код вызывается только когда результат готов.

**Почему Node.js быстрее традиционных многопоточных серверов для I/O-интенсивных задач?** В Apache или Tomcat каждое соединение создаёт новый поток. 1000 соединений = 1000 потоков = гигабайты памяти на стеки + дорогие context switch'и. Node.js использует один поток для JS-кода и делегирует I/O операционной системе. Результат: миллионы активных WebSocket-соединений на одном сервере (как в WhatsApp).

Реальный пример: API-сервер обрабатывает 1000 запросов

**Многопоточный сервер (Apache):** - 1000 запросов = 1000 потоков - Каждый поток: ~1MB стека = 1GB памяти - Context switch между потоками: тысячи переключений/сек - CPU тратит время на управление потоками, а не на полезную работу **Node.js:** - 1000 запросов = 1 поток JavaScript + libuv thread pool (4-128 потоков для I/O) - Память: ~50MB для JS heap + небольшой overhead на callback'и - Пока один запрос ждёт БД, Event Loop обрабатывает другие - CPU занят только когда есть реальная работа (выполнение JS-кода) Вот почему Node.js стал стандартом для микросервисов и real-time приложений.

**Главное правило:** Никогда не блокируйте Event Loop! Любая синхронная операция, которая длится больше нескольких миллисекунд (сложные вычисления, синхронное чтение больших файлов, `JSON.parse()` на мегабайтах данных) заморозит весь сервер. Для CPU-интенсивных задач используйте Worker Threads или выносите их в отдельные микросервисы.

API-сервер на Node.js обрабатывает запросы к БД. Средний запрос: 5ms CPU + 45ms ожидание ответа БД. Сколько запросов/сек теоретически может обработать один процесс?

Loop Phases

Event Loop - это не просто `while(true)`. Это строго упорядоченный цикл из **6 фаз**, каждая из которых обрабатывает свой тип задач. Понимание фаз критично для отладки: почему `setImmediate()` иногда выполняется раньше `setTimeout(0)`, почему `process.nextTick()` может заморозить сервер, как работает poll phase, который тратит большую часть времени.

После **каждой фазы** выполняются **microtasks**: сначала вся очередь `process.nextTick()`, затем `Promise.then()` / `queueMicrotask()`.

**Детали каждой фазы:** **1. Timers** - выполняет callback'и `setTimeout()` и `setInterval()`, чей таймер истёк. Важно: таймеры не гарантируют точное время выполнения. `setTimeout(fn, 100)` означает "выполни не раньше чем через 100ms", но может быть позже, если Event Loop занят. **2. Pending callbacks** - выполняет I/O callback'и, отложенные из предыдущего цикла (например, TCP ошибки). **3. Idle, prepare** - внутренняя фаза libuv, используется для подготовки к poll. **4. Poll** - САМАЯ ВАЖНАЯ фаза. Здесь Event Loop получает новые I/O события (входящие HTTP-запросы, ответы от БД, данные из сокетов) и выполняет их callback'и. Если очередь пустая, Event Loop **блокируется** здесь и ждёт новых событий (но не больше, чем ближайший таймер). **5. Check** - выполняет `setImmediate()` callback'и. Эта фаза существует, чтобы позволить выполнить код сразу после poll фазы. **6. Close callbacks** - выполняет callback'и закрытия соединений (`socket.on('close')`, `server.close()`).

Реальный кейс: медленные таймеры

Установлен `setTimeout(() => sendMetrics(), 5000)` для отправки метрик каждые 5 секунд. Но в production метрики приходят раз в 10-15 секунд. Почему? **Причина:** Event Loop заблокирован CPU-интенсивной задачей. Например, `JSON.stringify()` на большом объекте занимает 8 секунд. За это время: - Timer истёк через 5 секунд - Но Event Loop застрял в другом callback'е (парсинг JSON) - Только через 8 секунд Event Loop дойдёт до timers фазы - Таймер выполнится с задержкой 3 секунды **Решение:** Разбивайте тяжёлые операции на чанки или используйте Worker Threads.

**Опасность blocking операций:** Один `fs.readFileSync()` на 100MB файл заблокирует Event Loop на секунды. За это время: - Все новые HTTP-запросы висят в очереди ОС (или получают ECONNREFUSED) - Все таймеры выполняются с задержкой - WebSocket соединения могут разорваться по таймауту В production это означает полный downtime сервиса. ВСЕГДА используйте асинхронные версии: `fs.readFile()`, `crypto.pbkdf2()`, и т.д.

Создан HTTP-сервер. В каждом request handler вызывается `crypto.pbkdf2Sync()` (CPU-интенсивный хеш пароля), который занимает 500ms. Сервер получает 10 запросов одновременно. Через сколько секунд обработается последний запрос?

Microtasks

Microtasks - это особая очередь, которая выполняется **между фазами Event Loop** (и даже между отдельными callback'ами внутри фазы). В Node.js есть два типа microtasks с разным приоритетом: **`process.nextTick()`** (наивысший приоритет) и **Promise microtasks** (`Promise.then()`, `queueMicrotask()`).

Аналогия: Event Loop - почтальон, который обходит дома (фазы). Microtasks - это срочные письма, которые **должны** быть доставлены перед переходом к следующему дому. Более того, `process.nextTick()` - это письма с пометкой "вскрыть немедленно", которые обрабатываются ДО обычных microtasks.

**Критическое отличие от макротасок (macrotasks):** setTimeout, setImmediate, I/O callbacks - это macrotasks. Они выполняются в своих фазах Event Loop. Microtasks выполняются **между** фазами и имеют приоритет. Даже если в timers фазе ждут 100 setTimeout, сначала выполнятся ВСЕ microtasks.

**ОПАСНОСТЬ: process.nextTick() может заморозить Event Loop!** Если каждый nextTick callback создаёт новый nextTick, образуется бесконечная цепочка. Event Loop никогда не дойдёт до следующей фазы, потому что очередь nextTick постоянно пополняется. Это называется **nextTick starvation**.

Реальный баг: race condition из-за nextTick

```typescript class Database { private connected = false; connect() { // Эмуляция async подключения setImmediate(() => { this.connected = true; this.emit('ready'); }); } query(sql: string) { if (!this.connected) throw new Error('Not connected!'); // ... } } const db = new Database(); db.connect(); db.query('SELECT * FROM users'); // ОШИБКА! // Проблема: query() выполняется синхронно, // а connect() завершится только в следующей фазе Event Loop. // Решение 1: Promise-based API async connect() { await new Promise(resolve => { setImmediate(() => { this.connected = true; resolve(); }); }); } await db.connect(); db.query('SELECT * FROM users'); // OK // Решение 2: callback db.connect(() => { db.query('SELECT * FROM users'); // OK }); ``` Это классический пример, почему важно понимать асинхронность на уровне Event Loop, а не просто "async/await магия".

**Когда использовать каждый тип:** - **`process.nextTick()`** - для критически важной логики, которая должна выполниться ДО любых I/O операций. Например, эмит события 'error' перед завершением функции. Используйте ОЧЕНЬ осторожно! - **`Promise.then()` / `queueMicrotask()`** - стандартный способ для асинхронной логики. Более предсказуемый, меньше риск starvation. - **`setImmediate()`** - для деферинга работы на следующий Event Loop цикл. Идеально для разбиения тяжёлых задач на чанки. - **`setTimeout(fn, 0)`** - аналог setImmediate, но с гарантией "не раньше, чем через 1ms". Почти никогда не нужен в Node.js (есть setImmediate).

Дан код: ```typescript setTimeout(() => console.log('A'), 0); Promise.resolve().then(() => { console.log('B'); process.nextTick(() => console.log('C')); }); process.nextTick(() => console.log('D')); ``` Какой порядок вывода?

Poll Phase

**Poll phase** - это сердце Event Loop, место, где происходит вся магия асинхронности Node.js. Именно здесь Event Loop получает новые события из операционной системы: входящие HTTP-запросы, ответы от БД, данные из файлов, события сокетов. Poll phase - единственная фаза, где Event Loop может **блокироваться** и ждать новых событий.

Аналогия: официант, который обошёл все столы (прошёл все фазы Event Loop). Теперь он стоит у входа и ждёт новых клиентов. Но не будет стоять вечно - если на кухне готовится заказ (есть pending таймер), он проверит кухню (вернётся к timers фазе). Poll phase работает так же: блокируется и ждёт I/O событий, но не дольше, чем ближайший таймер.

**Как работает poll phase под капотом:** Node.js использует системные вызовы `epoll` (Linux), `kqueue` (macOS/BSD), `IOCP` (Windows) - это механизмы ядра ОС для эффективного мониторинга множества файловых дескрипторов. Вместо того, чтобы проверять каждый сокет в цикле (polling), ОС уведомляет Node.js, когда на каком-то дескрипторе появились данные. Это работает на уровне ядра без создания потоков.

Реальный кейс: почему сервер "спит" при простое

Express-сервер запущен, htop показывает Node.js процесс с 0% CPU. Это не баг, это **фича**! **Что происходит:** 1. Сервер обработал все запросы 2. Event Loop дошёл до poll phase 3. Poll queue пустая, pending таймеров нет 4. Event Loop вызвал `epoll_wait()` с timeout = ∞ 5. ОС перевела процесс в состояние SLEEP 6. Процесс не потребляет CPU, пока не придёт событие **Приходит новый HTTP-запрос:** 1. TCP пакет попадает в сетевую карту 2. Ядро Linux обрабатывает TCP handshake 3. Данные попадают в socket buffer 4. `epoll_wait()` возвращает управление с информацией о событии 5. Node.js пробуждается и обрабатывает запрос 6. Всё это занимает микросекунды Вот почему Node.js может обрабатывать тысячи соединений с минимальным потреблением ресурсов - большую часть времени он просто спит, ожидая событий от ОС.

**Опасность: CPU-интенсивная задача в I/O callback блокирует poll phase** ```typescript server.on('request', (req, res) => { // Парсинг огромного JSON const data = JSON.parse(hugeString); // 2 секунды res.json({ ok: true }); }); ``` Пока первый запрос парсит JSON: - Event Loop застрял в poll phase callback - Новые HTTP-запросы накапливаются в очереди ОС - Другие I/O события не обрабатываются - Сервер выглядит "зависшим" для новых клиентов **Решение:** Разбивайте на чанки или используйте Worker Threads для тяжёлых операций.

**Оптимизация poll phase для высоконагруженных приложений:** 1. **UV_THREADPOOL_SIZE** - размер libuv thread pool (по умолчани 4). Увеличьте до количества CPU cores для fs/crypto операций: ```bash UV_THREADPOOL_SIZE=16 node server.js ``` 2. **Используйте streams** вместо буферизации всего файла в память: ```typescript fs.createReadStream('huge.json') .pipe(parser) .pipe(res); ``` Event Loop будет обрабатывать чанки, не блокируясь на весь файл. 3. **Worker Threads** для CPU-интенсивных задач - выносите парсинг, криптографию, сжатие в отдельные потоки: ```typescript const { Worker } = require('worker_threads'); const worker = new Worker('./heavy-task.js', { workerData: data }); ``` 4. **Graceful degradation** - если Event Loop lag превышает порог, отклоняйте новые запросы с 503: ```typescript const toobusy = require('toobusy-js'); app.use((req, res, next) => { if (toobusy()) return res.status(503).send('Server too busy'); next(); }); ```

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

  • **Event Loop - это 6 фаз:** timers, pending callbacks, idle/prepare, poll, check, close. Каждая фаза обрабатывает свой тип задач в строгом порядке. Понимание фаз критично для предсказуемого поведения асинхронного кода.
  • **Microtasks выполняются между фазами:** process.nextTick имеет наивысший приоритет, затем Promise.then/queueMicrotask, затем macrotasks (setTimeout, setImmediate). Microtasks могут вызвать starvation, блокируя Event Loop.
  • **Poll phase - сердце асинхронности:** именно здесь Event Loop получает I/O события от ОС через epoll/kqueue и может блокироваться, ожидая новых событий. Это позволяет Node.js обрабатывать миллионы соединений с минимальным потреблением CPU.
  • **НИКОГДА не блокируйте Event Loop:** любая синхронная операция >10ms блокирует ВСЕ соединения. Используйте асинхронные API, разбивайте тяжёлые задачи на чанки, выносите CPU-интенсивную работу в Worker Threads.

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

Event Loop - это фундамент асинхронности Node.js. Для полного понимания изучите связанные концепции:

  • libuv и Thread Pool — libuv - это C-библиотека, которая реализует Event Loop. Понимание thread pool (для fs/crypto операций) и async I/O (для network) объясняет, почему одни операции параллельны, а другие - нет.
  • Streams и Backpressure — Streams используют Event Loop для обработки данных по чанкам, не блокируя память. Backpressure механизм предотвращает переполнение памяти при медленном потребителе.
  • Worker Threads — Для CPU-интенсивных задач Event Loop недостаточно - нужны отдельные потоки. Worker Threads позволяет выполнять JS-код параллельно, не блокируя основной Event Loop.
  • Memory Management и Garbage Collection — GC выполняется синхронно и блокирует Event Loop. Понимание V8 heap и GC паттернов критично для высоконагруженных приложений.

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

  • Сервер обрабатывает 1000 req/sec с avg latency 50ms. После деплоя новой фичи latency вырос до 500ms, но CPU остался на уровне 30%. Как Event Loop может помочь диагностировать проблему?
  • При использовании setImmediate для разбиения тяжёлой задачи на чанки под нагрузкой HTTP-запросы начинают таймаутиться. Какая фаза Event Loop блокируется и почему?
  • В каких сценариях process.nextTick предпочтительнее Promise.then, несмотря на риск starvation? Приведите пример из реальной библиотеки (например, EventEmitter).

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

  • os-01-intro
Event Loop: Сердце Node.js

0

1

Войти

setTimeout(fn, 0) и setImmediate(fn) - это одно и то же, просто разные названия

Это совершенно разные механизмы с разным поведением. setTimeout выполняется в timers фазе (начало цикла), setImmediate - в check фазе (после poll). Внутри I/O callback setImmediate ВСЕГДА выполнится раньше setTimeout(0)

setTimeout(fn, 0) на самом деле setTimeout(fn, 1) (минимум 1ms по спецификации). Event Loop должен дойти до timers фазы и проверить, истёк ли таймер. setImmediate специально создан для выполнения "сразу после текущей poll фазы". Внутри I/O callback порядок детерминирован: poll -> check (setImmediate) -> new cycle -> timers (setTimeout). Вот почему в production для откладывания работы на следующий тик используют setImmediate, а не setTimeout(0).

HTTP-сервер. В poll phase Event Loop получил 3 события одновременно: 1. новый HTTP-запрос, 2. ответ от PostgreSQL, 3. данные из файла fs.readFile(). Также в check queue ждёт setImmediate callback. Что выполнится первым?