nextTick и микрозадачи: две очереди между колбэками
Здесь будет встроен видео-разбор темы (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. Всё остальное осталось как было: фазы, часы, снимок очереди на входе в фазу и сон до ближайшего срока. Прогноз к первому примеру: колбэк фазы проверки назначает две работы в одну очередь и две в другую, вперемешку, — в каком порядке они напечатаются и где окажется строка «колбэк кончился»?
Порядок вывода — не порядок назначения, а порядок очередей: оба тика, потом обе микрозадачи. А «колбэк кончился» стоит раньше их всех, и это главное в примере: обе очереди — перерыв между колбэками, а не часть колбэка. Пока колбэк не вернулся, ни один тик и ни одна микрозадача не выполняются, сколько бы их ни назначили.
Имя в модели своё, как и раньше: loop.microtask — это и .then, и queueMicrotask, и продолжение после await. В Node всё это одна и та же очередь.
Список того, что в неё кладут, короткий: .then, .catch, .finally, продолжение кода после await и queueMicrotask. Всё остальное, что в коде выглядит отложенным, приходит другими путями.
Колбэк fs.readFile и обработчик запроса ждут не перерыва между колбэками, а своей фазы опроса; setImmediate — фазы проверки; setTimeout — фазы сроков. А слушатели события не ждут вообще ничего: emit зовёт их подряд прямо там, где случилось событие, — это обычный синхронный вызов, просто записанный через список функций.
Путаница здесь стоит дорого именно потому, что на вид всё одинаково: везде функция, переданная «на потом». Определять очередь приходится не по форме вызова, а по тому, кто эту функцию оттуда достанет — движок, Node или соседний код.
Слово «до дна» стоит понимать буквально: очередь разбирается, пока не опустеет, включая работы, назначенные по дороге. С одной очередью это просто, с двумя — интереснее. Что будет, если тик по ходу работы заведёт микрозадачу, а микрозадача — тик? Первая попадёт в очередь, которая ещё не начиналась. А вторая — в ту, которую только что объявили пустой.
Семь строк, семь работ. Выпишите их порядок, прежде чем нажимать Run: дальше он повторится на настоящем Node, и сверять будет интереснее.
Разберём по шагам. тик 1.1 встал в конец очереди тиков и выполнился в том же заходе — очередь живая, не снимок, в отличие от очередей фаз. микрозадача из тика 1 попала во вторую очередь, до которой ход ещё не дошёл, и разобралась вместе с остальными. А вот тик из микрозадачи 1 заставил цикл вернуться к первой очереди: в call за это отвечает do…while, который начинает круг заново, пока очередь тиков пополняется.
Поэтому «сначала тики, потом микрозадачи» — не два шага, а круг. Перерыв кончится только тогда, когда обе очереди окажутся пустыми одновременно; до этого цикл не вернётся ни в одну фазу.
Проверим настоящим node. Тот же набор, только loop.nextTick стал process.nextTick, loop.microtask — Promise.resolve().then, а loop.immediate — 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('колбэк кончился'); });
колбэк начался колбэк кончился тик 1 тик 2 тик 1.1, назначен из тика промис 1 промис 2 промис из тика 1 тик из промиса 1
Порядок работ — тот же, что в модели, вплоть до последней. Две строки про начало и конец колбэка стоят здесь рамкой: всё остальное напечаталось уже после того, как он вернулся. Значит, do…while в call не украшение и не вольность автора: именно так Node и разбирает свои очереди. Осушил тики, осушил микрозадачи, посмотрел, не появилось ли новых тиков, — и заново.
Правило «сначала тики» описывает перерыв между колбэками. Верхний уровень главного модуля — не колбэк: ни одной фазы ещё не прошло, а очереди уже пополняются. Что происходит с ними там, проще увидеть, чем вывести. Прогноз: одни и те же четыре строки стоят в трёх местах — на верхнем уровне main.mjs, в колбэке setImmediate и на верхнем уровне обычного файла 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 })); }); });
верх ES-модуля: промис 1 верх ES-модуля: промис 2 верх ES-модуля: тик 1 верх ES-модуля: тик 2 колбэк: тик 1 колбэк: тик 2 колбэк: промис 1 колбэк: промис 2 верх CommonJS: тик 1 верх CommonJS: тик 2 верх CommonJS: промис 1 верх CommonJS: промис 2
В колбэке — знакомый порядок, а на верхнем уровне main.mjs он обратный. Причина в том, как Node выполняет ES-модуль: тело модуля для загрузчика — работа промиса, и микроочередь осушается ещё внутри этой работы, до того как Node доберётся до очереди тиков. У файла CommonJS никакого промиса нет — его тело выполняет require, и порядок там обычный, тиковый. Одно и то же содержимое, а расширение и поле type меняют вывод местами.
Вывод короткий: опираться на порядок двух очередей можно внутри колбэков, а на верхнем уровне модуля он зависит от того, как файл загружен. Код, которому этот порядок важен, туда и не пишут.
Модель здесь молчит, и честно будет это назвать. У неё нет ни загрузчика, ни главного модуля: колбэки приходят только из фаз, а тик, назначенный в примере до loop.run(), пролежит в очереди до первого же колбэка — осушает её только call. Это первая из нескольких вещей, о которых модель говорит увереннее, чем вправе; остальные разберём в статье «Где модель врёт: планировщик на промисах и замкнутость мира».
У обещания «до дна» есть цена. Очередь, которая пополняет себя сама, не опустеет никогда, и перерыв между колбэками не кончится. В браузере это уже было: бесконечная цепочка микрозадач не даёт странице ни перерисоваться, ни услышать клик. В Node к этому добавляется вторая очередь, и цепочка тиков голодит не только цикл, но и микрозадачи: до них ход не дойдёт, пока очередь тиков не опустеет.
В модели это видно буквально — внутренний while по очереди тиков. Колбэк опроса назначает таймер на ноль, микрозадачу и цепочку из пяти тиков. Прогноз: сколько шагов цепочки пройдёт, прежде чем выполнится микрозадача, и сколько — прежде чем наступит таймер?
Оба дождались конца цепочки: и микрозадача, назначенная раньше первого шага, и таймер, который не мог наступить, пока цикл не вернулся в фазу сроков. Пятёрка в ограничителе — не украшение: без неё внутренний while не кончился бы никогда, и вкладка с примером перестала бы отвечать. В настоящем процессе вышло бы то же самое, только вешать там нечего, кроме сервера.
А цепочка в другой очереди — так же беспощадна? Не совсем, и разница ровно в том, что мы видели в разделе про круг. Прогноз: тот же ограничитель, но шаги идут микрозадачами, и рядом два тика — один назначен до первого шага, другой изнутри цепочки. Сколько шагов увидит каждый?
Первый тик прошёл вперёд цепочки, не потеряв ни шага: очередь тиков разбирается раньше микроочереди, и цепочка, которой ещё не дали ход, ему не помеха. Второму повезло меньше — он появился, когда очередь тиков уже объявили пустой, и дождался конца всей цепочки. Отсюда несимметричность: цепочка микрозадач голодит цикл и те тики, которые появятся позже, а цепочка тиков голодит вдобавок всю микроочередь целиком.
Настоящий node — с числом, при котором сомнения в «медленно, но дойдёт» отпадают: миллион шагов в каждой очереди. Прогноз: успеет ли таймер на ноль вклиниться хоть куда-нибудь?
// Голодание цикла: миллион шагов в очереди между колбэками — и таймер на ноль рядом. 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(шаг), () => {}); });
тики: 1000000 шагов позади, таймер за это время не сработал: да тики: таймер дождался, шагов к этому моменту 1000000 промисы: 1000000 шагов позади, таймер за это время не сработал: да промисы: таймер дождался, шагов к этому моменту 1000000
Обе цепочки довели счёт до миллиона, и только после этого цикл добрался до фазы сроков. Дело не в скорости: рекурсивная цепочка в любой из двух очередей — это синхронный код, разложенный на кусочки, с той разницей, что внешне он выглядит асинхронным. Сервер в это время не принимает соединения, не отвечает на пинги и не закрывается по сигналу.
Способов отложить работу теперь четыре, и выбирают между ними не по скорости, а по тому, кого при этом пропускают вперёд. Первые два не отдают ход циклу вообще, вторые два отдают. Считаем, как и в прошлой таблице, самый частый случай — назначение из колбэка фазы опроса.
| Как отложить | Когда выполнится | Кого пропустит вперёд |
|---|---|---|
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 при любом запросе. Почему так выходит и как это исправить, не трогая ни порядок строк, ни разбор?
Перепишите строку с пометкой // ваш код так, чтобы лог увидел разбор законченным.
Тик не «раньше всех» — он раньше микрозадач, а разбор как раз микрозадача. Лог должен стоять в той же очереди и за ней:
Вывод:
Никакой гонки здесь нет: обе работы назначены в одном колбэке, и очередь решает всё. Тик выполнялся до разбора не потому, что оказался быстрее, а потому, что очередь тиков осушается первой — и так будет в каждом запуске, на любой машине.
Ловушка живучая, потому что в асинхронном коде разбор обычно прячется за await, а запись в лог — за «отложу на потом, пусть не мешает ответу». Два разных мотива, две разные очереди и false, которого никто не объяснит, пока не вспомнит, что очередей две.
Никто не успел подписаться
Функция открыть возвращает соединение и тут же сообщает всем подписчикам, что оно готово. Беда в том, что подписаться на него можно только после возврата — раньше подписчику не на что сослаться. В итоге сообщение уходит в пустоту: в выводе одна строка вместо двух. Почините открыть, поменяв одну строку. Подписчика и вызов трогать нельзя — это чужой код.
Подсказка: сообщение должно уйти после того, как открыть вернёт соединение, но раньше любого ввода-вывода.
Сообщение откладывается тиком — этого достаточно, чтобы вызвавшая сторона успела получить соединение и подписаться:
Вывод:
Почему именно тик, а не микрозадача и не loop.immediate? Все трое дали бы подписчику шанс, но тик — самый ранний из них: он гарантирует, что событие придёт до того, как цикл займётся любым вводом-выводом. Для сообщений о собственном состоянии это то, что нужно: готовность соединения не должна приходить позже ответа сервера, который её ждёт.
Приём в Node стандартный: так поступают все, кто возвращает объект и тут же сообщает о его состоянии. Сам список слушателей — on, emit и ловушки вокруг них — вы соберёте в статье «Собираем EventEmitter: on, once, off, emit и его ловушки».