ГЛАВА 01
ПОДРОБНЫЙ РАЗБОР · ОТ БАЗЫ К КОДУ

Разбираем: Порядок Event Loop

Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.

СНАЧАЛА ПРОСТЫМИ СЛОВАМИ

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

ТЕХНИЧЕСКАЯ ОСНОВА

JavaScript-код главного Node-потока следует правилу run-to-completion: начатый участок кода не прерывается другим callback. После обычного callback Node сначала опустошает очередь process.nextTick, затем очередь V8 microtasks — Promise.then и queueMicrotask — и только после этого продолжает фазу или переходит дальше. Timer, I/O callback и setImmediate могут начаться лишь когда готовы и Event Loop дошёл до их timers, poll или check-контекста.

Зачем это знатьДля прогноза нужны три вопроса: где сейчас выполняется код, к какой очереди или фазе относится callback и готов ли источник. Порядок регистрации решает спор только после этих проверок — обычно внутри одной готовой FIFO-очереди.
ГДЕ ВЫПОЛНЯЕТСЯ РАБОТА
01ВАШ JS-КОДfunctions · callbacks
02NODE APIsfs · crypto · timers
03V8 + LIBUVheap · loop · pool
04ОПЕРАЦИОННАЯ СИСТЕМАI/O · threads · memory
Опорная модель

Порядок начала выполнения определяется на границах

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

  1. 01 · СтекТекущий JavaScript завершается

    Функция или callback выполняется до return. Ни timer, ни Promise, ни I/O не вклинятся в середину синхронного участка.

  2. 02 · CheckpointПроверяется приоритетная работа

    После обычного callback Node опустошает nextTick, затем Promise/queueMicrotask. Добавленная ими работа тоже может попасть в этот checkpoint.

  3. 03 · Event LoopПродолжается текущая или следующая фаза

    Node берёт следующий готовый callback фазы либо движется к poll, check и timers согласно текущему обороту цикла.

Отдельная очередь Nodeprocess.nextTick

Обычно опустошается перед V8 microtasks и может вызвать starvation при рекурсии.

Microtask queue V8Promise / queueMicrotask

Выполняются на checkpoint до продолжения фаз; новые microtasks тоже опустошаются.

TimerssetTimeout

Delay задаёт минимальный порог готовности, а не точный момент старта callback.

PollI/O callback

Стартует после готовности операции, достижения poll-контекста и освобождения стека.

ChecksetImmediate

Ждёт check; не может прервать уже выполняющийся timer или I/O callback.

Почему лестница 1 → 6 неточна

Sync действительно первый, а nextTick и microtasks получают checkpoint. Но timer, I/O и immediate — не три места глобальной очереди: их относительный старт зависит от фазы, готовности и места регистрации.

Где порядок записи всё-таки важен

В одной уже готовой FIFO-очереди callbacks обычно берутся по порядку добавления. Это нельзя переносить на разные очереди, разные I/O-источники или просто одинаковые значения delay.

Два готовых timer callback в одной timers-фазеtimer 1 → nextTick из timer 1 → Promise из timer 1 → timer 2 → setImmediate из timer 1

Checkpoint выполняется после timer 1, поэтому приоритетная работа может оказаться между двумя callbacks одной фазы. setImmediate ждёт check и внутрь timers-фазы не вклинивается.

Сравнение runtimes

Browser Event Loop и Node Event Loop — родственники, но не близнецы

Оба runtime выполняют JavaScript и Promise jobs по правилам ECMAScript, но окружение определяет, откуда приходит будущая работа. Browser встраивает JavaScript в события страницы и rendering, а Node — в I/O и фазы libuv.

Общее ядро

Один callback не прерывается другим JavaScript callback

В одном JavaScript agent действует run-to-completion. Готовность таймера, ответа сети или сообщения ещё не означает немедленный старт: текущий стек должен освободиться, а host должен выбрать эту работу.

  • Promise reactions и queueMicrotask выполняются как microtasks на checkpoint.
  • Долгий синхронный участок задерживает и обычные callbacks, и microtasks этого agent.
  • Параллельный JavaScript появляется только в другом agent: Web Worker, Worker Thread или процесс.
Browser host

Tasks, microtasks и возможность отрисовки

Главный browser event loop координирует JavaScript страницы, события пользователя и rendering. Это не цикл фаз libuv и не одна глобальная FIFO-очередь.

Tasks
Script, click, timer и другие host-события ставят tasks через разные task sources. Порядок внутри одного source сохраняется, но browser выбирает между доступными очередями.
Microtasks
После task browser выполняет microtask checkpoint: Promise.then, queueMicrotask и MutationObserver могут опустошить очередь до следующей task.
Rendering
При rendering opportunity browser может обновить style/layout, вызвать requestAnimationFrame перед paint и отрисовать кадр. Кадр не обязан появляться после каждой task; фоновые вкладки могут throttling-оваться.
Web Worker
Worker получает собственный global scope и event loop, не имеет прямого доступа к DOM и общается через сообщения. Его тяжёлая работа не занимает stack страницы, но конкурирует за CPU.
Node.js host

Очереди Node и фазы libuv без rendering

Node связывает V8 с файловой системой, сетью, процессами и libuv. У server runtime нет DOM, layout, paint и стандартного requestAnimationFrame.

libuv phases
Цикл проходит timers, pending callbacks, внутренние idle/prepare, poll, check и close callbacks. setImmediate относится к check, а готовые I/O callbacks обычно обрабатываются вокруг poll.
nextTick
process.nextTick — очередь Node, а не фаза libuv и не стандарт browser. После обычного callback она обычно опустошается перед V8 microtasks; рекурсивное пополнение способно задержать I/O.
I/O и pool
Сокеты обычно ждут readiness ОС, а часть fs, DNS, crypto и zlib использует ограниченный libuv thread pool. Готовый результат всё равно возвращает callback в JavaScript Event Loop.
Worker Thread
Worker Thread создаёт отдельный V8 isolate, stack и Event Loop внутри процесса. Он подходит для CPU-bound JavaScript; сообщения в main становятся асинхронными callbacks.
Один принцип, два host

Сначала завершается callback, затем host применяет собственные правила

Оба фрагмента показывают run-to-completion и microtask checkpoint. Отличающиеся API показывают именно host-часть, которой нет в самом языке JavaScript.

Browser · обработчик click
button.addEventListener('click', () => {
  console.log('handler');

  queueMicrotask(() => console.log('microtask'));
  requestAnimationFrame(() => console.log('rAF'));
  setTimeout(() => console.log('timer'), 0);
});

Сначала handler, после его возврата — microtask. rAF выполнится перед paint выбранного кадра, а timer — как отдельная будущая task; универсального порядка между rAF и timer нет.

Node · внутри I/O callback
readFile(new URL(import.meta.url), () => {
  console.log('I/O');

  process.nextTick(() => console.log('nextTick'));
  queueMicrotask(() => console.log('microtask'));
  setImmediate(() => console.log('immediate'));
  setTimeout(() => console.log('timer'), 0);
});

В этом I/O-контексте: I/O → nextTick → microtask → immediate → timer. immediate попадает в ближайшую check-фазу, новый timer ждёт следующего timers-контекста.

  • Promise и queueMicrotask относятся к общей JavaScript-модели jobs, но момент checkpoint встраивает host.
  • requestAnimationFrame описывает rendering browser; setImmediate и process.nextTick — специфичные API Node.
  • Web Worker и Worker Thread решают похожую задачу изоляции работы, но имеют разные API и окружение.
01 · СЛОВАРЬ

Термины этого эксперимента

Сначала поймите слова — затем порядок выполнения.

01

Call Stack

Стек вызываемых сейчас функций. Пока он не пуст, другой callback не начнёт выполнять JavaScript.

02

Callback

Функция, которую runtime вызовет позже после события: таймера, I/O, сообщения Worker и т. п.

03

Microtask

Приоритетное продолжение Promise или queueMicrotask. Очередь очищается между callbacks и фазами.

04

Фаза

Этап оборота libuv Event Loop. Для лаборатории важны timers, poll и check.

05

Регистрация

Синхронный момент, когда runtime получает callback и условия его будущего запуска. Регистрация ещё не является выполнением callback.

02 · МЕХАНИКА

Что происходит по шагам

Каждый шаг соответствует наблюдаемому состоянию runtime.

  1. 01
    Выполняется sync-код

    Строки читаются сверху вниз: console.log печатает сразу, а остальные вызовы регистрируют callbacks.

  2. 02
    Стек освобождается

    Только теперь другой callback получает право начать JavaScript. Это граница выбора, а не прерывание текущей функции.

  3. 03
    Приоритетный checkpoint

    После обычного callback Node опустошает process.nextTick, затем Promise.then/queueMicrotask. Но top-level ESM и уже выполняющийся microtask — отдельные контексты.

  4. 04
    Выбирается готовая работа фазы

    Timers проверяет достигнутые пороги, poll обрабатывает готовое I/O, check — setImmediate. Между ними нет универсального глобального FIFO.

  5. 05
    После callback правило повторяется

    После каждого завершившегося callback снова наступает checkpoint. Поэтому nextTick и Promise из timer 1 могут стартовать раньше timer 2.

03 · КОНТЕКСТ

Где результат требует оговорки

Эти детали объясняют, почему похожий код иногда даёт другой trace.

01

Порядок строк — это порядок регистрации

Сначала действительно выполняется строка nextTick, затем строка Promise и так далее. Но тела стрелочных функций выполнятся позже. Поэтому порядок строк не равен итоговому порядку console.log.

02

FIFO действует после проверки контекста

Два Promise.then, зарегистрированные подряд, сохраняют порядок. Два уже готовых однотипных callback одной фазы обычно тоже берутся по порядку очереди. Но это не правило для разных фаз, источников I/O или работ с разной готовностью.

03

Timer, immediate и I/O не образуют лестницу

В main-модуле setTimeout(0) и setImmediate могут поменяться местами. Если оба созданы внутри одного I/O callback, setImmediate выполняется раньше нового timer. Сам I/O начнётся тогда, когда операция станет готова и Event Loop сможет обработать её callback.

04

nextTick → Promise тоже требует контекста

После обычного callback и в CommonJS nextTick идёт перед Promise microtasks. ES-модуль вычисляется как microtask; если планирование происходит в top-level ESM или уже внутри microtask, Promise/queueMicrotask могут появиться раньше nextTick до возврата управления Node.

05

Версия Node влияет на старые схемы

Начиная с libuv 1.45 / Node 20 timers запускаются только после poll, а не до и после него. Поэтому диаграмма из старой статьи может не совпасть с современной Node.

01
Теория

Сначала разберитесь, какие части Node участвуют в выполнении.

02
Упрощённый код

Затем уберите служебные детали и рассмотрите только главную идею.

03
Runtime-код

После этого сопоставьте модель с кодом, который создаёт live trace.

04 · Упрощённый код

Минимальная модель без служебного кода

src/demos.js · учебный фрагментJavaScript
console.log('sync'); // Выполняется сейчас

// Эти строки регистрируют callbacks сверху вниз:
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('Promise'));
setTimeout(() => console.log('timer'), 0);
setImmediate(() => console.log('immediate'));

// Их тела выполнятся позже по правилам очередей и фаз.
05 · Runtime-код

Полный код, который выполняет сценарий

Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.

ФАКТИЧЕСКИЙ SOURCE

Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.

src/demos.js
сценарий117 строк
import { readFile } from 'node:fs';
import { fileURLToPath } from 'node:url';

const packageJsonPath = fileURLToPath(
  new URL('../package.json', import.meta.url),
);

const sleep = (ms) =>
  new Promise((resolve) => setTimeout(resolve, ms));

async function eventLoopOrder(emit) {
  emit('call-stack', 'sync', 'Синхронный код начал выполняться');

  const outerCallbacks = [];
  const waitForOuter = new Promise((resolve) => {
    let completed = 0;
    const done = () => {
      completed += 1;
      if (completed === 4) resolve();
    };

    process.nextTick(() => {
      outerCallbacks.push('process.nextTick');
      emit('nextTick', 'callback', 'process.nextTick callback');
      done();
    });

    Promise.resolve().then(() => {
      outerCallbacks.push('Promise.then');
      emit('microtasks', 'callback', 'Promise.then microtask');
      done();
    });

    queueMicrotask(() => {
      outerCallbacks.push('queueMicrotask');
      emit('microtasks', 'callback', 'queueMicrotask callback');
      done();
    });

    setTimeout(() => {
      outerCallbacks.push('setTimeout(0)');
      emit('timers', 'callback', 'setTimeout(0) callback');
      done();
    }, 0);

    setImmediate(() => {
      outerCallbacks.push('setImmediate');
      emit('check', 'callback', 'setImmediate callback');
      done();
    });
  });

  emit(
    'call-stack',
    'schedule',
    'Callbacks зарегистрированы; синхронный стек сейчас освободится',
  );
  await waitForOuter;

  // Ждём timer и immediate. Начальный порядок зависит от того, из какого
  // async-контекста вызывается сценарий, поэтому мы записываем наблюдение,
  // а не подгоняем его под заученную последовательность.
  while (
    !outerCallbacks.includes('setTimeout(0)') ||
    !outerCallbacks.includes('setImmediate')
  ) {
    await sleep(1);
  }

  emit('result', 'result', `Контекст запуска: ${outerCallbacks.join(' → ')}`);

  emit('poll', 'schedule', 'Запускаем fs.readFile и переходим к I/O-раунду');
  await new Promise((resolve, reject) => {
    readFile(packageJsonPath, 'utf8', (error) => {
      if (error) {
        reject(error);
        return;
      }

      emit('poll', 'callback', 'Callback fs.readFile: сейчас мы внутри poll-фазы');
      const ioOrder = [];
      let completed = 0;

      const done = () => {
        completed += 1;
        if (completed === 4) {
          emit('result', 'result', `Внутри I/O: ${ioOrder.join(' → ')}`);
          resolve();
        }
      };

      process.nextTick(() => {
        ioOrder.push('nextTick');
        emit('nextTick', 'callback', 'nextTick, созданный внутри I/O');
        done();
      });

      Promise.resolve().then(() => {
        ioOrder.push('Promise');
        emit('microtasks', 'callback', 'Promise, созданный внутри I/O');
        done();
      });

      setImmediate(() => {
        ioOrder.push('setImmediate');
        emit('check', 'callback', 'setImmediate, созданный внутри I/O');
        done();
      });

      setTimeout(() => {
        ioOrder.push('setTimeout');
        emit('timers', 'callback', 'setTimeout(0), созданный внутри I/O');
        done();
      }, 0);
    });
  });
}

Live trace — прикладное инструментирование сценария. Строки и отметки времени появляются в реальные моменты вызова emit(...) внутри callbacks, поэтому наблюдаемый порядок относится к настоящему запуску. Названия source и lane передаёт сам код сценария: это не profiler V8/libuv и не прямой снимок их внутренних очередей или фаз.

06 · РЕЦЕПТЫ

Практические шаблоны, которые можно подсмотреть

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

01

Microtasks между двумя timers

Увидеть checkpoint после каждого callback, а не только после завершения всей timers-фазы.

setTimeout(() => {
  console.log('timer 1');

  process.nextTick(() => console.log('nextTick from timer 1'));
  Promise.resolve().then(() => console.log('Promise from timer 1'));
  setImmediate(() => console.log('immediate from timer 1'));
}, 0);

setTimeout(() => console.log('timer 2'), 0);
  • Если оба таймера уже готовы в одной timers-фазе: timer 1 → nextTick → Promise → timer 2 → immediate.
  • nextTick и Promise стартуют после возврата из callback timer 1.
  • setImmediate ждёт check, поэтому не прерывает обработку timers-фазы.
02

Immediate и timer внутри I/O

Зафиксировать контекст, в котором относительный порядок предсказуем.

import { readFile } from 'node:fs';

readFile(new URL(import.meta.url), () => {
  setTimeout(() => console.log('timer'), 0);
  setImmediate(() => console.log('immediate'));
});

// Здесь: immediate → timer
  • readFile callback обрабатывается в I/O-контексте poll.
  • После poll Event Loop достигает check, поэтому новый immediate выполняется раньше нового нулевого timer.
  • Тот же вывод нельзя бездумно переносить на top-level main-модуля.
07 · PRODUCTION-КЕЙСЫ

Как учебная ошибка превращается в инцидент

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

КЕЙС 01

Событие заказа публикуется раньше обязательного аудита

Checkout endpoint сохраняет заказ, регистрирует публикацию события через setImmediate и затем ждёт запись аудита. Автор рассчитывал, что строки исходника задают порядок всех действий.

КОНТЕКСТ ИНЦИДЕНТА

После await стек освобождается. Пока audit.write ждёт I/O, check-фаза может выполнить setImmediate, и downstream consumer увидит OrderCreated раньше обязательной audit-записи.

ДОПРОБЛЕМНАЯ РЕАЛИЗАЦИЯ
async function checkout(input) {
  const order = await orders.insert(input);

  setImmediate(() => {
    broker.publish('OrderCreated', order);
  });

  await audit.write('order.created', order.id);
  return order;
}

Порядок строк определяет регистрацию, но callback setImmediate и продолжение await попадают в разные механизмы планирования. Между ними нет бизнес-гарантии порядка.

ПОСЛЕИСПРАВЛЕННАЯ РЕАЛИЗАЦИЯ
async function checkout(input) {
  const order = await orders.insert(input);

  await audit.write('order.created', order.id);
  await broker.publish('OrderCreated', order);

  return order;
}

// Если нужна атомарность с INSERT:
// order + outbox event записываются
// одной DB-транзакцией.

Обязательная зависимость выражена через await. Для гарантии «заказ и событие вместе» используется transactional outbox, а не порядок фаз Event Loop.

ФУНКЦИИ И КОНСТРУКЦИИ

Что делают непривычные вызовы из обоих фрагментов кода.

orders.insert(input)
Асинхронный метод репозитория: записывает заказ в БД и возвращает Promise с созданным заказом.
setImmediate(callback)
Регистрирует callback для check-фазы Event Loop. Он не означает «выполни строго после следующей строки».
await promise
Приостанавливает только текущую async-функцию. После settlement её продолжение будет поставлено в очередь microtasks.
broker.publish(...)
Отправляет событие брокеру сообщений. В корректном API возвращает Promise, чтобы вызывающий код мог дождаться подтверждения.
transactional outbox
Заказ и запись о будущем событии сохраняются одной DB-транзакцией, а отдельный publisher позднее отправляет событие.
ПОЧЕМУ ИСПРАВЛЕНИЕ РАБОТАЕТ

Event Loop определяет, когда callback получает стек, но не должен кодировать бизнес-последовательность. Зависимые эффекты связывают Promise-цепочкой или транзакционным протоколом; независимые запускают явно через Promise.all.

ЧТО БЫЛО ВИДНО В PRODUCTION
  • OrderCreated присутствует в broker раньше строки аудита.
  • Редкие race failures исчезают при искусственной задержке audit storage.
  • Интеграционный тест нестабилен только под I/O-нагрузкой.
08 · НЕ ПЕРЕПУТАЙТЕ

Популярные заблуждения

Миф слева, корректная модель справа.

МИФ

В Node существует одна общая очередь событий.

НА САМОМ ДЕЛЕ

Очередей и фаз несколько, между ними есть правила приоритета.

МИФ

setTimeout(fn, 0) выполняет fn немедленно.

НА САМОМ ДЕЛЕ

Ноль означает минимальную задержку; callback ещё должен дождаться подходящей фазы и свободного стека.

МИФ

Асинхронная функция может прервать текущий JavaScript.

НА САМОМ ДЕЛЕ

Нет: callback начнётся только после завершения текущего участка кода.

МИФ

Если строка записана выше, её callback обязательно сработает раньше.

НА САМОМ ДЕЛЕ

Это верно лишь при совместимых условиях, например внутри одной FIFO-очереди. Между очередями сначала применяются их приоритеты и правила фаз.

МИФ

process.nextTick всегда и везде раньше Promise.

НА САМОМ ДЕЛЕ

Так происходит в обычных callbacks и CommonJS. При вычислении top-level ESM Promise/microtask может получить преимущество.

09 · САМОПРОВЕРКА

Ответьте своими словами

Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.

  1. Почему Promise не выполняется в момент вызова Promise.resolve()?
  2. Что произойдёт с таймером, если текущая функция работает пять секунд?
  3. Когда порядок строк снова становится порядком callbacks, а когда его переопределяет очередь?