nextTick и микрозадачи: две очереди между колбэками

цикл событий≈ 11 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Планировщик из прошлой статьи знает один перерыв между колбэками — очередь тиков, которую осушает call. У настоящего Node таких очередей две: сначала тики process.nextTick, следом микрозадачи промисов, и каждая осушается до дна вместе с тем, что появилось в ней по ходу. Разница стоит одной очереди в модели и целого класса ошибок в жизни: проверка, которая всегда видит false; слушатель, который не успел подписаться; таймер, который не наступает никогда. Допишем вторую очередь в планировщик, посмотрим на порядок осушения глазами и воспроизведём голодание цикла — сперва в модели, потом настоящим node.

Разделы темы:·

Тик — не фаза, а довесок к текущему колбэку: цикл осушает очередь тиков, как только колбэк вернулся, и лишь потом берёт следующий. Это модель уже умеет. Чего она не знает — что очередей в этом перерыве две. Первая держит колбэки process.nextTick, вторая — реакции промисов: .then, .catch, .finally, продолжение кода после await и queueMicrotask. Порядок между ними жёсткий: сначала вся первая, потом вся вторая.

Делить их приходится потому, что они разного происхождения. Микроочередь — часть языка: её держит движок, и в браузере она ровно такая же, только соседствует там не с тиками, а с макрозадачами браузерного цикла. Очередь тиков — собственность Node: в браузере process нет вовсе. Node разбирает свою очередь первой и только после этого отдаёт ход микрозадачам.

В модели это одно поле micro, один назначатель loop.microtask и новый перерыв внутри call. Всё остальное осталось как было: фазы, часы, снимок очереди на входе в фазу и сон до ближайшего срока. Прогноз к первому примеру: колбэк фазы проверки назначает две работы в одну очередь и две в другую, вперемешку, — в каком порядке они напечатаются и где окажется строка «колбэк кончился»?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

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

Имя в модели своё, как и раньше: loop.microtask — это и .then, и queueMicrotask, и продолжение после await. В Node всё это одна и та же очередь.

Список того, что в неё кладут, короткий: .then, .catch, .finally, продолжение кода после await и queueMicrotask. Всё остальное, что в коде выглядит отложенным, приходит другими путями.

Колбэк fs.readFile и обработчик запроса ждут не перерыва между колбэками, а своей фазы опроса; setImmediate — фазы проверки; setTimeout — фазы сроков. А слушатели события не ждут вообще ничего: emit зовёт их подряд прямо там, где случилось событие, — это обычный синхронный вызов, просто записанный через список функций.

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

Слово «до дна» стоит понимать буквально: очередь разбирается, пока не опустеет, включая работы, назначенные по дороге. С одной очередью это просто, с двумя — интереснее. Что будет, если тик по ходу работы заведёт микрозадачу, а микрозадача — тик? Первая попадёт в очередь, которая ещё не начиналась. А вторая — в ту, которую только что объявили пустой.

Семь строк, семь работ. Выпишите их порядок, прежде чем нажимать Run: дальше он повторится на настоящем Node, и сверять будет интереснее.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Разберём по шагам. тик 1.1 встал в конец очереди тиков и выполнился в том же заходе — очередь живая, не снимок, в отличие от очередей фаз. микрозадача из тика 1 попала во вторую очередь, до которой ход ещё не дошёл, и разобралась вместе с остальными. А вот тик из микрозадачи 1 заставил цикл вернуться к первой очереди: в call за это отвечает do…while, который начинает круг заново, пока очередь тиков пополняется.

Поэтому «сначала тики, потом микрозадачи» — не два шага, а круг. Перерыв кончится только тогда, когда обе очереди окажутся пустыми одновременно; до этого цикл не вернётся ни в одну фазу.

Проверим настоящим node. Тот же набор, только loop.nextTick стал process.nextTick, loop.microtask — Promise.resolve().then, а loop.immediate — setImmediate.

прогон node 26.5.0 · 29 сентября 2026
Два тика и две реакции промиса в колбэке setImmediate; первый тик и первый промис добавляют работу по ходу
// Две очереди между колбэками: в каком порядке их разбирают и куда попадает то,
// что назначено по ходу разбора.

// колбэк фазы проверки назначает два тика и две реакции промиса; первый тик и
// первый промис по ходу добавляют к очередям ещё по одной работе
setImmediate(() => {
  console.log('колбэк начался');

  process.nextTick(() => {
    console.log('тик 1');
    process.nextTick(() => console.log('тик 1.1, назначен из тика'));
    Promise.resolve().then(() => console.log('промис из тика 1'));
  });
  process.nextTick(() => console.log('тик 2'));

  Promise.resolve().then(() => {
    console.log('промис 1');
    process.nextTick(() => console.log('тик из промиса 1'));
  });
  Promise.resolve().then(() => console.log('промис 2'));

  console.log('колбэк кончился');
});
node queues.mjs

Порядок работ — тот же, что в модели, вплоть до последней. Две строки про начало и конец колбэка стоят здесь рамкой: всё остальное напечаталось уже после того, как он вернулся. Значит, do…while в call не украшение и не вольность автора: именно так Node и разбирает свои очереди. Осушил тики, осушил микрозадачи, посмотрел, не появилось ли новых тиков, — и заново.

Правило «сначала тики» описывает перерыв между колбэками. Верхний уровень главного модуля — не колбэк: ни одной фазы ещё не прошло, а очереди уже пополняются. Что происходит с ними там, проще увидеть, чем вывести. Прогноз: одни и те же четыре строки стоят в трёх местах — на верхнем уровне main.mjs, в колбэке setImmediate и на верхнем уровне обычного файла CommonJS. Везде ли порядок одинаков?

прогон node 26.5.0 · 21 сентября 2026
Один и тот же набор назначений в трёх местах: верх ES-модуля, колбэк цикла и верх файла CommonJS
// Один и тот же набор назначений в трёх местах: верх ES-модуля, колбэк цикла и
// верх файла CommonJS. Порядок двух очередей везде ли одинаков?
import { execFileSync } from 'node:child_process';

const четыре = (где) => {
  process.nextTick(() => console.log(`${где}: тик 1`));
  Promise.resolve().then(() => console.log(`${где}: промис 1`));
  process.nextTick(() => console.log(`${где}: тик 2`));
  Promise.resolve().then(() => console.log(`${где}: промис 2`));
};

четыре('верх ES-модуля');

setImmediate(() => {
  четыре('колбэк');
  setImmediate(() => {
    // тот же код верхним уровнем CommonJS: увидеть его старт можно только
    // отдельным процессом — главный модуль у процесса один
    process.stdout.write(execFileSync(process.execPath, ['top.cjs'], { cwd: import.meta.dirname }));
  });
});
node main.mjs

В колбэке — знакомый порядок, а на верхнем уровне main.mjs он обратный. Причина в том, как Node выполняет ES-модуль: тело модуля для загрузчика — работа промиса, и микроочередь осушается ещё внутри этой работы, до того как Node доберётся до очереди тиков. У файла CommonJS никакого промиса нет — его тело выполняет require, и порядок там обычный, тиковый. Одно и то же содержимое, а расширение и поле type меняют вывод местами.

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

Модель здесь молчит, и честно будет это назвать. У неё нет ни загрузчика, ни главного модуля: колбэки приходят только из фаз, а тик, назначенный в примере до loop.run(), пролежит в очереди до первого же колбэка — осушает её только call. Это первая из нескольких вещей, о которых модель говорит увереннее, чем вправе; остальные разберём в статье «Где модель врёт: планировщик на промисах и замкнутость мира».

У обещания «до дна» есть цена. Очередь, которая пополняет себя сама, не опустеет никогда, и перерыв между колбэками не кончится. В браузере это уже было: бесконечная цепочка микрозадач не даёт странице ни перерисоваться, ни услышать клик. В Node к этому добавляется вторая очередь, и цепочка тиков голодит не только цикл, но и микрозадачи: до них ход не дойдёт, пока очередь тиков не опустеет.

В модели это видно буквально — внутренний while по очереди тиков. Колбэк опроса назначает таймер на ноль, микрозадачу и цепочку из пяти тиков. Прогноз: сколько шагов цепочки пройдёт, прежде чем выполнится микрозадача, и сколько — прежде чем наступит таймер?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

А цепочка в другой очереди — так же беспощадна? Не совсем, и разница ровно в том, что мы видели в разделе про круг. Прогноз: тот же ограничитель, но шаги идут микрозадачами, и рядом два тика — один назначен до первого шага, другой изнутри цепочки. Сколько шагов увидит каждый?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первый тик прошёл вперёд цепочки, не потеряв ни шага: очередь тиков разбирается раньше микроочереди, и цепочка, которой ещё не дали ход, ему не помеха. Второму повезло меньше — он появился, когда очередь тиков уже объявили пустой, и дождался конца всей цепочки. Отсюда несимметричность: цепочка микрозадач голодит цикл и те тики, которые появятся позже, а цепочка тиков голодит вдобавок всю микроочередь целиком.

Настоящий node — с числом, при котором сомнения в «медленно, но дойдёт» отпадают: миллион шагов в каждой очереди. Прогноз: успеет ли таймер на ноль вклиниться хоть куда-нибудь?

прогон node 26.5.0 · 29 сентября 2026
Миллион рекурсивных nextTick и миллион реакций промиса рядом с таймером на ноль
// Голодание цикла: миллион шагов в очереди между колбэками — и таймер на ноль рядом.
const ШАГОВ = 1_000_000;

// таймер на ноль: в спокойном цикле он наступил бы на ближайшем обороте
function опыт(имя, отложить, дальше) {
  let сделано = 0;
  let таймерБыл = false;
  setTimeout(() => {
    таймерБыл = true;
    console.log(`${имя}: таймер дождался, шагов к этому моменту ${сделано}`);
    дальше();
  }, 0);
  const шаг = () => {
    сделано += 1;
    if (сделано < ШАГОВ) отложить(шаг);
    else console.log(`${имя}: ${сделано} шагов позади, таймер за это время не сработал: ${таймерБыл ? 'нет' : 'да'}`);
  };
  шаг();
}

опыт('тики', (шаг) => process.nextTick(шаг), () => {
  опыт('промисы', (шаг) => Promise.resolve().then(шаг), () => {});
});
node starve.mjs

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

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

Как отложитьКогда выполнитсяКого пропустит вперёд
process.nextTickсразу после текущего колбэка, раньше любой микрозадачиникого: ни ввод-вывод, ни таймеры, ни реакции промисов
.then, await, queueMicrotaskв том же перерыве, но после всех тиковтолько тики — и те, что уже в очереди, и те, что появятся по ходу
setImmediateв фазе проверки этого же оборотавсё, что ждёт в фазе опроса: готовые сокеты, прочитанные файлы
setTimeoutв фазе сроков следующего оборотавесь оборот целиком, а нулевую задержку Node поднимет до миллисекунды

Тик берут, когда довесок обязан случиться раньше любого ввода-вывода и раньше любой реакции промиса, — например, чтобы сообщить о событии так, чтобы вызвавшая сторона успела подписаться (это вторая задача статьи). Микрозадачи обычно не выбирают вовсе: они получаются сами, как только в коде появляется await. А всё, что может подождать до конца оборота, откладывают через setImmediate.

Обратное правило важнее прямого: в этих двух очередях не должно быть ничего длинного и ничего рекурсивного. Порция работы, которая назначает следующую порцию тиком или промисом, — это тот же блокирующий счёт, только записанный так, что при чтении кода он выглядит безобидно. Резать счёт на порции через setImmediate вы уже умеете; а когда порции не спасают, счёт уносят из цикла совсем — в отдельный поток, и это статья «Заблокировать цикл и вынести счёт в отдельный поток».

  • Между колбэками у Node две очереди: тики process.nextTick и микрозадачи промисов. Сначала разбирается вся первая, потом вся вторая.
  • Каждая очередь осушается до дна вместе с тем, что назначено по ходу, а если микрозадача завела тик, перерыв начинается заново: это круг, а не два шага.
  • Обе очереди — перерыв между колбэками: пока колбэк не вернулся, из них не выполнится ничего, сколько бы туда ни положили.
  • На верхнем уровне ES-модуля порядок обратный — микрозадачи идут раньше тиков, потому что тело модуля для загрузчика само по себе работа промиса. В файле CommonJS порядок обычный.
  • Рекурсивная цепочка в любой из двух очередей голодит цикл: таймеры и ввод-вывод ждут её конца, а цепочка тиков к тому же не пускает вперёд микрозадачи.
  • Отсюда выбор: тик — довесок, который обязан случиться до любого ввода-вывода; setImmediate — работа, которая может подождать конца оборота; ничего длинного и рекурсивного ни в одной из двух очередей.

1/4В колбэке setImmediate подряд назначены process.nextTick(a), Promise.resolve().then(b) и process.nextTick(c). В каком порядке они выполнятся?

2/4Микрозадача по ходу работы назначила process.nextTick. Когда выполнится этот тик?

3/4Те же два process.nextTick и две реакции промиса стоят на верхнем уровне main.mjs. Что напечатается первым?

4/4В колбэке назначены process.nextTick(a) и бесконечная цепочка реакций промиса, каждая из которых назначает следующую. Выполнится ли a?

Проверка, которая всё время видит false

Обработчик дочитал тело запроса, и разбор заканчивается реакцией промиса — то есть микрозадачей. Эту строку трогать нельзя: так устроен разбор. Рядом захотели записать в лог, что разбор позади, и поставили запись тиком — «он ведь раньше всех». Лог печатает false при любом запросе. Почему так выходит и как это исправить, не трогая ни порядок строк, ни разбор?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Перепишите строку с пометкой // ваш код так, чтобы лог увидел разбор законченным.

Тик не «раньше всех» — он раньше микрозадач, а разбор как раз микрозадача. Лог должен стоять в той же очереди и за ней:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

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

Ловушка живучая, потому что в асинхронном коде разбор обычно прячется за await, а запись в лог — за «отложу на потом, пусть не мешает ответу». Два разных мотива, две разные очереди и false, которого никто не объяснит, пока не вспомнит, что очередей две.

Никто не успел подписаться

Функция открыть возвращает соединение и тут же сообщает всем подписчикам, что оно готово. Беда в том, что подписаться на него можно только после возврата — раньше подписчику не на что сослаться. В итоге сообщение уходит в пустоту: в выводе одна строка вместо двух. Почините открыть, поменяв одну строку. Подписчика и вызов трогать нельзя — это чужой код.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Подсказка: сообщение должно уйти после того, как открыть вернёт соединение, но раньше любого ввода-вывода.

Сообщение откладывается тиком — этого достаточно, чтобы вызвавшая сторона успела получить соединение и подписаться:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Почему именно тик, а не микрозадача и не loop.immediate? Все трое дали бы подписчику шанс, но тик — самый ранний из них: он гарантирует, что событие придёт до того, как цикл займётся любым вводом-выводом. Для сообщений о собственном состоянии это то, что нужно: готовность соединения не должна приходить позже ответа сервера, который её ждёт.

Приём в Node стандартный: так поступают все, кто возвращает объект и тут же сообщает о его состоянии. Сам список слушателей — on, emit и ловушки вокруг них — вы соберёте в статье «Собираем EventEmitter: on, once, off, emit и его ловушки».