{ "index": 318, "slug": "editorial-2019-03-practice-event-loop", "title": "Event loop без догадок: как отличить порядок callback от блокировки UI", "excerpt": "Promise-обработчик может прийти раньше таймера, а интерфейс — перестать отвечать. Разбираем две причины по наблюдаемым меткам, профилю и явному критерию готовности.", "contentHtml": "
Симптом обычно формулируют неточно: «асинхронность поменяла порядок» или «таймер не сработал вовремя». На странице это выглядит конкретнее: обработчик Promise.then пишет в лог раньше setTimeout, а после импорта данных кнопка несколько мгновений не отвечает. Если начать менять задержки на глаз, можно скрыть один запуск и оставить ту же блокировку на другом устройстве. Цена ошибки — потерянное действие пользователя и повторная отправка данных.
Маршрут проверки состоит из двух независимых опытов. Сначала мы записываем порядок синхронных строк, Promise-реакции и timer callback. Затем отдельно создаём длинную синхронную работу и измеряем её границы через performance.now(). Так одна проблема распадается на две: неожиданная очередность и занятый главный поток.
В браузерном коде полезно различать текущую синхронную функцию, задачу event loop и microtask checkpoint. Promise-реакция ставится в очередь микрозадач и не прерывает уже исполняющуюся функцию. Когда текущая задача заканчивается, браузер выполняет checkpoint и обрабатывает очередь микрозадач; только после этого event loop выбирает следующую задачу. Callback таймера тоже не появляется в середине этой функции: истекшая задержка делает его кандидатом на будущую задачу. Отсюда первое правило: «через ноль миллисекунд» означает не «немедленно».
\nНельзя сводить всё к одной универсальной очереди. HTML-стандарт оперирует очередями задач и источниками задач; браузер выбирает, что исполнить дальше по собственному алгоритму. Поэтому для реального сбоя важно записать происхождение каждого callback: пользовательское событие, timer, сетевое завершение, Promise-цепочка или ваш прямой вызов. Один только номер строки в Console не доказывает порядок между разными источниками.
\n| Наблюдаемый фрагмент | Куда попадает работа | Что не обещает механизм | Первая проверка |
|---|---|---|---|
| Обычный вызов функции | Текущий стек JavaScript | Что браузер отрисует до возврата из функции | Поставить отметки до и после вызова |
Promise.resolve().then(...) | Promise Job, который host запускает как microtask | Прерывание текущей синхронной функции | Сравнить место отметки с концом текущего стека |
setTimeout(fn, 0) | Будущая задача после истечения минимальной задержки | Точный момент запуска и превосходство над другими источниками задач | Записать метку внутри callback, не только перед постановкой |
| Тяжёлый цикл или parse JSON | Тот же текущий стек | Реакцию на input, пока цикл не вернул управление | Измерить начало и конец работы, посмотреть performance trace |
Эта таблица не заменяет спецификацию конкретного API. Она нужна для первого разворота расследования. Если в лог попал fetch, сначала устанавливаем, что именно логируется: момент старта запроса, Promise после ответа или собственная функция разбора. Обещание «всё async» здесь бесполезно — важна граница, на которой ваш код вернул управление браузеру.
Откройте чистую вкладку браузера и вставьте пример в Console либо во временный модуль страницы. Он не измеряет скорость сети и не сравнивает браузеры. Он фиксирует только относительный порядок четырёх точек внутри одного сценария. Время сохраняем рядом с названием, но проверяем именно список меток: абсолютные миллисекунды зависят от нагрузки и точности часов.
\nconst marks = [];\n\nfunction mark(label) {\n marks.push({ label, at: performance.now() });\n}\n\nmark('A: sync start');\n\nsetTimeout(() => {\n mark('C: timer task');\n console.table(marks);\n}, 0);\n\nPromise.resolve().then(() => {\n mark('B: Promise microtask');\n});\n\nmark('D: sync end');\nДля этого опыта ожидаемая причинная запись — 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 — интерфейс перестанет виснуть». Если внутри Promise сразу выполняется большой цикл, всё остаётся на том же главном потоке. Promise.resolve().then(run) лишь переносит начало run в microtask; сама функция всё равно занимает поток целиком, пока не вернёт управление. Пользовательский input и следующая отрисовка ждут эту границу.
const button = document.createElement('button');\nbutton.type = 'button';\nbutton.textContent = 'Запустить проверку';\ndocument.body.append(button);\n\nfunction blockFor(milliseconds) {\n const startedAt = performance.now();\n\n while (performance.now() - startedAt < milliseconds) {\n // Искусственная нагрузка для опыта. Результат вычисления не важен.\n }\n}\n\nbutton.addEventListener('click', () => {\n Promise.resolve().then(() => {\n const startedAt = performance.now();\n blockFor(120);\n const finishedAt = performance.now();\n\n console.log('sync work duration', finishedAt - startedAt);\n });\n});\nЧисло 120 здесь — параметр искусственного опыта, не обещание метрики для сайта. После клика смотрим два факта: длительность, записанную самим сценарием, и виден ли этот участок в профиле производительности. Пока выполняется цикл, другой click handler на этой же странице не начнёт JavaScript-работу. Если нужно сравнить правки, запускайте один и тот же сценарий с той же входной строкой и фиксируйте условия: браузер, профиль CPU и размер данных.
Не пытайтесь обнаружить это периодическим «пингом таймера» в боевом коде. Он может сам менять картину и не укажет, какой стек занял время. Для расследования достаточно trace в инструментах браузера; для поддерживаемых сред можно отдельно проверить доступность PerformanceObserver с типом longtask. Даже если такой API доступен, отсутствие записи не оправдывает ощущаемую пользователем задержку и не заменяет trace.
Сбой порядка и зависание часто встречаются рядом, но лечатся по-разному. Если лог показывает, что значение из Promise приходит раньше timer callback, это может быть штатный порядок microtask и задачи. Правка состоит в явной зависимости: вызвать следующий шаг в нужном then, вернуть Promise из функции или хранить состояние в одном месте. Добавлять случайную задержку нельзя: она создаёт гонку, а не контракт.
Если callback начинает работать поздно, а в профиле перед ним виден длинный синхронный участок, причина другая. Находим работу, которую можно сократить, разбить на куски или перенести в worker. Перенос в setTimeout даёт event loop возможность выбрать другие задачи между порциями, но не делает вычисление быстрым и не гарантирует кадр после каждой порции. Сначала измеряем одну порцию, затем выбираем её размер по данным, а не по красивому числу в коде.
performance.now() и источник события.Если функция должна начаться строго после HTTP-ответа, пусть вызывающая сторона получает Promise и строит дальнейший шаг в его цепочке. Если пользователь меняет фильтр несколько раз, добавьте номер запроса или отмену там, где это поддерживается, — не рассчитывайте, что Promise упорядочит независимые ответы сети. Если на главном потоке происходит сортировка тысяч записей, сначала проверяем, не нужна ли сортировка полностью, затем рассматриваем порции или worker. Это три разные задачи, хотя в Console они могут выглядеть одинаковым «опозданием».
\nТекст намеренно не называет универсальный размер порции и не обещает, что один API лечит каждый freeze. Размер зависит от объёма данных, устройства, текущего DOM и конкурирующей работы. Хорошая техническая заметка оставляет читателю не рецепт «добавить timeout», а инструмент: измерить порядок, обнаружить синхронную границу и выбрать действие, соответствующее именно ей.
\nPromise не выполняется «раньше всего», а таймер не запускается «ровно через ноль». Сначала заканчивается текущий JavaScript, затем выполняется доступная microtask-работа, после чего event loop выбирает будущую задачу. Когда экран завис, ищем не слово async, а длинную синхронную границу. Этот порядок делает диагноз проверяемым: лог даёт последовательность, профиль даёт длительность, а исправление привязано к конкретной причине.
\n