awesome-everything EN
↑ Обратно к восхождению

Браузер и фронтенд-рантайм

Пулы воркеров, Comlink и наблюдаемость в продакшене

Суть Пул N воркеров по размеру hardwareConcurrency − 1, диспетчеризация через priority-очередь с backpressure, Comlink для эргономики — но task-hop postMessage всё равно стоит, и каждый поток воркера требует собственной телеметрии.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 16 min

Вы перенесли 400 мс задачу обработки изображения в воркер. Затем пользователи начинают быстро кликать, и страница накапливает пятьдесят ожидающих задач, каждая порождает новый воркер, который никогда не убивается. Вкладка теперь потребляет 400 МБ и убивается на мобильных устройствах.

Когда воркеры окупаются — и когда нет

Запуск воркера не бесплатен: новый realm, новый event loop, свежая копия импортированных скриптов — обычно 5–20 мс старта плюс несколько МБ памяти. Воркер окупается, когда:

  • Задача долгая (десятки мс и более), так что старт амортизируется.
  • Стоимость передачи результата мала относительно вычислений.

Чистый убыток — когда задача короткая: накладные расходы на postMessage round-trip и клон могут превысить саму работу.

Для повторяющихся мелких задач: держите пул воркеров вместо порождения воркера на каждую задачу.

Пул воркеров

Пул амортизирует стоимость запуска и ограничивает память:

размер пула = navigator.hardwareConcurrency − 1  // оставить одно ядро для главного потока

Компоненты:

  1. N воркеров, созданных однажды и переиспользуемых.
  2. Очередь задач — ожидающая работа для свободного воркера.
  3. Диспетчер — при поступлении задачи: если воркер свободен, отправить; иначе добавить в очередь.

Паттерн пула устраняет повторные расходы на старт и ограничивает суммарную память воркеров.

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 делает await worker.heavyCompute(data) похожим на локальный вызов, оборачивая воркер в Proxy. Это эргономично, но абстракция скрывает две стоимости, которые по-прежнему важны:

  1. Каждый аргумент structured-clone’d, если явно не обёрнут в Comlink.transfer.
  2. Каждый вызов — 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 используется для кодека.
Почему это работает

Почему navigator.hardwareConcurrency − 1 — правило размера пула? Использование всех N ядер для воркеров лишает главный поток ресурсов — рендеринг, ввод и ваш JS работают там. Оставляя одно ядро свободным для главного потока, сохраняем плавные 60 fps анимации и обработку ввода, пока пул воркеров работает на полной мощности. На устройстве с 2 ядрами пул — 1 воркер; на 8-ядерной машине — 7. Это то же рассуждение, что и оставление одного CPU для OS-планировщика в серверных деплоях. − 1 — эвристика, не закон: рабочие нагрузки с очень короткими задачами могут извлечь пользу из меньшего пула; с I/O-bound воркерами — из большего. Сначала профилируйте.

Вспомните перед уходом
  1. 01
    Коллега предлагает вынести медленный React re-render в web worker для исправления jank. Объясните, почему это не сработает, и что реально поможет.
  2. 02
    Что такое проблема task-hop в Comlink и как её обойти при проектировании?
  3. 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
Продолжить восхождение ↑Воркеры: тест с выбором ответа
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.