Node под капотом: event loop
Один поток, неблокирующий ввод-вывод и event loop — секрет того, как Node обслуживает тысячи соединений.
«Node не делает много дел одновременно — он делает их по очереди, но никогда не ждёт впустую.»
Чтобы понимать поведение Express под нагрузкой, нужно знать, как работает Node изнутри. Главная особенность Node — он однопоточный для твоего кода, но при этом легко держит тысячи одновременных соединений. Магия здесь в цикле событий (event loop) и неблокирующем вводе-выводе.
В этом уроке разберём, почему "один поток" не означает "медленно", что такое блокирующая операция и почему в Express нельзя делать тяжёлые синхронные вычисления прямо в обработчике маршрута.
Один поток, но не один в поле
Когда твой код вызывает что-то медленное — чтение файла, запрос к базе, сетевой вызов — Node не сидит и не ждёт. Он передаёт операцию операционной системе, а сам идёт обрабатывать следующее событие. Когда медленная операция завершится, её результат вернётся в очередь, и Node вызовет твой колбэк. Этот бесконечный круг "взять задачу — выполнить — вернуться за следующей" и называется event loop.
+-----------------------------+
| EVENT LOOP |
| берёт готовые задачи и |
| вызывает их колбэки |
+-----------------------------+
^ |
готово | | 'почитай файл'
| v
+--------------------------------------+
| ОС / пул потоков: диск, сеть, БД |
| работают ПАРАЛЛЕЛЬНО, пока loop |
| занят другими запросами |
+--------------------------------------+Пока диск читает файл для запроса №1, event loop уже обрабатывает запрос №2. Поэтому один поток справляется с огромным числом соединений — при условии, что ты не блокируешь его.
Блокирующее против неблокирующего
Эмулируем event loop в браузере, чтобы прочувствовать порядок выполнения. Синхронный код выполняется сразу, а отложенный (через setTimeout, как аналог завершённого ввода-вывода) — позже, когда стек освободится:
console.log('1. начало запроса');
// имитируем медленный ввод-вывод (запрос к БД)
setTimeout(function () {
console.log('3. данные из БД пришли');
}, 0);
console.log('2. синхронный код выполнился сразу');
// Порядок будет: 1, 2, 3 -- даже при задержке 0!
// Колбэк ждёт, пока стек освободится.Нажми "Попробуй сам ▶". Несмотря на нулевую задержку, строка "3" печатается последней — она ушла в очередь и ждала, пока выполнится весь синхронный код. Так же ведут себя и реальные операции ввода-вывода.
Как работает под капотом
У Node несколько очередей задач: таймеры, колбэки ввода-вывода, микрозадачи (промисы). На каждом обороте цикла Node разгребает их в строгом порядке. Тяжёлые блокирующие вызовы (например, синхронное чтение огромного файла или бесконечный цикл) останавливают весь оборот: пока твой код считает, event loop не может обработать ни один другой запрос. Для всех клиентов сервер словно зависает.
Частые ошибки
- Тяжёлые вычисления в обработчике. Хеширование пароля без асинхронной версии, сортировка миллиона элементов, парсинг гигантского JSON — всё это блокирует loop. Выноси такое в воркеры или используй асинхронные API.
- Синхронные fs-методы в проде.
fs.readFileSyncблокирует поток; в обработчиках запросов используй асинхронные версии. - Думать, что async = многопоточность. Твой JS по-прежнему в одном потоке; параллелится только ввод-вывод на уровне ОС.
Best practices
- В обработчиках Express предпочитай асинхронные операции (промисы, async/await).
- Тяжёлый CPU-bound код выноси в отдельные процессы или Worker Threads.
- Никогда не блокируй event loop надолго — это убивает отзывчивость всего сервера.
Итоги
Event loop — сердце Node. Один поток обрабатывает события по очереди и никогда не ждёт ввод-вывод впустую. Express работает поверх этой модели, поэтому хороший серверный код — асинхронный и неблокирующий. Дальше перейдём к маршрутам: научим приложение по-разному отвечать на разные пути и методы.
Фазы цикла и микрозадачи
Event loop не однороден: каждый его оборот проходит несколько фаз — таймеры, отложенные колбэки ввода-вывода, проверка setImmediate и так далее. Между этими фазами Node разгребает очередь микрозадач: колбэки разрешённых промисов и queueMicrotask. Микрозадачи имеют приоритет — они выполняются раньше следующего таймера. На практике это значит, что await в твоём коде продолжится при первой же возможности, как только освободится стек, не дожидаясь нового оборота цикла. Понимать эту иерархию не обязательно для каждого маршрута, но это спасает, когда нужно объяснить неожиданный порядок логов или отладить тонкую гонку между промисом и таймером.