Zenn
🫧

JavaScriptのマルチスレッド

2025/03/27に公開2

マルチスレッドとは

1つのプロセス内で複数のスレッドを同時に実行する仕組み。
メインスレッドとは別のスレッドを作成し、マルチコアで処理(並列処理)すること。
CPU負荷の高い処理を行いたい時に、ユーザー体験を悪化させずに効率的に実行できる。

なぜ必要なのか(非同期処理との使い分け)

非同期処理は、メインスレッド上で複数の処理を高速に切り替えて実行(並行処理)することで、
ブロッキングの発生によるユーザー体験の悪化(画面フリーズなど)を防ぐことができる。

CPU負荷が少なく待ち時間が発生する処理には有効だが、
画像処理のようなCPU負荷の高い処理の場合にはあまり効果的ではない。

そもそも並行処理/並列処理とは

並行処理 (Concurrent)
1つのスレッド上で複数の処理を高速に切り替えながら実行すること。マルチタスク。
APIリクエスト、ファイル操作などの、I/O処理を行いたいときに有用。
例:非同期処理(Promise, async/await, setTimeout)

並列処理 (Parallel)
複数のスレッド/プロセスを、複数のCPUを使って、物理的に同時実行すること。マルチコア。
画像処理、大量データの計算、リアルタイム処理、バックグラウンド処理などの、
CPU負荷の高い計算処理を行いたいときに有用。
例:マルチスレッド(Web Workers, Worker Threads)、マルチプロセス

マルチスレッドの実装方法

Web Workers(ブラウザ環境)

ブラウザ上でメインスレッドとは別のスレッドで処理を実行する仕組み。

種類

Dedicated Worker
特定のスクリプトの重い処理をオフロードするワーカー。

Shared Worker
複数のスクリプトが共有できるワーカー。
(複数のスクリプト: 同じオリジンの異なるウィンドウ、iframe、ワーカー)

Service Worker
Webページとは独立して動作するイベント駆動型のワーカー。
ブラウザとネットワークの間のプロキシサーバーのように振る舞う。

使い方(Dedicated Worker)

メインスレッド側

  // worker.js を読み込んでワーカーを生成
  const myWorker = new Worker("worker.js");

  // ワーカーにデータを送信
  myWorker.postMessage({ data: "some data" });

  // ワーカーからのメッセージを受信
  myWorker.onmessage = function (e) {
    console.log("[メイン] ワーカーから受信:", e.data);
    // ワーカーを終了 (不要になったら)
    // myWorker.terminate();
  };

ワーカー側(worker.js)

// メインスレッドからのメッセージを受信
self.onmessage = function(e) {
  console.log("[ワーカー] メインから受信:", e.data);

  // 何か重い処理を実行
  const result = performHeavyCalculation(e.data);

  // メインスレッドに結果を送信
  self.postMessage({ result: result });
};

function performHeavyCalculation(data) {
  console.log("[ワーカー] 処理実行中...");
  // 時間がかかる処理
  return 10 * 2;
}

// ワーカー内から自身を閉じることも可能
// self.close();


ポイント

  • メインスレッドとWorkerスレッド間のデータのやり取りは、
    postMessage()onmessage イベントによる、メッセージパッシングで行われる。
    ⇒ メッセージは同じインスタンスを共有するのではなく、
      コピーしたオブジェクトを送り合うため、大きなデータの転送にはオーバーヘッドが発生する。

  • グローバルオブジェクトwindow は、Workerには存在せず、代わりに self が使われる。

  • Worker内から直接DOMを操作したり、
    一部の window オブジェクトのデフォルト機能(例: alert)の利用はできない。

Worker Threads(Node.js環境)

Node.js環境で、マルチスレッドを実現するためのモジュール。

使い方

// main.js
const {
  Worker,
  isMainThread,
  parentPort,
  workerData,
  threadId,
} = require("worker_threads");

if (isMainThread) {
  // メインスレッド側の処理
  console.log(`[メイン] スレッドID=${threadId} で開始`);

  const worker = new Worker(__filename, {
    workerData: { value: 10 }, // ワーカーに初期データを渡せる
  });

  // ワーカーからのメッセージ受信
  worker.on("message", (message) => {
    console.log(`[メイン] ワーカー ${worker.threadId} から受信:`, message);
  });

  // ワーカーへメッセージを送信する場合
  //   worker.postMessage("Hello Worker!");
} else {
  // ワーカー側の処理
  console.log(
    `[ワーカー ${threadId}] 開始 初期データ: ${JSON.stringify(workerData)}`
  );
  // メインからのメッセージ受信
  parentPort.on("message", (message) => {
    console.log(`[ワーカー ${threadId}] メインから受信:`, message);
  });

  // 何か処理を実行
  console.log(`[ワーカー ${threadId}] 処理実行中...`);
  const result = workerData.value * 2;

  // メインスレッドに結果を送信
  parentPort.postMessage({ result });
}

Web Workerとの違い

  • workerDataオプションで初期データを渡せる。
  • MessageChannel を使ってワーカー間で直接通信も可能。
  • Node.jsのAPI(一部を除く)が利用可能。

SharedArrayBuffer / Atomics

複数のスレッドでメモリ領域を直接共有するための仕組み。
共有時にコピーではなく参照を渡すことで、大きなデータの転送に伴うオーバーヘッドを軽減できる。

SharedArrayBuffer

スレッド間で共有可能な、固定長の生のバイナリデータを格納するための、メモリ領域(バッファ)を確保するオブジェクト。

Atomics

SharedArrayBufferによって確保された共有メモリ上のデータに対し、複数のスレッドが同時にアクセスしても競合状態が発生しないよう、
安全に操作を行うための静的メソッドを提供するグローバルオブジェクト。

使い方

メインスレッド側(Node.js環境)

const { Worker } = require("worker_threads");
const path = require("path");

// 4バイト (Int32) の共有メモリを作成
const sab = new SharedArrayBuffer(4);
const int32Array = new Int32Array(sab);

const worker = new Worker(path.resolve(__dirname, "worker_sab.js"));

// SharedArrayBuffer をワーカーに送信 (コピーではなく参照が渡る)
worker.postMessage({ sab });

// 少し待ってからワーカーに通知
setTimeout(() => {
  console.log("[メイン] 共有メモリ[0]に値を書き込み、ワーカーを起こす");
  Atomics.store(int32Array, 0, 123); // index 0 に 123 を安全に書き込む
  Atomics.notify(int32Array, 0, 1); // index 0 で待機しているワーカーを1つ起こす
}, 1000);

ワーカー側 (Node.js環境/worker_sab.js)

const { parentPort } = require("worker_threads");

parentPort.on("message", (e) => {
  const { sab } = e;
  const int32Array = new Int32Array(sab);

  console.log(
    "[ワーカー] メインスレッドからの通知を待機開始 (メモリ[0]が0の間)"
  );
  // index 0 の値が 0 である限り待機 (タイムアウトなし)
  Atomics.wait(int32Array, 0, 0);

  console.log("[ワーカー] メインスレッドからの通知で再開");

  const value = Atomics.load(int32Array, 0); // index 0 の値を安全に読み込む
  console.log("[ワーカー] 共有メモリ[0]の値を確認 =", value); // => 123
});

注意点

ブラウザ環境でSharedArrayBufferを利用するには、
サイドチャネル攻撃脆弱性のため、以下のHTTPヘッダーをサーバーから送信する必要がある。

  • COOP (Cross-Origin-Opener-Policy): same-origin
  • COEP (Cross-Origin-Embedder-Policy): require-corp または credentialless

以上

Discussion

junerjuner

コピーしたオブジェクトを送り合うため、大きなデータの転送にはオーバーヘッドが発生する。

transferable な ArrayBufer とか ReadableStream とか を移譲することで オーバーヘッドを押さえることができますね。(postMessage の第二引数で 移譲対象の transferable を指定する必要はありますが。

https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API/Transferable_objects

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer

https://developer.mozilla.org/ja/docs/Web/API/ReadableStream

※ただし、 ReadableStream の transferable は safari はまだ対応させてくれない。

uuuunnnniiuuuunnnnii

ありがとうございます!!
Atomics使いこなすの大変そうだなぁ…と思っていましたが、
多くのユースケースでは、transferableなオブジェクトで安全に実装できそうですね!
参考になります🙌

ログインするとコメントできます