Где модель врёт: планировщик на промисах и замкнутость мира

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

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

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

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

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

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

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

Прогноз к примеру: колбэк фазы проверки заводит цепочку из двух реакций промиса и модельный таймер. В каком порядке напечатаются четыре строки и на каком обороте окажется каждая? Опереться есть на что: в Node цепочка промиса разбирается целиком раньше любого таймера.

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

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

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

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

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

Причина одна, и она в await. Он ставит на паузу функцию, в которой написан, и отдаёт управление наружу; а продолжение после паузы возвращается не само по себе, а микрозадачей — в ту самую очередь, где уже стоят реакции промисов из примера. Значит, после каждого колбэка fake.run встаёт в общую очередь и ждёт там наравне со всеми. Фаза модели перестала быть фазой: теперь это просто очередная микрозадача.

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

Что выполняетсяОчередь микрозадач после этогоЧто напечатано
Колбэк проверки; await ставит в очередь продолжение циклашаг 1, продолжение циклаоборот 1: колбэк проверки
Шаг 1 цепочки: он заводит второе звенопродолжение цикла, шаг 2оборот 1: промис, шаг 1
Продолжение цикла: новый оборот, фаза сроковшаг 2, продолжение циклаоборот 2: модельный таймер
Шаг 2 цепочкипродолжение циклаоборот 2: промис, шаг 2

Никакой мистики: планировщик не «обгоняет» цепочку и не важнее её. Он просто встал в ту же очередь и получил ход по-честному, в порядке живой очереди. Беда в том, что очередь эта — предмет урока. Модель, которая объясняет очередь промисов, устроена на очереди промисов и в первом же примере показывает её поведение вместо своего.

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

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

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

На честной модели ничего подобного не случается, и не потому, что она умнее. Она вся — один синхронный кусок кода: while, вызовы колбэков, осушение очередей. Пока loop.run() не вернулся, ни одна микрозадача среды не выполнится: вклиниться в середину оборота нечему. Тот же сюжет с цепочкой, только обе микрозадачи модельные:

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

Порядок совпал с тем, что даёт Node: оба шага цепочки в первом обороте, таймер — во втором. И совпал он не случайно, а потому, что так написано в call: микрозадача в честной модели — это loop.microtask, то есть запись функции в массив, и второе звено цепочки — тоже запись в массив, сделанная по ходу осушения. Модель изображает механизм, а не пользуется им. Ровно это и позволяет ей не врать: у изображения нет своих сроков и своей очереди, оно делает только то, что написано в тридцати строках рядом.

Признак вранья виден раньше вывода: если в самом планировщике есть await, then, setTimeout или queueMicrotask, его порядок задаёт среда, а не тридцать строк рядом. Читать при этом весь код не нужно — хватит поиска по четырём словам.

Утверждение «фаза не может влезть в середину цепочки» стоит проверить, а не объявить: без эталона спор двух моделей остаётся спором двух мнений. Колбэк таймера заводит цепочку из двух реакций, тик и ещё один таймер на ноль. Прогноз: где окажется второй таймер — между звеньями цепочки, как в нечестной модели, или после неё?

прогон node 26.5.0 · 29 сентября 2026
Цепочка из двух реакций промиса, тик и второй таймер, назначенные из колбэка первого таймера
// Эталон честности: что Node делает с цепочкой промиса, тиком и вторым таймером,
// заведёнными внутри колбэка первого таймера. С этим порядком и сверяется модель.

setTimeout(() => {
  console.log('таймер: колбэк начался');

  // цепочка из двух реакций: вторая появится только когда выполнится первая
  Promise.resolve()
    .then(() => console.log('таймер: промис, шаг 1'))
    .then(() => console.log('таймер: промис, шаг 2'));

  process.nextTick(() => console.log('таймер: тик'));
  setTimeout(() => console.log('следующий оборот: второй таймер'), 0);

  console.log('таймер: колбэк кончился');
}, 0);
node mix.mjs

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

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

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

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

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

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

У синхронности есть цена, и знать её надо заранее. Раз loop.run() — один синхронный кусок, то для среды весь наш цикл целиком — часть одной задачи: микроочередь она разберёт не раньше, чем этот кусок кончится. Проверим, что это значит на практике: положим в колбэк модели настоящий Promise рядом с модельной микрозадачей. Прогноз: какой по счёту напечатается строка про настоящий промис и какой номер оборота в ней окажется?

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

Последней — позже даже той строки, что стоит за loop.run(). А счётчик оборотов в ней замер на двойке: цикл давно кончился, оборотов больше не будет, и число в строке говорит не «когда выполнилось», а «чем всё закончилось». Модель здесь не соврала, она просто ни при чём: настоящий промис её миру не принадлежит. Соврал бы тот, кто по этому выводу стал бы рассуждать о порядке в Node.

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

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

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

Всё, где участвует настоящая среда — таймеры, файлы, сокеты, процессы, — уходит в записанный прогон: там вывод снят настоящим node и подписан датой и версией. Смешать их в одном примере значило бы получить вывод, который не объясняется ни моделью, ни Node. Ровно это и делает нечестный планировщик — только не нарочно.

Второе враньё нечестного планировщика опаснее первого, потому что выглядит как забота о порядке. await fn() читается как «дождаться, пока колбэк закончит»: с синхронным колбэком разницы не видно — он и так кончается сразу. А если колбэк асинхронный, то есть такой, какими пишут обработчики запросов?

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

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

Две. Цикл встал на первом же обработчике: тот ждёт ответ базы, а ответ базы стоит в очереди той же фазы опроса, до которой ход уже не дойдёт. /пинг не обслужен, байты не разобраны, процесс жив и не делает ничего — при том что работа лежит прямо перед ним. Заодно видна и вторая половина беды: строка за fake.run() напечаталась сразу. Асинхронный run вернул промис, а не результат, и мир модели перестал быть замкнутым — снаружи цикла теперь видно его середину.

В Node такая программа работает, и это не мелочь, а смысл всей платформы: ждущее соединение не занимает ни потока, ни цикла. Повторим сюжет настоящим node: обработчик setImmediate с await внутри, сосед по фазе проверки и таймер, который приносит ответ. Прогноз: где в выводе окажется вторая строка ждущего обработчика — сразу за первой или в самом конце?

прогон node 26.5.0 · 29 сентября 2026
Обработчик с await, сосед по фазе проверки и таймер, который его будит
// Обработчик ждёт ответ базы, а ответ приносит таймер. Где в выводе окажется
// сосед по фазе проверки и где — продолжение ждущего обработчика?

let ответить;
const ответБазы = new Promise((resolve) => {
  ответить = resolve;
});

setImmediate(async () => {
  console.log('/отчёт: жду ответ базы');
  await ответБазы;
  console.log('/отчёт: отчёт готов');
});

setImmediate(() => console.log('/пинг: отвечаю сразу'));

setTimeout(() => {
  console.log('фаза сроков: ответ базы разобран');
  ответить();
}, 10);
node handler.mjs

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

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

У атомарности есть обратная сторона, и на сервере она стоит дорого. Цикл зовёт колбэк и забывает про него — а значит, не смотрит и на то, что колбэк вернул. Асинхронный колбэк возвращает промис, и если тот отклонён, перехватывать отклонение некому: ни цикл его не видит, ни try..catch вокруг строки с назначением — она давно выполнилась.

Прогноз: успеет ли напечататься строка соседа по фазе проверки и с каким кодом выйдет процесс?

прогон node 26.5.0 · 29 сентября 2026
Асинхронный колбэк setImmediate, который бросает исключение, и сосед по той же фазе; каталог примера, внутренние кадры node и номер версии закрыты масками
// Цикл не смотрит на то, что вернул колбэк, — а вернул он отклонённый промис.
// Что из этого выйдет для соседа по фазе проверки и для кода выхода?

setImmediate(async () => {
  console.log('/отчёт: начал, жду базу');
  throw new Error('база не ответила');
});

setImmediate(() => console.log('/пинг: отвечаю сразу'));
node throw.mjs

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

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

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

ВопросПризнак вранья
Синхронна ли она?В планировщике есть await, .then, setTimeout или queueMicrotask. Порядок в такой модели задаёт среда, а не код модели
Замкнут ли её мир?В примерах рядом с модельными вызовами стоит настоящая асинхронность. Выводом управляют два планировщика, и чтением кода он не предсказывается
Признаёт ли она, чего не умеет?Модель отвечает на любой вопрос и нигде не говорит «здесь я молчу». Упрощения есть у всякой модели; названные — не враньё, умолчанные — да

На первые два вопроса наша модель отвечает «да» по построению. Третий — наш долг, и вот он целиком, чтобы к нему больше не возвращаться.

О чём модель молчитКак на самом деле
Старт процессаТик, назначенный до loop.run(), пролежит до первого колбэка. В настоящем главном модуле очереди разбираются ещё до первого оборота, и порядок зависит от того, как загружен файл
Длительность оборотаВ модели оборот стоит один тик, в жизни — столько, сколько шли колбэки. Поэтому модельные сроки ровные, а настоящие плывут под нагрузкой
Три фазы из шестиДве служебные заняты внутренними делами libuv, фаза закрытия в порядке остальных ничего не меняет. Упрощение названо, но оно есть
Фаза опросаОчередь опроса наполняем мы сами вызовом loop.io. Настоящая фаза спит на системном вызове и просыпается от пришедших байтов — или по будильнику ближайшего срока
Порядок на стартеimmediate раньше таймера модель обещает твёрдо, а Node не обещает вовсе: до первого оборота это жребий, и выпадает он в обе стороны
Падение колбэкаВ модели исключение вылетает из loop.run() и уносит весь цикл вместе с очередями. В Node оно тоже кончает процесс, но идёт своим путём и выглядит иначе

Ни один из этих долгов не мешает модели отвечать на вопрос, ради которого она собрана: что за чем идёт внутри колбэка. Дальше её придётся поставить рядом с настоящим libuv и посмотреть, где совпадение кончается, — это статья «Настоящий цикл, libuv и пул потоков». А перед ней мы вынесем из цикла то, чему в нём не место вовсе: «Заблокировать цикл и вынести счёт в отдельный поток».

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

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

Хорошая модель отвечает не на все вопросы, а на один — и прямо говорит, на какой. Та, что отвечает на все, просто не сообщила, где перестала знать.

  • Планировщик, который разбирает фазы через await, ставит свой оборот в общую очередь микрозадач: модельный таймер влезает между двумя звеньями цепочки промиса, чего в Node не бывает.
  • Врёт он не всегда: на сюжете покороче порядок случайно совпадает с настоящим. Модель, которая права через раз, одним примером не проверяется.
  • Он же перестаёт быть циклом событий: await fn() заставляет цикл дожидаться асинхронного колбэка, и один ждущий обработчик останавливает всё — при том что ради обратного Node и сделан.
  • Честная модель синхронна: пока loop.run() не вернулся, среда не выполняет ни одной микрозадачи, и порядок целиком задан кодом модели.
  • Честная модель замкнута: она изображает механизм своими примитивами, а не пользуется им. Настоящий Promise внутри неё выполнится после всего цикла и расскажет про среду, а не про модель.
  • Отсюда три вопроса к любой модели: синхронна ли она, замкнут ли её мир и названы ли упрощения. Умолчанное упрощение и есть враньё — наша модель молчит про старт процесса, длительность оборота, три недостающие фазы и настоящую фазу опроса.

1/4Планировщик разбирает фазы через await fn(). В колбэке заведены цепочка из двух реакций промиса и модельный таймер. Что окажется в выводе между первым и вторым звеньями цепочки?

2/4Почему честная синхронная модель не может перепутать порядок реакций настоящих промисов?

3/4В колбэк честной модели попал настоящий Promise.resolve().then(…). Когда выполнится реакция?

4/4Планировщик зовёт колбэки через await fn(). Обработчик ждёт ответ базы, а ответ базы стоит в очереди той же фазы опроса. Что произойдёт?

Пример, который доказывает обратное

В чужой статье про цикл событий стоит пример с подписью «реакция промиса обгоняет таймер — смотрите сами». Утверждение верное, а пример печатает ровно обратное: сначала таймер, потом реакция. Автор ничего не подделывал — он просто смешал в одном примере два мира. Найдите строку, из-за которой пример доказывает не то, и почините её так, чтобы вывод подтверждал подпись. Модель и таймер трогать нельзя.

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

Настоящий Promise в модельный мир не входит: его реакция ждёт конца всей задачи среды, а задача эта — loop.run() целиком. Поэтому она и печатается после таймера, хотя заведена раньше. Микрозадача должна быть модельной:

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

Вывод:

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

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

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

Ожидание, которое не выпадает из модели

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

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

Ответ берётся колбэком — единственным способом, который модель понимает:

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

Вывод:

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

База отвечает на втором обороте, и отчёт теперь готов там же. Через промис он уезжал в самый конец: resolve модельный таймер вызывает вовремя, но реакция на него — настоящая микрозадача, а она ждёт конца всего loop.run(). Сторож на пятом тике успевал раньше, и это не медленная база, а выпадение из модели.

В настоящем Node оба способа равноценны, и промис там даже удобнее. Разница появляется только в модели и появляется ровно потому, что модель синхронна. Цена замкнутого мира вот такая: то, чем удобно пользоваться снаружи, внутрь не проносят.

Планировщик, который никого не дожидается

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

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

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

Цикл не должен ждать колбэк, поэтому await уходит:

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

Вывод:

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

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

А вот последняя строка осталась не на месте: отчёт готов позже строки, стоящей за fake.run(), то есть после конца всего цикла. Это второе враньё, и одной строкой оно не лечится: обработчик написан на await, а продолжение после await — настоящая микрозадача, которая ждёт конца задачи среды. Синхронная модель не умеет продолжать асинхронный колбэк — и не должна уметь. Ожидание в ней выражают колбэком, как в предыдущей задаче.