Java | Java 詳細・モダン文法:並行・非同期 – ThreadPool 設計

Java Java
スポンサーリンク

ThreadPool を一言でいうと「スレッドの再利用工場」

ThreadPool(スレッドプール)は、
あらかじめ何本かスレッドを作っておき、タスクをそこに流し込んで再利用する仕組み」です。

毎回 new Thread していると、スレッドの作成・破棄コストが高くなり、
大量のタスクを捌くときに逆に遅くなったり、不安定になったりします。

ThreadPool は、
「スレッドは工場の作業員、タスクは作業依頼」
というイメージで、作業員を使い回しながらタスクを処理します。

ここでは、その ThreadPool を「どう設計するか」に焦点を当てて話します。


ThreadPool 設計で必ず考えるべき 3 つの軸

軸1:タスクの性質(CPU バウンドか I/O バウンドか)

まず最初に考えるべきなのは、
「このプールで実行するタスクは何者か?」です。

CPU をガリガリ使う計算処理(画像処理、暗号、集計など)は CPU バウンド、
ネットワークやディスク待ちが多い処理(HTTP 通信、DB アクセス、ファイル I/O など)は I/O バウンドです。

CPU バウンドなタスクは、
「CPU コア数以上にスレッドを増やしても、ほとんど意味がない」
どころか、コンテキストスイッチが増えて逆に遅くなります。

I/O バウンドなタスクは、
「待ち時間が多いので、コア数より多めのスレッドを動かしても回る」
ことが多いです。

ThreadPool のスレッド数を決めるとき、
「このプールは CPU バウンド用か? I/O バウンド用か?」
をまず言語化しておくのが、設計の第一歩です。

軸2:同時にどれくらい並列にしたいか(スレッド数)

次に、「同時に何本まで動かしたいか」です。

固定スレッドプール(newFixedThreadPool(n))なら、
この n がそのまま「最大並列数」になります。

CPU バウンドなら、
「コア数」か「コア数+1〜2」くらいが目安になります。

I/O バウンドなら、
「コア数の数倍」でも回ることが多いですが、
外部システム(DB、API)の負荷も考慮する必要があります。

大事なのは、
「なんとなく 100」とかではなく、
「なぜこの数なのか」を一言で説明できることです。

例えば、
「このプールは CPU バウンドの画像処理用なので、
8 コアマシンで 9 スレッドにしている」
といった具合です。

軸3:タスクが溢れたときどうするか(キューと拒否戦略)

スレッド数より多くタスクが来たとき、
ThreadPool は「キュー」にタスクを溜めて順番待ちさせます。

ここで考えるべきことは 2 つです。

キューを無限にするのか、上限を決めるのか。
上限を決めた場合、溢れたタスクをどう扱うのか(拒否戦略)。

無限キューは一見安全そうですが、
タスクが増え続けるとメモリを食い尽くします。

上限付きキューにすると、
「これ以上は受け付けない」という線を引けますが、
溢れたタスクをどうするかを決めないといけません。

例えば、
「新しいタスクを拒否して例外を投げる」
「呼び出し元スレッドで実行してしまう」
などの戦略があります。

ThreadPool 設計では、
「タスクが来すぎたときにどう振る舞うか」を
あらかじめ決めておくことがとても重要です。


Java の ThreadPool(ThreadPoolExecutor)を設計視点で見る

ThreadPoolExecutor の主要パラメータ

Java の ThreadPoolExecutor は、
ThreadPool 設計の要素をそのままパラメータにしたようなクラスです。

ざっくり言うと、次のようなものを指定できます。

コアスレッド数(corePoolSize)
最大スレッド数(maximumPoolSize)
キュー(BlockingQueue<Runnable>)
スレッドのアイドル時間(keepAliveTime)
拒否戦略(RejectedExecutionHandler)

例えば、固定スレッドプールは、
「コア数 = 最大数 = n、キューは無限、拒否戦略はデフォルト」
という設定の ThreadPoolExecutor だと思って構いません。

自前で ThreadPoolExecutor を組み立てる例

少し踏み込んで、自分で設計してみます。

import java.util.concurrent.*;

public class CustomPoolExample {
    public static void main(String[] args) {
        int core = 4;
        int max = 8;
        long keepAlive = 60L;

        BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(100);

        RejectedExecutionHandler handler = new ThreadPoolExecutor.CallerRunsPolicy();

        ThreadPoolExecutor executor =
                new ThreadPoolExecutor(core, max, keepAlive, TimeUnit.SECONDS, queue, handler);

        // ここにタスクを submit していく
    }
}
Java

この設計を言葉にすると、こうなります。

「通常は 4 本のスレッドで回し、
タスクが溜まってきたら最大 8 本まで増やす。
タスクの待ち行列は最大 100 個まで。
それ以上来たら、タスクを投げたスレッド自身に実行させる(CallerRunsPolicy)。
しばらく使われないスレッドは 60 秒で破棄する。」

このように、ThreadPoolExecutor のパラメータは、
「負荷が高いときにどう振る舞うか」を設計するための部品だと捉えると理解しやすくなります。


典型的な ThreadPool 設計パターン

パターン1:CPU バウンド処理用の固定プール

画像処理や集計など、CPU をフルに使う処理用のプールです。

int cores = Runtime.getRuntime().availableProcessors();
ExecutorService cpuPool = Executors.newFixedThreadPool(cores + 1);
Java

設計の意図は、
「CPU コア数と同じか、少し多いくらいのスレッドで回す。
I/O 待ちがほぼないので、これ以上増やしても意味がない。」
というものです。

このプールには、
「重い計算タスクだけを投げる」
というルールにしておくと、設計がクリアになります。

パターン2:I/O バウンド処理用のやや多めプール

外部 API 呼び出しや DB アクセスなど、
待ち時間が多い処理用のプールです。

int cores = Runtime.getRuntime().availableProcessors();
int poolSize = cores * 2; // 例としてコア数の2倍
ExecutorService ioPool = Executors.newFixedThreadPool(poolSize);
Java

設計の意図は、
「スレッドの多くが I/O 待ちで止まっている時間が長いので、
コア数より多めにスレッドを動かしても CPU は飽和しない。」
というものです。

ただし、外部システム側の負荷(DB の接続数上限など)もあるので、
「なぜこの数なのか」をインフラ側ともすり合わせる必要があります。

パターン3:シングルスレッドで順番を保証するプール

順番が重要な処理(例えば、ログの書き込みや、
あるユーザー単位で順序を守りたい処理)には、
シングルスレッドのプールが向きます。

ExecutorService single = Executors.newSingleThreadExecutor();
Java

設計の意図は、
「並列にはしたくないが、メインスレッドとは別に処理したい。
このプールに投げたタスクは、必ず 1 つずつ順番に実行される。」
というものです。


ThreadPool 設計でやりがちな落とし穴

落とし穴1:とりあえず大きなプールを作る

「スレッドが多いほど速いだろう」と思って、
newFixedThreadPool(1000) のようにしてしまうパターンです。

実際には、

スレッドが増えるほどコンテキストスイッチが増え、
CPU は「仕事」ではなく「スレッドの切り替え」に時間を使うようになります。

また、スレッドごとにスタックメモリも必要なので、
メモリも無駄に消費します。

「スレッド数は、CPU コア数とタスクの性質から決める」
という原則を忘れないことが大事です。

落とし穴2:プールを作りっぱなしで shutdown しない

ExecutorService を作ったら、
使い終わったタイミングで shutdown() する必要があります。

これを忘れると、
プログラムが終了しなかったり、
テストが終わらなかったりします。

長寿命のアプリケーション(Web アプリなど)では、
アプリケーションのライフサイクルに合わせて
プールの開始と終了を管理する設計が必要です。

落とし穴3:何でもかんでも同じプールに投げる

CPU バウンドなタスクも、I/O バウンドなタスクも、
重い処理も軽い処理も、全部同じプールに投げてしまうと、
どこで詰まっているのか分かりにくくなります。

理想は、

「このプールは何用か?」
「どんなタスクだけを受け付けるのか?」

を決めておき、
用途ごとにプールを分けることです。


まとめ:ThreadPool 設計を自分の言葉で説明するなら

あなたの言葉で ThreadPool 設計をまとめると、こうなります。

「ThreadPool は、スレッドを使い回してタスクを実行する工場。
設計するときは、
『このプールで実行するタスクは CPU バウンドか I/O バウンドか』
『同時に何本まで並列にしたいか(スレッド数)』
『タスクが溢れたときどうするか(キューと拒否戦略)』
の 3 つをまず決める。

Java では ThreadPoolExecutor のパラメータとして、
コア数・最大数・キュー・keepAlive・拒否戦略を組み合わせて、
負荷が高いときの振る舞いを細かく設計できる。

大事なのは、
“なんとなく” ではなく
『なぜこのスレッド数なのか』『なぜこのキューサイズなのか』を
自分の言葉で説明できる ThreadPool を作ること。」

タイトルとURLをコピーしました