DarkRiDDeR11 мин

Event loop: как воспроизвести порядок Promise, таймера и синхронного кода

JavaScriptАсинхронностьПрактика

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

Ниже не «объяснение магии Promise», а маленький воспроизводимый маршрут. Мы сначала записываем порядок синхронных строк, Promise-реакции и timer callback. Потом отдельно создаём длинную синхронную работу и измеряем её границы через performance.now(). Так одна проблема распадается на две: неожиданная очередность и занятый главный поток.

Что именно наблюдаем

В браузерном коде есть как минимум текущий вызов JavaScript, задачи, которые выбирает event loop, и microtask checkpoint. Promise-реакция не прерывает уже исполняющуюся функцию. Она попадает в работу после того, как текущий стек освободится. Callback таймера тоже не появляется в середине этой функции: истекшая задержка делает его кандидатом на будущую задачу. Отсюда первое правило: «через ноль миллисекунд» означает не «немедленно».

Нельзя сводить всё к одной универсальной очереди. HTML-стандарт оперирует очередями задач и источниками задач; браузер выбирает, что исполнить дальше по собственному алгоритму. Поэтому для реального сбоя важно записать происхождение каждого callback: пользовательское событие, timer, сетевое завершение, Promise-цепочка или ваш прямой вызов. Один только номер строки в Console не доказывает порядок между разными источниками.

Наблюдаемый фрагментКуда попадает работаЧто не обещает механизмПервая проверка
Обычный вызов функцииТекущий стек JavaScriptЧто браузер отрисует до возврата из функцииПоставить отметки до и после вызова
Promise.resolve().then(...)Promise Job, который host запускает как microtaskПрерывание текущей синхронной функцииСравнить место отметки с концом текущего стека
setTimeout(fn, 0)Будущая задача после истечения минимальной задержкиТочный момент запуска и превосходство над другими источниками задачЗаписать метку внутри callback, не только перед постановкой
Тяжёлый цикл или parse JSONТот же текущий стекРеакцию на input, пока цикл не вернул управлениеИзмерить начало и конец работы, посмотреть performance trace

Эта таблица не заменяет спецификацию конкретного API. Она нужна для первого разворота расследования. Если в лог попал fetch, сначала устанавливаем, что именно логируется: момент старта запроса, Promise после ответа или собственная функция разбора. Обещание «всё async» здесь бесполезно — важна граница, на которой ваш код вернул управление браузеру.

Минимальный опыт с порядком callback

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

const marks = [];

function mark(label) {
  marks.push({ label, at: performance.now() });
}

mark('A: sync start');

setTimeout(() => {
  mark('C: timer task');
  console.table(marks);
}, 0);

Promise.resolve().then(() => {
  mark('B: Promise microtask');
});

mark('D: sync end');

Для этого опыта ожидаемая причинная запись — A, затем D, затем B, затем C. Сначала заканчивается текущий синхронный фрагмент. После него браузер выполняет microtask checkpoint, в котором может отработать Promise-реакция. Таймерная задача берётся позже, когда event loop выберет следующую доступную работу. Не подменяйте это ожидание тестом вроде «разница всегда ровно 0 или 4 ms»: такого договора у кода нет.

Полезнее добавить к каждой отметке источник. В проекте через неделю появится ещё один then или debounce, и голый лог 1, 2, 3 перестанет объяснять причину. Название search: parsed response или filter: timer commit делает цепочку пригодной для диффа между двумя запусками. Временную диагностику затем удаляем либо оставляем под локальным флагом, чтобы не слать шум в production-логи.

Схема порядка: синхронный стек заканчивается первым, затем выполняется Promise microtask, после чего event loop может взять timer-задачу
Минимальный опыт проверяет последовательность A → D → B → C; задержка таймера не является обещанием точной секунды запуска.

Опыт с зависанием: Promise не выносит вычисление

Вторая ошибка звучит так: «обернём тяжёлую функцию в Promise — интерфейс перестанет виснуть». Если внутри Promise сразу выполняется большой цикл, всё остаётся на том же главном потоке. Promise.resolve().then(run) лишь переносит начало run в microtask; сама функция всё равно занимает поток целиком, пока не вернёт управление. Пользовательский input и следующая отрисовка ждут эту границу.

function blockFor(milliseconds) {
  const startedAt = performance.now();

  while (performance.now() - startedAt < milliseconds) {
    // Искусственная нагрузка для опыта. Результат вычисления не важен.
  }
}

document.querySelector('.js-run-check').addEventListener('click', () => {
  const startedAt = performance.now();
  blockFor(120);
  const finishedAt = performance.now();

  console.log('sync work duration', finishedAt - startedAt);
});

Число 120 здесь — параметр искусственного опыта, не обещание метрики для сайта. После клика смотрим два факта: длительность, записанную самим сценарием, и виден ли этот участок в профиле производительности. Пока выполняется цикл, другой click handler на этой же странице не начнёт JavaScript-работу. Если нужно сравнить правки, запускайте один и тот же сценарий с той же входной строкой и фиксируйте условия: браузер, профиль CPU и размер данных.

Не пытайтесь обнаружить это периодическим «пингом таймера» в боевом коде. Он может сам менять картину и не укажет, какой стек занял время. Для расследования достаточно trace в инструментах браузера; для поддерживаемых сред можно отдельно проверить доступность PerformanceObserver с типом longtask. API длинных задач описывает порог 50 ms, но отсутствие записи не оправдывает ощущаемую пользователем задержку и не заменяет trace.

Как отделить очередь от блокирующего кода

Сбой порядка и зависание часто встречаются рядом, но лечатся по-разному. Если лог показывает, что значение из Promise приходит раньше timer callback, это может быть штатный порядок microtask и задачи. Правка состоит в явной зависимости: вызвать следующий шаг в нужном then, вернуть Promise из функции или хранить состояние в одном месте. Добавлять случайную задержку нельзя: она создаёт гонку, а не контракт.

Если callback начинает работать поздно, а в профиле перед ним виден длинный синхронный участок, причина другая. Находим работу, которую можно сократить, разбить на куски или перенести в worker. Перенос в setTimeout даёт event loop возможность выбрать другие задачи между порциями, но не делает вычисление быстрым и не гарантирует кадр после каждой порции. Сначала измеряем одну порцию, затем выбираем её размер по данным, а не по красивому числу в коде.

Последовательность проверки

  1. Сформулировать один наблюдаемый симптом: какая кнопка, какой лог или какое состояние оказалось не в том порядке. Сохранить входные данные и шаги воспроизведения.
  2. Поставить именованные отметки до прямого вызова, после него, внутри Promise-реакции и внутри timer callback. К отметке добавить performance.now() и источник события.
  3. Проверить относительный порядок на минимальном примере. Не переносить его автоматически на сетевой ответ, worker или другой tab.
  4. Если есть задержка интерфейса, снять performance trace и найти участок синхронной работы на main thread. Отделить ваш код от layout, GC и стороннего скрипта.
  5. Для зависимости по данным вернуть Promise или передать результат явным аргументом. Для CPU-работы сократить, нарезать или вынести расчёт; не маскировать причину дополнительным timeout.
  6. Повторить сценарий с прежними входными данными. Критерий готовности — понятный порядок меток и измеренная граница тяжёлой работы, а не один случай без ошибки.

Практический выбор действия

Если функция должна начаться строго после HTTP-ответа, пусть вызывающая сторона получает Promise и строит дальнейший шаг в его цепочке. Если пользователь меняет фильтр несколько раз, добавьте номер запроса или отмену там, где это поддерживается, — не рассчитывайте, что Promise упорядочит независимые ответы сети. Если на главном потоке происходит сортировка тысяч записей, сначала проверяем, не нужна ли сортировка полностью, затем рассматриваем порции или worker. Это три разные задачи, хотя в Console они могут выглядеть одинаковым «опозданием».

Текст намеренно не называет универсальный размер порции и не обещает, что один API лечит каждый freeze. Размер зависит от объёма данных, устройства, текущего DOM и конкурирующей работы. Хорошая техническая заметка оставляет читателю не рецепт «добавить timeout», а инструмент: измерить порядок, обнаружить синхронную границу и выбрать действие, соответствующее именно ей.

Ограничения опыта

  • Пример рассчитан на окно браузера. Модель очередей Node.js и поведение worker имеют собственные детали; их нельзя выводить из одного лога в tab.
  • Временная шкала нужна для сравнения точек одного запуска. Точность и доступное разрешение времени зависят от окружения и политики браузера.
  • Между разными источниками задач не следует строить бизнес-логику по случайному порядку. Передавайте зависимость явно через данные или Promise.
  • Длинный участок в trace может включать не только JavaScript. Перед переписыванием цикла надо увидеть, что именно занимает main thread.

Итог

Promise не выполняется «раньше всего», а таймер не запускается «ровно через ноль». Сначала заканчивается текущий JavaScript, затем выполняется доступная microtask-работа, после чего event loop выбирает будущую задачу. Когда экран завис, ищем не слово async, а длинную синхронную границу. Этот порядок делает диагноз проверяемым: лог даёт последовательность, профиль даёт длительность, а исправление привязано к конкретной причине.

Проверяемые источники

  • HTML Standard: Event loops — модель очередей задач, выбор следующей задачи и microtask checkpoint в браузере
  • ECMAScript: Jobs and Job Queues — языковая модель Job и host hook для постановки Promise-реакции
  • W3C High Resolution Time — семантика монотонной временной шкалы и метода performance.now()
  • W3C Long Tasks API — почему длинная работа на UI-потоке задерживает input, обработчики и отрисовку