Использование worker-потоков

В браузерных визуализациях на базе Vega и Vega-Lite основной узкий участок производительности почти всегда связан не с отрисовкой как таковой, а с подготовкой данных, компиляцией спецификаций и частыми пересборками графиков при интерактивных изменениях. Перенос этих этапов в worker-потоки позволяет разгрузить основной поток, стабилизировать FPS интерфейса и обеспечить предсказуемую реакцию на взаимодействие даже при объёмах данных в сотни тысяч и миллионы строк.

Типичная система визуализации на Vega/Vega-Lite состоит из трёх крупных этапов:

  • обработка данных (парсинг, фильтрация, агрегация)
  • компиляция Vega-Lite спецификации в Vega spec
  • рендеринг через vega.View

В стандартной модели все три этапа выполняются в main thread, что приводит к блокировкам при:

  • обновлении сигналов (signals)
  • изменении фильтров
  • динамической подгрузке данных
  • анимациях и интерактивных трансформациях

Worker-модель переносит первые два этапа (и частично третий) в отдельный поток, оставляя основному потоку только управление DOM-контейнером и минимальные управляющие операции.

Обобщённая схема:

Main Thread:
  UI events → postMessage → Worker → render instructions → Canvas/SVG upd ate

Worker:
  spec + data → compile → transform → vega runtime → serialized output

Базовый Web Worker для Vega

Worker создаётся как отдельный модуль, принимающий спецификацию и данные:

// worker.js
importScripts("https://cdn.jsdelivr.net/npm/vega@5");

let view;

self.onmess age = async (event) => {
  const { spec, data, action } = event.data;

  if (action === "init") {
    const runtime = vega.parse(spec);

    view = new vega.View(runtime)
      .renderer("canvas")
      .initialize();

    view.insert("source", data);

    const result = await view.runAsync();

    const canvas = view.toCanvas();
    const bitmap = await createImageBitmap(canvas);

    self.postMessage({ bitmap }, [bitmap]);
  }
};

Важный момент заключается в том, что Vega runtime способен существовать вне DOM, но взаимодействие с canvas и image data требует аккуратной сериализации результата.

Ограничения сериализации и стоимость postMessage

Worker-сообщения используют structured clone algorithm. Это означает:

  • большие массивы объектов копируются, а не передаются по ссылке
  • сложные Vega datasets могут стать узким местом
  • функции и классы не сериализуются

Поэтому критическим становится перенос не самих объектов Vega, а:

  • ArrayBuffer
  • TypedArray
  • заранее подготовленных числовых массивов

Оптимальный формат передачи данных:

const buffer = new Float64Array(rawData);

worker.postMessage({
  spec,
  data: buffer
}, [buffer.buffer]);

Передача владения буфером (transferable objects) исключает копирование и снижает нагрузку на GC.

Компиляция Vega-Lite в worker

Vega-Lite добавляет дополнительный слой: преобразование декларативной спецификации в полноценный Vega runtime.

В worker это выглядит следующим образом:

importScripts(
  "https://cdn.jsdelivr.net/npm/vega",
  "https://cdn.jsdelivr.net/npm/vega-lite",
  "https://cdn.jsdelivr.net/npm/vega-embed"
);

self.onmess age = async (event) => {
  const { vlSpec, data } = event.data;

  const vgSpec = vegaLite.compile(vlSpec).spec;

  const runtime = vega.parse(vgSpec);

  const view = new vega.View(runtime)
    .renderer("canvas")
    .initialize();

  view.insert("source", data);

  await view.runAsync();

  const canvas = view.toCanvas();
  const bitmap = await createImageBitmap(canvas);

  self.postMessage({ bitmap }, [bitmap]);
};

Ключевой эффект такого подхода — устранение блокировки main thread на этапе компиляции, который особенно дорог при наличии сложных трансформаций (aggregate, bin, calculate, window).

Инкрементальные обновления и сигналы

Vega активно использует систему сигналов для реактивного обновления визуализаций. При переносе в worker возникает задача синхронизации состояния.

Решение основано на диффах сигналов:

self.onmess age = async ({ data }) => {
  const { signalChanges } = data;

  signalChanges.forEach(({ name, value }) => {
    view.signal(name, value);
  });

  await view.runAsync();

  const bitmap = await createImageBitmap(view.toCanvas());

  self.postMessage({ bitmap }, [bitmap]);
};

Для уменьшения нагрузки применяется:

  • batching сигналов
  • debounce обновлений
  • приоритизация сигналов (например, zoom/pan выше фильтров)

Работа с большими наборами данных

Основное преимущество worker-подхода проявляется при обработке больших таблиц.

Типичная проблема Vega-Lite — выполнение трансформаций в main thread:

  • фильтрация
  • агрегация
  • сортировка
  • window-функции

В worker это переносится в runtime:

const data = new vegaChangeset()
  .insert(largeDataset);

view.change("source", data);

await view.runAsync();

При этом важно избегать повторной передачи всего датасета. Вместо этого применяется паттерн incremental update:

const changeset = vega.changeset()
  .insert(newRows)
  .remove(oldRows);

view.change("source", changeset);

OffscreenCanvas и рендеринг вне DOM

Дополнительное ускорение достигается через OffscreenCanvas, позволяющий выполнять рендеринг полностью внутри worker:

const canvas = new OffscreenCanvas(800, 600);

const view = new vega.View(runtime)
  .renderer("canvas")
  .initialize(canvas);

await view.runAsync();

const bitmap = canvas.transferToImageBitmap();

self.postMessage({ bitmap }, [bitmap]);

В этой модели main thread вообще не участвует в процессе отрисовки, получая только готовые bitmap-кадры.

Протокол взаимодействия main thread ↔︎ worker

При усложнении визуализации требуется формализация сообщений:

// main thread
worker.postMessage({
  type: "update",
  payload: {
    signal: { brush: [10, 50] },
    filters: { category: "A" }
  }
});

В worker вводится маршрутизация:

self.onmess age = async ({ data }) => {
  switch (data.type) {
    case "init":
      initChart(data.payload);
      break;

    case "update":
      applyUpdate(data.payload);
      break;
  }
};

Такой подход позволяет:

  • отделить управление состоянием от рендера
  • масштабировать систему до нескольких графиков
  • реализовать кеширование runtime-объектов

Кэширование и повторное использование runtime

Создание Vega runtime и парсинг спецификации — дорогая операция. Поэтому worker обычно хранит состояние:

let runtimeCache = new Map();

function getRuntime(specHash, spec) {
  if (runtimeCache.has(specHash)) {
    return runtimeCache.get(specHash);
  }

  const runtime = vega.parse(spec);
  runtimeCache.se t(specHash, runtime);

  return runtime;
}

Хэширование спецификации позволяет избежать повторной компиляции при идентичных визуализациях.

Проблемы и ограничения worker-архитектуры

Несмотря на выигрыш в производительности, возникают специфические сложности:

  • увеличение сложности архитектуры
  • стоимость сериализации bitmap-результатов
  • невозможность прямого доступа к DOM из worker
  • необходимость ручного управления состоянием визуализации

Особенно критичен момент синхронизации интерактивности: события мыши должны передаваться в worker, а обратно возвращаются только визуальные обновления.

Пример передачи взаимодействия:

canvas.addEventListener("mousemove", (e) => {
  worker.postMessage({
    type: "cursor",
    x: e.offsetX,
    y: e.offsetY
  });
});

Гибридная модель рендеринга

На практике часто используется комбинированный подход:

  • worker: data processing + Vega compile + signal evaluation
  • main thread: DOM interaction + lightweight overlays
  • canvas: рендеринг кадров

Такая модель позволяет сохранять интерактивность без потери производительности при больших нагрузках.


В системах визуализации, построенных на Vega и Vega-Lite, worker-потоки становятся не вспомогательной оптимизацией, а базовым элементом архитектуры при работе с динамическими и объёмными данными, обеспечивая предсказуемое время отклика и изоляцию вычислительно тяжёлых операций от интерфейсного потока.