8 lines
20 KiB
JSON
8 lines
20 KiB
JSON
{
|
||
"index": 317,
|
||
"slug": "editorial-2019-03-mechanism-event-loop",
|
||
"title": "Event loop без магии: где Promise ждёт, а интерфейс блокируется",
|
||
"excerpt": "Кнопка не отвечает, таймер срабатывает позже ожидания, а Promise обгоняет setTimeout. Разбираем стек, microtask и task, затем выбираем проверяемое исправление для данных и CPU-работы.",
|
||
"contentHtml": "<p>Кнопка нажата, но экран меняется через несколько сотен миллисекунд. В Console строка из <code>Promise.then</code> появляется раньше строки из <code>setTimeout</code>. Команда добавляет ещё один <code>await</code> и ждёт, что браузер успеет нарисовать состояние. Иногда это меняет порядок логов. Зависание остаётся.</p>\n<p>Цена ошибки заметна сразу: пользователь повторяет клик, отправляет форму дважды или видит устаревшие данные. Таймер превращается в случайную паузу. Логи перестают объяснять причину. В коде становится больше ожиданий, но главный поток выполняет ту же синхронную работу.</p>\n<h2>Сценарий: клик, Promise и зависший интерфейс</h2>\n<p>Представим фильтр списка. Обработчик клика получает массив строк, запускает <code>refresh</code>, а после завершения Promise синхронно нормализует и сортирует данные. Пока <code>normalizeRows</code> или <code>rankRows</code> не вернули управление, браузер не вставит другой обработчик в середину цикла. Так один и тот же симптом воспроизводится на фиксированном входе, даже если сетевой запрос уже завершился.</p>\n<p>Рабочая модель такая: event loop не прерывает текущий JavaScript. Promise откладывает продолжение до microtask checkpoint. Таймер ждёт будущую task. Ни одна из этих границ сама по себе не делает вычисление параллельным и не обещает кадр. Чтобы исправить сбой, определите источник работы, измерьте синхронный участок и выберите нужную границу.</p>\n<h2>Что выполняется прямо сейчас</h2>\n<p>Когда браузер вызывает обработчик, код выполняется синхронно. Функция вызывает вложенные функции и возвращает управление. Пока стек не опустел, другой обработчик этого же JavaScript-агента не начнёт выполняться. Браузер не вставит обработку клика в середину цикла только потому, что пользователь ждёт.</p>\n<p>Это относится и к коду внутри Promise. Вызов <code>Promise.resolve().then(commit)</code> не запускает <code>commit</code> в отдельном потоке. Он ставит реакцию Promise в очередь Jobs, а host выполняет её на подходящей microtask-границе. До этой границы текущая синхронная функция должна закончиться.</p>\n<p>Task приходит из host-источника. Так работают, например, пользовательские события и callback таймера. Истёкшая задержка делает timer callback доступным для выбора. Она не вставляет callback в середину текущего стека и не задаёт точный момент запуска. Между задачами браузер может выполнять служебную работу и выбирать rendering по собственной модели.</p>\n<p>Термин «одна очередь» слишком груб для диагностики. У языка есть Jobs и host hook для Promise-реакций. У платформы есть event loop, очереди задач и microtask checkpoint. Для прикладного расследования достаточно различать код на стеке, накопленные microtasks и будущую task. Ошибка обычно появляется на переходе между ними.</p>\n<div class=\"table-scroll\"><table><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td><code>then</code> срабатывает раньше таймера</td><td>Promise reaction выполняется на microtask checkpoint до следующей task</td><td>Записать метки до и после стека, внутри <code>then</code> и timer callback</td><td>Зафиксировать зависимость данными, а не случайной задержкой</td></tr><tr><td>Кнопка не отвечает во время <code>then</code></td><td>В continuation выполняется длинный синхронный цикл или разбор данных</td><td>Снять performance trace и измерить начало и конец функции</td><td>Упростить алгоритм, ограничить порцию или выбрать worker</td></tr><tr><td><code>setTimeout(fn, 0)</code> запускается поздно</td><td>Текущий стек, microtasks или другие задачи заняли поток</td><td>Проверить источник длинного участка и фактическое время callback</td><td>Убрать блокировку, не рассчитывать на точное число миллисекунд</td></tr><tr><td>Старый ответ перезаписывает новый</td><td>Независимые Promise завершились в неожиданном порядке</td><td>Присвоить операциям <code>runId</code> и вывести его до commit</td><td>Отменять устаревшую работу или принимать только актуальный результат</td></tr></tbody></table></div>\n<p>Таблица начинает расследование с наблюдаемого факта. Она не заменяет описание конкретного host. Порядок Promise и таймера в коротком примере можно проверить точно. Порядок сетевого callback, input и timer между разными источниками нельзя превращать в контракт без проверки среды и API.</p>\n<h2>Минимальный пример порядка</h2>\n<p>Следующий код проверяет только порядок. Он не измеряет производительность страницы и не доказывает, что таймер всегда срабатывает в определённом месте. Важны границы стека и microtask checkpoint.</p>\n<pre><code>const 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');</code></pre>\n<p>В учебном запуске обе sync-метки появляются первыми. Затем выполняется первая Promise-реакция и добавленная ею microtask. После checkpoint доступна timer task. Ожидаемый относительный порядок: <code>sync: start → sync: end → microtask: first → microtask: nested → task: timer</code>.</p>\n<p>Причина важнее чисел. <code>setTimeout(fn, 0)</code> не означает «вызвать после текущей строки». Он означает «сделать callback доступным для будущей задачи после минимальной задержки, если host сможет её выбрать». Если код создаёт новые microtasks, они могут отложить следующий шаг event loop.</p>\n<p>Пример ограничен одним сценарием в браузере. Он не утверждает, что любой сетевой callback уступает timer или что отрисовка всегда происходит между двумя строками. В рабочем логе называйте источник: <code>input: click</code>, <code>promise: parsed response</code>, <code>timer: debounce</code>. Тогда лог можно сопоставить с профилем.</p>\n<h2>Почему Promise не спасает от длинной работы</h2>\n<p>Типичная правка разделяет нормализацию и сортировку resolved Promise:</p>\n<pre><code>async function refresh(rows) { const normalized = normalizeRows(rows); await Promise.resolve(); const ranked = rankRows(normalized); renderRows(ranked); }</code></pre>\n<p>Вызов <code>await Promise.resolve()</code> разделяет функцию на два продолжения, но не ограничивает время <code>normalizeRows</code> и <code>rankRows</code>. Каждая функция выполняется без прерывания. Сортировка, JSON-разбор или цикл по тысячам записей по-прежнему занимают main thread. Input и отрисовка ждут возврата управления.</p>\n<p>Даже <code>Promise.resolve().then(() => normalizeRows(rows))</code> только меняет момент старта. Вычисление остаётся на том же потоке. Это отрицательный путь: Promise полезен для результата и ошибок, но не является worker и не делает CPU-код параллельным.</p>\n<p>Отдельно проверяйте DOM. Быстрый JavaScript может запустить дорогие style recalculation, layout или paint. Если trace показывает браузерную работу после серии изменений DOM, добавление таймера в Promise-цепочку не доказывает исправление. Сначала уменьшите количество изменений или объедините commit.</p>\n<h2>Как уступить управление осмысленно</h2>\n<p>Если алгоритм нельзя сразу заменить или перенести в worker, работу можно нарезать на порции. Порция должна закончиться сама. Следующая порция должна попасть в будущую task, чтобы event loop получил возможность выбрать ожидающий input.</p>\n<pre><code>function processInSlices(rows, onDone) { const result = []; let index = 0; let cancelled = false; const cancel = () => { cancelled = true; }; function runSlice() { if (cancelled) return; const deadline = performance.now() + 8; while (!cancelled && index < rows.length && performance.now() < deadline) { result.push(normalizeRow(rows[index])); index += 1; } if (cancelled) return; if (index < rows.length) { setTimeout(runSlice, 0); return; } onDone(result); } runSlice(); return cancel; }</code></pre>\n<p>Восемь миллисекунд здесь — параметр учебного опыта, а не обещание для каждого устройства. Граница зависит от данных, фоновой нагрузки и стоимости commit. Большая порция снова задержит input. Слишком мелкая порция увеличит накладные расходы и может ухудшить DOM-обновления.</p>\n<p>Нарезка не уменьшает общее число операций. Она создаёт точки, в которых браузер получает выбор. Сохраните возвращённую <code>cancel</code>-функцию и вызовите её при смене фильтра. Тогда уже поставленная timer task завершится без обработки, а новый результат не будет вытеснен старым commit.</p>\n<p>Worker подходит для CPU-работы, которая не требует прямого доступа к DOM. Он добавляет стоимость сериализации данных и обмена сообщениями. Сначала измерьте, что блокирует main thread. Не выносите код в worker только потому, что в нём есть слово <code>async</code>.</p>\n<figure><img src=\"/assets/editorial/2019/event-loop-frame-budget-2019.svg\" alt=\"Длинный синхронный обработчик занимает главный поток, а ограниченные порции оставляют event loop возможность выбрать ожидающие задачи\" loading=\"lazy\" /><figcaption>Чтобы интерфейс получил шанс обработать input, текущая порция должна завершиться и вернуть управление event loop. Promise сам по себе этого не гарантирует.</figcaption></figure>\n<h2>Как измерить, а не угадать</h2>\n<p>Поставьте отметки вокруг подозрительной функции. Используйте <code>performance.now()</code> для длительности внутри одного процесса. Записывайте источник, размер входа и идентификатор запуска.</p>\n<pre><code>function measure(label, work) { const startedAt = performance.now(); const value = work(); const finishedAt = performance.now(); console.log({ label, duration: finishedAt - startedAt }); return value; }</code></pre>\n<p>Этот фрагмент — учебный измеритель. Он не даёт production-результата и не заменяет performance trace. В trace ищите связь между input, длинным JavaScript-участком и commit. Сравнивайте одинаковый сценарий: тот же объём данных, тот же браузер и понятное состояние кеша. Одно удачное открытие страницы не подтверждает исправление.</p>\n<p>Если задержка появляется после нескольких кликов, добавьте <code>runId</code>. При старте увеличивайте номер. Перед применением результата сравнивайте его с текущим. Так проверяется отрицательный путь, где старый Promise завершился позже нового. Порядок завершения независимых запросов нельзя выводить из порядка запуска.</p>\n<h2>Порядок действий</h2>\n<ol><li>Записать симптом одним предложением: какая реакция, какой callback и какая задержка наблюдаются.</li><li>Разделить работу по источнику: текущий вызов, Promise reaction, timer, input, сетевой ответ или worker message.</li><li>Поставить метки до и после синхронной функции, внутри Promise-цепочки и внутри timer callback.</li><li>Повторить минимальный сценарий без лишнего кода. Проверить относительный порядок, но не принять его за гарантию всех API.</li><li>Если есть фриз, снять trace и измерить main-thread участок. Отделить JavaScript от layout, paint, сборки мусора и стороннего скрипта.</li><li>Для зависимости данных вернуть Promise и передать результат явно. Для CPU-работы изменить алгоритм, ограничить порции или выбрать worker.</li><li>Для порций определить отмену и правило актуальности результата. Проверить старый запуск после нового.</li><li>Повторить прежний сценарий с прежним входом и сохранить метки вместе с длительностью.</li></ol>\n<h2>Ограничения</h2>\n<p>Модель описывает браузерный host, а не любой JavaScript runtime. Node.js имеет собственные фазы event loop и правила планирования. Нельзя переносить вывод из окна браузера на сервер без проверки соответствующей документации.</p>\n<p>Microtask не равна паузе для rendering. Длинная цепочка Promise может задержать timer и input. Timer не равен кадру. Браузер может увеличить задержку в фоновой вкладке, при нагрузке или из-за throttling.</p>\n<p>Нарезка помогает отзывчивости, но не исправляет лишнюю сортировку. Worker изолирует вычисление, но требует обмена сообщениями и не может напрямую менять DOM окна. <code>performance.now()</code> помогает сравнить участки, но числа зависят от окружения.</p>\n<p>Критерий готовности проверяемый: для заданного сценария лог показывает источник и актуальность результата; trace показывает, что прежний длинный участок исчез, сократился или получил ограниченные границы; отрицательный путь не применяет устаревший commit. Формулировка «добавили await» таким критерием не является.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://html.spec.whatwg.org/multipage/webappapis.html#event-loops\" target=\"_blank\" rel=\"noopener noreferrer\">HTML Standard: Event loops</a> — модель event loop, задач и microtask checkpoint в браузере.</li><li><a href=\"https://tc39.es/ecma262/multipage/control-abstraction-objects.html#sec-jobs-and-job-queues\" target=\"_blank\" rel=\"noopener noreferrer\">ECMAScript Language Specification: Jobs and Job Queues</a> — языковая модель Jobs и постановка Promise-реакций через host hook.</li><li><a href=\"https://www.w3.org/TR/hr-time-3/\" target=\"_blank\" rel=\"noopener noreferrer\">W3C: High Resolution Time</a> — правила временной шкалы для <code>performance.now()</code>.</li></ul>"
|
||
}
|