前回からのつづき

これまで、メインスレッドと Web Worker との postMessage() による、

  • コピーされるデータ(構造化複製)
  • 転送されるデータ(Transferable Objects)
  • 共有メモリ(SharedArrayBuffer)と同期(Atomics)

という 3 種類のデータの取り扱いについて見てきました。

しかし、実はこれらのデータの取り扱いとはまったく異なる第4のデータの取り扱い方法が存在します。

それは、 ブラウザが実体を管理するオブジェクト の取り扱いです。

今回は、 Web Worker の使い方の最終回として、この 第4のデータの postmessage() での取り扱いにはじまり、 筆者が調査中に疑問に思って調べたことをまとめて説明していきたいと思います。


ブラウザ管理オブジェクトとは何か?

実は、ブラウザには JavaScript が直接保持しているのではなく、ブラウザプロセス側が実体を管理しているオブジェクトが存在します。

代表的なものは以下です。

  • Blob
  • File
  • FileList
  • Response
  • Request
  • ReadableStream
  • WritableStream
  • ImageBitmap
  • OffscreenCanvas
  • FileSystemFileHandle( OPFS )

これらのオブジェクトは JavaScript が「参照」を持つだけで、実体はブラウザの内部にあり、通常のオブジェクトとは扱いが異なります。

そして、これらのオブジェクトは 不変( immutable )であるという特徴を持っています。

例えば、

  • Blob の中身は変更できない
  • File の内容も変更できない
  • FileList の中身も変更できない
  • Response のボディは読み取り専用である
  • ImageBitmap は描画用の固定リソースである

👉 つまり、編集できない代わりに安全に共有できるという性質を持っています。

※ Blob や File は不変ですが、arrayBuffer() で取り出した ArrayBuffer は編集可能です。ただし編集しても元の Blob / File は変わりません。編集したい場合は 新しい Blob / File を作り直す必要があります。

postMessage() での取り扱い

これらの不変( immutable )オブジェクトは、 postMessage() で 受け渡すとき、コピーされるわけでも、転送されるわけでもありません。

では、どうなるのか?

👉 参照が共有 されることになります。

つまり、メインスレッドと Web Worker は、ブラウザプロセスに 1 つだけ存在する同じ不変( immutable )オブジェクト( Blob / File / Response など)を参照することになります。

┌──────────────────────────────────────────┐
│             ブラウザプロセス               │
│       (実データを一元的に保持・管理)        │
│       Blob実データ (UUID: xxxx)           │
└──────────┬────────────────────┬──────────┘
           │                    │
           ▼                    ▼
  ┌────────────────┐  ┌───────────────────┐
  │ メインスレッド  │  │ Web Workerスレッド │
  │ (UUID参照のみ)  │  │   (UUID参照のみ)   │
  └────────────────┘  └───────────────────┘

そのため、どちらから読み出しても同じ内容になります。また、不変なので競合が起きません。

これは SharedArrayBuffer のような「共有メモリ」とは異なり、 読み取り専用の共有 になります。

※ ここでいう「参照共有」は SharedArrayBuffer のような「同じメモリ領域を共有する」という意味ではありません。 実体はブラウザ内部にあり、メインスレッドと Worker が 同じオブジェクトを読み出せる という意味です。

メリット

  • 大きなファイルを扱うときに効率が良い

巨大な Blob や File をコピーするとメモリ負荷が大きくなります。しかし、参照共有ならコピーが発生しないため高速です。

  • Web Worker での画像処理・ ZIP 化・ OPFS 保存に向いている

Blob や File を Web Worker に渡して処理する場合、コピーが発生しないためパフォーマンスが高くなります。

  • ストリーム処理と相性が良い

Response や ReadableStream を Web Worker に渡して処理することも可能です。

コピー・転送・共有メモリとの違い

postMessage() のデータの取り扱い方法の違いを下記にまとめます。

種類 挙動
コピー オブジェクト、配列、ArrayBuffer 構造化複製される
転送 ArrayBuffer 、MessagePort 、OffscreenCanvas 所有権が移動する
共有メモリ SharedArrayBuffer 同じメモリ領域を参照する(書き換え可能)
参照共有 Blob、File、FileList、Response、Stream ブラウザ管理の実体を共有(読み取り専用)


postmessage() で、DOM の受け渡しは可能か?

結論から言うと、DOM は Web Worker へ Transfer することも、コピーすることもできません。

つまり、このコードはできません。

worker.postMessage(document.body);

あるいは

worker.postMessage(document.body, [document.body]);

もできません。

DOM の見た目にはオブジェクトですが、実際には、

ブラウザのレンダリングエンジン
        │
        ├─ DOMツリー
        ├─ CSS
        ├─ レイアウト
        └─ 描画

と密接に結び付いています。

これを Web Worker へ渡すことを許してしまうと、Web Worker から document.body.innerHTML = "..."; のような操作が可能になってしまいます。

しかし、その DOM は、メインスレッドが画面描画にも使っているので、

メインスレッド
    │
    ▼
   DOM
    ▲
    │
Web Worker

となり、2つのスレッドが同じ DOM を同時に変更することが可能となってしまいます。

これは非常に危険です。そのため設計として、DOM は、

  • Structured Clone ❌
  • Transfer ❌
  • Shared ❌

となっています。

これは、DOM のイベントも同様です。

const event = new MouseEvent("click");
worker.postMessage(event);

これもできません。

Web Worker でイベントに応じた処理を行いたい場合は、メッセージにより、それを模倣する仕組みをとる必要があります。

例えば、メインスレッドで、DOM のイベントがあったら、そのデータを Web Worker にメッセージで送信します。

const button = document.getElementById('button1');
button.addEventListener('click', function(event) {
  worker.postMessage({
    type: "click",
    x: 100,
    y: 200
  });
});

Web Worker では

self.onmessage = (event) => {
  if (event.data.type === "click") {
    // クリック相当の処理
  }
};

で、イベント発生時の処理を行います。

つまり、イベントそのものではなく、「イベントを表すデータ」を送るのが基本です。

逆に、Web Worker から DOM 操作を行いたい場合は、

Web Worker からメインスレッドへ

self.postMessage({
  type: "finished"
});

とメッセージを送ります。

メインスレッドでは、そのメッセージを受け、

worker.onmessage = (event) => {
  if (event.data.type === "finished") {
    button.click();
  }
};

のように、必要に応じて、DOM イベントを発生させる必要があります。

Web Workerの設計思想は、

Web Worker
  ↓
「DOMを操作したい」
  ↓
メインスレッドへお願いする
  ↓
メインスレッドが DOM を操作する

です。


Web Worker は複数起動可能か?

Web Worker は、複数起動が可能です。実際、Web Workerは「必要なだけ作ることができる」ように設計されています。

例えば、メインスレッドの main.js で、

const worker1 = new Worker("worker.js");
const worker2 = new Worker("worker.js");
const worker3 = new Worker("worker.js");

これで3つのWorkerが動作します。

イメージとしては、

      ┌───────────────────────┐
      │         Main          │
      └──┬────────┬────────┬──┘
         │        │        │
         │        │        │
      Worker1  Worker2  Worker3

それぞれが独立した JavaScript 実行環境になります。

どの Web Worker も同じ worker.js を実行していますが、それぞれ別々のメモリを持っています。

例えば、

main.js

const worker1 = new Worker("worker.js");
const worker2 = new Worker("worker.js");
const worker3 = new Worker("worker.js");

worker1.onmessage = (event)=>{
    console.log(`worker1 → ${event.data}`);
}
worker2.onmessage = (event)=>{
    console.log(`worker2 → ${event.data}`);
}
worker3.onmessage = (event)=>{
    console.log(`worker3 → ${event.data}`);
}
worker1.postMessage(null);
worker2.postMessage(null);
worker2.postMessage(null);
worker3.postMessage(null);
worker3.postMessage(null);
worker3.postMessage(null);

worker.js

let counter = 0;

self.onmessage = () => {
    counter++;
    self.postMessage(counter);
};

結果は

worker1 → 1
worker2 → 1
worker2 → 2
worker3 → 1
worker3 → 2
worker3 → 3

になり、counterは共有されません。

何個まで作成可能か?

作成できる個数に、仕様上は明確な上限はありませんが、

  • CPUコア数
  • メモリ
  • ブラウザ

によって実用的な数は変わります。

例えば、CPUコア数程度までは並列性を活かしやすいことが多いですが、100個、200個と作ると、

  • メモリ消費
  • スレッド切り替え(コンテキストスイッチ)

の負荷が大きくなり、かえって遅くなることがあります。

Web Worker 同士の通信は可能か?

通常の Dedicated Worker 同士は直接通信できず、一般的にはメインスレッドを経由します。

Worker1
    │
    ▼
  Main
    ▲
    │
Worker2

メインスレッドにて、

worker1.onmessage = (e) => {
        :
  worker2.postMessage(e.data);
};

のように、転送処理する必要があります。

どのような場面で使用するか?

現実の Web アプリでは、次のような構成がよく見られます。

  • Worker 1:画像の読み込み
  • Worker 2:画像の加工
  • Worker 3:OPFS への保存

あるいは、

  • Worker A:CSV の解析
  • Worker B:データ集計
  • Worker C:グラフ用データ作成

というように、処理を役割ごとに、Web Worker で分担します。


Web Worker から Web Worker を作成可能か?

実は、あまり知られていませんが、Web Worker から別の Web Worker を起動することも可能です。これを一般に Nested Worker(ネストされたWorker) と呼びます。

例えば、

Main
  │
  ▼
Worker A
  │
  ▼
Worker B
  │
  ▼
Worker C

という構成を作成できます。

例えば、

main.js

const worker = new Worker("workerA.js");

workerA.js

const workerB = new Worker("workerB.js");

workerB.js

self.postMessage("Hello");

このように普通に作成できます。

何のために使うのか?

役割毎に処理を分割して、パイプライン形式で処理を行う場合などに、利用できます。

Main
  │
  ▼
画像処理Worker
  │
  ▼
AI推論Worker
  │
  ▼
圧縮Worker

ただし、実際にはあまり見かけません。理由は管理の煩雑さにあります。

エラーが起きたときに、「どのWorkerで起きたのか」が分かりにくくなります。

それよりも、シンプルに、

Main
  ├──Worker A
  ├──Worker B
  └──Worker C

メインスレッドから起動した方が、起動、停止及び、エラー処理を一元管理できます。

現在のブラウザならメインスレッドから Web Worker を複数作っても問題ない為、わざわざ Web Worker から Web Worker を作るメリットが少ないのです。

Nested Worker が活躍する場面は?

例えば、巨大な画像を処理する Web Worker が、

画像を16分割
↓
16個のWorkerへ処理依頼
↓
結果を集約

のような処理をしたい場合です。

これは並列計算の世界では、Fork-Join(フォーク・ジョイン)という有名な設計パターンと呼ばれ、かなり高度ですが、並列処理ライブラリなどでは実際に採用されることがあります。


まとめ

  • Web Worker 起動/停止は、メインスレッドからのWeb API により行われる。
  • メインスレッドと Web Worker はメッセージの双方向受信によりデータ受け渡しが可能
  • メッセージによるデータの受け渡しは、基本的にコピーにて行われる。
  • 指定により、メッセージによる受け渡しデータをコピーではなく、転送することが可能
  • 転送できるオブジェクトは、Transferable Object と呼ばれるオブジェクトに限定される。
  • メッセージによる受け渡しには、コピーでも、転送でもない、共有メモリを使った方法が存在する。
  • 共有メモリを使った方法を競合から守り、安全に使用する為に、Atomics API が提供されている。
  • Blob / File / FileList / Response などは ブラウザが実体を管理する不変(immutable)オブジェクト
  • 不変(immutable)オブジェクトは、受け渡し時に、コピーでも、転送でも、共有メモリでもなく、参照が共有される。
  • メッセージによるデータの受け渡しでは、DOM オブジェクトの受け渡しはできない。
  • 共有メモリの使用は、セキュリティの観点からクロスオリジン分離(Cross-Origin Isolation)が有効である必要がある。