LASSIC Media らしくメディア
スレッドプールとは|並行処理の実行を束ねる仕組み
Webアプリケーションやバッチ処理では、同時に大量のリクエストやジョブをさばく場面が増えています。処理のたびに新しいスレッドを立ち上げる方式は分かりやすい反面、同時実行数が伸びるほどメモリ消費と切り替えの負荷が積み上がっていきます。台数を増やしても応答が改善せず、かえって重くなるという逆転現象も起こり得るのです。開発会社に処理方式を相談すると「スレッドプールで並行数を管理しましょう」という提案を受けることも多いテーマでしょう。
この悩みを抑える仕組みが、スレッドプールです。あらかじめ用意した複数のスレッドを使い回し、タスクの実行を効率よく束ねる考え方で、Webサーバやジョブ実行基盤の土台として広く採り入れられています。なお、複数のスレッドが同じデータへ同時にアクセスする際の競合状態や、それを防ぐ排他制御・ロックは別のテーマです。本記事では、実行の並行度を管理してリソースの枯渇を防ぐ仕組みとしてのスレッドプールに絞って整理します。発注担当者やプロジェクトマネージャーが、開発会社とのレビューでそのまま使える粒度を意識して構成しました。用語の細かな実装差よりも、判断に使える考え方の骨格を優先する構成です。
目次
この記事のポイント
- スレッドプールとは、あらかじめ用意した複数のスレッドを使い回し、大量のタスクを効率よく並行処理するための仕組みです。
- スレッドの生成・破棄コストを抑えつつ同時実行数に上限を設けられるため、負荷が急増してもシステム全体が崩れにくくなります。
- CPUバウンドとI/Oバウンドで適正なプールサイズが異なり、キュー長の上限やあふれ時の挙動(待機・拒否)を決めておくことが設計の勘所です。
スレッドプールとは
スレッドプールとは、あらかじめ用意した複数のスレッド(処理の実行単位)を使い回し、大量のタスクを効率よく並行処理するための仕組みです。「並行処理を束ねる置き場」と捉えると、イメージがつかみやすいでしょう。
混同されやすい概念に触れておきます。複数のスレッドが同じデータへ同時にアクセスする際に生じる競合状態(レースコンディション)や、それを防ぐ排他制御・ロックは、スレッドプールとは別のテーマです。本記事は、そうした整合性の話ではなく、実行の並行度そのものを管理し、リソースの枯渇を抑える仕組みに範囲を絞って扱います。
スレッドの生成と破棄には、OSレベルでの処理コストがかかります。リクエストのたびに新しいスレッドを作っては捨てる方式では、この生成・破棄のコストが積み重なり、本来の処理に割くべき計算資源が奪われてしまうのです。Wikipedia英語版の解説でも、あらかじめ複数のスレッドを起動しておく設計が紹介されています。生成コストをプールの構築時だけに抑え、性能と安定性の向上につながる、という説明です。
もう一つの狙いは、同時に動くスレッド数を管理下に置くことです。プールのスレッド数を決めておけば同時実行数の上限が定まり、負荷が急に増えてもシステム全体が崩れにくくなります。Java公式ドキュメントのThreadPoolExecutor解説でも、タスク実行時に消費されるリソースの管理という利点が挙げられているのです。
身近な例で例えると、窓口を都度増設・撤去するのではなく、決まった数の窓口担当者が列に並んだ来訪者を順番にさばいていく体制に近いといえます。担当者の人数を変えずに列だけが伸び縮みするため、混雑の波を担当者の入れ替えなしで受け止められる点が、この仕組みの持ち味です。
一方で、どんな場面でもプールが向くわけではありません。処理件数がわずかで、都度生成のコストが問題にならない小規模な用途では、プールを管理する複雑さの方が上回ることもあるでしょう。適用の可否は、想定する同時実行数や処理頻度を踏まえて判断する事項になります。判断に迷う場合は、まず現状の処理件数と応答時間を計測してから検討するのが手堅い進め方です。
使い回さない場合に生じるコストと過負荷
スレッドを使い回さない設計を取ると、二つの困りごとが表面化します。順番に見ていきましょう。
一つ目は、リクエストのたびにスレッドを生成するコストです。生成と破棄をくり返すほどメモリ消費とコンテキストスイッチの負荷が増え、本来のタスク処理に割ける時間が目減りします。処理件数が少ないうちは気づきにくいものの、同時アクセスが伸びるにつれて負荷は無視できなくなっていきます。
二つ目は、スレッド数に上限を設けない場合の危うさです。Java公式ドキュメントでは、プールの上限数(maximumPoolSize)をInteger.MAX_VALUEに設定できると説明されています。この状態は「アンバウンド形式のプール」と位置付けられており、負荷が急増するとスレッドが際限なく増えてメモリを圧迫し、処理全体が詰まる要因になり得ます。
Wikipedia英語版の解説でも、控えるスレッド数が多すぎる場合はメモリを浪費し、スレッド切り替えのたびに性能面の代償が生じると指摘されています。使い回しの仕組みを持たない、あるいは上限のないスレッド運用は、平常時は問題なく動いていても、負荷が跳ね上がった瞬間に破綻しやすい設計だといえるでしょう。
発注担当者の視点では、この二つの困りごとは開発の初期段階で見落とされがちな点でもあります。機能要件のレビューでは触れられにくい一方、アクセス数が伸びた局面で初めて表面化しがちです。要件定義や設計レビューの早い段階から、論点に含めておくことが望ましいといえます。
特に、キャンペーンやメディア掲載などで一時的にアクセスが集中する見込みがある場合は、平常時の負荷だけでなくピーク時の負荷も想定に含めておく必要があります。ピーク時の想定を欠いたまま設計を進めると、稼働後に慌てて構成を見直す事態になりかねません。
仕組み:固定数のワーカーとタスクのキュー
スレッドプールの土台となる考え方は、固定数のワーカースレッドと、そこに渡されるタスクのキューの組み合わせです。仕組み自体は、意外なほどシンプルになっています。
タスクが発生すると、まずキューに一旦積まれます。あらかじめ用意された複数のワーカースレッドは、手が空くたびにキューの先頭からタスクを取り出し、順番に処理していくのです。新しいスレッドをその都度作ることはなく、既存のスレッドが役目を終えるたびに次のタスクへ移ります。下の図は、この関係を表したものになります。
この仕組みの利点は、同時に動くスレッド数を決まった範囲に保ちながら、大量のタスクを取りこぼさずに受け止められる点にあります。Python公式ドキュメントでも、ThreadPoolExecutorは待機中のワーカースレッドを再利用してタスクを割り当てる設計だと説明されています。
Java公式ドキュメントのExecutors解説を見ると、newFixedThreadPoolは指定した数のスレッドを再利用するプールを作るメソッドです。すべてのスレッドが稼働中のときは、追加のタスクをキューで待たせると説明されています。プールの規模を固定し、キューで受け止めるという基本形は、この記事の図とも対応しているといえるでしょう。
プールの規模は、固定数だけでなく可変にする設計も選べます。Java公式ドキュメントのThreadPoolExecutor解説では、通常時に維持するスレッド数(corePoolSize)が定義されています。加えて、負荷が高い局面で一時的に増やせる上限数(maximumPoolSize)を、別途設定できるとも説明されているのです。両者に差を持たせておけば、平常時は少ない数で運用し、混雑時だけスレッドを追加する、という柔軟な構成も選択肢になるでしょう。ただし、増やしたスレッドを保つ時間(キープアライブ時間)の設定しだいで、アイドル時の解体タイミングも変わってくる点には注意が要ります。
ただし、プールのスレッド数そのものは、多ければよいというものではありません。負荷に対してどの程度の規模が向いているかで、起きやすい挙動が変わってきます。
| プールサイズ | 起きやすい挙動 |
|---|---|
| 小さすぎる場合 | ワーカーが埋まりきり、キューに滞留するタスクが増えて応答が遅れます。 アクセス集中時ほど、この遅れが目立ちやすくなります。 |
| 負荷に見合った場合 | キューの滞留と切り替えの負荷がともに抑えられ、処理が安定して流れます。 実測しながら調整した規模は、この状態に近づきやすいでしょう。 |
| 大きすぎる場合 | スレッドの切り替えコストが増え、メモリ消費もかさんで性能面の代償が生じます。 台数を増やすほど性能が上がるとは限らない点に注意が要ります。 |
設定の勘所:処理特性とキューあふれへの対応
スレッドプールの規模を決める作業は、製品まかせで済む場面ばかりではありません。開発会社に一任した設定値であっても、発注者側が観点を知っておけば、レビューの質は大きく変わってくるでしょう。発注段階で押さえておきたい観点を挙げていきます。
CPUバウンドとI/Oバウンドで目安が変わる
プロセッサを使う計算中心の処理では、コア数に近いスレッド数が目安になります。それを超えて増やしても、切り替えの負荷が処理効率を上回ってしまうのです。画像処理や暗号化処理のように、CPUを使い切るタスクが典型例といえるでしょう。
一方、外部APIやディスクへの問い合わせを待つ時間が長いI/Oバウンドな処理では、待機中に別のタスクを進められるため、コア数を上回るスレッド数が有効に働きます。Python公式ドキュメントのconcurrent.futures解説によると、ThreadPoolExecutorはI/O処理を重視する設計です。そのためProcessPoolExecutorより、ワーカー数を増やす前提が示されています。max_workersの既定値は、min(32, os.cpu_count() + 4)という式で決まります。コア数が少ない環境でも、扱えるワーカー数の下限を確保する狙いだと読み取れるでしょう。
キューの方式とあふれの防ぎ方を選ぶ
受け付けるタスクの量が処理速度を上回り続けると、キューは際限なく伸びてメモリを圧迫します。Java公式ドキュメントのThreadPoolExecutor解説では、キューの持たせ方として3つの方式が挙げられており、どれを選ぶかで挙動が変わってきます。
| キューの方式 | 挙動の特徴 |
|---|---|
| 直接ハンドオフ(SynchronousQueue) | タスクを保持せず、空いているスレッドがなければ新しいスレッドをその場で生成します。 スレッド数の上限が高く保たれていないと、処理が滞る可能性があります。 |
| アンバウンド形式(LinkedBlockingQueue) | 容量制限がなく、コアの数を超えるスレッドは新たに作られません。 急な負荷増にも対応できる反面、メモリ圧迫のリスクを抱えます。 |
| バウンド形式(ArrayBlockingQueue) | 容量に制限を設け、リソースの枯渇を防ぎます。 あふれた際の扱いをあわせて設計する必要があります。 |
容量制限のない方式は手軽な反面、負荷が続くとメモリを圧迫する危うさを抱えています。容量制限付きの方式を選ぶ場合は、あふれたタスクをどう扱うかという次の論点とあわせて設計しておくことが欠かせません。
あふれたタスクの扱い方を決めておく
キューが上限に達したタスクをどう扱うかも、設計段階で決めておきたい点です。呼び出し元を待たせて流量を抑える方式と、受け付けを断って早期にエラーを返す方式のどちらを選ぶかで、システム全体の挙動が変わってきます。どちらの方式を採るにせよ、あふれが発生した際の通知や記録を用意しておかないと、原因の特定に時間がかかってしまいます。
稼働状況を可視化しておく
設定した値が実運用に見合っているかどうかは、動かしてみないと分からない部分もあります。Java公式ドキュメントのThreadPoolExecutor解説では、完了したタスク数などの基本的な統計情報を保持すると説明されています。こうした情報を監視の指標に取り込む設計にしておくと、運用開始後の判断材料が増えるでしょう。稼働中のスレッド数やキューの滞留量を定点観測できる状態にしておけば、設定変更の要否を勘や経験に頼らず判断しやすくなります。ダッシュボードやアラートへの組み込み方まで、発注段階の要件に含めておくと後工程の手戻りを防げます。
身近な例:Webサーバ・DB・ジョブ実行基盤
スレッドプールは、身近な基盤のあちこちで働いています。代表的な使いどころを見てみましょう。
Webサーバでは、受信したリクエストをワーカースレッドのプールに割り当て、同時に処理できるリクエスト数を管理する土台として使われています。フレームワークの設定画面でスレッド数を指定する項目を見かけた場合、その裏側ではおおむねこの仕組みが動いていると考えてよいでしょう。
データベースまわりでも「接続」を使い回すプールの仕組みが広く使われています。これはスレッドではなく接続そのものを再利用する別の仕組みであり、詳しい設計は別記事の範囲です。ただし、生成コストの高い資源を再利用するという発想は、スレッドプールと共通しているといえるでしょう。
バッチ処理やジョブ実行基盤でも、投入されたジョブをキューに積む設計が使われています。あらかじめ用意したワーカーが順番に消化していく発想は、スレッドプールと変わりません。
| 使われる場面 | 役割の例 |
|---|---|
| Webサーバ | 受信したリクエストをワーカースレッドに割り当て、同時処理数を管理します。 設定画面のスレッド数・接続数といった項目に反映されている場合があります。 |
| ジョブ実行基盤 | 投入されたジョブをキューに積み、空いたワーカーが順に取り出して処理します。 バッチのスケジューラや非同期処理の基盤で見られる構成です。 |
| 言語・実行環境の標準ライブラリ | JavaのExecutorsやPythonのThreadPoolExecutorなど、上位APIとして提供されます。 アプリケーション側は、細かな生成・破棄の処理を意識せずに済みます。 |
いずれの用途にも共通するのは、「タスクの数が処理能力を上回る局面が起こり得る」という前提です。混雑時に全体を組み替えずに済む土台があることが、そのまま応答の安定へつながります。名前を表に出さずとも、身近なサービスの裏側で静かに働いている場面は珍しくないでしょう。
フレームワークや実行基盤に組み込まれた既定値をそのまま使い続けている現場も見受けられます。既定値は汎用的な想定で決められているため、対象システムの負荷特性に合っているとは限りません。次の章のテーマは、発注・レビューの場で確認しておきたい観点です。
発注・レビューで確認したいポイント
スレッドプールそのものを一から作り込む場面は、実のところ多くありません。ほとんどは、それを内部で使っている製品やフレームワークの設定を、正しく握って活かすことがほとんどです。設計書やソースコードのレビューでは、この設定値がどこにあり、どんな根拠で決められたのかを確認する視点が欠かせません。発注やレビューの段階で意識しておきたい点を挙げます。
想定同時実行数を握れているか
まずは、対象のシステムがどの程度の同時アクセスやジョブ量を想定しているのかを、発注者と開発側で共有しておくことが欠かせません。ここが曖昧なままだと、プールのサイズ設定そのものが根拠のない当てずっぽうになってしまいます。ピーク時の想定値と平常時の想定値を分けて共有しておくと、レビューの精度が上がるでしょう。過去のアクセスログや類似システムの実績があれば、それを根拠に想定値を組み立てると説得力が増します。
上限設定の有無を確認する
スレッド数やキューの長さに上限が設けられているかどうかは、設計レビューで欠かさず確認したい項目でしょう。上限のない設定は、平常時は問題が出にくい一方、負荷が跳ね上がった局面で一気に表面化するリスクを抱えています。
あふれたときの挙動を確かめる
キューやプールが上限に達した際に、待たせるのか、拒否するのかという方針を、開発会社との会話で具体的に確認しておきます。この作業を発注側だけで内製しようとすると、Java・Pythonをはじめとする複数言語の実装差を把握する知識が求められます。フレームワーク固有の既定値まで踏み込むと、担当者一名では手が回りにくい範囲になりがちでしょう。専門パートナーに設計・レビューを依頼すれば、こうした挙動確認と負荷試験をあわせて任せられる違いがあります。実際に想定した負荷をかけて挙動を確かめる負荷試験まで含めてレビューできると、公開後の想定外を減らせるでしょう。
ここまで挙げた3つの確認点は、いずれも稼働してから初めて問題が見えるという共通の性質を持っています。設計レビューの議事録に明文化し、開発会社と認識をそろえておくことが、後々の手戻りを防ぐ近道になるはずです。
まとめ
- スレッドプールは、あらかじめ用意した複数のスレッドを使い回し、タスクを効率よく並行処理する仕組みである。
- 使い回さない設計や上限のない設定は、生成コストの積み重ねや負荷急増時のメモリ圧迫につながりやすい。
- 固定数のワーカーとタスクのキューを組み合わせ、プールサイズは負荷に見合った規模に整えるのが基本となる。
- CPUバウンドかI/Oバウンドかで適正なスレッド数は変わり、キューの上限とあふれ時の方針も合わせて設計する。
- 稼働状況を統計情報として可視化しておくと、設定変更の要否を判断しやすくなる。
- 発注・レビューでは、想定同時実行数・上限設定の有無・あふれ時の挙動の3点を確認しておきたい。
よくある質問
スレッドプールのサイズは、どのように決めればよいですか。
処理の性質によって目安が変わります。プロセッサを使う計算中心の処理ではコア数に近い数、外部APIやディスクへの問い合わせを待つ時間が長い処理ではコア数より多い数が有効に働きます。Python公式ドキュメントでは、こうした前提を踏まえThreadPoolExecutorの既定値をコア数に応じた式で算出する設計です。最終的には、想定する同時実行数に合わせて実測しながら調整するのが現実的な進め方でしょう。フレームワークの既定値をそのまま使う場合も、一度は根拠を確認しておくことをおすすめします。
タスクがキューにあふれた場合は、どうなりますか。
あふれた際の挙動は、採用する方式によって異なります。呼び出し元を待たせて流量を抑える方式もあれば、受け付けを断って早期にエラーを返す方式もあり、どちらを選ぶかはシステムの要件次第です。Java公式ドキュメントでも、容量制限のないキューと制限のあるキューでは、あふれに至るまでの挙動が異なると整理されています。あふれの発生を検知し記録する仕組みも、あわせて用意しておくと運用時の調査がしやすくなるでしょう。発注段階でこの方針を決めておくと、開発途中の手戻りを防ぎやすくなります。
スレッドプールと、データベースの接続プールは同じ仕組みですか。
別の仕組みです。スレッドプールはタスクを実行する「スレッド」を使い回す仕組みであるのに対し、接続プールはデータベースへの「接続」を使い回す仕組みを指します。どちらも生成コストの高い資源を再利用して負荷を抑える発想は共通していますが、扱う対象と設定すべき項目は異なります。混同せず、別々に設計することが大切でしょう。両方を組み合わせて使うシステムも珍しくありません。
スレッドプールを使えば、競合状態(レースコンディション)への対策も不要になりますか。
不要にはなりません。スレッドプールが管理するのは、同時に動くスレッドの数や実行の割り当てです。複数のスレッドが同じデータへ同時にアクセスする際の整合性は、排他制御やロックなど別の仕組みで担う領域になります。プールを導入した場合でも、共有データを扱う処理では従来どおり競合状態への配慮が求められるでしょう。両者は組み合わせて設計する場面がほとんどです。
著者:テレリモ総研編集部 鈴木 亮佑
ご不明な点はお問い合わせフォームからもご連絡いただけます。
出典
- Oracle「Java Platform, Standard Edition 8 API仕様 ThreadPoolExecutor」
- Oracle「Java Platform, Standard Edition 8 API仕様 Executors」
- Python Software Foundation「Python 3 ドキュメント concurrent.futures — 並列タスクの実行」
- Wikipedia(英語版)「Thread pool」