{ "index": 317, "slug": "editorial-2019-03-mechanism-event-loop", "title": "Event loop без магии: где Promise ждёт, а интерфейс блокируется", "excerpt": "Кнопка не отвечает, таймер срабатывает позже ожидания, а Promise обгоняет setTimeout. Разбираем стек, microtask и task, затем выбираем проверяемое исправление для данных и CPU-работы.", "contentHtml": "
Кнопка нажата, но экран меняется через несколько сотен миллисекунд. В Console строка из Promise.then появляется раньше строки из setTimeout. Команда добавляет ещё один await и ждёт, что браузер успеет нарисовать состояние. Иногда это меняет порядок логов. Зависание остаётся.
Цена ошибки заметна сразу: пользователь повторяет клик, отправляет форму дважды или видит устаревшие данные. Таймер превращается в случайную паузу. Логи перестают объяснять причину. В коде становится больше ожиданий, но главный поток выполняет ту же синхронную работу.
\nТезис статьи простой: event loop не прерывает текущий JavaScript. Promise откладывает продолжение до microtask checkpoint. Таймер ждёт будущую task. Ни одна из этих границ сама по себе не делает вычисление параллельным и не обещает кадр. Чтобы исправить сбой, определите источник работы, измерьте синхронный участок и выберите нужную границу.
\nКогда браузер вызывает обработчик, код выполняется синхронно. Функция вызывает вложенные функции и возвращает управление. Пока стек не опустел, другой обработчик этого же JavaScript-агента не начнёт выполняться. Браузер не вставит обработку клика в середину цикла только потому, что пользователь ждёт.
\nЭто относится и к коду внутри Promise. Вызов Promise.resolve().then(commit) не запускает commit в отдельном потоке. Он ставит реакцию Promise в очередь Jobs, а host выполняет её на подходящей microtask-границе. До этой границы текущая синхронная функция должна закончиться.
Task приходит из host-источника. Так работают, например, пользовательские события и callback таймера. Истёкшая задержка делает timer callback доступным для выбора. Она не вставляет callback в середину текущего стека и не задаёт точный момент запуска. Между задачами браузер может выполнять служебную работу и выбирать rendering по собственной модели.
\nТермин «одна очередь» слишком груб для диагностики. У языка есть Jobs и host hook для Promise-реакций. У платформы есть event loop, очереди задач и microtask checkpoint. Для прикладного расследования достаточно различать код на стеке, накопленные microtasks и будущую task. Ошибка обычно появляется на переходе между ними.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
then срабатывает раньше таймера | Promise reaction выполняется на microtask checkpoint до следующей task | Записать метки до и после стека, внутри then и timer callback | Зафиксировать зависимость данными, а не случайной задержкой |
Кнопка не отвечает во время then | В continuation выполняется длинный синхронный цикл или разбор данных | Снять performance trace и измерить начало и конец функции | Упростить алгоритм, ограничить порцию или выбрать worker |
setTimeout(fn, 0) запускается поздно | Текущий стек, microtasks или другие задачи заняли поток | Проверить источник длинного участка и фактическое время callback | Убрать блокировку, не рассчитывать на точное число миллисекунд |
| Старый ответ перезаписывает новый | Независимые Promise завершились в неожиданном порядке | Присвоить операциям runId и вывести его до commit | Отменять устаревшую работу или принимать только актуальный результат |
Таблица начинает расследование с наблюдаемого факта. Она не заменяет описание конкретного host. Порядок Promise и таймера в коротком примере можно проверить точно. Порядок сетевого callback, input и timer между разными источниками нельзя превращать в контракт без проверки среды и API.
\nСледующий код проверяет только порядок. Он не измеряет производительность страницы и не доказывает, что таймер всегда срабатывает в определённом месте. Важны границы стека и microtask checkpoint.
\nconst order = []; const mark = (name) => order.push(name); mark('sync: start'); setTimeout(() => { mark('task: timer'); console.log(order.join(' -> ')); }, 0); Promise.resolve().then(() => { mark('microtask: first'); Promise.resolve().then(() => mark('microtask: nested')); }); mark('sync: end');\nВ учебном запуске обе sync-метки появляются первыми. Затем выполняется первая Promise-реакция и добавленная ею microtask. После checkpoint доступна timer task. Ожидаемый относительный порядок: sync: start → sync: end → microtask: first → microtask: nested → task: timer.
Причина важнее чисел. setTimeout(fn, 0) не означает «вызвать после текущей строки». Он означает «сделать callback доступным для будущей задачи после минимальной задержки, если host сможет её выбрать». Если код создаёт новые microtasks, они могут отложить следующий шаг event loop.
Пример ограничен одним сценарием в браузере. Он не утверждает, что любой сетевой callback уступает timer или что отрисовка всегда происходит между двумя строками. В рабочем логе называйте источник: input: click, promise: parsed response, timer: debounce. Тогда лог можно сопоставить с профилем.
Типичная правка разделяет нормализацию и сортировку resolved Promise:
\nasync function refresh(rows) { const normalized = normalizeRows(rows); await Promise.resolve(); const ranked = rankRows(normalized); renderRows(ranked); }\nВызов await Promise.resolve() разделяет функцию на два продолжения, но не ограничивает время normalizeRows и rankRows. Каждая функция выполняется без прерывания. Сортировка, JSON-разбор или цикл по тысячам записей по-прежнему занимают main thread. Input и отрисовка ждут возврата управления.
Даже Promise.resolve().then(() => normalizeRows(rows)) только меняет момент старта. Вычисление остаётся на том же потоке. Это отрицательный путь: Promise полезен для результата и ошибок, но не является worker и не делает CPU-код параллельным.
Отдельно проверяйте DOM. Быстрый JavaScript может запустить дорогие style recalculation, layout или paint. Если trace показывает браузерную работу после серии изменений DOM, добавление таймера в Promise-цепочку не доказывает исправление. Сначала уменьшите количество изменений или объедините commit.
\nЕсли алгоритм нельзя сразу заменить или перенести в worker, работу можно нарезать на порции. Порция должна закончиться сама. Следующая порция должна попасть в будущую task, чтобы event loop получил возможность выбрать ожидающий input.
\nfunction 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(); }\nВосемь миллисекунд здесь — параметр учебного опыта, а не обещание для каждого устройства. Граница зависит от данных, фоновой нагрузки и стоимости commit. Большая порция снова задержит input. Слишком мелкая порция увеличит накладные расходы и может ухудшить DOM-обновления.
\nНарезка не уменьшает общее число операций. Она создаёт точки, в которых браузер получает выбор. Если пользователь меняет фильтр, старые порции нужно отменять или помечать устаревшими. Иначе новый результат появится на экране, а старый процесс позже применит свой commit.
\nWorker подходит для CPU-работы, которая не требует прямого доступа к DOM. Он добавляет стоимость сериализации данных и обмена сообщениями. Сначала измерьте, что блокирует main thread. Не выносите код в worker только потому, что в нём есть слово async.
Поставьте отметки вокруг подозрительной функции. Используйте performance.now() для длительности внутри одного процесса. Записывайте источник, размер входа и идентификатор запуска.
function measure(label, work) { const startedAt = performance.now(); const value = work(); const finishedAt = performance.now(); console.log({ label, duration: finishedAt - startedAt }); return value; }\nЭтот фрагмент — учебный измеритель. Он не даёт production-результата и не заменяет performance trace. В trace ищите связь между input, длинным JavaScript-участком и commit. Сравнивайте одинаковый сценарий: тот же объём данных, тот же браузер и понятное состояние кеша. Одно удачное открытие страницы не подтверждает исправление.
\nЕсли задержка появляется после нескольких кликов, добавьте runId. При старте увеличивайте номер. Перед применением результата сравнивайте его с текущим. Так проверяется отрицательный путь, где старый Promise завершился позже нового. Порядок завершения независимых запросов нельзя выводить из порядка запуска.
Модель описывает браузерный host, а не любой JavaScript runtime. Node.js имеет собственные фазы event loop и правила планирования. Нельзя переносить вывод из окна браузера на сервер без проверки соответствующей документации.
\nMicrotask не равна паузе для rendering. Длинная цепочка Promise может задержать timer и input. Timer не равен кадру. Браузер может увеличить задержку в фоновой вкладке, при нагрузке или из-за throttling.
\nНарезка помогает отзывчивости, но не исправляет лишнюю сортировку. Worker изолирует вычисление, но требует обмена сообщениями и не может напрямую менять DOM окна. performance.now() помогает сравнить участки, но числа зависят от окружения.
Критерий готовности проверяемый: для заданного сценария лог показывает источник и актуальность результата; trace показывает, что прежний длинный участок исчез, сократился или получил ограниченные границы; отрицательный путь не применяет устаревший commit. Формулировка «добавили await» таким критерием не является.
\nperformance.now().