Команда JavaScript for Devs подготовила перевод статьи о том, как Node.js выжимает максимум из I/O в 2025 году. В ней подробно объясняется цикл событий, порядок фаз и то, как правильно писать код, чтобы серверы оставались отзывчивыми даже под нагрузкой.


Разработчики до сих пор спотыкаются о старые вопросы: если рантайм однопоточный, почему он не зависает?

Почему setTimeout(0) срабатывает «с задержкой»? Куда на самом деле попадают Promises, process.nextTick и setImmediate? В этой статье я разберу цикл событий простыми словами, покажу, куда уходит время, и поделюсь приёмами, которые помогают серверам оставаться отзывчивыми под нагрузкой.

Зачем нужен цикл событий

Рантайм выполняет JavaScript в одном потоке, но не даёт себе увязнуть в медленных операциях ввода-вывода. Долгие задачи передаются операционной системе через нативный слой; когда результат готов, JavaScript вызывается снова. Цикл событий управляет этим процессом: он проходит по чётко определённым фазам, выбирает готовые колбэки из внутренних очередей и выполняет их в порядке приоритета. Поняв этот порядок, вы перестанете удивляться странным задержкам и сможете настраивать производительность осознанно, а не наугад.

Один поток, неблокирующее поведение — никакой магии

Когда программа запускается, выполняется весь код из начального модуля: импорты, объявления, запуск сервера и планирование асинхронных задач. Чтение файла, принятие соединения по сокету или установка таймера не блокируют этот первый проход. Тяжёлая работа выполняется за пределами JavaScript: ОС следит за завершением операции и уведомляет рантайм, который затем ставит ваши колбэки в очередь для следующих фаз цикла.
Представьте это так: ваш код говорит «прочитай этот файл», ОС отвечает «дам знать, когда закончу», а цикл событий через регулярные промежутки проверяет, что готово, и забирает результаты.

Фазы, делающие тайминг предсказуемым

Без таблиц — только порядок и главное, что стоит помнить:

Timers

Колбэки от setTimeout/setInterval, у которых истекла минимальная задержка. Задержка — это нижняя граница, а не гарантия; длинная синхронная работа перед этой фазой отодвигает таймеры назад.

Pending Callbacks

Оставшиеся низкоуровневые системные колбэки, отложенные с предыдущего прохода цикла (часто обработка ошибок I/O). Это предотвращает блокировку других фаз тяжёлой системной работой.

Idle/Prepare

Внутренняя подготовка перед проверкой ввода-вывода. Ваш код здесь не выполняется.

Poll

Сердце цикла. Рантайм собирает готовые операции ввода-вывода (входящие запросы, чтение файлов и т. д.) и выполняет их колбэки. Если делать нечего, он может эффективно «подождать» здесь, пока не появятся новые события ввода-вывода, не сработает таймер или не придёт очередь setImmediate.

Check

Колбэки, запланированные через setImmediate. Полезно, если нужно выполнить работу после текущего цикла I/O, но до следующей партии таймеров.

Close Callbacks

Финализация при закрытии ресурсов: сокетов, серверов, дескрипторов. Здесь выполняются обработчики вроде 'close'.

Есть ещё две части, которые встраиваются «между» фазами:

  • process.nextTick — выполняется сразу после текущей операции, ещё до microtasks и до перехода к фазе цикла событий. Самый высокий приоритет; чрезмерное использование может «задушить» цикл.

  • Microtasks (Promises) — обработчики .then/.catch/.finally запускаются после всех nextTick-колбэков, но всё ещё до перехода к следующей фазе цикла.

Запомните этот ритм:

(ваш синхронный код) → nextTick → microtasks → Timers → nextTick → microtasks → Pending → … → Poll → microtasks → Check → microtasks → Close → …

Три практических сценария (и как их исправить)

1. Дрейф интервала: почему он возникает и как его исправить

Ситуация: нужно опрашивать внешний сервис раз в секунду:

setInterval(tick, 1000);

function tick() {
  const t0 = Date.now();
  // имитируем нагрузку на CPU
  while (Date.now() - t0 < 300) {} // ~300 мс синхронной нагрузки
  console.log('Check at', new Date().toISOString());
}

Что происходит на самом деле.

Каждое выполнение блокирует поток примерно на 300 мс. Поскольку цикл не может запустить следующий вызов, пока не завершится текущий, «интервал в 1 секунду» начинает плыть: вызовы всё больше отстают.

Лучший подход (компенсация дрейфа).

let planned = Date.now() + 1000;

function tick() {
  const start = Date.now();

  // делаем полезную синхронную работу (в разумных пределах)
  while (Date.now() - start < 300) {}

  console.log('Check at', new Date().toISOString());

  const now = Date.now();
  planned += 1000; // идеальная метка времени для следующего запуска
  const delay = Math.max(0, planned - now);
  setTimeout(tick, delay);
}

setTimeout(tick, 1000);

Почему это работает.

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

2. «Голодание» цикла из-за process.nextTick (и как этого избежать)

Ситуация: хотим мгновенно обработать длинную цепочку задач, поэтому каждое действие ставим через process.nextTick:

function schedule(i = 0) {
  if (i >= 1e5) return;
  process.nextTick(() => {
    if (i % 20000 === 0) console.log('Processed', i);
    schedule(i + 1);
  });
}
schedule();
console.log('Start');

Что происходит на самом деле.

nextTick выполняется раньше микрозадач и любых фаз цикла. Рекурсивная постановка в очередь создаёт бесконечный поток колбэков с наивысшим приоритетом. Цикл не может дойти до Poll (I/O), Timers и даже до Promises: это и есть «голодание».

Лучший подход (пакетная обработка + уступить циклу через setImmediate).

const BATCH = 1000;

function schedule(i = 0) {
  const end = Math.min(i + BATCH, 1e5);

  while (i < end) {
    if (i % 20000 === 0) console.log('Processed', i);
    // выполняем небольшой объём синхронной работы
    i++;
  }

  if (i < 1e5) {
    setImmediate(() => schedule(i)); // отдаём управление Poll/Timers/Promises
  }
}
schedule();
console.log('Start');

Почему это работает.

setImmediate срабатывает на фазе Check, после Poll. Это даёт движку время принять новые сокеты, обработать готовые таймеры и выполнить микрозадачи. Пропускная способность растёт, а приложение остаётся отзывчивым.

3. Стриминг + CPU без остановки I/O

Ситуация: я читаю большой файл и выполняю нетривиальную обработку. Если запускать тяжёлую CPU-работу внутри обработчика 'data', это блокирует поток, который принимает следующие куски данных.

const fs = require('fs');

const chunks = [];
const stream = fs.createReadStream('big.log');

stream.on('data', (buf) => {
  chunks.push(buf);
  // избегаем тяжёлой CPU-работы прямо здесь
});

stream.on('end', () => {
  console.log('File complete');
});

Лучший подход (накапливать небольшие партии; считать после I/O через setImmediate).

const fs = require('fs');

let bucket = [];
let scheduled = false;

function flush() {
  // ресурсоёмкая обработка содержимого 'bucket'
  bucket = [];
  scheduled = false;
}

fs.createReadStream('big.log')
  .on('data', (buf) => {
    bucket.push(buf);
    if (!scheduled) {
      scheduled = true;
      setImmediate(flush); // запустить после текущего цикла Poll
    }
  })
  .on('end', () => {
    if (bucket.length) flush();
    console.log('Done');
  });

Почему это работает

Не выполняя тяжёлую работу в колбэке 'data', я позволяю фазе Poll стабильно продолжать стриминг. Сама CPU-работа идёт на фазе Check, что предсказуемо и не «душит» ввод-вывод.

Практические правила, которые сэкономят часы отладки

  • setTimeout(fn, 0) ≠ «прямо сейчас». Это значит «как только цикл дойдёт до фазы Timers», а она идёт после текущего синхронного кода, nextTick и microtasks.

  • Используйте setImmediate для «после I/O, до новых таймеров». Отлично подходит, если нужно отреагировать, когда текущий цикл Poll завершился.

  • Используйте process.nextTick умеренно. Он идеален для завершения конструктора, выброса ошибки или выполнения кода сразу после разворота текущего стека. Не стройте на нём длинные очереди.

  • Promises выполняются как microtasks. Они идут после всех колбэков nextTick и до следующей фазы цикла. Поэтому .then может сработать раньше таймера.

  • Ограничивайте синхронную работу. Длинные CPU-участки — главная причина скачков задержки. Разбивайте их на части с помощью setImmediate, выносите в worker threads или отдельные процессы — или комбинируйте оба подхода.

  • Ожидайте неточности таймеров под нагрузкой. Таймеры имеют минимальную задержку, но не являются real-time alarm. Компенсируйте дрейф, когда важна точность (см. пример 1).

Распространённые антипаттерны (и как их исправить)

  • Делать «всё» внутри одного колбэка. Разделяйте на короткие шаги и возвращайте управление циклу между ними.

  • Слепо доверять setInterval. Поддерживайте идеальный график и вычисляйте каждую задержку динамически; не позволяйте дрейфу накапливаться.

  • Неограниченный буферинг. Буферы конечны. Старайтесь ограничивать размер и время накопления, применяйте backpressure и обрабатывайте данные партиями в предсказуемые фазы.

  • Рекурсивные цепочки nextTick. Заменяйте их пакетной обработкой и использованием setImmediate или микрозадач там, где это уместно.

Коротко о нативном слое

Под капотом компактная нативная библиотека абстрагирует возможности ОС: файловые дескрипторы, таймеры и уведомления о готовности. Она поддерживает единообразную модель на всех платформах, чтобы вы могли рассуждать в терминах очередей и фаз, а не системных вызовов конкретной ОС. Ценность для повседневной разработки — предсказуемость: как только вы понимаете, как колбэки проходят через фазы, вы можете размещать работу там, где ей место.

Русскоязычное JavaScript сообщество

Друзья! Эту статью перевела команда «JavaScript for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Frontend. Подписывайтесь, чтобы быть в курсе и ничего не упустить!

Главная мысль

Цикл событий — не чёрный ящик. Это фиксированный ритм: Timers → Pending → Idle/Prepare → Poll → Check → Close, с process.nextTick и микрозадачами Promises, встроенными в определённые точки. Если проектировать код с учётом этого ритма, можно уменьшить задержки, избежать «голодания» и сохранить высокую пропускную способность. Когда вы «мысленно раскладываете код по фазам», баги с таймингом превращаются в простые решения о том, где разместить работу.

Комментарии (0)


  1. pavlushk0
    17.09.2025 18:26

    А в каких uv_handle_t обрабатываются микротаски? Из доков libuv не ясно. По схеме из статьи они выполняются между idle check poll и т.д. но если глянуть в uv_run там нет ничего такого. Или нода использует патчиный libuv?