Javaの理論と実践: スレッド・プールとワーク・キュー

スレッド・プールによる最適なリソース使用

Multithreaded Java programmingディスカッション・フォーラム に寄せられる最も一般的な質問の1つは、「どのようにスレッド・プールを作成するか」といった類の質問です。どのサーバー・アプリケーションでもたいてい、スレッド・プールとワーク・キューに関する問題が出てきます。今回の記事ではBrian Goetz氏が、スレッド・プールを使用する動機、いくつかの基本的な実装と調整のテクニック、回避すべきいくつかの一般的な障害について説明します。

Brian Goetz (brian@quiotix.com), Principal Consultant, Quiotix

Brian Goetz は18 年間以上に渡って、専門的ソフトウェア開発者として働いています。彼はカリフォルニア州ロスアルトスにあるソフトウェア開発コンサルティング会社、Quiotixの主席コンサルタントであり、またいくつかのJCP Expert Groupの一員でもあります。2005年の末にはAddison-Wesleyから、Brianによる著、Java Concurrency In Practiceが出版される予定です。Brian著による有力業界紙に掲載済みおよび掲載予定の記事のリストを参照してください。



2002年 7月 01日

なぜスレッド・プールか

Webサーバーやデータベース・サーバー、ファイル・サーバー、メール・サーバーなどの多くのサーバー・アプリケーションは、あるリモート・ソースから到着する多数の短いタスクを処理するのに向いています。要求は、ネットワーク・プロトコル (HTTP、FTP、POPなど) やJMSキューを介したり、時にはデータベースをポーリングするなどの何らかの方法でサーバーに到着します。要求がどのような方法で到着するかとは関係なく、多くの場合、サーバー・アプリケーションでは各タスクの処理時間は短く、また要求数は多くなります。

サーバー・アプリケーションを構築するためのきわめて単純な1つのモデルは、要求が到着するたびに新しいスレッドを作成し、その新しいスレッドで要求に対応することです。このアプローチはプロトタイピングに対しては実際に適切に機能しますが、そのように機能するサーバー・アプリケーションを実配備しようとした場合、大きな欠点が明らかになるでしょう。要求ごとにスレッドを作成するアプローチの欠点の1つは、各要求に対して新しいスレッドを作成する際のオーバーヘッドが大きいという点です。各要求に対して新しいスレッドを作成するサーバーは、スレッドの作成と破棄のために、実際のユーザー要求を処理するよりも多くの時間を費やし、より多くのシステム・リソースを消費することになります。

スレッドの作成と破棄のオーバーヘッドに加えて、アクティブなスレッドもシステム・リソースを消費します。1つのJVM内でスレッドを多く作りすぎると、システムではメモリーの過剰消費によるメモリー不足やスラッシングが発生する可能性があります。リソース・スラッシングを避けるためには、一定時に処理される要求数を制限する何らかの手段がサーバー・アプリケーションに必要です。

スレッド・プールは、スレッドのライフサイクル・オーバーヘッドの問題とリソース・スラッシングの問題の両方に対するソリューションを提供します。複数のタスクに対してスレッドを再利用することによって、スレッド作成のオーバーヘッドが多くのタスクに分散されます。さらに、要求が到着したときにスレッドがすでに存在しているため、スレッド作成によって生じる遅延が解消されます。したがって、要求に直ちに対応することができ、アプリケーションの応答性を高めることができます。また、スレッド・プール内のスレッド数を適切に調整し、一定のしきい値を超えた要求は、スレッドがそれを処理できるようになるまで待たせることによって、リソース・スラッシングを回避することができます。


スレッド・プールの代替手段

スレッド・プールが、サーバー・アプリケーション内で複数のスレッドを使用する唯一の方法というわけでは決してありません。上述のように、新しいタスクごとに新しいスレッドを作成するのがきわめて適切である場合もあります。しかし、タスクの作成頻度が高く、タスクの平均持続時間が短い場合には、タスクごとに新しいスレッドを作成するとパフォーマンスの問題が生じます。

別の一般的なスレッド化モデルは、特定のタイプのタスクに対して単一のバックグラウンド・スレッドとタスク・キューを持つことです。AWTとSwingはGUIイベント・スレッドをもつこのモデルを使用しており、ユーザー・インターフェースの変更をもたらすワーク全体はそのスレッドで実行しなければなりません。しかしAWTスレッドは1つしかないので、完了までにかなりの時間がかかる可能性のあるタスクをAWTスレッドで実行するのは望ましくありません。その結果Swingアプリケーションでは、多くの場合、長時間実行されるUI関連のタスクに対して追加のワーカー(作業用)・スレッドが必要になります。

タスクごとにスレッドを作成するアプローチも、単一のバックグラウンド・スレッドを持つアプローチも、状況によっては完ぺきに機能します。タスクごとにスレッドを作成するアプローチは、少ない数の長時間実行されるタスクの場合きわめて適切に機能します。単一のバックグラウンド・スレッドを持つアプローチは、優先順位の低いバックグラウンド・タスクの場合と同様、スケジューリングの予測可能性が重要でない限りきわめて適切に機能します。しかしほとんどのサーバー・アプリケーションは、短時間実行されるタスクやサブタスクを多量に処理することを指向しているため、これらのタスクを低いオーバーヘッドで効率的に処理するメカニズムと、リソース管理およびタイミングの予測可能性に関する何らかの手段を備えることが望ましくなります。こうした利点を提供するのがスレッド・プールです。


ワーク・キュー

スレッド・プールを実際にどのように実装するかという点に関して、「スレッド・プール」という用語が多少誤解を招いています。というのは、スレッド・プールの「明白な」実装は、ほとんどの場合、私たちが望んでいるとおりの結果をもたらすわけではないからです。「スレッド・プール」という用語はJavaプラットフォーム以前のもので、おそらくあまりオブジェクト指向ではないアプローチから生まれたものです。とはいっても、この用語は相変わらず広く使用されています。

クライアント・クラスが利用可能なスレッドを待ち、実行のためにタスクをそのスレッドに渡し、タスクが完了するとそのスレッドをプールに戻すといったスレッド・プール・クラスを簡単に実装できるようになる一方で、このアプローチにはいくつかの潜在的に望ましくない影響があります。たとえば、プールが空の場合はどうなるでしょうか。タスクをプール・スレッドに渡そうとする呼び出し側は、プールが空の状況に直面すると、そのスレッドは利用可能なプール・スレッドを待っている間にブロックされることになります。多くの場合、バックグラウンド・スレッドを使用する理由の1つは、実行依頼するスレッドがブロックされるのを防ぐことです。呼び出し側にまでブロッキングの影響を与えるということは、スレッド・プールの「明白な」実装の場合と同様、私たちが解決しようとしていた問題と同じ問題を招くことになります。

私たちが通常望んでいるものは、固定数のワーカー・スレッドのグループと結合したワーク・キューであり、これはwait()notify() を使用して、新しいワークが到着したことを待機中のスレッドに通知します。ワーク・キューは通常、モニター・オブジェクトを関連付けられて、一種のリンク・リストとして実装されます。リスト1は、簡単なプールされたワーク・キューの例を示しています。Runnable オブジェクトのキューを使用するこのパターンは、特にThreadのAPIからの要請があるというわけではないのですが、スケジューラーやワーク・キューで慣習的によく用いられています

リスト1. ワーク・キューとスレッド・プール
public class WorkQueue
{
    private final int nThreads;
    private final PoolWorker[] threads;
    private final LinkedList queue;
    public WorkQueue(int nThreads)
    {
        this.nThreads = nThreads;
        queue = new LinkedList();
        threads = new PoolWorker[nThreads];
        for (int i=0; i<nThreads; i++) {
            threads[i] = new PoolWorker();
            threads[i].start();
        }
    }
    public void execute(Runnable r) {
        synchronized(queue) {
            queue.addLast(r);
            queue.notify();
        }
    }
    private class PoolWorker extends Thread {
        public void run() {
            Runnable r;
            while (true) {
                synchronized(queue) {
                    while (queue.isEmpty()) {
                        try
                        {
                            queue.wait();
                        }
                        catch (InterruptedException ignored)
                        {
                        }
                    }
                    r = (Runnable) queue.removeFirst();
                }
                // If we don't catch RuntimeException, // the pool could leak threads
                try {
                    r.run();
                }
                catch (RuntimeException e) {
                    // You might want to log something here
                }
            }
        }
    }
}

みなさんは、リスト1の実装がnotifyAll() ではなくnotify() を使用していることにすでにお気付きかもしれません。ほとんどの専門家は、notify() ではなくnotifyAll() を使用するよう勧めており、これにはもっともな理由があります。notify() の使用に関しては多少のリスクが伴い、ある特定の条件下でのみ使用するのが適切です。その一方で、適切に使用した場合、notify()notifyAll() よりも望ましいパフォーマンス特性を示します。特に、notify() を使用するとコンテキスト・スイッチが大幅に減ることになり、このことはサーバー・アプリケーションにとって重要なことです。

リスト1のワーク・キューの例は、notify() を安全に使用するための要件を満たしています。ぜひご自分のプログラムで実際に使用してみてください。ただし、他の状況でnotify() を使用する場合には、十分に注意してください。


スレッド・プールを使用する際のリスク

スレッド・プールは、マルチスレッド化アプリケーションを構築するための強力なメカニズムですが、リスクがまったくないわけではありません。スレッド・プールによって構築されたアプリケーションには、同期エラーやデッドロックなど、他のマルチスレッド化アプリケーションとまったく同じような並行性に関するリスクと、プールに関連するデッドロックやリソース・スラッシング、スレッド漏出など、スレッド・プールに固有のリスクがいくつかあります。

デッドロック

どのようなマルチスレッド化アプリケーションにも、デッドロックのリスクがあります。プロセスのセットまたはスレッドのセットにおいて、セット内のそれぞれが、同じセット内の別のプロセスのみで発生させることができるイベントを待っている場合に、デッドロックされていると言います。デッドロックの最も簡単なケースは、スレッドAがオブジェクトXに対して排他ロックをかけ、オブジェクトYに対するロックを待っている一方、スレッドBがオブジェクトYに対して排他ロックをかけ、オブジェクトXに対するロックを待っているような状態です。ロックを待っている状態から抜け出す何らかの方法がない限り (Javaロック機能によるサポートはない)、デッドロックされたスレッドは永久に待ち続けることになります。

デッドロックはどのマルチスレッド化プログラムでもリスクとなりますが、スレッド・プールは別のデッドロックの可能性をもたらします。これは、プール・スレッドがすべて、キュー上の別のタスクの結果を待ってブロックされているタスクを実行しているにもかかわらず、利用可能なスレッドが空いていないために別のタスクを実行できないような状態です。これは、多くの対話オブジェクトを伴うシミュレーションの実装にスレッド・プールが使用され、シミュレートされるオブジェクトが、待機タスクとして後で実行されるクエリーを相互に送ることができ、クエリーを送ったオブジェクトが同期して応答を待つ場合に起こる可能性があります。

リソース・スラッシング

スレッド・プールの1つの利点は、スレッド・プールが通常、代替のスケジューリング・メカニズム (いくつかについてはすでに説明しました) に比べて有効に機能するという点です。ただしこれは、スレッド・プールのサイズが適切に調整されている場合のみに当てはまります。スレッドは、メモリーやその他のシステム・リソースなど、非常に多くのリソースを消費します。Thread オブジェクトに必要なメモリーに加えて、各スレッドには2つの実行呼び出しスタックが必要であり、これらのスタックが大きくなる可能性があります。さらにJVMは、おそらくJavaスレッドごとにネイティブ・スレッドを作成するので、システム・リソースをさらに消費することになります。最後に、スレッド間のスイッチングに関するスケジューリング・オーバーヘッドは小さいとはいえ、スレッドが多ければコンテキスト・スイッチングはプログラムのパフォーマンスに対する大きな障害となります。

スレッド・プールが大きすぎる場合は、そうしたスレッドによって消費されるリソースはシステム・パフォーマンスに大きな影響を与えるでしょう。スレッド間のスイッチングに時間が取られるようになり、必要以上のスレッドを持つことによってリソース不足の問題が生じることになります。というのは、他のタスクによってより効果的に使用できるはずのリソースをプール・スレッドが消費してしまうからです。スレッド自体によって使用されるリソースに加えて、要求に対応するワークも、JDBC接続やソケット、ファイルなどのリソースをさらに必要とするでしょう。これらもまた限られたリソースであり、あまりに多くの要求が同時に発生すると、JDBC接続の割り当てに失敗するなどの障害が生じることになります。

並行性エラー

スレッド・プールやその他のキューイング・メカニズムは、wait()notify() メソッドの利用に依存していますが、これらのメソッドには注意が必要です。誤ってコード化すると通知が失われる恐れがあり、その結果、キュー内に処理対象のワークがある場合でも、スレッドがアイドル状態のままになってしまいます。専門家でさえ間違えるため、これらの機能を使用する場合には十分な注意が必要です。ただし、より適切な方法は、この後の「自分で作る必要はない」で説明しているutil.concurrent パッケージなど、機能することがすでに分かっている既存の実装を使用することです。

スレッド漏出

あらゆる種類のスレッド・プールで重大なリスクとなるのはスレッド漏出です。これは、タスクを実行するためにスレッドがプールから削除された後、タスクが完了してもプールに戻されない場合に発生します。これが起こる1つのケースは、タスクがRuntimeException またはError をスローした場合です。プール・クラスがこれらをキャッチしないとスレッドが終了してしまい、スレッド・プールのサイズは1つずつ減っていき、永久に小さくなっていきます。これが何度も起こると、結局スレッド・プールが空になり、タスクの処理に利用できるスレッドがなくなってしまうため、システムが停止することになります。

利用できるようになるという保証のないリソースや、帰宅してしまったかもしれないユーザーからの入力をいつまでも待ち続ける恐れのあるタスクなど、永久に停止しているタスクもまた、スレッド漏出と同じような状況を招きます。そうしたタスクによって永久に消費されている場合、スレッドは事実上プールから削除されているのと同じことです。このようなタスクに対しては固有のスレッドを与えるか、待機時間を制限する必要があります。

要求のオーバーロード

サーバーが要求だけでいっぱいになってしまう可能性があります。そのような場合には、到着する要求のすべてをワーク・キューに入れる必要はありません。というのは、キューに入れた実行用タスクが多くのシステム・リソースを消費してしまい、リソース不足を招く恐れがあるからです。このような場合にどうするかを決めるのはみなさん自身です。単に要求を退けたり、より高いレベルのプロトコルにより後で再度試みることもできますし、サーバーが一時的にビジーであることを示す応答によって要求を拒否することもできます。


スレッド・プールを効果的に使用するためのガイドライン

次のようないくつかの簡単なガイドラインに従っている限り、スレッド・プールはサーバー・アプリケーションを構築するためのきわめて効果的な方法となります。

  • 他のタスクの結果を同期して待つタスクをキューに入れない。キューに入れると、上述の形のデッドロック (スレッドがすべてタスクで占拠され、それらのタスク自体、すべてのスレッドがビジーであるために実行できないで待機しているタスクからの結果を待っている状態) の原因となります。
  • 長時間実行の可能性のあるオペレーションに対してプールされたスレッドを使用する場合は注意する。プログラムがI/Oの完了など、リソースを待つ必要がある場合には、最大待ち時間を指定して、タスクをフェイルさせるか、後で実行するために再キューイングします。これで、正常に完了するタスクのためにスレッドを解放して、最終的にいくらかでも前に進めることができます。
  • タスクを理解する。スレッド・プールのサイズを効果的に調整するには、キューに入っているタスクと、それらが何を行うのかについて理解することが必要です。タスクがCPU制約であるか、あるいはI/O制約であるか。その答えによって、アプリケーションを調整する方法が変わります。完全に異なる特性を有するさまざまなクラスのタスクがある場合は、タスクの異なるタイプごとに複数のワーク・キューを持ち、それに応じて各プールを調整するとよいでしょう。

プール・サイズの調整

スレッド・プールのサイズの調整は、主にスレッドが「少なすぎる」か「多すぎる」という誤りを回避する問題にかかわっています。幸いにもほとんどのアプリケーションでは、「少なすぎる」と「多すぎる」の中間部分がかなり広くなっています。

アプリケーションでスレッド化を使用することには主に2つの利点があることを思い出してください。1つは、I/Oなどの遅いオペレーションを待っている間に処理が継続される点であり、もう1つは、複数のプロセッサーの可用性を活用できる点です。Nプロセッサー・マシンで実行するCPU制約のアプリケーションでは、スレッドの追加によりスレッド数がNに近づくにつれてスループットが改善されますが、Nを超えるスレッドの追加は有効でなくなります。実際にスレッドが多すぎると、コンテキスト・スイッチングのオーバーヘッドが増えるため、パフォーマンスの低下さえ招きかねません。

スレッド・プールの最適なサイズは、利用可能なプロセッサー数とワーク・キュー上のタスクの性質によって変わります。完全にCPU制約のタスクを保持するワーク・キューのためのNプロセッサー・システムでは、通常、NまたはN+1スレッドのスレッド・プールの場合に最大CPU使用率を達成します。

たとえば、ソケットからHTTP要求を読み取るタスクなど、I/Oの完了を待つ可能性のあるタスクの場合は、プール・サイズを利用可能なプロセッサー数よりも大きくする必要があるでしょう。というのは、すべてのスレッドが常に動作しているわけではないからです。プロファイリングを使用すると、標準的な要求のためのサービス時間 (ST) に対する待ち時間 (WT) の比率を測定することができます。この比率をWT/STとした場合、Nプロセッサー・システムに関してプロセッサーをフルに使用する状態を維持するには、約N*(1+WT/ST) のスレッドが必要になるでしょう。

スレッド・プールのサイズを調整する際の考慮事項は、プロセッサー使用率だけではありません。スレッド・プールが大きくなるにつれて、スケジューラーや利用可能なメモリー、あるいはソケットやオープン・ファイル・ハンドル、データベース接続の数など、その他のシステム・リソースの制限に直面することになるでしょう。


自分で作る必要はない

Doug Lea氏が、並行性ユーティリティーのすばらしいオープンソース・ライブラリーであるutil.concurrent を作成しました。これには、mutexやセマフォー、並行アクセスの下で適切に実行されるキューやハッシュ・テーブルのようなコレクション・クラス、およびいくつかのワーク・キューの実装が含まれています。このパッケージのPooledExecutor クラスは、ワーク・キューに基づいたスレッド・プールの、効率的で幅広く使用されている正しい実装です。自分で作成すると間違いやすいので、util.concurrent のいくつかのユーティリティーを使用することを検討してみてください。リンクや詳細については、参考文献を参照してください。

util.concurrent ライブラリーはJSR 166のきっかけにもなりました。JSR 166とは、java.util.concurrent パッケージのJavaクラス・ライブラリーに収めるための並行性ユーティリティー・セットを作成するJava Community Process (JCP) ワーキング・グループです。この並行性ユーティリティー・セットはJava Development Kit 1.5リリースで利用できるようになるはずです。


結論

スレッド・プールは、サーバー・アプリケーションの構築に役立つツールです。スレッド・プールの概念はきわめて簡単なものですが、その実装や使用の際には、デッドロックやリソース・スラッシング、wait()notify() の複雑性など、注意すべき問題がいくつかあります。みなさんのアプリケーションにスレッド・プールが必要な場合には、まったく初めからスレッド・プールを作成するのではなく、PooledExecutor などのutil.concurrentExecutor クラスを使用することを検討してみてください。短時間実行のタスクを処理するためにスレッドを作成しようと思っている場合には、代わりにスレッド・プールを使用することをぜひとも検討すべきです。

参考文献

コメント

developerWorks: サイン・イン

必須フィールドは(*)で示されます。


IBM ID が必要ですか?
IBM IDをお忘れですか?


パスワードをお忘れですか?
パスワードの変更

「送信する」をクリックすることにより、お客様は developerWorks のご使用条件に同意したことになります。 ご使用条件を読む

 


お客様が developerWorks に初めてサインインすると、お客様のプロフィールが作成されます。会社名を非表示とする選択を行わない限り、プロフィール内の情報(名前、国/地域や会社名)は公開され、投稿するコンテンツと一緒に表示されますが、いつでもこれらの情報を更新できます。

送信されたすべての情報は安全です。

ディスプレイ・ネームを選択してください



developerWorks に初めてサインインするとプロフィールが作成されますので、その際にディスプレイ・ネームを選択する必要があります。ディスプレイ・ネームは、お客様が developerWorks に投稿するコンテンツと一緒に表示されます。

ディスプレイ・ネームは、3文字から31文字の範囲で指定し、かつ developerWorks コミュニティーでユニークである必要があります。また、プライバシー上の理由でお客様の電子メール・アドレスは使用しないでください。

必須フィールドは(*)で示されます。

3文字から31文字の範囲で指定し

「送信する」をクリックすることにより、お客様は developerWorks のご使用条件に同意したことになります。 ご使用条件を読む

 


送信されたすべての情報は安全です。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Java technology
ArticleID=224158
ArticleTitle=Javaの理論と実践: スレッド・プールとワーク・キュー
publish-date=07012002