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 を作ること。」
