🧱

Worker Threads でブロッキングを回避しよう

2024/01/13に公開

ブロッキングとは

Because Node.js handles many clients with few threads, if a thread blocks handling one client's request, then pending client requests may not get a turn until the thread finishes its callback or task.
https://nodejs.org/en/guides/dont-block-the-event-loop#what-does-this-mean-for-application-design

ブロッキングとは、1つのクライアントの処理がスレッドをブロックをすることであり、ブロッキングが発生すると、処理待ちのクライアントに順番が回らない可能性があります。

詳細については、以下の記事にて記載しています。
https://zenn.dev/dozo13189/articles/78e26ea96c5ae4

Worker Threads でブロッキングを回避しよう

Node.js を実装する上では、ブロッキングを避けながら実装を進める必要があるのですが、暗号化/復号化/圧縮などの処理は CPU バウンドになりがちです。そういったケースで Worker Thread を使用することで、メインスレッドとは別のスレッドに CPU バウンドな処理を移譲し、メインスレッドのブロッキングを回避することができます。

これは JavaScript の話? Node.js の話?

Worker Threads についてなので Node.js の話です。
https://nodejs.org/api/worker_threads.html#worker-threads

Web Worker というほぼ同じ仕組みがブラウザにも存在します。おそらく...Web Worker が先にブラウザにあり、その後に Node.js に同じ仕組みとして実装されています(自信なし)。
https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API/Using_web_workers

この記事は、Node.js の Worker Threads について記載していますが、ブロッキングを防ぐためにマルチスレッドを使用する、という考え方はブラウザでも同じです。

Worker Threads を使おう

以下に /simple という、sha256 を 100000 回するシンプルにメインスレッドをブロッキングするエンドポイントを実装しました。この CPU バウンドな処理を Worker Threads を使って、メインスレッドから切り離してみようと思います。

const http = require("http");
const sha256 = require('js-sha256');


http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader("Content-Type", "text/plain");

  if (req.url === "/simple") {
    let n = 100000;
    let hashed = String(Date.now())
    for (let i = 0; i < n; i++) {
      hashed = sha256(hashed + String(i))
    }
    return res.end(`${hashed}\n`);
  }

  return res.end(`Simple\n`);
})
.listen(3000);

/simple というエンドポイントを /threadsha というエンドポイントに書き換えました。sha256 を 100000 回する処理をメインスレッドとは別スレッドでしています。

// threads-hash.js
const http = require("http");
const threads = require("threads");

http
  .createServer(async (req, res) => {
    res.statusCode = 200;
    res.setHeader("Content-Type", "text/plain");

    if (req.url === "/threadsha") {
      const hashed = await main().catch(console.error);
      return res.end(`${hashed}\n`);
    }

    return res.end(`threads\n`);
  })
  .listen(4000);

async function main() {
  const auth = await threads.spawn(new threads.Worker("./threads-auth"));
  const hashed = await auth.hashPassword();

  await threads.Thread.terminate(auth);
  return hashed;
}
// threads-auth.js
const worker = require('threads/worker');
const sha256 = require('js-sha256');

worker.expose({
  hashPassword() {
    let n = 100000;
    let hashed = String(Date.now())
    for (let i = 0; i < n; i++) {
      hashed = sha256(hashed + String(i))
    }
    return hashed
  }
})

ちなみにライブラリは、threads.js を使用しています。今回は使用していませんが、スレッドを使い回す仕組み(スレッドプール)が実装されているためです。
https://github.com/andywer/threads.js

このような形でメインスレッドをブロッキングせずに、CPU バウンドな処理を別スレッドで進めることができます。

スレッドセーフなの?

マルチスレッドを扱う上では、スレッドセーフな処理となっているかは気をつける必要があります。

ウェブワーカーは他のスレッドとの通信ポイントが慎重に制御されているため、同時実行の問題を引き起こすことは実際には非常に困難です。
https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers#about_thread_safety

上記は、Web Worker に関する記載ですが、仕組みは Worker Threads も同じなのでこちらを参照します。ドキュメントによると、できるだけスレッドセーフとなるような仕組みとなっている(意訳)とあります。確かにグローバル変数の共有などはできませんし、スレッド間でのデータの受け渡しする方法は限られています。踏み込みが浅く、大したことはわかっていませんが、スレッドセーフに関する問題は起こしづらい、というふんわり理解を私はしました。

マルチスレッドの落とし穴

スレッドの生成はある程度、時間を要する処理のようです。「Worker Threads を使おう」の章で記載しているサンプルコードは、Worker Threads を使わない場合と使った場合を比較して、使った場合の方が私の実行環境ではパフォーマンスの向上が見られました(捌けるリクエストの数が向上しました)。

しかし、n の値によってはレスポンスタイムや捌けるリクエストの数など軒並み、Worker Threads を使わない場合の方がパフォーマンスがいいこともありました。

if (req.url === "/simple") {
  let n = 100000;
  let hashed = String(Date.now())
  for (let i = 0; i < n; i++) {
    hashed = sha256(hashed + String(i))
  }
  return res.end(`${hashed}\n`);
}

CPU バウンドな処理を Worker Threads に移譲してもパフォーマンスが向上するかどうかはわからないです。Worker Threads を使い始める場合は、どのパフォーマンス(レスポンスタイム/捌けるリクエスト数/エラー率など)を向上させる必要があるのかを決めた上で、負荷テストツールを用いて測定しながら進めるのが良いかと思います。

まとめ

Worker Threads でブロッキングは回避した方がいいですが、パフォーマンスが向上するかどうかは負荷テストツールで測定しながら慎重に進めましょう、という話でした。

Discussion