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) |
| Фаза | timers | check |
| Минимальная задержка | округляется до 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 мс.