Собираем планировщик: фазы вместо одной очереди

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

Здесь будет встроен видео-разбор темы (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 и тик. Прогноз: в каком порядке напечатаются три строки и на каком обороте окажется каждая?

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

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

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

И ещё одно свойство модели стоит назвать вслух, потому что дальше оно окажется важнее остальных: её мир замкнут и синхронен. Очередь опроса наполняем мы сами вызовом loop.io — «байты пришли»; настоящего ввода-вывода в браузере нет, да он здесь и не нужен, урок про порядок, а не про то, кто именно положил колбэк в очередь. Пока loop.run не вернулся, в мире модели не происходит ничего постороннего, и любое предсказание проверяется чтением кода. Стоит впустить в планировщик настоящую асинхронность — и он начинает врать; на чём именно, разбирает статья «Где модель врёт: планировщик на промисах и замкнутость мира».

В модели оборот стоит один тик, в жизни — столько, сколько шли колбэки. Это упрощение не влияет на порядок: сроки сравниваются с часами, а не с номером оборота.

Имена у модели свои: loop.timeout, loop.immediate, loop.nextTick. Так честнее — setImmediate и process.nextTick существуют только в Node, а этот пример выполняется прямо здесь, в браузере.

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

Край даёт снимок. Фаза забирает очередь целиком, обнуляет её и проходит по снятому списку; всё, что назначено по дороге, копится в уже пустой очереди и достанется следующему обороту. Ровно эта строчка превращает «несколько очередей» в честный круг, где у каждой фазы есть шанс.

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

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

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

Это и есть ответ на вопрос «почему цикл вообще куда-то движется». Не из-за честности очередей, а из-за снимка: каждая фаза обязуется сделать ровно то, что было назначено до неё.

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

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

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

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

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

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

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

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

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

Правило выбора короткое: нужен довесок к текущему колбэку — тик; нужно уступить ход и продолжить в этом же обороте — immediate; нужна настоящая пауза — таймер с настоящим числом.

Модель наша, поэтому сверим её с настоящим Node. Файл назначает четыре продолжения подряд — таймер на ноль, setImmediate, реакцию промиса и process.nextTick, — и делает это дважды: сначала внутри колбэка readFile, то есть в фазе опроса, потом внутри колбэка таймера, то есть в фазе сроков. Прогноз: совпадёт ли порядок четырёх строк с моделью и будет ли он одинаковым в двух разных фазах?

прогон node 26.5.0 · 16 сентября 2026
Один и тот же набор продолжений, назначенный из колбэка чтения файла и из колбэка таймера
// Порядок продолжений, назначенных из одного колбэка.
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);
  });
});
node order.mjs

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

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

прогон node 26.5.0 · 29 сентября 2026
Колбэк чтения назначает таймер на ноль и 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'));
  });
});
node round.mjs

Сбылось: между двумя setImmediate прошла фаза сроков нового оборота. Значит, снимок очереди — не выдумка модели, а поведение настоящего цикла, и «оборот» в рассуждениях о Node означает вполне конкретную вещь: круг, на котором каждая фаза получает ровно то, что было назначено до её начала.

Есть место, где модель обещает больше, чем Node может дать, и знать об этом нужно заранее. Назначьте таймер на ноль и immediate не из колбэка, а прямо из главного модуля — до того, как цикл сделал первый оборот.

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

Модель отвечает твёрдо: 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 ждёт, пока сервер займётся отчётностью. Поменяйте одну строку так, чтобы метрика ушла в конец того же оборота и ни одного готового запроса не задержала. Номер оборота в выводе меняться не должен.

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

Метрика — это «уступи ход и продолжи в этом же обороте», то есть loop.immediate:

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

Вывод:

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

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

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

Свой повтор поверх таймера

В модели есть одиночный таймер, а повтора нет. Допишите loop.interval(fn, delay) так, чтобы fn вызывалась каждые delay тиков, пока не вернёт false. Цикл и остальные методы не трогайте: loop.timeout для этого достаточно. Ожидаемый вывод — три удара на часах 2, 4 и 6, каждый в своём обороте.

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

Повтор — это таймер, который в конце работы назначает сам себя:

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

Вывод:

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

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

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