Web Workers: 重い処理をバックグラウンドにオフロードしてUIのレスポンスを維持する

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

クイックスタート — 5分でWorkerを動かす

6ヶ月前、私は50,000行以上のCSVファイルをクライアントサイドで処理するデータ可視化ダッシュボードをリリースした。ユーザーがファイルをアップロードするたびに、ブラウザが3〜4秒間フリーズした。ボタンは反応しなくなり、アニメーションはカクつき、スクロールバーは完全にロックされた。修正には約30行のコードとサードパーティライブラリは一切不要だった — Web Workersだけで解決できた。

以下は、処理をメインスレッドから切り離すための最低限のコードだ。フレームワークもビルドステップも不要。

worker.jsというファイルを作成する:

// worker.js
self.onmessage = function (e) {
  const result = heavyComputation(e.data);
  self.postMessage(result);
};

function heavyComputation(data) {
  // 重い処理をシミュレート
  let sum = 0;
  for (let i = 0; i < data.iterations; i++) {
    sum += Math.sqrt(i) * Math.sin(i);
  }
  return sum;
}

次に、メインスクリプトから接続する:

// main.js
const worker = new Worker('worker.js');

worker.postMessage({ iterations: 10_000_000 });

worker.onmessage = function (e) {
  console.log('Workerからの結果:', e.data);
  // ここでUIを更新 — メインスレッドに戻っているので安全
};

worker.onerror = function (err) {
  console.error('Workerエラー:', err.message);
};

これだけだ。Workerが1000万回のイテレーションを処理している間も、UIスレッドは自由なままだ。ボタンは反応し、アニメーションは動き続け、ユーザーは何も気づかない。

詳細解説 — 内部で何が起きているのか

シングルスレッドの問題

JavaScriptはシングルスレッドで動作する。イベントループがすべてを処理する:DOMの更新、ユーザー入力、ネットワークコールバック、アプリケーションロジック。1つのタスクが長く実行されると — たとえば10万件のレコードのソートや大きな画像のデコードなど — 他のすべてのタスクは順番待ちになる。その待ち時間こそ、ユーザーがインターフェイスのフリーズとして感じるものだ。

Web Workersは、JavaScriptを完全に別のOSスレッドで実行することでこの問題を解決する。各Workerは独自のヒープ、独自のイベントループ、独自のグローバルスコープ(windowの代わりにself)を持つ。2つのスレッドはメモリを直接共有しない。メッセージパッシングを通じて通信するため、互いに安全に分離されている。

メッセージングモデル

スレッド間でやり取りされるデータは、デフォルトでは構造化クローンアルゴリズムを使ってコピーされる。オブジェクト、配列、型付き配列、MapSetArrayBufferに対応しているが、関数、DOMノード、メソッドを持つクラスインスタンスには対応していない。

// 複雑なデータの送信
worker.postMessage({
  matrix: [[1, 2], [3, 4]],
  config: { normalize: true, precision: 4 }
});

// worker.js内
self.onmessage = function (e) {
  const { matrix, config } = e.data;
  const result = processMatrix(matrix, config);
  self.postMessage(result);
};

Transferableオブジェクト — 大きなデータのゼロコピー転送

大きなArrayBuffer — 生の音声サンプルや画像のピクセルデータなど — をコピーするのは高コストだ。代わりに、所有権をWorkerに転送できる。転送はバッファサイズに関わらず一定時間で完了する:

// main.js — 10MBのArrayBufferをWorkerに転送
const buffer = new ArrayBuffer(10 * 1024 * 1024);
worker.postMessage({ buffer }, [buffer]);
// この行以降、`buffer`は無効化される — メインスレッドはもう読み取れない

// worker.js — 処理後に転送して返す
self.onmessage = function (e) {
  const buf = e.data.buffer;
  // ... bufを処理 ...
  self.postMessage({ result: buf }, [buf]);
};

私は本番環境のcanvas画像処理でこのパターンを使っている。4K画像バッファの転送時間は約8msから0.1ms未満に短縮された。数百KB以上のデータであれば、転送はコピーよりも常に優れている。

応用的な使い方

並列処理のためのWorkerプール

Workerが1つあれば1スレッド追加できる。データセットのチャンクのソート、並列推論の実行、個々の動画フレームの処理など、ワークロードを分割できる場合、Workerのプールによってスループットをコア数に比例して向上させられる。

// worker-pool.js
class WorkerPool {
  constructor(workerScript, poolSize = navigator.hardwareConcurrency || 4) {
    this.workers = Array.from({ length: poolSize }, () => ({
      worker: new Worker(workerScript),
      busy: false
    }));
    this.queue = [];
  }

  run(data) {
    return new Promise((resolve, reject) => {
      const free = this.workers.find(w => !w.busy);
      if (free) {
        this._dispatch(free, data, resolve, reject);
      } else {
        this.queue.push({ data, resolve, reject });
      }
    });
  }

  _dispatch(slot, data, resolve, reject) {
    slot.busy = true;
    slot.worker.onmessage = (e) => {
      resolve(e.data);
      slot.busy = false;
      if (this.queue.length > 0) {
        const next = this.queue.shift();
        this._dispatch(slot, next.data, next.resolve, next.reject);
      }
    };
    slot.worker.onerror = (err) => {
      reject(err);
      slot.busy = false;
    };
    slot.worker.postMessage(data);
  }
}

// 使用例
const pool = new WorkerPool('worker.js', 4);
const promises = chunks.map(chunk => pool.run(chunk));
const results = await Promise.all(promises);

navigator.hardwareConcurrencyは論理CPUコア数を返す — ミドルレンジのノートPCなら4、最新のデスクトップなら12以上だ。プールサイズをコア数に合わせることで、ローエンドのスマートフォンでのスレッド過剰を避けながら、高性能なハードウェアでは最大限に活用できる。

Blob URLを使ったインラインWorker

バンドラーを使う場合、別のworker.jsファイルを用意するのは面倒になる。代わりにWorkerをインラインで定義する方法がある:

const workerCode = `
  self.onmessage = function(e) {
    const result = e.data.reduce((acc, n) => acc + n, 0);
    self.postMessage(result);
  };
`;

const blob = new Blob([workerCode], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
const worker = new Worker(url);
URL.revokeObjectURL(url); // Worker作成後にURLをクリーンアップ

ViteとWebpack 5はどちらもnew Worker(new URL('./worker.js', import.meta.url))を完全なESMモジュールサポートとともに利用できる。モダンなビルド環境を使っているなら、こちらがよりスッキリした方法だ。

タブ間通信のためのShared Worker

通常のWorkerは1つのタブに閉じているが、SharedWorkerは同一オリジンのすべてのタブにまたがって動作する — 共有WebSocket接続やタブをまたいだキャッシュレイヤーに便利だ:

// shared-worker.js
const connections = [];

self.onconnect = function (e) {
  const port = e.ports[0];
  connections.push(port);

  port.onmessage = function (msg) {
    // 接続中のすべてのタブにブロードキャスト
    connections.forEach(p => p.postMessage(msg.data));
  };

  port.start();
};

// main.js (任意のタブ)
const sw = new SharedWorker('shared-worker.js');
sw.port.onmessage = (e) => console.log('ブロードキャスト受信:', e.data);
sw.port.start();
sw.port.postMessage('タブ1からのメッセージ');

本番環境からの実践的なTips

Workerを使うべきでないケースを知る

Workerはタダではない。起動には約5〜10msとメモリ数MBのコストがかかる。50ms以内に完了するタスクでは、オーバーヘッドがメリットを打ち消してしまう可能性が高い。私の経験則:処理がUIを1アニメーションフレーム(約16ms)以上ブロックするなら、Workerに移すべきだ。

適切な用途:大きなJSONのパース、暗号化処理、圧縮・解凍、画像の畳み込み、ONNX推論、重い計算ループ全般。

不向きな用途:数千件程度のソート、文字列のフォーマット、DOMアクセスが必要な処理 — WorkerにはDOMへのアクセス手段がなく、documentwindowも存在しない。

エラーハンドリングとグレースフルデグラデーション

const worker = new Worker('worker.js');

worker.onerror = (e) => {
  console.error(`Workerエラー (${e.filename}:${e.lineno}) — ${e.message}`);
  // メインスレッドでのフォールバック実行
  const result = heavyComputation(pendingData);
  updateUI(result);
};

// 長時間実行されるWorkerには必ずタイムアウトを設定する
const TIMEOUT_MS = 30_000;
const timeoutId = setTimeout(() => {
  console.warn('Workerがタイムアウト、終了します');
  worker.terminate();
}, TIMEOUT_MS);

worker.onmessage = (e) => {
  clearTimeout(timeoutId);
  updateUI(e.data);
};

Chrome DevToolsでのWorkerデバッグ

DevToolsを開き → Sourcesタブ → 右側のThreadsパネルを確認する。Workerは独自のコールスタックとともにそこに表示される。Workerスクリプト内にもメインスレッドのコードと同様にブレークポイントを設定できる。正直なところ、もっと早く知っていればよかった — ブレークポイント1つで10秒で見つかったバグを、ログを仕込んで2時間かけて追いかけていた。

効果を計測する

リリース前に、PerformanceタブでWorkerあり・なしの両方のトレースを記録して計測しよう。注目すべき指標はLong Tasks — メインスレッドを50ms以上ブロックするタスクだ。CSVパーサーをWorkerに移した後、ファイルアップロード中のLong Tasksは4件からゼロになった。LighthouseのTotal Blocking Timeは同じワークロードで680msから40msに低下した。

Web Workersはエッジケースに当たるまで簡単に見えるAPIの一つだ。コアAPIはメソッド3つだけ。難しいのはその周辺すべてだ:プールサイズの決定、Transferableの正しい扱い方、グレースフルフォールバックの実装、オーバーヘッドが割に合わないケースの見極め。基本的なメンタルモデル — メッセージで通信する2つの独立したスレッド — を正しく理解すれば、あとは自然と身についてくる。そうなれば、Workerの起動はasync関数を書くのと同じくらい当たり前の作業に感じられるはずだ。

Share: