Web Workerとメモリの孤島:なぜ私たちはデータを「コピー」しなければならないのか
コードレビューをしていて、メインスレッドのパフォーマンス低下に悩むチームのコードを見ると、いまだに「重い処理をとりあえずWeb Workerに投げた」というだけの、場当たり的な実装に出くわすことがある。
「Workerを使えばメインスレッドがブロックされない」――それは半分正解で、半分は危険な誤解だ。
V8エンジンの実態を知る者にとって、メインスレッドとWeb Workerの関係は、同じ要塞にいながら決してドアを開けない「完全隔離された別棟の実験室」のようなものだ。今回は、この隔離されたメモリ空間の真実と、データ受け渡しのメカニズム、そしてプロダクションコードで絶対に踏んではいけない地雷について、チーフアーキテクトの視点から徹底的に紐解いていこう。
—
1. なぜWorker間で変数を共有できないのか?(V8ヒープの物理的現実)
JavaScriptはシングルスレッドで動作する言語だと長年言われてきたが、Web Workerの登場により、ブラウザ上でもマルチスレッド(正確には並行処理)が可能になった。しかし、C++やJavaのような「マルチスレッドによるメモリ共有(Shared Memory)」とはアプローチが根本的に異なる。
プロセスとスレッド、そして「共有不可」の哲学
JavaScriptの実行環境であるV8エンジンは、コンテキストごとに独自の「Isolate(独立したV8インスタンスの空間)」を持っている。
メインスレッドとWeb Workerは、完全に独立したOSレベルのスレッド、かつ独自のV8ヒープ領域として動いている。
もし、ここでC言語のように「ポインタを渡して、同じメモリ領域の変数を書き換える」なんてことを許可したらどうなるか?
- 2つのスレッドから同時に同じオブジェクトのプロパティが書き換えられる(Race Condition:競合状態)。
- ガベージコレクタ(GC)がどのスレッドのヒープを参照しているか追跡できず、メモリリークやセグメンテーション違反の温床になる。
JavaScriptが「安全に並行処理を行う」ための代償として、「変数の共有は一切禁止する。データのやり取りは『コピー』を介して行う」という厳格なルールが敷かれているのだ。
—
2. 構造化複製アルゴリズム(Structured Clone)の裏側
では、メインスレッドからWorkerへ、あるいはその逆へデータを送るとき、何が起きているのか?
ここで登場するのが Structured Clone Algorithm(構造化複製アルゴリズム) である。
JSON.parse(JSON.stringify(data)) と似ているが、それよりもはるかに高機能だ。
- 循環参照を持つオブジェクトの複製が可能
- `Map`, `Set`, `Date`, `RegExp`, `ArrayBuffer` などの特殊なビルトインオブジェクトをそのままコピーできる
10MBの配列を送ったとき、何が起きるか?
ここに大きな罠がある。「コピー」である以上、送信側と受信側でメモリの二重確保が発生する。
1. メインスレッド側で、10MBの型付き配列(Float64Arrayなど)を直列化(シリアライズ)する。
2. そのバイナリデータをOS/ブラウザの層を跨いでWorkerスレッドへ転送する。
3. Worker側でそのバイナリを復元(デシリアライズ)し、Worker側のヒープに新たな10MBの領域を確保する。
つまり、瞬時にメモリ消費量が倍になり、シリアライズ/デシリアライズのCPUコスト(メインスレッドのブロッキング要因になり得る)が発生する。これを理解せずに巨大なデータをポンポンWorkerに投げるコードは、パフォーマンス改善のつもりが逆にアプリを重くしている最悪のアンチパターンだ。
—
3. 【プロダクション実装】ゼロコピーを実現する「Transferable Objects」
「じゃあ、数メガバイト、数百メガバイトもある画像データや音声データをWorkerに渡すたびにメモリをコピーするのか?」
答えは No だ。ここで使うべきなのが Transferable Objects(転送可能オブジェクト) である。
`ArrayBuffer` や `MessagePort` などの特定のオブジェクトは、コピーではなく「所有権の移転(Move)」を行うことができる。
送信元のスレッドはデータの所有権を失い(=アクセスできなくなる)、一瞬でWorker側へポインタの権利が渡る。シリアライズのコストも、メモリの二重確保も発生しない(これを「ゼロコピー」と呼ぶ)。
以下のプロダクションコードを見てほしい。実務でそのまま使える、堅牢なWorker管理とデータ転送の設計パターンだ。
実装例:メインスレッド側(`main.js`)
/
- 高負荷な画像処理(ピクセル操作など)をWorkerに安全に委譲するマネージャー
/
class ImageProcessingWorkerManager {
constructor(workerUrl) {
this.worker = new Worker(workerUrl, { type: ‘module’ });
this.setupListeners();
}
setupListeners() {
this.worker.onmessage = (event) => {
const { success, data, error } = event.data;
if (success) {
console.log(‘Workerでの処理が完了しました:’, data);
// 必要に応じてUIを更新
} else {
console.error(‘Workerエラー:’, error);
}
};
this.worker.onerror = (error) => {
console.error(‘Workerランタイムエラー:’, error.message);
};
}
/
- ArrayBufferの所有権を移転し、ゼロコピーでWorkerへ送信する
- @param {ArrayBuffer} buffer – 処理対象のバイナリデータ
/
processImageData(buffer) {
// 【重要】インスタンスの型チェック
if (!(buffer instanceof ArrayBuffer)) {
throw new TypeError(‘Transferable object must be an ArrayBuffer.’);
}
console.log(`送信前のバッファサイズ: ${buffer.byteLength} bytes`);
// 第2引数に転送するオブジェクトのリスト(Transferable List)を指定する
// これにより、この瞬間から main.js 側では buffer が空(byteLength = 0)になる
this.worker.postMessage(
{ type: ‘INVERT_COLORS’, payload: buffer },
[buffer]
);
console.log(`送信後のバッファサイズ(所有権喪失確認): ${buffer.byteLength} bytes`);
// 出力: 0 bytes (メモリの所有権がWorkerへ移動したため)
}
terminate() {
this.worker.terminate();
}
}
// 実行例
const manager = new ImageProcessingWorkerManager(‘./imageWorker.js’);
const dummyBuffer = new ArrayBuffer(1024 1024 10); // 10MBのダミーデータ
manager.processImageData(dummyBuffer);
実装例:Worker側(`imageWorker.js`)
/
- Worker側スクリプト
/
self.onmessage = (event) => {
const { type, payload } = event.data;
if (type === ‘INVERT_COLORS’) {
try {
// payload は ArrayBuffer として届く。所有権は完全にこちらにある。
const buffer = payload;
const view = new Uint8Array(buffer);
// 例として、全バイトデータを反転させる極めて重い処理
for (let i = 0; i < view.length; i++) {
view[i] = 255 - view[i];
}
// 処理が終わったバッファを、再びメインスレッドへゼロコピーで返す
self.postMessage(
{ success: true, data: buffer },
[buffer] // 再び転送可能リストに含めて返す
);
} catch (err) {
self.postMessage({ success: false, error: err.message });
}
}
};
このパターンにおいて最も重要なのは、「所有権を移転した瞬間、送信元(メインスレッド)の変数は使い物にならなくなる(`byteLength` が 0 になる)」という点だ。これを理解していないと、「送信したはずのデータが消えた!」という不可解なバグに直面することになる。
—
4. チーフアーキテクトからの警鐘:設計上の落とし穴
Web Workerを使う際、以下のアンチパターンに陥っていないか、自分のプロジェクトのコードを見直してほしい。
1. 安易な「構造化複製」の乱用
数千件の複雑なオブジェクトツリー(VueやReactのリアクティブなプロキシオブジェクトなど)をそのまま `postMessage` しようとしていないか? リアクティブオブジェクトは構造化複製の対象外(エラーになる)であるし、通常の巨大オブジェクトであってもシリアライズコストでメインスレッドがカクつく。Workerに渡すデータは、極力プレーンなデータ構造(POJO)か、TypedArrayに絞るべきだ。
2. ライフサイクル管理の欠如
コンポーネントが破棄(アンウント)された後も、Workerがバックグラウンドで動き続け、メモリリークや無駄なCPUサイクルを消費しているケースが後を絶たない。SPA(Single Page Application)においては、画面遷移時や不要になったタイミングで確実に `worker.terminate()` を叩くクリーンアップコードを強制すること。
3. 「同期処理のように扱える」という誤解
Workerとの通信は本質的に非同期(イベント駆動)である。`async/await` でラップするユーティリティを書くことは有効だが、ネットワークリクエストと同様に「途中でWorkerがクラッシュする可能性」「タイムアウトの可能性」を常に考慮した堅牢なエラーハンドリング(Promiseのreject処理)を設計に組み込まなければならない。
—
結びに代えて
Web Workerは、フロントエンドの限界を突破するための強力な武器だ。しかし、「別スレッドだから安全に動く」という甘い認識で設計すると、V8ヒープの挙動やメモリの所有権問題という深い罠に足元をすくわれる。
メモリの空間が隔離されているという現実を直視し、コピーとムーブ(Transferable Objects)を適切に使い分けること。それこそが、大規模なフロントエンドアプリケーションを極限まで滑らかに保つための、プロフェッショナルの条件である。