Files
progcode/editorial/agent-rewrites/316.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
16 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 316,
"slug": "editorial-2019-03-field-event-loop",
"title": "Зависший фильтр: как отделить блокировку event loop от гонки ответов",
"excerpt": "Фильтр может тормозить по двум разным причинам: тяжёлая работа блокирует главный поток, а старый async-ответ перезаписывает новый. Разбираем обе ветки по трассе, коду и проверяемому критерию готовности.",
"contentHtml": "<p>Пользователь вводит строку в поле поиска. Список перестаёт реагировать, а через мгновение показывает не последний запрос, а предыдущий. Цена ошибки — не только раздражение. Тяжёлый экранный код задерживает ввод, а устаревший ответ может показать неверные данные или вернуть человеку уже отменённый выбор.</p><p>У симптома две независимые причины. Синхронный <code>parse</code>, фильтр, сортировка или рендер занимают главный поток. Другой запрос завершается позже и получает право изменить экран, хотя уже не относится к текущему вводу. Если назвать всё «тормозами event loop», команда добавит задержку и не исправит ни блокировку, ни порядок результатов.</p><p>Тезис простой: сначала нужно построить трассу, затем разделить время CPU и актуальность результата. Promise не создаёт второй поток и не упорядочивает независимые ответы. Для CPU-работы нужны меньший объём, другой алгоритм, порции или worker. Для ответа нужен явный признак актуальности и, при необходимости, отмена.</p><h2>Что происходит между вводом и экраном</h2><p>Обработчик ввода запускает новый поиск. До первого <code>await</code> он работает синхронно. После готовности Promise продолжение функции попадает в очередь Promise jobs, а браузер выполняет его на том же агенте. Сетевой ответ может прийти в любом порядке. Успешный старый запрос не знает, что пользователь уже ввёл другой текст.</p><p>В браузерной модели текущая task выполняет JavaScript до конца. После неё выполняется microtask checkpoint. Если функция внутри этого участка сортирует большой массив или создаёт тысячи узлов, интерфейс ждёт. Передача той же функции в <code>Promise.resolve().then()</code> только меняет очередь. Она не прерывает вычисление.</p><pre><code>let activeRun = 0;\n\nfunction trace(runId, label, extra = {}) {\n console.log({\n runId,\n label,\n at: performance.now(),\n ...extra,\n });\n}\n\nasync function refreshSearch(query) {\n const runId = ++activeRun;\n trace(runId, 'start', { queryLength: query.length });\n\n const response = await fetch('/api/search?q=' + encodeURIComponent(query));\n trace(runId, 'response', { status: response.status });\n\n const payload = await response.json();\n trace(runId, 'parsed', { count: payload.items.length });\n\n if (runId !== activeRun) {\n trace(runId, 'drop-stale');\n return;\n }\n\n renderResults(payload.items);\n trace(runId, 'commit-current');\n}</code></pre><p>Это учебный пример. Он не измеряет реальный продукт и не обещает конкретной задержки. <code>runId</code> задаёт правило: экран принимает только последний запуск. Проверка нужна даже при отмене запроса. Отмена может произойти после того, как ответ уже начал выполняться. В production-логи не следует бездумно отправлять строку поиска; здесь в трассу попадает только её длина.</p><h2>Симптом → причина → проверка → действие</h2><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>Поле не принимает ввод во время фильтра</td><td>Длинный синхронный JS, layout или commit</td><td>Performance trace и метки вокруг parse, filter, sort, render</td><td>Сократить работу, изменить алгоритм, нарезать её или перенести CPU-часть в worker</td></tr><tr><td>Старый список заменяет новый</td><td>Поздний async-ответ не проверяет актуальность</td><td>Сравнить runId в start, response и commit</td><td>Отбросить устаревший результат; добавить AbortController, если это поддерживает контракт</td></tr><tr><td>Promise callback опережает timer</td><td>Обычный microtask checkpoint</td><td>Записать источник callback и относительный порядок</td><td>Не добавлять timeout для «исправления порядка»; выразить зависимость через Promise</td></tr><tr><td>Короткий JS, но commit запаздывает</td><td>Layout, сторонний скрипт или ограничение фоновой вкладки</td><td>Проверить полный trace и состояние вкладки</td><td>Найти владельца времени до переписывания бизнес-кода</td></tr></tbody></table></div><p>Таблица нужна для выбора следующей проверки. Debounce может уменьшить число запусков, но не доказывает причину фриза. Он не делает одну тяжёлую сортировку дешёвой и не защищает от позднего ответа, если правило актуальности отсутствует.</p><figure><img src=\"/assets/editorial/2019/event-loop-trace-2019.svg\" alt=\"Трасса event loop: номер запуска ведёт от наблюдаемого события к различению устаревшего результата и блокировки главного потока\" loading=\"lazy\" /><figcaption>Сначала фиксируем порядок событий, затем выбираем действие для конкретной причины.</figcaption></figure><h2>Измеряем синхронную границу</h2><p>Трасса запроса не показывает, сколько времени съел локальный код после ответа. Поставьте метки вокруг названных операций. Не используйте одну метку <code>processData</code>: по ней нельзя сопоставить лог с профилем.</p><pre><code>function measure(label, work) {\n const startedAt = performance.now();\n const value = work();\n const duration = performance.now() - startedAt;\n\n console.log({ label, duration });\n return value;\n}\n\nfunction prepareCurrentItems(items) {\n const filtered = measure('sync: filter', () =&gt; filterItems(items));\n const sorted = measure('sync: sort', () =&gt; sortItems(filtered));\n return measure('sync: view-model', () =&gt; makeViewModels(sorted));\n}</code></pre><p>Helper измеряет только тело функции. Он не покажет сборку мусора, перерасчёт стилей или работу виджета между вызовами. Поэтому после локальной оценки нужен Performance trace того же сценария. Если длинный участок принадлежит <code>sortItems</code>, исправляйте алгоритм или объём. Если время уходит в layout, worker не устранит всю задержку.</p><p>Порог 50 миллисекунд из Long Tasks API — полезный технический ориентир для длинной задачи, а не готовый бюджет конкретного экрана. Чувствительность зависит от устройства, частоты ввода и сценария. Число в критерии готовности нужно получить на выбранном воспроизводимом входе и согласовать для этого интерфейса.</p><h2>Почему наивный фикс не работает</h2><pre><code>function refreshBad(items) {\n return Promise.resolve(items)\n .then(prepareCurrentItems)\n .then(renderResults);\n}</code></pre><p>В этом варианте <code>prepareCurrentItems</code> всё равно выполняется целиком на главном потоке. Promise отложил старт до microtask, но не разрешил браузеру прервать сортировку. Цикл microtask также может задержать следующую task, если постоянно добавляет новые продолжения.</p><p>Если алгоритм уже выбран правильно, работу можно разделить на порции. Каждая порция заканчивается, а следующая становится отдельной task. Это учебная конструкция, а не готовая библиотека: она требует правила отмены, контроля памяти и единственного commit.</p><pre><code>function refreshWithSlices(items, onDone) {\n let index = 0;\n const prepared = [];\n\n function runSlice() {\n const deadline = performance.now() + 8;\n\n while (index &lt; items.length &amp;&amp; performance.now() &lt; deadline) {\n prepared.push(normalizeItem(items[index]));\n index += 1;\n }\n\n if (index &lt; items.length) {\n setTimeout(runSlice, 0);\n return;\n }\n\n onDone(prepared);\n }\n\n runSlice();\n}</code></pre><p>Порции дают браузеру возможность обработать другой ввод, но не уменьшают общее число операций. Таймер с нулевой задержкой не обещает кадр в конкретный момент. Если вычисление можно сделать дешевле, сначала меняйте алгоритм. Worker изолирует CPU-работу, но требует обмена данными и не может напрямую менять DOM окна.</p><h2>Порядок действий</h2><ol><li>Выберите один сценарий: последовательность строк, фиксированный ответ и действие пользователя. Не смешивайте несколько дефектов.</li><li>Добавьте <code>runId</code> и <code>performance.now()</code> вокруг запроса, parse, локальной обработки и commit.</li><li>Повторите сценарий на одном входе. Запишите относительный порядок start, response, parsed и commit. Не усредняйте разные трассы без объяснения расхождения.</li><li>Откройте Performance trace и найдите длинный участок. Отделите ваш JS от layout, GC и стороннего кода.</li><li>Если commit устарел, введите проверку актуальности и подходящую отмену. Если блокирует CPU, сократите работу, измените алгоритм, используйте порции или worker.</li><li>Повторите тот же сценарий после правки. Проверьте также отрицательный путь: старый запрос завершается последним, а его данные не меняют экран.</li></ol><h2>Ограничения</h2><p>Номер запуска защищает экран, но старый запрос всё равно может расходовать сеть и сервер. Для дорогих запросов нужна поддерживаемая отмена и отдельная политика на сервере. Нарезка работы меняет промежуточные состояния. Нельзя показывать частичный список без явного UX-решения. Worker требует сериализации данных и контроля версии результата. Фоновая вкладка, throttling, layout и сторонние скрипты меняют наблюдаемое время. Проверяйте их отдельно.</p><p>Не называйте учебные числа production-результатом. В статье нет утверждения об ускорении конкретного продукта. Воспроизводимый ответ, выбранное устройство и trace нужны, чтобы сделать такой вывод в своей среде.</p><h2>Критерий готовности</h2><p>Разбор готов, если на одном зафиксированном сценарии trace показывает владельца задержки, текущий <code>runId</code> единственный может выполнить commit, устаревший запуск не меняет экран, а измеренный синхронный участок укладывается в заранее согласованный бюджет. Повторный прогон должен подтвердить оба пути: актуальный ответ отображается, поздний старый ответ отбрасывается. Только после этого можно считать исправленной причину, а не один удачный порядок событий.</p><h2>Проверяемые источники</h2><ul><li><a href=\"https://html.spec.whatwg.org/multipage/webappapis.html#event-loops\" target=\"_blank\" rel=\"noopener noreferrer\">WHATWG HTML Standard: Event loops</a> — processing model, tasks и microtask checkpoint.</li><li><a href=\"https://tc39.es/ecma262/2026/multipage/control-abstraction-objects.html#sec-jobs-and-host-operations-to-enqueue-jobs\" target=\"_blank\" rel=\"noopener noreferrer\">ECMAScript Language Specification: Jobs and host operations</a> — Promise jobs и передача их host-среде.</li><li><a href=\"https://w3c.github.io/longtasks/\" target=\"_blank\" rel=\"noopener noreferrer\">W3C Long Tasks API</a> — определение длинной задачи и её связь с отзывчивостью.</li></ul>"
}