DarkRiDDeR12 мин

Event loop под капотом: почему Promise обгоняет таймер, но не прерывает код

JavaScriptАсинхронностьМеханизм

Сбой начинается с простой фразы в ревью: «поставим await, тогда браузер успеет отрисовать кнопку». Иногда кнопка действительно меняется на конкретной машине, но причина не доказана. Promise-реакция не может вклиниться в середину уже идущей JavaScript-функции. Если перед await был тяжёлый parse или цикл, интерфейс уже ждал; если после await снова идёт тяжёлая работа, он будет ждать следующую границу. Цена ошибки — увеличить число ожиданий, не освободив главный поток.

Разберём механизм без слишком широкой метафоры «у JavaScript одна очередь». В языке есть Jobs и host hooks, а в браузере — event loop, задачи, microtask checkpoint и шаги рендеринга. Для прикладного кода достаточно держать три вопроса: какая работа сейчас на стеке, что поставлено как microtask и какой callback ждёт будущую задачу. Эта тройка объясняет неожиданный порядок Promise и timer без выдуманной точности таймера.

Три слоя, которые не стоит смешивать

Стек исполнения — это то, что выполняется прямо сейчас. Пока синхронная функция не вернулась, браузер не переключит JavaScript на другой callback того же event loop. ECMAScript описывает Jobs как абстрактные единицы работы и определяет host hook для постановки Promise Job. Браузер связывает эту языковую часть с microtask queue: когда он дошёл до checkpoint, накопленные microtasks выполняются до перехода к обычной следующей задаче.

Task — понятие host-уровня. Timer, пользовательское событие и некоторые другие источники помещают работу в соответствующие очереди задач. Важная техническая оговорка: порядок между разными task source не надо превращать в API вашего приложения. HTML-стандарт описывает выбор задачи, а не обещание «сначала всегда timer, потом всегда input». Поэтому логику сохранения формы нельзя строить на том, что один callback обычно успевал раньше другого на вашем ноутбуке.

СлойПримерКогда способен начатьсяПолезная диагностика
Текущий стекrenderRows(data)Сразу при вызове и непрерывно до returnПоставить отметку до и после функции; увидеть длительность в trace
Promise Job / microtaskpromise.then(commit)После освобождения текущего стека на microtask checkpointЛогировать очередь и не ожидать paint между несколькими microtasks
Browser tasksetTimeout(commit, 0)Когда timer стал доступен и event loop выбрал задачуПроверять источник callback и фактическую задержку, не только аргумент timeout
Обновление renderingstyle, layout, paintНа шаге, который выбирает браузер между задачамиСмотреть timeline, а не считать любой timeout гарантией кадра

Такое разделение полезно ещё и для терминов в команде. Вместо «эта штука асинхронная» можно сказать: «сейчас синхронно собираем 15 тысяч строк; затем Promise continuation записывает state; commit отложен в task». Фраза длиннее на несколько слов, зато сразу сообщает, где возможна блокировка и где можно поставить проверку. Для 2019-проекта это уже лучше, чем прятать систему в общей функции delay.

Порядок Promise и таймера на воспроизводимом коде

Следующий пример усиливает предыдущий опыт: microtask добавляет ещё одну microtask. Он нужен не для запоминания букв, а чтобы увидеть границу checkpoint. Пока очередь microtasks пополняется, browser task с таймером не становится следующим JavaScript-вызовом только потому, что его задержка уже истекла. Наблюдать стоит относительный порядок меток; их числовое время не является ожидаемым результатом теста.

const order = [];
const write = (label) => order.push(label);

write('sync: start');

setTimeout(() => {
  write('task: timer');
  console.log(order.join(' -> '));
}, 0);

Promise.resolve().then(() => {
  write('microtask: first');

  Promise.resolve().then(() => {
    write('microtask: nested');
  });
});

write('sync: end');

Семантическая последовательность здесь такая: обе sync-метки появляются до любой Promise-реакции; затем отрабатывает первая и вложенная microtask; после этого доступна timer-задача. Из этого не следует, что любой сетевой callback уступит таймеру или что вкладка всегда будет рисовать между конкретными двумя задачами. Это означает лишь, что собственная цепочка Promise может исчерпать microtask queue до следующего шага event loop.

Рекурсивно добавлять microtasks опасно. Код, который на каждом then ставит следующий then, может долго не давать браузеру выбрать user input или timer task. Внешне это похоже на обычный цикл, хотя в профиле будут короткие функции подряд. Если работа действительно должна быть дискретной, необходимо определить место, где она отдаёт управление не в новую microtask, а в будущую task либо в другой поток. Без такого места «асинхронность» остаётся лишь дроблением той же блокировки.

Длинный синхронный обработчик занимает главный поток и задерживает input, а нарезанные порции оставляют event loop возможность выбрать ожидающие задачи
Promise не делает вычисление параллельным: чтобы интерфейс получил шанс на работу, нужно завершить текущую порцию и вернуть управление event loop.

Почему await сам по себе не отдаёт кадр

В async-функции выражение await promise откладывает продолжение до settlement Promise. Если Promise уже resolved, продолжение всё равно не выполняется как обычная синхронная строка: оно ставится в реакцию Promise. Но это именно microtask-граница, а не автоматический «перерыв на rendering». Несколько продолжений могут отработать одна за другой до следующей browser task. Поэтому await Promise.resolve() не стоит применять как договор с UI.

async function refreshBad(rawRows) {
  const normalized = normalizeAllRows(rawRows); // тяжёлая sync-работа уже блокирует UI

  await Promise.resolve();

  const ranked = rankAllRows(normalized); // ещё одна тяжёлая sync-работа
  renderRows(ranked);
}

Функция выше не стала безопасной для кадра из-за одного await. Она разделила процесс на две синхронные части, но не определила бюджет каждой и не доказала, что между ними браузер сможет обработать нужное событие. Правильная следующая проверка — измерить normalizeAllRows и rankAllRows отдельно, увидеть их на main thread и решить, можно ли упростить алгоритм, обрабатывать только видимые элементы или отправить CPU-работу в worker.

Как корректно уступать управление

Иногда полный перенос в worker пока невозможен: код читает DOM или работает с legacy-виджетом. Тогда процесс можно нарезать. Ключевое требование — одна порция имеет ограниченную работу, а планировщик следующей порции создаёт будущую browser task. setTimeout часто используют как простой адаптер, но его задержка не является частотой кадров. Смысл здесь не в числе ноль, а в том, что текущая задача закончилась и у event loop появился выбор.

function processInSlices(rows, onDone) {
  const result = [];
  let index = 0;

  function runSlice() {
    const deadline = performance.now() + 8;

    while (index < rows.length && performance.now() < deadline) {
      result.push(normalizeRow(rows[index]));
      index += 1;
    }

    if (index < rows.length) {
      setTimeout(runSlice, 0);
      return;
    }

    onDone(result);
  }

  runSlice();
}

В примере восемь миллисекунд — стартовая гипотеза для замера, а не норматив. Он намеренно не делает DOM-обновление на каждой строке: иначе выигрыш от нарезки можно потерять на layout и paint. В реальном коде надо зафиксировать, что считается одной порцией, какие входные данные она берёт и как отменить устаревший процесс. Без отмены пользователь успеет изменить фильтр, а старые порции продолжат занимать main thread уже без пользы.

Измерение: что записать до оптимизации

Для короткого повторяемого опыта достаточно записать начало и конец ваших функций через performance.now(). Спецификация High Resolution Time задаёт подходящую для измерений монотонную шкалу, но не гарантирует одинаковое разрешение в каждом окружении. Запись { label, startedAt, finishedAt, rowCount } полезнее одного console.time: её можно сопоставить с числом строк и с performance trace.

В DevTools trace ищем не абстрактный «красный участок», а конкретную связь: обработчик input начал выполнение, затем синхронная функция заняла main thread, а нужный commit произошёл позже. Если в середине видны style/layout, не переписывайте немедленно Promise-цепочку — возможно, основной счёт выставляет DOM. Если доминирует ваш JavaScript, сравните алгоритм на одинаковом объёме данных до и после изменения. Это и есть техническое доказательство, а не впечатление от плавности.

Порядок проверки механизма

  1. Выписать callback и его источник: прямой вызов, Promise reaction, timer, input, сетевой ответ или worker message. Не называть все их одним словом «async».
  2. Поставить метки вокруг текущего синхронного участка и внутри продолжений. Сначала проверить относительный порядок на чистом минимальном сценарии.
  3. Если есть фриз, снять trace и измерить длительность именно тех функций, которые выполняются на main thread. Отделить CPU-код от layout и сторонних скриптов.
  4. Для корректной зависимости по данным вернуть Promise или await результат. Для CPU-нагрузки выбрать оптимизацию алгоритма, ограниченные порции или worker.
  5. Если используются порции, добавить отмену устаревшей работы и проверку, что commit применяет только актуальный результат.
  6. Повторить один входной сценарий и сохранить порядок меток вместе с длительностью. Релизный критерий — конкретно улучшенная граница, а не слово «асинхронно».

Границы модели

  • ECMAScript Jobs и browser microtasks связаны host-реализацией. Не переносите детали window в Node.js без проверки его event loop.
  • Timer API не предоставляет точную плановую гарантию. Вкладка в фоне, нагрузка и политика браузера способны увеличить наблюдаемую задержку.
  • Нарезка работы помогает отзывчивости, но не уменьшает количество операций. Если алгоритм лишний, первым исправлением будет алгоритм.
  • Worker отделяет CPU-работу, но требует сериализации данных и явной коммуникации. Он не может напрямую менять DOM окна.

Итог

Механизм проще держать как три разных состояния: работа на стеке, накопленные microtasks и будущие tasks. Promise обгоняет таймер в учебном опыте, потому что checkpoint наступает после освобождения текущего стека; Promise не прерывает уже работающий код. Когда нужен отзывчивый UI, сначала измеряем синхронный участок, затем возвращаем event loop управляемую границу или выносим CPU-задачу. Так выбор между await, порцией и worker перестаёт быть религией.

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

  • 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, обработчики и отрисовку