前回からのつづき

前回、Web Worker のライフサイクルの話に始まり、使い方として、起動/停止方法から、メインスレッドと Web Worker 間のデータの受け渡し方法について説明してきました。

データの受け渡し方法は、基本的にコピーになる旨の説明を行ってきましたが、ある種のデータに関しては、コピーではなく、データそのものを受け渡す転送という方法をとることが可能です。

今回は、その方法を中心に説明をしていきたいと思います。


コピー以外のデータの受け渡し方法

基本的に、postmessage() でのメッセージによるデータの受け渡し方法は、コピーとなります。

しかし、巨大な ArrayBuffer などを送りたい場合、コピーすると処理が遅くなります。

そこで、postmessage() では、引数の指定で、そのデータ領域そのものをメインスレッドと Web Worker の間で転送することが可能です。

これは、参照渡しではなく、転送(データ領域の所有権を受け渡す)という仕組みです。

これにより、コピーではなく、メインスレッドの buffer オブジェクトの所有権が、Web Worker に受け渡され、普通にアクセスできるようになります。

postMessage() の構文

worker.postMessage(message, [buffer]);
  • 第1引数: メッセージ
  • 第2引数: メッセージに指定されたオブジェクトの中で転送するオブジェクトの一覧

例: buffer オブジェクトを転送する場合

const buffer = new ArrayBuffer(1024);

worker.postMessage(buffer, [buffer]);

同じ buffer が2回出てくるので紛らわしいですが、役割はまったく異なります。

worker.postMessage(
    buffer,     // ← Workerに渡すデータ
    [buffer]    // ← このデータはコピーではなく「転送」してくださいという指定
);

つまり、

  • 第1引数 … 「何を送るか」
  • 第2引数 … 「その中のどのオブジェクトを転送扱いにするか」

を表しています。

この設計になっているのは、第1引数が単純な buffer だけでなく、

worker.postMessage(
    {
        id: 1,
        file: buffer,
        name: "sample"
    },
    [buffer]
);

のように、複雑なオブジェクトの一部だけを転送対象にできるようにするためです。

例:第2引数がない場合、

const buffer = new ArrayBuffer(1024);

worker.postMessage(buffer);

この場合は、

メインスレッド
┌──────────────┐
│ ArrayBuffer  │
└──────────────┘
       │
       │ コピー
       ▼
Web Worker
┌──────────────┐
│ ArrayBuffer  │
└──────────────┘

Web Worker 側にはコピーが届きます。

つまり、

メインスレッド Web Worker
1024B 1024B

メモリが2つになります。

例:第 2 引数を書いた場合、

const buffer = new ArrayBuffer(1024);

worker.postMessage(buffer, [buffer]);

すると

メインスレッド
┌──────────────┐
│ ArrayBuffer  │
└──────────────┘
       │
       │ 所有権を移動
       ▼
Web Worker
┌──────────────┐
│ ArrayBuffer  │
└──────────────┘

コピーではなく、転送されて所有権が移ります。

※ 現在、postMessage() は、従来の

worker.postMessage(message, [buffer]);

に加え、

worker.postMessage(message, {
    transfer: [...]
});

という新しい構文も使用できます。

本ブログでは、以降、従来通りの構文の記法を用いて、説明を行います。

なぜ第2引数が配列なのか?

なぜ、buffer ではなく [buffer] なのかというと、転送では、複数が指定できるからです。

例えば

const a = new ArrayBuffer(1024);
const b = new ArrayBuffer(2048);

worker.postMessage(
    {
        first: a,
        second: b
    },
    [a, b]
);

このように a と b の2つとも転送できます。

Worker側では、

self.onmessage = (event)=>{
    const view1 = new Uint8Array(event.data.first);
    const view2 = new Uint8Array(event.data.second);
    console.log(view1[0]); // a の値が出力される
    console.log(view2[0]); // b の値が出力される
}

で受け取れます。


転送する(所有権が移る)とはどういうことか?

ここで、注意したいのは、転送する(所有権が移る)という意味をしっかり理解しないといけないということです。

重要なのは、転送する(所有権が移る)前後で、何がおきているかということです。

例えば、buffer オブジェクトをメインスレッドから Web Worker に転送した場合

転送前

オブジェクト メインスレッド Web Worker
buffer 読み書き〇 読み書き✖

転送後

オブジェクト メインスレッド Web Worker
buffer 読み書き✖ 読み書き〇

Web Worker 側では、そのオブジェクトに普通にアクセスできるようになる半面、そのオブジェクトの持ち主であった、メインスレッドからは、アクセスできなくなります。

👉 つまり、 buffer オブジェクトそのものが、メインスレッドのメモリ空間から切り離され、Web Worker のメモリ空間に接続されてしまうということです。

転送元はどうなるか?

例えば、メインスレッドから Web Worker に buffer オブジェクトを転送した場合、

const buffer = new ArrayBuffer(1024);

worker.postMessage(buffer, [buffer]);

console.log(buffer.byteLength); // buffer の長さを出力

結果は 0 になります。

何が起こっているかというと、メインスレッドの ArrayBuffer が Detached ArrayBuffer という状態になっています。

つまり、メインスレッドからは、切り離された状態ということです。

※ Detached ArrayBuffer になった ArrayBuffer は自動で元に戻ることはありません。再び利用したい場合は、転送先からもう一度転送してもらう必要があります。

転送がコピーではない証拠

例えば、メインスレッドから Web Worker に buffer オブジェクトを転送した場合、

const buffer = new ArrayBuffer(8);

const view = new Uint8Array(buffer);

view[0] = 100;

worker.postMessage(buffer, [buffer]);

console.log(view[0]);

console.log(view[0]); を実行したタイミングで、 TypeError になります。

なぜなら view オブジェクトが指している buffer オブジェクトそのものが、Web Worker へと移動してしまったからです。

転送は、通常のコピーの受け渡しとは、まったく異なります。


なぜ転送という仕組みが必要なのか?

仮に 10GB の ArrayBuffer だったとすると、通常の受け渡しでは、コピー処理となるので、合計 20GB のメモリを必要とします。

そして、このコピー処理は非常に遅くなります。

しかし、転送なら、オブジェクトそのものを移動するだけなので、新たなメモリを必要とせず、 10GB のみで済みます。

つまり、

  • コピー時間
  • メモリ使用量

の両方を節約することができるのです。

尚、転送は、メインスレッド ⇒ Web Worker への一方通行だけではなく、その逆も可能です。

例:メインスレッドから Web Worker に転送されたオブジェクトを再び、メインスレッドに転送する場合、

main.js

const buffer = new ArrayBuffer(1024);
const worker = new Worker("worker.js");

worker.onmessage = (event)=>{
    const view = new Uint8Array(event.data);
    console.log(view[0]);
};
worker.postMessage(buffer, [buffer]); // メインスレッド ⇒ Web Worker

worker.js

self.onmessage = (event)=>{
    const view = new Uint8Array(event.data);

    view[0] = 100;
    
    self.postMessage(event.data, [event.data]); // Web Worker ⇒ メインスレッド
};

結果、100 と出力されます。

👉 転送は、繰り返し行うことが可能です。この仕組みにより1つの buffer オブジェクトをメインスレッドと Web Worker で排他的に受け渡しながら利用できます。


転送できるオブジェクト

では、全てのオブジェクトが転送可能かというと、そうではありません。

転送が可能なオブジェクトは、Transferable Object と呼ばれるオブジェクトに限定されます。それ以外のオブジェクトは、転送ができず、コピーとなります。

Transferable Object には、ArrayBuffer以外にも、下記のようなものがあります。

  • ArrayBuffer
  • MessagePort
  • ImageBitmap
  • OffscreenCanvas
  • ReadableStream(対応ブラウザでは利用可能)

例:通常のオブジェクトを転送しようとした場合、

const obj = { name: "Taro" };

worker.postMessage(obj, [obj]); // エラー

これは obj オブジェクト が Transferable Object ではないためです。

(つづく)