setTimeout, setImmediate и порядок

Урок объясняет, чем setImmediate отличается от setTimeout(0) и почему их порядок зависит от того, откуда они вызваны.

setImmediate ставит колбэк в фазу check текущего оборота, а setTimeout(fn, 0) — в фазу timers следующего оборота; отсюда их разный порядок.

Два таймера, оба «как можно скорее», но ведут себя по-разному. setTimeout(fn, 0) просит «вызови через ноль миллисекунд», а setImmediate(fn) — «вызови на ближайшей фазе check». Кажется, что это одно и то же, но фазы у них разные: timers против check. Из этого вытекает вся интрига порядка, на которой спотыкаются даже опытные разработчики.

Оба способа решают одну задачу — «выполни этот колбэк не сейчас, а чуть позже, не блокируя текущий код». Но «чуть позже» у них наступает в разных точках оборота цикла, и эта разница в пару фаз порождает поведение, которое в одном контексте детерминировано, а в другом плавает от запуска к запуску. Именно поэтому нельзя выбирать между ними «по привычке»: правильный выбор зависит от того, откуда и зачем вы откладываете работу.

Главная мысль урока: их относительный порядок зависит от контекста вызова. На верхнем уровне скрипта он недетерминирован, а внутри I/O-колбэка — строго предсказуем. Разберём оба случая.

Зачем это на практике

Когда нужно «отложить работу на следующий тик, но как можно раньше» — например, разбить тяжёлую обработку на куски, чтобы не блокировать цикл, — правильный выбор почти всегда setImmediate, а не setTimeout(0). setImmediate отдаёт управление циклу и гарантированно выполнится после текущей фазы I/O, не дожидаясь следующего оборота таймеров. А ещё знание разницы спасает от плавающих багов, когда тесты иногда падают из-за «неправильного» порядка колбэков.

Случай 1: верхний уровень — порядок недетерминирован

Если вызвать оба на верхнем уровне модуля, порядок может меняться от запуска к запуску. Скрипт использует таймеры Node — блок для чтения (language-text).

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

// Возможный вывод: timeout, immediate
// Или:            immediate, timeout
// Порядок не гарантирован!

Почему так? setTimeout(fn, 0) внутри превращается в задержку 1 мс (ноль не допускается, минимум — 1). Успеет ли таймер «дозреть» к моменту, когда цикл войдёт в фазу timers, зависит от того, сколько микросекунд заняли подготовка процесса и старт цикла. Если на старте прошла хотя бы 1 мс — таймер готов, и timeout выходит первым. Если меньше — цикл проскочит timers (таймер ещё не дозрел) и дойдёт до check, где сработает immediate. Это гонка с миллисекундным таймером, поэтому результат плавает.

Случай 2: внутри I/O-колбэка — порядок гарантирован

А вот если оба зарегистрированы внутри колбэка ввода-вывода (например, в колбэке fs.readFile), setImmediate всегда срабатывает раньше setTimeout(0).

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});

Вывод (стабильно):

immediate
timeout

Логика железная. Колбэк readFile выполняется в фазе poll. Сразу после poll идёт фаза check — там немедленно срабатывает зарегистрированный setImmediate. А setTimeout придётся ждать фазы timers следующего оборота цикла. Поэтому внутри любого I/O-колбэка setImmediate опережает setTimeout(0) — это уже не гонка, а прямое следствие порядка фаз: poll → check, и только потом новый оборот с timers.

Как это работает под капотом

В libuv нет таймера с задержкой ровно 0: минимальный интервал — 1 мс, и Node округляет setTimeout(fn, 0) до 1 мс. Перед каждой фазой timers цикл смотрит на текущее время и забирает только те таймеры, чей дедлайн уже наступил. Если момент дедлайна ещё не настал (прошло меньше миллисекунды), таймер пропускается до следующего оборота.

Фаза check, наоборот, не зависит от времени: всё, что положили в setImmediate, выполняется на этой фазе безусловно. Поэтому в момент, когда цикл уже находится в обороте после I/O (то есть гарантированно прошёл через poll и идёт в check), immediate выигрывает у «ещё не дозревшего» таймера. На верхнем же уровне старт цикла и дозревание таймера соревнуются, и кто успел — тот и первый.

СвойствоsetTimeout(fn, 0)setImmediate(fn)
Фазаtimerscheck
Минимальная задержкаокругляется до 1 мснет, ближайший check
Порядок на верхнем уровненедетерминирован (гонка с таймером)
Порядок внутри I/O-колбэкавторойпервый (всегда)

Частые ошибки

Закладываться на порядок на верхнем уровне. Тест вида «timeout раньше immediate» будет иногда падать на CI — там другое железо и тайминги. Не полагайтесь на относительный порядок таймеров вне I/O-колбэка.

Брать setTimeout(0) для «следующего тика». Если цель — отдать управление циклу и продолжить как можно раньше, setImmediate точнее: он не зависит от дозревания миллисекундного таймера и не уходит на целый оборот вперёд.

Думать, что setImmediate «немедленный». Имя обманчиво: он не выполняется сию секунду, а ждёт фазы check текущего оборота. Любой синхронный код и микрозадачи отработают раньше.

Полагаться на ровно 0 мс у setInterval(fn, 0). Минимальный реальный интервал — 1 мс, и при загруженном цикле фактический период будет ещё больше из-за дрейфа таймеров.

Итоги

  • setTimeout(fn, 0) идёт в фазу timers (с округлением до 1 мс), setImmediate — в фазу check.
  • На верхнем уровне их порядок недетерминирован — это гонка старта цикла с дозреванием таймера.
  • Внутри I/O-колбэка setImmediate всегда раньше setTimeout(0), потому что после poll сразу идёт check.
  • Для «отдать управление и продолжить как можно скорее» выбирайте setImmediate.
  • Задержки ровно 0 мс не существует: libuv поднимает её минимум до 1 мс.
Проверьте себя
1. Внутри колбэка fs.readFile вызваны setTimeout(fn, 0) и setImmediate(fn). Что выполнится первым?
AsetTimeout — у него меньшая задержка
BsetImmediate — после фазы poll сразу идёт фаза check
CПорядок недетерминирован
DОба сработают в одной фазе одновременно
2. Почему на верхнем уровне модуля порядок setTimeout(0) и setImmediate не гарантирован?
ANode специально рандомизирует таймеры
BsetTimeout(0) округляется до 1 мс, и успеет ли он дозреть к фазе timers — зависит от времени старта цикла
CsetImmediate работает в отдельном потоке
DЭто баг конкретной версии Node