Браузер и фронтенд-рантайм
Пулы воркеров, Comlink и наблюдаемость в продакшене
Вы перенесли 400 мс задачу обработки изображения в воркер. Затем пользователи начинают быстро кликать, и страница накапливает пятьдесят ожидающих задач, каждая порождает новый воркер, который никогда не убивается. Вкладка теперь потребляет 400 МБ и убивается на мобильных устройствах.
Когда воркеры окупаются — и когда нет
Запуск воркера не бесплатен: новый realm, новый event loop, свежая копия импортированных скриптов — обычно 5–20 мс старта плюс несколько МБ памяти. Воркер окупается, когда:
- Задача долгая (десятки мс и более), так что старт амортизируется.
- Стоимость передачи результата мала относительно вычислений.
Чистый убыток — когда задача короткая: накладные расходы на postMessage round-trip и клон могут превысить саму работу.
Для повторяющихся мелких задач: держите пул воркеров вместо порождения воркера на каждую задачу.
Пул воркеров
Пул амортизирует стоимость запуска и ограничивает память:
размер пула = navigator.hardwareConcurrency − 1 // оставить одно ядро для главного потокаКомпоненты:
- N воркеров, созданных однажды и переиспользуемых.
- Очередь задач — ожидающая работа для свободного воркера.
- Диспетчер — при поступлении задачи: если воркер свободен, отправить; иначе добавить в очередь.
Паттерн пула устраняет повторные расходы на старт и ограничивает суммарную память воркеров.
Backpressure. Без ограничения очереди она растёт неограниченно под нагрузкой. Два варианта:
- Сбрасывать старейшие задачи в очереди.
- Отклонять новые задачи (возвращать rejected promise), чтобы вызывающий код мог притормозить.
Приоритетная маршрутизация. Не все задачи равны — предпросмотр фильтра, которого пользователь ждёт прямо сейчас, важнее фонового генерирования эскизов. Используйте очередь с приоритетными тегами: интерактивные задачи пропускают фоновые.
Отмена. Если пользователь прокрутил прочь до завершения задачи — отменить её. Для задачи в очереди: удалить из очереди. Для задачи в процессе: чистый способ — атомарный флаг отмены в SharedArrayBuffer, который воркер периодически проверяет и выходит досрочно, оставаясь живым для следующей задачи. Грубый способ — worker.terminate() — но это уничтожает воркер, вынуждая снова платить стоимость запуска.
- Правило размера пула
- hardwareConcurrency − 1
- Старт воркера
- 5–20 мс + парсинг скрипта
- Латентность task-hop postMessage
- ~1 мс в каждую сторону
- Память пустого воркера
- Несколько МБ
- Воркер + крупная библиотека
- Десятки МБ
- Утечка воркера (забытый terminate)
- Живёт до закрытия вкладки
MessageChannel и BroadcastChannel
MessageChannel создаёт прямой двунаправленный канал между двумя конечными точками без участия главного потока:
const { port1, port2 } = new MessageChannel();
worker1.postMessage({ port: port1 }, [port1]); // передать port1 воркеру 1
worker2.postMessage({ port: port2 }, [port2]); // передать port2 воркеру 2
// Теперь воркеры общаются напрямую через портыЭто устраняет главный поток как посредника для воркер-воркер коммуникации — задача критична для пайплайнов (воркер-продюсер → воркер-консюмер) без bottle-neck главного потока.
BroadcastChannel делает то, что его название говорит — транслирует одно сообщение всем слушателям одного origin, включая другие вкладки:
const bc = new BroadcastChannel('app-events');
bc.postMessage({ type: 'theme-changed', value: 'dark' });
// Все вкладки и воркеры, слушающие 'app-events', получают этоЭто не замена SharedWorker (у которого один JS realm, единый разделяемый кеш), а более простой pub/sub канал когда вам нужно только широковещательное событие, а не разделяемое состояние.
Ошибка DOM-in-a-worker
Самая частая архитектурная ошибка: команды хватаются за воркер для «ускорения рендеринга» и обнаруживают, что воркер вообще не может трогать DOM. Воркер может вычислить что нужно рендерить, но не рендерить — результат должен быть отправлен обратно и применён главным потоком.
Если узким местом является сама мутация DOM (вставка 10 000 узлов, гигантская React-сверка), воркер не помогает — дорогая часть всё равно должна происходить на главном потоке. Воркеры помогают, когда узкое место — чистые вычисления, дающие небольшой результат:
- Парсить 5 МБ JSON (воркер) → отправить массив из 200 строк (дёшево) → главный поток рендерит 200 строк (быстро). ✓
- React-сверка 10 000 узлов (узкое место — commit, а не деривация) — воркер не поможет. ✗
Исключение: OffscreenCanvas. Canvas-рендеринг можно делать из воркера. Передайте OffscreenCanvas, и воркер рисует 2D или WebGL полностью вне главного потока.
Comlink и иллюзия RPC
Comlink делает await worker.heavyCompute(data) похожим на локальный вызов, оборачивая воркер в Proxy. Это эргономично, но абстракция скрывает две стоимости, которые по-прежнему важны:
- Каждый аргумент structured-clone’d, если явно не обёрнут в
Comlink.transfer. - Каждый вызов — task hop в обе стороны — round-trip сообщение между потоками.
Иллюзия рушится для chatty интерфейсов — API воркера со многими мелкими методами в цикле платит task hop за каждый вызов и сериализует программу на round-trip’ах. Проектируйте интерфейсы воркеров coarse: один вызов, делающий batch работы и возвращающий batch результатов, а не много мелкозернистых вызовов. Тот же принцип, что в дизайне сетевого API: минимизируйте round-trip, максимизируйте работу на round-trip.
Утечки памяти воркеров в SPA
В SPA-компонентах воркеры — распространённый источник утечек памяти. Паттерн:
// НЕПРАВИЛЬНО — утечка воркера на каждый remount
useEffect(() => {
const worker = new Worker('processor.js');
worker.onmessage = handleResult;
// нет cleanup!
}, []);
// ПРАВИЛЬНО — cleanup при размонтировании
useEffect(() => {
const worker = new Worker('processor.js');
worker.onmessage = handleResult;
return () => worker.terminate(); // убиваем при размонтировании
}, []);Каждый useEffect без cleanup создаёт нового воркера при каждом remount (навигация туда-сюда в SPA, React StrictMode двойной монтаж в dev). После дюжины навигаций накапливается пул мёртвых воркеров, которые никто намеренно не создавал.
Обнаружение: DevTools → Performance → Threads показывает все активные воркеры. Неожиданные idle-потоки — утечки. В Chrome DevTools Memory можно сделать heap snapshot: утечающие воркеры видны как DedicatedWorkerGlobalScope с ненулевым retained size.
Наблюдаемость в продакшене
Каждый воркер и каждый service worker — отдельный контекст в DevTools. Веб-воркеры появляются в списке потоков панели Sources. Service workers имеют выделенную панель в Application → Service Workers.
Телеметрия через потоки:
- Инструментируйте обе стороны каждого postMessage метками времени. Измеряйте реальную латентность task-hop и стоимость клонирования в продакшене — local dev на быстрой машине систематически занижает и то, и другое.
- Отслеживайте длительность
fetch-обработчика service worker. Медленный обработчик задерживает каждую навигацию и загрузку ресурсов страницы, и потому что он запускается до того, как главный поток видит ответ, регрессия там невидима для обычного main-thread профилирования.
Нужно выполнить 400 мс задачу обработки изображения по клику кнопки, не замораживая страницу. Выберите подход.
Спроектируйте threading-архитектуру для браузерного видеоредактора: 4K timeline, real-time предпросмотр фильтров и экспорт. Должен держать 60 fps при скраббинге и никогда не замораживать UI.
- Главный поток зарезервирован только для DOM, ввода и timeline UI.
- Предпросмотры фильтров должны обновляться в течение 100 мс после изменения параметра.
- Экспорт многоминутного клипа не должен блокировать UI и должен показывать прогресс.
- Большие буферы кадров должны пересекать потоки без стоимости клонирования на кадр.
- Приложение должно мгновенно загружаться при повторных визитах и переживать перезагрузку в середине редактирования.
- Многопоточный WASM используется для кодека.
- Зарезервируйте главный поток для DOM/ввода; вся пиксельная работа — в воркеры.
- Передавайте буферы кадров как transferables или через SharedArrayBuffer — никогда by-value clone.
- OffscreenCanvas позволяет рендерингу canvas'а происходить вне главного потока.
- Многопоточный WASM требует cross-origin isolation: COOP + COEP, с CORP на каждом кроссоригинном ресурсе.
- MessageChannel для прямого воркер-воркер канала без главного потока как посредника.
- Service worker даёт мгновенные повторные загрузки; IndexedDB чекпойнтирует состояние при перезагрузке.
Почему это работает
Почему navigator.hardwareConcurrency − 1 — правило размера пула? Использование всех N ядер для воркеров лишает главный поток ресурсов — рендеринг, ввод и ваш JS работают там. Оставляя одно ядро свободным для главного потока, сохраняем плавные 60 fps анимации и обработку ввода, пока пул воркеров работает на полной мощности. На устройстве с 2 ядрами пул — 1 воркер; на 8-ядерной машине — 7. Это то же рассуждение, что и оставление одного CPU для OS-планировщика в серверных деплоях. − 1 — эвристика, не закон: рабочие нагрузки с очень короткими задачами могут извлечь пользу из меньшего пула; с I/O-bound воркерами — из большего. Сначала профилируйте.
- 01Коллега предлагает вынести медленный React re-render в web worker для исправления jank. Объясните, почему это не сработает, и что реально поможет.
- 02Что такое проблема task-hop в Comlink и как её обойти при проектировании?
- 03Как обнаружить и предотвратить утечки воркеров в React-приложении?
Пулы воркеров амортизируют 5–20 мс стоимость запуска и ограничивают память — размер пула по navigator.hardwareConcurrency − 1. Добавьте backpressure (ограничьте очередь, отклоняйте или сбрасывайте при переполнении) и приоритетную маршрутизацию (интерактивные задачи перед фоновыми). MessageChannel создаёт прямые воркер-воркер каналы без главного потока как посредника; BroadcastChannel транслирует события во все вкладки и воркеры одного origin. Comlink убирает boilerplate postMessage, но скрывает стоимость клонирования и task-hop — держите API воркеров coarse. Воркеры не помогают с DOM-мутацией — только чистые вычисления. В продакшене инструментируйте метки времени postMessage на обеих сторонах; отслеживайте длительность fetch-обработчика service worker; следите за утечками воркеров в SPA-компонентах через DevTools Threads.
встречается в41
- Federation и lookahead: батчинг за пределами DataLoadermiddle
- Senior GraphQL API: scheduling-контракт, изоляция арендаторов, наблюдаемостьsenior
- Лок и single-flight: ограничение параллельных rebuildmiddle
- Stale-while-revalidate и CDN request coalescingmiddle
- Детектирование stampede и дизайн TTL для продакшенаmiddle
- Метастабильный сбой, fencing-токены и production-постмортемыsenior
- Что такое отношение: таблицы, строки, ключи и ограниченияjunior
- Ограничения, ключи и типы данных Postgresmiddle
- JSONB, массивы и когда side table побеждаетmiddle
- Целостность схемы: deferral, версионирование и сбои в продакшнеsenior
- Где происходит data fetching — и почему это решает LCPjunior
- React Server Components и Suspense streamingmiddle
- Senior internals: RSC payload, слои кэша и production паденияsenior
- Конверт IPjunior
- Читаем IP-заголовокmiddle
- Что делает TLS и зачем он нуженjunior
- Расписание ключей, SNI, ALPN и расширенияsenior
- Защита 0-RTT, ECH, гибридный PQ и продакшн TLSsenior
- Двенадцать слоёв: один URL, семь действующих лицjunior
- Устойчивость: каскадные повторы, circuit breakers и error budgetsenior
- Что такое OpenTelemetry: API, SDK, Collector, OTLPjunior
- Сигналы OTel, Semantic Conventions и проводной формат OTLPmiddle
- Collector OTel: receivers, processors, exporters и паттерны развёртыванияmiddle
- Vendor-нейтральность, eBPF-инструментирование, Operator и OTel в браузере и serverlesssenior
- Эксплуатация OTel Collector: надёжность, version skew, режимы отказа и управлениеsenior
- Что такое trace propagation и почему сломанная propagation хуже отсутствия трейсовjunior
- traceparent и tracestate: полный формат W3C-заголовкаmiddle
- Baggage и async-границы: перенос контекста через очереди и callback''''иmiddle
- Async context на разных языках, service mesh, миграция B3 и безопасностьsenior
- Production-сбои propagation, span links и платформенный дизайнsenior
- Debugging-воронка: SLO → RED → trace → profilejunior
- Архитектура OTel: один SDK, четыре сигнала, один wire-форматmiddle
- Петля инцидента: от пейджера до постмортема до предотвращенияmiddle
- Масштаб, безопасность и ROI наблюдаемых системsenior
- At-most-once, at-least-once, exactly-once: три контракта доставкиjunior
- Consumer-side dedup: самый дешёвый путь к exactly-once processingmiddle
- Exactly-once в production: impossibility-доказательство, гибридные паттерны и реальные инцидентыsenior
- Что такое OAuth и почему пароли — не ответjunior
- Authorization code flow с PKCEmiddle
- Sender-constrained токены: DPoP и mTLSsenior
- OAuth в production: audience атаки, observability и реальные провалыsenior