Event Loop крутится, Node.js мутится. Часть 3. За пределами Event Loop.

Event Loop крутится, Node.js мутится. Часть 3. За пределами Event Loop.

Anastasia Kotova

Предыдущие части

Event Loop крутится, Node.js мутится. Часть 1. Браузер VS сервер.

Event Loop крутится, Node.js мутится. Часть 2. Нюансы работы Event Loop.


В предыдущих частях мы разобрали, как работает Event Loop в Node.js, его отличия от браузерного, а также специфические нюансы. Пришло время поговорить о том, есть ли что-то за пределами Event Loop в Node.js. И спойлер - есть!

Что такое libuv

До этого момента мы говорили про Event Loop как про некий волшебный механизм, который позволяет Node.js выполнять асинхронный код. Но на самом деле сам JavaScript не умеет ни читать файлы, ни слушать порты. Всем этим занимается libuv — C-библиотека, которая скрывается под капотом Node.js и делает всю грязную работу.

Именно libuv реализует Event Loop, а заодно берёт на себя кучу других задач: работу с файловой системой, сетевыми соединениями, DNS, асинхронным вводом-выводом и даже многопоточностью. Всё это работает прозрачно: мы пишем асинхронный JS-код, а libuv под капотом разруливает, когда, где и каким потоком обработать операцию.

И “магия”, когда fs.readFile() не блокирует выполнение, а просто вызывает callback через пару миллисекунд, — это результат работы libuv, о чём мы поговорим дальше.

В каком-то смысле libuv — это “двигатель”, который приводит Event Loop в движение и заодно отвечает за всё, что происходит вне самого JavaScript. Без него Node.js не был бы асинхронным. И теперь, когда мы уже разобрались, как устроен Event Loop на верхнем уровне, пришло время заглянуть чуть глубже — туда, где всё крутится на самом деле.

Когда Node.js успевает читать файлы

Мы видели, что в фазах Event Loop обрабатываются результаты I/O-операций — например, callback fs.readFile() срабатывает в Poll Phase, когда данные уже готовы. Но когда происходит само чтение файла? Ведь внутри цикла нет “фазы чтения”, да и в Event Loop мы нигде явно не видим, что идёт доступ к диску.

На самом деле, это и не обязанность Event Loop — эту работу за него делает libuv. Когда мы вызываем fs.readFile(), Node.js не начинает читать файл прямо в текущем потоке. Вместо этого задача отправляется в фоновый thread pool, где и происходит чтение. По завершении операции результат “возвращается” в Event Loop — и только тогда срабатывает callback.

Thread pool — это набор фоновых потоков, которые Node.js использует для выполнения тяжёлых задач вне основного потока. Когда нужно прочитать файл, посчитать хэш или сжать данные, задача отправляется в один из потоков пула.

Таким образом, всё тяжёлое происходит параллельно, а Event Loop лишь обрабатывает уведомление “готово!”. Это позволяет JS-коду не блокироваться и выполнять другие задачи, пока файл читается где-то в фоне.

Что ещё работает так же?

Не только работа с файлами уходит в фон. Вот список встроенных модулей, задачи в которых тоже уходят в thread pool:

  • fs.* — чтение и запись файлов (readFile, writeFile, stat, и т. д.);
  • zlib.* — сжатие и распаковка данных (gzip, deflate, gunzip, и т. д.);
  • crypto.pbkdf2, crypto.scrypt — ресурсоёмкие криптографические операции;
  • dns.lookup — разрешение доменных имён в IP-адреса.

А сколько таких фоновых потоков?

По умолчанию — 4. Это значит, что если мы одновременно запустим 10 crypto.pbkdf2(), первые 4 выполнятся сразу, а остальные встанут в очередь:

const crypto = require("crypto");

const start = Date.now();

function runTask(id) {
  crypto.pbkdf2("password", "salt", 100_000, 64, "sha512", () => {
    const time = Date.now() - start;
    console.log(`🔐 Задача ${id} завершена через ${time} мс`);
  });
}

// Запускаем 10 тяжёлых задач
for (let i = 1; i <= 10; i++) {
  runTask(i);
}

Вывод, который мы получим по умолчанию:

🔐 Задача 1 завершена через 31 мс
🔐 Задача 2 завершена через 37 мс
🔐 Задача 4 завершена через 37 мс
🔐 Задача 3 завершена через 37 мс
🔐 Задача 5 завершена через 62 мс
🔐 Задача 7 завершена через 64 мс
🔐 Задача 6 завершена через 64 мс
🔐 Задача 8 завершена через 68 мс
🔐 Задача 9 завершена через 91 мс
🔐 Задача 10 завершена через 92 мс

Здесь мы видим, что задачи завершаются группами, с задержкой между ними.

Лимит потоков можно изменить через переменную окружения: UV_THREADPOOL_SIZE=8 node app.js.

Теперь одновременно выполнятся 8 задач — и вывод в консоли будет идти плотнее:

🔐 Задача 7 завершена через 35 мс
🔐 Задача 8 завершена через 41 мс
🔐 Задача 5 завершена через 43 мс
🔐 Задача 3 завершена через 44 мс
🔐 Задача 6 завершена через 46 мс
🔐 Задача 1 завершена через 48 мс
🔐 Задача 4 завершена через 50 мс
🔐 Задача 2 завершена через 54 мс
🔐 Задача 9 завершена через 67 мс
🔐 Задача 10 завершена через 71 мс

Значение можно поднять до 1024, но чаще всего 8–16 достаточно с головой. Главное — помнить, что thread pool не бесконечный и перегружать его не стоит.

Стоит отметить, что порядок завершения задач может не совпадать с порядком их запуска. Это связано с тем, что операции выполняются параллельно в разных потоках, и точное время завершения зависит от множества факторов — загрузки системы, особенностей запуска потока, внутреннего распределения задач. В результате некоторые задачи, начатые позже, могут завершиться раньше. Это нормальное и ожидаемое поведение многопоточной обработки.

А как же многоядерные процессоры?

Node.js традиционно считается однопоточным, потому что весь JavaScript-код выполняется в одном потоке — внутри Event Loop. Но это не значит, что он не может использовать мощность многоядерных систем.

Во-первых, libuv использует thread pool для тяжёлых операций (чтение файлов, криптография, сжатие), и каждый поток из этого пула может выполняться на своём ядре. Таким образом, параллельная работа уже есть — просто не в самом JS-коде.

Во-вторых, Node.js предоставляет собственные инструменты для распараллеливания задач: cluster и worker_threads, о которых мы поговорим отдельно в следующих частях. Каждый такой поток может также работать на отдельном ядре.

По итогу Node.js все же использует несколько ядер, и в каких-то случаях мы даже можем этим управлять.

Как не заблокировать Node.js

Если начать активно загружать thead pool тяжёлыми задачами или использовать синхронные методы, можно легко заблокировать Event Loop или заставить задачи ждать слишком долго.

Вот несколько практических рекомендаций, основанных на официальной документации Node.js:

  • Избегать синхронных методов из модулей fs, crypto, zlib, особенно в коде, который обрабатывает входящие запросы. Например, fs.readFileSync() может заморозить весь сервер, пока не прочитает файл.
  • Не запускать слишком много тяжёлых операций одновременно, особенно если они идут в thread pool. Например, десятки crypto.pbkdf2() или zlib.gzip() в одном цикле забьют очередь и часть задач просто будет ждать освобождения потока.
  • Увеличивать размер пула, если задача действительно требует параллельной обработки большого числа I/O-операций. Это можно сделать через переменную UV_THREADPOOL_SIZE. Однако больше не всегда лучше, ведь каждый дополнительный поток несёт и дополнительные накладные расходы.

Также стоит быть осторожными с регулярными выражениями и JSON.parse(). Они выполняются синхронно, прямо в основном потоке, и если регулярка слишком сложная, а JSON слишком большой — Event Loop встанет. Особенно критично это становится, если такие операции попадают в код обработки пользовательских запросов.

И здесь мы подбираемся к отдельной категории проблем — не только про производительность, но и про безопасность.

Что такое REDOS и как он может положить сервер

REDOS (Regular Expression Denial of Service) — это атака, при которой злоумышленник отправляет специально сконструированную строку, вызывающую крайне медленную обработку регулярного выражения. Выполнение такой регулярки в основном потоке приводит к блокировке работы всего сервера Node.js.

Пример:

// Уязвимая регулярка
const regex = /(a+)+$/;
const input = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX";

input.match(regex); // может зависнуть

Выглядит этот код, кажется, нормально, но стоит добавить в конец строки “X”, и движок регулярных выражений начинает бесконечно перебирать все возможные пути сопоставления, чтобы понять, почему X не подходит. Чем длиннее строка, тем дольше он “думает”.

При этом всё это происходит прямо в Event Loop, который блокируется и не может обрабатывать ни другие запросы, ни таймеры, ни события.

Как защититься от REDOS:

  • Не использовать регулярки с вложенными квантификаторами, особенно (a+)+, (.*)+, (.+)+ и им подобные.
  • Проверять пользовательский ввод заранее, если он попадёт в регулярное выражение.
  • Ограничивать размер входных данных — это справедливо и для JSON, и для строк.
  • Использовать инструменты для проверки потенциально опасных шаблонов.
  • Если есть сомнения — не использовать регулярку вовсе, а обрабатывать данные через индекс поиска, split и т. п.

Хоть мы и говорили в этой статье преимущественно про работу thread pool, важно помнить: все задачи в Node.js стартуют с Event Loop. И если Event Loop завис — никакие потоки не спасут, никакие таймеры не сработают, сервер не примет новые соединения. Ничего не произойдёт. Поэтому беречь Event Loop — это не просто хорошая практика. Это крайне важно, когда вы работаете с Node.js.

Выводы

В этой части мы заглянули под капот Event Loop и впервые по-настоящему познакомились с libuv — той самой библиотекой, которая делает возможной асинхронность в Node.js. Мы выяснили, что Event Loop сам не читает файлы, не шифрует данные и не делает запросы, а лишь координирует выполнение задач, передавая их в thread pool, который работает параллельно.

На реальных примерах посмотрели, как fs.readFile, crypto.pbkdf2 и другие встроенные модули уходят в фоновую очередь, и почему одновременно выполняются только 4 такие задачи. А заодно — что может пойти не так, если нагрузить этот пул по полной. Также разобрали, почему даже одна тяжёлая синхронная операция может “повесить” всё приложение, и как это связано с REDOS-атакой, при которой регулярка, написанная в спешке, может положить весь сервер.

Самое важное, что становится очевидным: даже с thread pool и фоновыми процессами Event Loop остаётся сердцем приложения. Он запускает задачи, принимает соединения, обрабатывает события. И если он зависнет — всё остальное встанет.

Впереди — ещё глубже: сервера, распараллеливание задач и управление потоками. Но это уже истории для следующих частей.


Следующие части

Event Loop крутится, Node.js мутится. Часть 4. HTTP-сервер.

Event Loop крутится, Node.js мутится. Часть 5. Настоящая многопоточность.

Event Loop крутится, Node.js мутится. Часть 6. Профилировать и замерять.

Report Page