Собираем планировщик: фазы вместо одной очереди
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В прошлой главе цикл событий Node обошёлся одной очередью: колбэк стал готов — встал в хвост, главный поток дойдёт и выполнит. Этого хватило, чтобы объяснить, почему долгий счёт задерживает всех сразу. Но на вопрос «что выполнится раньше, если из колбэка чтения файла назначить и таймер на ноль, и setImmediate» одна очередь ответа не даёт, а Node отвечает на него твёрдо и одинаково от запуска к запуску. Сегодня соберём планировщик с фазами — сорок строк на массивах и обычном while, — научимся предсказывать порядок по фазе, в которой колбэк назначен, и сверим предсказание с настоящим node.
В браузере очередь задач одна и честная: кто раньше стал готов, тот раньше и выполнится. Порядок там определяется временем — моментом, когда колбэк попал в очередь. Модель прошлой статьи устроена так же, и для своего урока она верна: колбэк, который считает триста миллисекунд, задержит всех, кто ждёт, в какой бы очереди они ни стояли.
Но стоит спросить конкретнее — и одна очередь молчит. Колбэк readFile отработал и назначил два продолжения: таймер на ноль миллисекунд и setImmediate. Оба готовы «как можно скорее», оба назначены одной и той же строкой кода подряд. По правилу «кто раньше стал готов» ответ зависел бы от того, чей срок наступит первым, то есть от скорости машины. На самом деле ответ у Node один и тот же всегда, и зависит он не от времени, а от того, в какой фазе цикл сейчас находится и какая фаза идёт следующей.
Цикл Node — не «очередь и поток», а оборот по фазам. Каждая фаза — это своя очередь и свой повод в неё попасть; оборот проходит фазы всегда в одном порядке, а закончив последнюю, начинает сначала. Отсюда всё остальное: колбэк, назначенный из фазы, которая идёт раньше, дождётся своего в этом же обороте, а назначенный из фазы, которая идёт позже, — пропустит целый круг.
| Фаза оборота | Что стоит в её очереди | Чем назначается |
|---|---|---|
| сроки | колбэки таймеров, у которых наступил срок | setTimeout, setInterval |
| отложенные колбэки | системные ошибки, отложенные с прошлого оборота | сам Node, из кода в неё не попасть |
| ожидание и подготовка | внутренние дела libuv | сам Node |
| опрос | готовый ввод-вывод: пришли байты, дочитан файл | чтение файла, сокет, ответ базы |
| проверка | колбэки, назначенные «сразу после опроса» | setImmediate |
| закрытие | колбэки закрытия | событие close у сокета |
Главная из них для сервера — опрос. Запрос клиента, ответ базы, прочитанный файл, байты из чужого API — всё это попадает в очередь опроса, и почти весь код, который вы напишете в этой части, выполняется именно там. Поэтому вопрос «что раньше» на практике почти всегда звучит так: я нахожусь в фазе опроса и назначаю продолжение — где оно окажется?
Из шести фаз автору кода доступны три: сроки, опрос и проверка. Две служебные заняты внутренними делами libuv, а закрытие — редкий гость, который к тому же ничего не меняет в порядке остальных. Поэтому в модель мы возьмём именно три; честно проговорим это и пойдём дальше — модель, которая не признаётся в своих упрощениях, опаснее отсутствия модели.
Фаза — это не «приоритет». Колбэк из фазы проверки не важнее колбэка таймера: просто в обороте проверка идёт после опроса, а сроки — до. Поменяйте точку, из которой назначаете, и порядок перевернётся.
Соберём планировщик. Три очереди фаз — обычные массивы; оборот — тело while, которое крутится, пока в очередях хоть что-то есть. Время в модели идёт тиками, а не миллисекундами: так порядок не зависит от скорости вашего компьютера, и его можно предсказывать на бумаге. Часы loop.now показывают текущий тик, счётчик loop.turn — номер оборота; печатать номер оборота в каждой строке и есть главный приём сегодняшнего дня.
Две строки в модели важнее остальных. Первая — срок таймера: loop.now + Math.max(1, delay). Ноль превращается в один тик, потому что срок — это «не раньше», а не «сейчас»: в браузере это уже разбиралось, и в Node правило записано прямо в исходнике таймеров — задержка меньше единицы становится единицей. Вторая — splice(0) в фазах опроса и проверки: фаза забирает очередь целиком и работает по снятой копии, а не по живому массиву. Что за этим стоит, разберём отдельным примером; пока достаточно знать, что назначенное во время фазы в эту фазу уже не попадает.
Проверим модель на той самой загадке. Колбэк чтения файла — это фаза опроса; из него назначаются три продолжения подряд: таймер на ноль, immediate и тик. Прогноз: в каком порядке напечатаются три строки и на каком обороте окажется каждая?
Порядок вывода — точно обратный порядку назначения, и это не совпадение. Тик не ждал вообще ничего: очередь тиков осушается сразу после колбэка, не дожидаясь конца фазы. immediate дождался фазы проверки — она в том же обороте, сразу за опросом. А таймер, хотя его срок «ноль», уехал в следующий оборот: фаза сроков этого оборота прошла раньше, чем колбэк чтения вообще начался, и ближайшая фаза сроков наступит только на втором круге.
Обратите внимание на строчку со сном. Когда в очередях опроса и проверки пусто, модель не крутит обороты вхолостую, а двигает часы к ближайшему сроку. Настоящий цикл поступает так же: в фазе опроса он засыпает на системном вызове и ставит будильник ровно на ближайший срок — потому ждущий сервер и не ест процессор.
И ещё одно свойство модели стоит назвать вслух, потому что дальше оно окажется важнее остальных: её мир замкнут и синхронен. Очередь опроса наполняем мы сами вызовом loop.io — «байты пришли»; настоящего ввода-вывода в браузере нет, да он здесь и не нужен, урок про порядок, а не про то, кто именно положил колбэк в очередь. Пока loop.run не вернулся, в мире модели не происходит ничего постороннего, и любое предсказание проверяется чтением кода. Стоит впустить в планировщик настоящую асинхронность — и он начинает врать; на чём именно, разбирает статья «Где модель врёт: планировщик на промисах и замкнутость мира».
В модели оборот стоит один тик, в жизни — столько, сколько шли колбэки. Это упрощение не влияет на порядок: сроки сравниваются с часами, а не с номером оборота.
Имена у модели свои: loop.timeout, loop.immediate, loop.nextTick. Так честнее — setImmediate и process.nextTick существуют только в Node, а этот пример выполняется прямо здесь, в браузере.
Теперь про splice(0). Представьте, что фаза проверки работала бы «пока очередь не опустеет». Тогда колбэк, который в конце себя назначает такой же immediate, держал бы фазу вечно: очередь не пустеет никогда, до фазы сроков дело не доходит, таймеры не наступают, готовые сокеты не разбираются. Одна цепочка съела бы весь процесс — и не потому, что она долгая, а потому, что у неё нет края.
Край даёт снимок. Фаза забирает очередь целиком, обнуляет её и проходит по снятому списку; всё, что назначено по дороге, копится в уже пустой очереди и достанется следующему обороту. Ровно эта строчка превращает «несколько очередей» в честный круг, где у каждой фазы есть шанс.
Проверим. Колбэк чтения файла назначает таймер на ноль и immediate, а первый immediate назначает второй. Прогноз: в каком порядке напечатаются четыре строки и на каких оборотах — и главное, окажется ли таймер между двумя immediate?
Таймер вклинился между ними. Первый immediate отработал в первом обороте, второй — во втором, а между ними прошла фаза сроков нового оборота и выпустила таймер, чей срок как раз наступил. Если бы фаза брала очередь по живому, второй immediate напечатался бы сразу за первым, а таймер — последним. Одна и та же пара строк, два разных порядка: разница видна только тогда, когда у оборота есть граница.
Это и есть ответ на вопрос «почему цикл вообще куда-то движется». Не из-за честности очередей, а из-за снимка: каждая фаза обязуется сделать ровно то, что было назначено до неё.
В таблице фаз тика нет, и это не пропуск. process.nextTick назначает не «работу на потом в одной из фаз», а довесок к текущему колбэку: как только колбэк вернулся, цикл осушает очередь тиков до дна и только потом берёт следующий колбэк — из этой же фазы или из другой, неважно. В модели за это отвечает call: четыре строки, которые оборачивают каждый вызов.
Из-за этого тик обгоняет всё подряд, но платит за скорость: он не отдаёт ход циклу. Между колбэком и его тиками цикл не заглядывает ни в сроки, ни в опрос — он вообще ещё не вернулся из фазы. Посмотрим на это на двух таймерах с одинаковым сроком: первый назначает тик и ещё один таймер на ноль. Прогноз: где окажется тик — между таймерами или после обоих, и на каком обороте выполнится второй таймер, назначенный изнутри фазы сроков?
Тик встал между двумя колбэками одной фазы: второй таймер ждал, пока разберут довески первого, хотя его собственный срок наступил одновременно. А таймер, назначенный внутри фазы сроков, уехал в следующий оборот — по тому же правилу снимка, что и immediate в прошлом разделе. В настоящем Node здесь ровно то же: фаза сроков сравнивает сроки с часами, которые зафиксированы на входе в оборот, и свежий срок «сейчас плюс одна миллисекунда» этой проверки не проходит.
В модели очередь между колбэками одна. У Node их две: сначала тики, потом микрозадачи промисов, и обе осушаются до дна, включая то, что появилось по ходу. Разница между ними, порядок осушения и знаменитое голодание цикла, когда тики не дают ему дышать, — тема следующей статьи, «nextTick и микрозадачи: две очереди между колбэками». Пока запомним одно: тик — это «сразу после текущего колбэка», а не «в начале следующей фазы».
Три способа сказать «не сейчас, а потом» выглядят одинаково и стоят разного. Разница не в скорости, а в том, кого вы пропускаете вперёд — и на сервере это решает, дождётся ли ответа сосед по обороту. В таблице — самый частый на сервере случай, назначение из колбэка опроса; immediate, назначенный из самой фазы проверки, ждёт следующего оборота по правилу снимка из прошлого раздела.
| Как назначить | Когда выполнится | Кого пропустит вперёд |
|---|---|---|
process.nextTick | сразу после текущего колбэка, до конца фазы | никого: цикл ещё не вернулся в оборот |
setImmediate | в фазе проверки этого же оборота | всех, кто уже готов в фазах до проверки |
setTimeout на ноль | в фазе сроков следующего оборота, и не раньше чем через миллисекунду | весь остаток оборота: проверку и закрытие |
Отсюда практическое правило, которое обещала задача про отчёт из прошлой главы: долгий счёт режут на порции и ставят следующую порцию через setImmediate. Порция кончилась — цикл доводит оборот до конца, на следующем выпускает наступившие таймеры, разбирает готовые сокеты в фазе опроса — и только потом берётся за следующую порцию. Ход отдан всем, и отдан ровно один раз за порцию.
Таймер на ноль для этого хуже по двум причинам. Он теряет оборот целиком — работа поедет медленнее без всякой пользы, — и его ноль всё равно не ноль: Node поднимает любую задержку меньше единицы до миллисекунды, так что тысяча коротких порций добавит к счёту около секунды чистого ожидания. А тик не годится вовсе: он выполняется до того, как цикл вернётся в оборот, поэтому «порции через тик» не отпускают поток ни на мгновение — это тот же синхронный счёт, только записанный длиннее.
Правило выбора короткое: нужен довесок к текущему колбэку — тик; нужно уступить ход и продолжить в этом же обороте — immediate; нужна настоящая пауза — таймер с настоящим числом.
Модель наша, поэтому сверим её с настоящим Node. Файл назначает четыре продолжения подряд — таймер на ноль, setImmediate, реакцию промиса и process.nextTick, — и делает это дважды: сначала внутри колбэка readFile, то есть в фазе опроса, потом внутри колбэка таймера, то есть в фазе сроков. Прогноз: совпадёт ли порядок четырёх строк с моделью и будет ли он одинаковым в двух разных фазах?
// Порядок продолжений, назначенных из одного колбэка. import { readFile } from 'node:fs'; // четыре назначения подряд: таймер, immediate, промис, тик function schedule(where, next) { setTimeout(() => { console.log(`${where}: setTimeout(0)`); next(); }, 0); setImmediate(() => console.log(`${where}: setImmediate`)); Promise.resolve().then(() => console.log(`${where}: промис`)); process.nextTick(() => console.log(`${where}: nextTick`)); } // первый опыт: колбэк чтения файла — это фаза опроса readFile(new URL(import.meta.url), () => { console.log('колбэк чтения файла назначил четыре продолжения'); schedule('чтение', () => { // второй опыт: тот же набор внутри колбэка таймера — это фаза сроков setTimeout(() => { console.log('колбэк таймера назначил те же четыре'); schedule('таймер', () => {}); }, 0); }); });
колбэк чтения файла назначил четыре продолжения чтение: nextTick чтение: промис чтение: setImmediate чтение: setTimeout(0) колбэк таймера назначил те же четыре таймер: nextTick таймер: промис таймер: setImmediate таймер: setTimeout(0)
Порядок тот же, что в модели, и он не зависит от фазы, в которой стоял колбэк: тик, промис, immediate, таймер. Внутри колбэка ввода-вывода это гарантия, а не везение — фаза проверки идёт сразу за опросом, а ближайшая фаза сроков наступит только на следующем обороте. Промиса в модели нет, но своё место он занял тоже внутри перерыва: после тика и до возвращения в оборот.
Второй прогон проверяет границу оборота. Колбэк чтения назначает таймер на ноль и setImmediate; первый setImmediate двадцать миллисекунд держит поток занятым, чтобы срок таймера точно наступил, и назначает второй setImmediate. Модель обещала, что таймер вклинится между ними. Прогноз: обещание сбудется или второй setImmediate пойдёт сразу за первым?
// Граница оборота: куда попадёт колбэк, назначенный во время фазы проверки. import { readFile } from 'node:fs'; // подержать поток, чтобы к следующему обороту срок таймера точно наступил const hold = (ms) => { const until = Date.now() + ms; while (Date.now() < until) {} }; readFile(new URL(import.meta.url), () => { console.log('колбэк чтения файла: назначены таймер на 0 и setImmediate'); setTimeout(() => console.log('таймер'), 0); setImmediate(() => { console.log('первый setImmediate: держим поток 20 мс и назначаем второй'); hold(20); setImmediate(() => console.log('второй setImmediate')); }); });
колбэк чтения файла: назначены таймер на 0 и setImmediate первый setImmediate: держим поток 20 мс и назначаем второй таймер второй setImmediate
Сбылось: между двумя setImmediate прошла фаза сроков нового оборота. Значит, снимок очереди — не выдумка модели, а поведение настоящего цикла, и «оборот» в рассуждениях о Node означает вполне конкретную вещь: круг, на котором каждая фаза получает ровно то, что было назначено до её начала.
Есть место, где модель обещает больше, чем Node может дать, и знать об этом нужно заранее. Назначьте таймер на ноль и immediate не из колбэка, а прямо из главного модуля — до того, как цикл сделал первый оборот.
Модель отвечает твёрдо: immediate первым, таймер вторым. Настоящий Node такого не обещает. Он успевает или не успевает потратить на запуск целую миллисекунду, и от этого зависит, наступил ли срок таймера к первой фазе сроков. Замеры на машине автора, node 26.5.0: 21.09.2026 — четыре захода по четыреста восемьдесят запусков, два вхолостую и два под нагрузкой, шестью параллельными сериями по восемьдесят; 29.09.2026 — ещё восемь заходов той же длины. Таймер оказывался первым от двадцати до сорока с небольшим раз за заход. Разбросы наложились друг на друга: загруженность машины здесь ничего не решает, монета просто падает на setImmediate чаще, чем в девяти случаях из десяти — но не всегда, а «почти всегда» договором не бывает. Записанным прогоном такое не покажешь — два десятка одинаковых строк подряд выглядели бы как правило, а не как удачная серия. Разбирать этот случай будет «Настоящий цикл, libuv и пул потоков» — там же рядом с моделью встанет настоящий libuv.
Отсюда правило пользования моделью: она отвечает на вопрос «что за чем внутри колбэка», а не «что за чем на старте процесса». Внутри колбэка порядок — договор, на который можно опереться в коде; до первого оборота — случайность, на которую опираться нельзя, даже если у вас она десять раз подряд выпала одной стороной.
- У цикла Node не одна очередь, а оборот по фазам: сроки, опрос, проверка (и ещё три, до которых коду почти нет дела). Порядок колбэков определяется фазой, в которой они назначены, а не временем назначения.
- Колбэк, назначенный через
setImmediateиз фазы опроса, выполнится в этом же обороте — фаза проверки идёт следом. Таймер на ноль ждёт фазы сроков следующего оборота, и его ноль Node всё равно поднимает до миллисекунды. - Фаза снимает свою очередь на входе и идёт по копии. Назначенное во время фазы достаётся следующему обороту — без этого снимка цепочка
setImmediateдержала бы цикл вечно, и таймеры не наступали бы никогда. process.nextTick— не фаза, а перерыв между колбэками: очередь тиков осушается сразу после каждого колбэка, поэтому тик обгоняет всё, но не отдаёт ход циклу.- Отсюда выбор продолжения: довесок к колбэку — тик, уступить ход и продолжить в этом обороте —
setImmediate, настоящая пауза — таймер с настоящим числом. - Модель отвечает на вопрос «что за чем внутри колбэка». На старте процесса, до первого оборота, порядок таймера и
setImmediate— жребий, и модель обещает здесь больше, чем Node.
1/4Внутри колбэка fs.readFile назначены setTimeout(f, 0) и setImmediate(g). Что выполнится раньше?
2/4Колбэк setImmediate в конце своей работы назначает ещё один setImmediate. Когда выполнится второй?
3/4Обработчик считает большой отчёт и режет счёт на порции, чтобы не задерживать других клиентов. Чем ставить следующую порцию?
4/4В фазе опроса готовы два колбэка. Первый из них назначает process.nextTick. Когда выполнится тик?
Метрика, которая никого не обгоняет
Два запроса пришли вместе и ждут фазы опроса. Обработчик /a отвечает клиенту и хочет записать метрику — работу нужную, но не срочную. Сейчас метрика назначена тиком и вклинивается между двумя ответами: клиент /b ждёт, пока сервер займётся отчётностью. Поменяйте одну строку так, чтобы метрика ушла в конец того же оборота и ни одного готового запроса не задержала. Номер оборота в выводе меняться не должен.
Метрика — это «уступи ход и продолжи в этом же обороте», то есть loop.immediate:
Вывод:
Оба ответа ушли подряд, метрика — после них, и всё это в первом же обороте: фаза проверки идёт сразу за опросом, круг терять не пришлось. Тик такого не умеет — он выполняется до того, как цикл вернётся в фазу опроса, поэтому любая «мелочь через тик» встаёт впереди уже готовых запросов.
Таймер на ноль решил бы задачу по-другому и хуже: метрика уехала бы во второй оборот. На одном запросе разницы не видно, на тысяче — это тысяча лишних кругов и у каждой метрики миллисекунда, которую Node добавит сам, подняв нулевую задержку до единицы.
Свой повтор поверх таймера
В модели есть одиночный таймер, а повтора нет. Допишите loop.interval(fn, delay) так, чтобы fn вызывалась каждые delay тиков, пока не вернёт false. Цикл и остальные методы не трогайте: loop.timeout для этого достаточно. Ожидаемый вывод — три удара на часах 2, 4 и 6, каждый в своём обороте.
Повтор — это таймер, который в конце работы назначает сам себя:
Вывод:
Каждый удар — отдельный оборот: между ними цикл спит до ближайшего срока. Приём знаком по вложенному setTimeout вместо интервала: пауза отсчитывается от конца предыдущего вызова, поэтому долгий fn сдвигает следующий удар, а не копит их пачкой.
Настоящий setInterval считает срок от старта предыдущего вызова, и в Node это видно в исходнике таймеров буквально: отметку времени для следующего срока снимают до вызова колбэка, а возвращают таймер в очередь после. Поэтому повтор на таймере и setInterval — не одно и то же, даже когда пишутся в две строки.