diff --git a/editorial/agent-rewrites/316.json b/editorial/agent-rewrites/316.json index 2bf02a2..9a3de8d 100644 --- a/editorial/agent-rewrites/316.json +++ b/editorial/agent-rewrites/316.json @@ -3,5 +3,5 @@ "slug": "editorial-2019-03-field-event-loop", "title": "Зависший фильтр: как отделить блокировку event loop от гонки ответов", "excerpt": "Фильтр может тормозить по двум разным причинам: тяжёлая работа блокирует главный поток, а старый async-ответ перезаписывает новый. Разбираем обе ветки по трассе, коду и проверяемому критерию готовности.", - "contentHtml": "
Пользователь вводит строку в поле поиска. Список перестаёт реагировать, а через мгновение показывает не последний запрос, а предыдущий. Цена ошибки — не только раздражение. Тяжёлый экранный код задерживает ввод, а устаревший ответ может показать неверные данные или вернуть человеку уже отменённый выбор.
У симптома две независимые причины. Синхронный parse, фильтр, сортировка или рендер занимают главный поток. Другой запрос завершается позже и получает право изменить экран, хотя уже не относится к текущему вводу. Если назвать всё «тормозами event loop», команда добавит задержку и не исправит ни блокировку, ни порядок результатов.
Тезис простой: сначала нужно построить трассу, затем разделить время CPU и актуальность результата. Promise не создаёт второй поток и не упорядочивает независимые ответы. Для CPU-работы нужны меньший объём, другой алгоритм, порции или worker. Для ответа нужен явный признак актуальности и, при необходимости, отмена.
Обработчик ввода запускает новый поиск. До первого await он работает синхронно. После готовности Promise продолжение функции попадает в очередь Promise jobs, а браузер выполняет его на том же агенте. Сетевой ответ может прийти в любом порядке. Успешный старый запрос не знает, что пользователь уже ввёл другой текст.
В браузерной модели текущая task выполняет JavaScript до конца. После неё выполняется microtask checkpoint. Если функция внутри этого участка сортирует большой массив или создаёт тысячи узлов, интерфейс ждёт. Передача той же функции в Promise.resolve().then() только меняет очередь. Она не прерывает вычисление.
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}Это учебный пример. Он не измеряет реальный продукт и не обещает конкретной задержки. runId задаёт правило: экран принимает только последний запуск. Проверка нужна даже при отмене запроса. Отмена может произойти после того, как ответ уже начал выполняться. В production-логи не следует бездумно отправлять строку поиска; здесь в трассу попадает только её длина.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Поле не принимает ввод во время фильтра | Длинный синхронный JS, layout или commit | Performance trace и метки вокруг parse, filter, sort, render | Сократить работу, изменить алгоритм, нарезать её или перенести CPU-часть в worker |
| Старый список заменяет новый | Поздний async-ответ не проверяет актуальность | Сравнить runId в start, response и commit | Отбросить устаревший результат; добавить AbortController, если это поддерживает контракт |
| Promise callback опережает timer | Обычный microtask checkpoint | Записать источник callback и относительный порядок | Не добавлять timeout для «исправления порядка»; выразить зависимость через Promise |
| Короткий JS, но commit запаздывает | Layout, сторонний скрипт или ограничение фоновой вкладки | Проверить полный trace и состояние вкладки | Найти владельца времени до переписывания бизнес-кода |
Таблица нужна для выбора следующей проверки. Debounce может уменьшить число запусков, но не доказывает причину фриза. Он не делает одну тяжёлую сортировку дешёвой и не защищает от позднего ответа, если правило актуальности отсутствует.
Трасса запроса не показывает, сколько времени съел локальный код после ответа. Поставьте метки вокруг названных операций. Не используйте одну метку processData: по ней нельзя сопоставить лог с профилем.
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', () => filterItems(items));\n const sorted = measure('sync: sort', () => sortItems(filtered));\n return measure('sync: view-model', () => makeViewModels(sorted));\n}Helper измеряет только тело функции. Он не покажет сборку мусора, перерасчёт стилей или работу виджета между вызовами. Поэтому после локальной оценки нужен Performance trace того же сценария. Если длинный участок принадлежит sortItems, исправляйте алгоритм или объём. Если время уходит в layout, worker не устранит всю задержку.
Порог 50 миллисекунд из Long Tasks API — полезный технический ориентир для длинной задачи, а не готовый бюджет конкретного экрана. Чувствительность зависит от устройства, частоты ввода и сценария. Число в критерии готовности нужно получить на выбранном воспроизводимом входе и согласовать для этого интерфейса.
function refreshBad(items) {\n return Promise.resolve(items)\n .then(prepareCurrentItems)\n .then(renderResults);\n}В этом варианте prepareCurrentItems всё равно выполняется целиком на главном потоке. Promise отложил старт до microtask, но не разрешил браузеру прервать сортировку. Цикл microtask также может задержать следующую task, если постоянно добавляет новые продолжения.
Если алгоритм уже выбран правильно, работу можно разделить на порции. Каждая порция заканчивается, а следующая становится отдельной task. Это учебная конструкция, а не готовая библиотека: она требует правила отмены, контроля памяти и единственного commit.
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 < items.length && performance.now() < deadline) {\n prepared.push(normalizeItem(items[index]));\n index += 1;\n }\n\n if (index < items.length) {\n setTimeout(runSlice, 0);\n return;\n }\n\n onDone(prepared);\n }\n\n runSlice();\n}Порции дают браузеру возможность обработать другой ввод, но не уменьшают общее число операций. Таймер с нулевой задержкой не обещает кадр в конкретный момент. Если вычисление можно сделать дешевле, сначала меняйте алгоритм. Worker изолирует CPU-работу, но требует обмена данными и не может напрямую менять DOM окна.
runId и performance.now() вокруг запроса, parse, локальной обработки и commit.Номер запуска защищает экран, но старый запрос всё равно может расходовать сеть и сервер. Для дорогих запросов нужна поддерживаемая отмена и отдельная политика на сервере. Нарезка работы меняет промежуточные состояния. Нельзя показывать частичный список без явного UX-решения. Worker требует сериализации данных и контроля версии результата. Фоновая вкладка, throttling, layout и сторонние скрипты меняют наблюдаемое время. Проверяйте их отдельно.
Не называйте учебные числа production-результатом. В статье нет утверждения об ускорении конкретного продукта. Воспроизводимый ответ, выбранное устройство и trace нужны, чтобы сделать такой вывод в своей среде.
Разбор готов, если на одном зафиксированном сценарии trace показывает владельца задержки, текущий runId единственный может выполнить commit, устаревший запуск не меняет экран, а измеренный синхронный участок укладывается в заранее согласованный бюджет. Повторный прогон должен подтвердить оба пути: актуальный ответ отображается, поздний старый ответ отбрасывается. Только после этого можно считать исправленной причину, а не один удачный порядок событий.
Пользователь вводит строку в поле поиска. Список перестаёт реагировать, а через мгновение показывает не последний запрос, а предыдущий. Цена ошибки — не только раздражение. Тяжёлый экранный код задерживает ввод, а устаревший ответ может показать неверные данные или вернуть человеку уже отменённый выбор.
У симптома две независимые причины. Синхронный parse, фильтр, сортировка или рендер занимают главный поток. Другой запрос завершается позже и получает право изменить экран, хотя уже не относится к текущему вводу. Если назвать всё «тормозами event loop», команда добавит задержку и не исправит ни блокировку, ни порядок результатов.
Тезис простой: сначала нужно построить трассу, затем разделить время CPU и актуальность результата. Promise не создаёт второй поток и не упорядочивает независимые ответы. Для CPU-работы нужны меньший объём, другой алгоритм, порции или worker. Для ответа нужен явный признак актуальности и, при необходимости, отмена.
Обработчик ввода запускает новый поиск. До первого await он работает синхронно. После готовности Promise продолжение функции становится Promise reaction job: host ставит его в очередь, а код окна выполняется в том же агенте. Это не обещает, что между этим продолжением и отрисовкой появится кадр. Сетевой ответ может прийти в любом порядке. Успешный старый запрос не знает, что пользователь уже ввёл другой текст.
В браузерной модели текущая task выполняет JavaScript до конца. После неё выполняется microtask checkpoint. Если функция внутри этого участка сортирует большой массив или создаёт тысячи узлов, интерфейс ждёт. Передача той же функции в Promise.resolve().then() только меняет очередь. Она не прерывает вычисление.
let activeRun = 0;\nlet activeController = null;\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 if (activeController) activeController.abort();\n const controller = new AbortController();\n activeController = controller;\n trace(runId, 'start', { queryLength: query.length });\n\n try {\n const response = await fetch('/api/search?q=' + encodeURIComponent(query), {\n signal: controller.signal,\n });\n if (!response.ok) throw new Error('HTTP ' + response.status);\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 } catch (error) {\n if (runId !== activeRun || error.name === 'AbortError') return;\n throw error;\n } finally {\n if (runId === activeRun) activeController = null;\n }\n}Это учебный пример. Он не измеряет реальный продукт и не обещает конкретной задержки. runId задаёт правило: экран принимает только последний запуск. Проверка нужна даже при отмене запроса. Отмена может произойти после того, как ответ уже начал выполняться. В production-логи не следует бездумно отправлять строку поиска; здесь в трассу попадает только её длина.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Поле не принимает ввод во время фильтра | Длинный синхронный JS, layout или commit | Performance trace и метки вокруг parse, filter, sort, render | Сократить работу, изменить алгоритм, нарезать её или перенести CPU-часть в worker |
| Старый список заменяет новый | Поздний async-ответ не проверяет актуальность | Сравнить runId в start, response и commit | Отбросить устаревший результат; добавить AbortController, если это поддерживает контракт |
| Promise callback, поставленный в текущей task, выполняется до timer | Microtask checkpoint проходит перед выбором следующей task | Зафиксировать task, в которой поставлены оба callback, и их относительный порядок | Не добавлять timeout для «исправления порядка»; выразить зависимость через Promise |
| Короткий JS, но commit запаздывает | Layout, сторонний скрипт или ограничение фоновой вкладки | Проверить полный trace и состояние вкладки | Найти владельца времени до переписывания бизнес-кода |
Таблица нужна для выбора следующей проверки. Debounce может уменьшить число запусков, но не доказывает причину фриза. Он не делает одну тяжёлую сортировку дешёвой и не защищает от позднего ответа, если правило актуальности отсутствует.
Трасса запроса не показывает, сколько времени съел локальный код после ответа. Поставьте метки вокруг названных операций. Не используйте одну метку processData: по ней нельзя сопоставить лог с профилем.
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', () => filterItems(items));\n const sorted = measure('sync: sort', () => sortItems(filtered));\n return measure('sync: view-model', () => makeViewModels(sorted));\n}Helper измеряет только тело функции. Он не покажет сборку мусора, перерасчёт стилей или работу виджета между вызовами. Поэтому после локальной оценки нужен Performance trace того же сценария. Если длинный участок принадлежит sortItems, исправляйте алгоритм или объём. Если время уходит в layout, worker не устранит всю задержку.
Порог 50 миллисекунд и более в Long Tasks API — критерий для уведомления о длинной задаче, а не готовый бюджет конкретного экрана. Чувствительность зависит от устройства, частоты ввода и сценария. Число в критерии готовности нужно получить на выбранном воспроизводимом входе и согласовать для этого интерфейса.
function refreshBad(items) {\n return Promise.resolve(items)\n .then(prepareCurrentItems)\n .then(renderResults);\n}В этом варианте prepareCurrentItems всё равно выполняется целиком на главном потоке. Promise отложил старт до microtask, но не разрешил браузеру прервать сортировку. Цикл microtask также может задержать следующую task, если постоянно добавляет новые продолжения.
Если алгоритм уже выбран правильно, работу можно разделить на порции. Каждая порция заканчивается, а следующая становится отдельной task. Это учебная конструкция, а не готовая библиотека: она требует правила отмены, контроля памяти и единственного commit.
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 < items.length && performance.now() < deadline) {\n prepared.push(normalizeItem(items[index]));\n index += 1;\n }\n\n if (index < items.length) {\n setTimeout(runSlice, 0);\n return;\n }\n\n onDone(prepared);\n }\n\n runSlice();\n}Порции дают браузеру возможность обработать другой ввод, но не уменьшают общее число операций. Таймер с нулевой задержкой не обещает кадр в конкретный момент. Если вычисление можно сделать дешевле, сначала меняйте алгоритм. Worker изолирует CPU-работу, но требует обмена данными и не может напрямую менять DOM окна.
runId и performance.now() вокруг запроса, parse, локальной обработки и commit.Номер запуска защищает экран, но старый запрос всё равно может расходовать сеть и сервер. AbortController снижает лишнюю работу только там, где fetch и серверный контракт действительно поддерживают отмену; проверка runId остаётся обязательной. Для дорогих запросов нужна отдельная политика на сервере. Нарезка работы меняет промежуточные состояния. Нельзя показывать частичный список без явного UX-решения. Worker требует сериализации данных и контроля версии результата. Фоновая вкладка, throttling, layout и сторонние скрипты меняют наблюдаемое время. Проверяйте их отдельно.
Не называйте учебные числа production-результатом. В статье нет утверждения об ускорении конкретного продукта. Воспроизводимый ответ, выбранное устройство и trace нужны, чтобы сделать такой вывод в своей среде.
Разбор готов, если на одном зафиксированном сценарии trace показывает владельца задержки, текущий runId единственный может выполнить commit, устаревший запуск не меняет экран, а измеренный синхронный участок укладывается в заранее согласованный бюджет. Повторный прогон должен подтвердить оба пути: актуальный ответ отображается, поздний старый ответ отбрасывается. Только после этого можно считать исправленной причину, а не один удачный порядок событий.