スレッドプール

事前に生成したスレッドを再利用し、スレッド生成のオーバーヘッドを削減する並行処理パターン

並行処理パフォーマンス

スレッドプールとは

スレッドプールは、事前に一定数のスレッドを生成しておき、タスクが到着するとプールからスレッドを割り当てて実行し、完了後にスレッドをプールに返却するパターンである。リクエストごとにスレッドを生成・破棄するオーバーヘッドを回避し、同時実行数を制御する。

仕組み

実体は「固定数のスレッド」と「タスクキュー」の組である。スレッドはキューの先頭からタスクを 1 つ取り出して実行し、終わったらまたキューを見に行く。この構造だけで、生成コストの削減と同時実行数の上限という 2 つの役目を同時に果たす。

タスクの到着 → [ タスクキュー ]  Task6 Task5 Task4
                     |
     +---------------+---------------+
 [Thread1]       [Thread2]       [Thread3]   ← 固定 3 スレッド
 Task1 実行中     Task2 実行中     Task3 実行中
     +--- 完了したらキューから次の 1 件を取る ---+

設計で最初に決めるべきなのは、タスクの到着が処理を追い越したときの振る舞いである。キューが無制限なら、待ちタスクがメモリに積み上がり続けて最後にプロセスが落ちる。キューに容量を決めておけば、満杯になった時点で投入側が待たされるか拒否され、負荷が上流へ押し戻る。後者を選ぶのが、あとから効いてくる判断になる。

Node.js の Worker Thread プール

Node.js が単一スレッドで回しているのは JavaScript の実行 (イベントループ) であり、プロセス自体は複数のスレッドを持つ。画像変換や暗号計算のようにイベントループを長く占有する処理は、worker_threads で別スレッドへ逃がす。プールの管理を自分で書かずに済ませるなら piscina が広く使われている。

import Piscina from 'piscina';

const pool = new Piscina({
  filename: './worker.js',
  maxThreads: 4,  // プールサイズ
});

// タスクをプールに投入
const results = await Promise.all(
  images.map(img => pool.run({ path: img, width: 800 }))
);

libuv のスレッドプール

アプリケーションが自分でプールを作らなくても、Node.js の内部には libuv のスレッドプールが既にある。既定サイズは 4 スレッドで、ここへ回されるのはファイルシステム API のほぼ全部 (同期版と fs.FSWatcher() を除く)、dns.lookup()dns.lookupService()crypto.pbkdf2()crypto.scrypt() のような重い暗号処理、そして zlib の非同期 API である。

逆に、TCP や HTTP のネットワーク I/O はこのプールを通らない。OS が用意する非同期通知をイベントループが直接受け取るため、接続 1 本ごとにスレッドを占有しない。「非同期処理は全部プールで動いている」と誤解すると、サイズを増やしても何も速くならない場面で調整に時間を使うことになる。

# libuv のスレッドプールサイズを変更
UV_THREADPOOL_SIZE=8 node app.js

この環境変数は起動時にだけ読まれる。プールはユーザーコードより先に作られるので、実行中に process.env へ代入しても効かない。指定できる上限は 1024 で、libuv 1.30.0 で 128 から引き上げられた。

ファイル I/O や pbkdf2 の呼び出しが多いアプリケーションでは、既定の 4 スレッドがボトルネックになることがある。ただしプールが初めて使われた時点で指定数のスレッドをまとめて作るため、増やせばその分のスレッドスタックを抱える。効果と引き換えを計測しながら動かす値である。

プールサイズの設計

判断の軸は 1 つで、スレッドが実際に CPU を使っている時間の割合である。計算しかしないタスクはコア数を超えて増やしても実行待ちが増えるだけだが、待ち時間の長いタスクなら待っている間に別のスレッドが走れるため、コア数より多いほうが速くなる。

ワークロードサイズの目安理由
CPU バウンドCPU コア数コア数以上にしてもコンテキストスイッチが増えるだけ
I/O バウンドCPU コア数の 2〜4 倍待ち時間と計算時間の比が大きいほど、増やす余地も大きい
混合計測して調整プロファイリングで最適値を決定

倍率は最初の当たりを付けるための数字で、正解ではない。動かす前に、飽和しているのがプールなのか、その先のディスク・DB・外部 API なのかを切り分ける。後者ならスレッドを増やしても待ち行列の置き場所が変わるだけで終わる。

DB コネクションプールとの関係

DB コネクションプールはスレッドプールの応用ではなく、「作り直さずに再利用する」という同じ考え方 (オブジェクトプール) を TCP 接続に当てたものである。接続確立のコスト (TCP ハンドシェイク、TLS、認証) を毎回払わずに済む。

この 2 つのプールは独立に決められない。スレッド数を接続数より大きくすると、あふれたスレッドは接続の空きを待つだけになり、待ち行列がスレッドプールから接続プールへ移動して終わる。Lambda のように実行環境が横に大量に並ぶ構成では DB 側の接続上限が先に尽きるため、RDS Proxy のような外部のプールへ接続の管理を寄せる。

Java の ExecutorService

Java ではスレッドプールの窓口が ExecutorService になる。submit() がタスクを預かって Future を返し、get() が完了まで待って結果を取り出す。get() は検査例外を 2 つ投げるため、呼び出し側は必ずその処理を書くことになる。

ExecutorService pool = Executors.newFixedThreadPool(4);
try {
    Future<String> future = pool.submit(() -> {
        return processData();
    });
    String result = future.get(); // 完了までブロックする
    System.out.println(result);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt(); // 割り込み状態を戻してから抜ける
} catch (ExecutionException e) {
    // タスクが投げた例外は ExecutionException に包まれて届く
    System.err.println("タスク失敗: " + e.getCause());
} finally {
    pool.shutdown(); // 新規受付を止める (実行中のタスクは完了を待つ)
}

落とし穴は newFixedThreadPool() の内側にある。このファクトリが作るのは共有の無制限キューを持つプールなので、投入が処理より速い状態が続くとキューが際限なく伸びる。流量に上限を設けたいなら ThreadPoolExecutor を直接組み立て、容量を決めた BlockingQueue を渡す。満杯時の既定の振る舞いは AbortPolicy で、RejectedExecutionException を投げて投入側に失敗を伝える。

現場での応用を知るには関連書籍も役立つ。

この記事は役に立ちましたか?

関連用語

関連する記事