前回からのつづき

前回、メインスレッドと Web Worker とのデータの受け渡し方法として、転送による受け渡しについて説明してきました。

転送での受け渡しでは、メインスレッドと Web Worker との間で共通のメモリを所有権を受け渡すことにより、排他的にアクセスできることを説明してきました。

今回は、第3の受け渡しである、共有メモリを使用したデータの受け渡しを中心に説明していきます。


共有メモリ領域( SharedArrayBuffer )を使用したデータの受け渡し

メインスレッドと Web Worker との間には、コピー、転送というデータの受け渡し以外に、もう1つ異なるデータの受け渡し方法が存在します。

それは、共有メモリ領域( SharedArrayBuffer )を使用したデータの受け渡しです。

SharedArrayBuffer は、ArrayBuffer と同様に、固定長のバイト列を保持できるオブジェクトです。

ArrayBuffer オブジェクトとは異なり、SharedArrayBuffer オブジェクトは、複数の実行コンテキスト(Main スレッドや Web Worker など)から同じメモリ領域を共有できるという特徴があります。

つまり、ArrayBuffer の転送による共有では、アクセスが排他的であったにも関わらず、SharedArrayBuffer を使った共有では、同時にアクセスが可能となるということです。

メッセージ送信前

受け渡し メインスレッド Web Worker
ArrayBufferの転送 読み書き〇 読み書き✖
SharedArrayBuffer 読み書き〇 読み書き✖

メッセージ送信後

受け渡し メインスレッド Web Worker
ArrayBufferの転送 読み書き✖ 読み書き〇
SharedArrayBuffer 読み書き〇 読み書き〇

SharedArrayBuffer の受け渡しもコピーではない為、新たなメモリの確保は行われません。

大きく異なるのは、受け渡しが完了した後、メインスレッド側でも通常通り、読み書きが可能であるという点です。

SharedArrayBuffer に対して、メインスレッドと Web Worker は、

メインスレッド
    ▲
    │
    ▼       
┌───────────────────┐
│ SharedArrayBuffer │
└───────────────────┘
    ▲
    │
    ▼
Web Worker

双方の SharedArrayBuffer オブジェクトは別インスタンスですが、同じ共有メモリを参照しています。

例:メインスレッドから Web Worker に共有メモリの buffer オブジェクトを受け渡す場合、

Main.js

const buffer = new SharedArrayBuffer(4);
const view = new Uint8Array(buffer);

worker.onmessage = (event)=> {
  console.log(view[0]);
}

view[0] = 100;

worker.postMessage(buffer);
// コピーも転送も行われず、共有メモリを参照する SharedArrayBuffer が Web Worker 側へ渡される

Worker.js

self.onmessage = (event)=>{

    const view = new Uint8Array(event.data);

    view[0] = 200;

    self.postMessage("SUCCESS");
}

結果 200 となり、Web Worker で書き換えられた値が見えます。

※ SharedArrayBuffer の受け渡しでは、postMessage() の第2引数も必要ありません。


SharedArrayBuffer のセキュリティ上の制限

SharedArrayBuffer は高性能ですが、過去に CPU の脆弱性( Spectre:CPU の投機実行(Speculative Execution)の仕組みを利用した攻撃 )を利用した情報漏えいの問題が見つかりました。

その関係で、2018年には、ほぼ全ブラウザで SharedArrayBuffer が無効化されました。

ただ、その結果、その機能に依存していた

  • ゲーム
  • WebAssembly
  • CAD
  • 動画編集
  • SQLite WASM

などが困ることになります。

そこで、完全に廃止するのではなく、安全なページだけ使えるようすることで、再度、 SharedArrayBuffer が有効化されることになりました。

この、安全なページだけ使えるようする仕組みのことを クロスオリジン分離(Cross-Origin Isolation) と呼びます。

そのため現在は、多くのブラウザで、利用する際に、クロスオリジン分離(Cross-Origin Isolation)が必要になっています。

正確に言うと、SharedArrayBufferが利用できる条件は、

  • セキュアコンテキスト(Secure Context)であること( HTTPS 使用必須 )
  • クロスオリジン分離(Cross-Origin Isolation)が有効であること

の両方です。

つまり、ほとんどの環境では、単にコードを書くだけでは使用できなくなっています。

一方、ArrayBufferにはそのような制限はありません。

※ HTTPでは、通常 http://example.com などのセキャアでないコンテキストでは、 SharedArrayBuffer は利用できませんが、開発用途として、http://localhosthttp://127.0.0.1 は例外的にセキュアコンテキストとして扱われます。そのため、ローカル開発ではHTTPS証明書がなくても試せることがあります。


クロスオリジン分離(Cross-Origin Isolation)とは

クロスオリジン分離(Cross-Origin Isolation)とは、他のオリジン(サイト)から自分のページを隔離し、安全な実行環境を作る仕組みのことをいいます。

例えば、 https://example.com というサイトがあるとします。

普通は、example.com のページの中には、

  • JavaScript
  • 画像
  • フォント
  • iframe
  • 広告
  • CDN

など、様々なオリジンのデータが混在しています。

example.com
├── script (example.com)
├── image (cdn.example.com)
├── iframe (google.com)
├── font (fonts.example.com)
└── analytics (analytics.example.com)

複数のサイトのデータが混在しているこの状態は、本当に安全なのかどうかが、判別できません。

そこで、サーバーから送付する HTTPヘッダー に特別な設定を行い、それを受け取ったブラウザが制限を設けることで、「安全な実行環境」を実現する仕組みが導入されました。

それが、Cross-Origin Isolation です。

具体的には、下記の値を HTTPヘッダー に設定します。

  • Cross-Origin-Opener-Policy: same-origin
  • Cross-Origin-Embedder-Policy: require-corp

■ Cross-Origin-Opener-Policy: same-origin

これを COOP と略します。

意味は、「他のオリジンとはブラウザの実行コンテキストを共有しない」です。

■ Cross-Origin-Embedder-Policy: require-corp

これを COEP と略します。

意味は、「読み込むリソースも、安全だと確認できるものだけに限定する」です。

COEP を有効にすると、他オリジンのリソースは、CORP や CORS により共有が許可されたものだけ読み込めるようになります。

■ クロスオリジン分離には、両方の設定が必要

COOP + COEP の両方が有効になって初めて、crossOriginIsolated === true の状態となり、クロスオリジン分離が有効になります。

その結果、安全に

  • SharedArrayBuffer
  • Atomics.wait()
  • OPFS の同期アクセス API( createSyncAccessHandle()
  • WebAssembly Threads

などが使えるようになります。


共有メモリ領域( SharedArrayBuffer )による共有の危険性

まず理解しないといけないことは、メインスレッドと Web Worker は全く別の実行環境で並列動作しているということです。

つまり、メモリの同じ場所に対して、同時に書き換えが起こる可能性があるということです。

これは、協調して動作する場合に、致命的な問題を引き起こす可能性があります。

例えば、同じ共有メモリ領域( SharedArrayBuffer )に対して、

main.js

view[0]++;

worker.js

view[0]++;

という処理を行っていた場合に、両方が同時に実行すると、処理結果を下記のように期待していたところ、

100
↓
101
↓
102

実際には、

100

Main 読む   → 100

Worker 読む → 100

Main 書く   → 101

Worker 書く → 101

と処理され、102 になるはずが 101 になることがあります。

これを Race Condition(競合)と呼びます。

このような問題を防ぐ為には、メインスレッドと Web Worker の間での何らかの排他制御が必要となります。

共有メモリ領域( SharedArrayBuffer )には、前述のような問題を防ぐために、Atomics APIが用意されています。

※ 尚、Atomics API の使用は競合する部分のみとし、メインスレッドと Web Worker が同時にアクセスする可能性がない部分に関しては、view[0] といった通常の配列指定での読み書きでも特に問題はありません。


Atomics API とは

競合を防ぐために、共有メモリへの1回の操作を、途中で割り込まれない形で実行するための API です。

例えば、

Atomics.add(view, 0, 1);

これは、

view[0] += 1;

ではありません。内部では、

読む
↓
加算
↓
書く

を絶対に途中で割り込まれないように実行します。

これを Atomic(不可分)と呼びます。

このように、Atomics API は、共有メモリ領域( SharedArrayBuffer )に直接アクセスする場合と異なり、排他的なアクセスを可能する 同期アクセス API 群のことを指します。

Atomics API の主なものを示します。

  • Atomics.add
  • Atomics.load
  • Atomics.store
  • Atomics.sub
  • Atomics.and
  • Atomics.or
  • Atomics.xor
  • Atomics.exchange
  • Atomics.compareExchange
  • Atomics.wait
  • Atomics.notify
  • Atomics.waitAsync(対応ブラウザ要確認)
  • Atomics.isLockFree()

(つづく)