目次


Javaの理論と実践

スレッドはどこへ消えた?

サーバー・アプリケーションのスレッド・リークを回避する方法

Comments

コンテンツシリーズ

このコンテンツは全#シリーズのパート#です: Javaの理論と実践

このシリーズの続きに乞うご期待。

このコンテンツはシリーズの一部分です:Javaの理論と実践

このシリーズの続きに乞うご期待。

単一スレッドのアプリケーションでは、メイン・スレッドがキャッチされない例外をスローするとスタック・トレースがコンソールでプリントされる (プログラムも停止する) ので、それに気付くことが多いでしょう。しかし、マルチスレッド化されたアプリケーション、特に、サーバーとして実行され、コンソールに接続されていないアプリケーションでは、スレッドの消滅はより気が付きにくいイベントとして部分的なシステム障害を発生させたり、ひいては、アプリケーションの振る舞いを混乱させかねません。

Javaの理論と実践 の7月の記事ではスレッド・プールについて取り上げ、適切に作成されていないスレッド・プールがスレッドをどのように「リーク」し、最終的にすべてのスレッドがなくなってしまうかについて調べました。ほとんどのスレッド・プールの実装は、スローされた例外をキャッチするか消滅したスレッドを再開することによってこの問題に対処しています。しかし、スレッド・リークはスレッド・プールに限定された問題ではなく、スレッドを使用してワーク・キューを処理するサーバー・アプリケーションにも起こる可能性があります。サーバー・アプリケーションは、ワーカー・スレッドを失っても、しばらくの間は正常に動いているように見えるため、問題の本当の原因を突き止めるのは簡単ではありません。

多くのアプリケーションは、スレッドを使用して、イベント・キューのタスクの処理、ソケットのコマンドの読み取り、あるいはUIスレッド以外の長時間のタスクの実行といったバックグラウンド・サービスを提供します。こうしたスレッドの1つが、キャッチされないRuntimeExceptionError をスローしたことが原因で消滅した場合、あるいは、単に、予期せずにブロックされたI/O操作で待機して停止した場合、どうなるのでしょうか。

スレッドがスペルチェックのようなユーザー起動の長時間のタスクを実行しているときなど、処理が進んでいないことにユーザーが気付いて、操作またはプログラムを中止する場合もあります。しかし、それ以外の場合、バックグラウンド・スレッドは、目立たないタスクを実行するので、スレッドの消滅は長時間気付かれない可能性があります。

サーバー・アプリケーションの例

次のような仮のミドルウェア・サーバー・アプリケーションについて考えてみましょう。このサーバー・アプリケーションは、さまざまな入力ソースのメッセージを集約し、それらを外部のサーバー・アプリケーションに送り、外部アプリケーションから応答を受け取り、応答を該当する入力ソースまで戻します。各入力ソースには、独自の方法で入力メッセージを受け取るプラグインがあります (ファイルのディレクトリーをスキャンしたり、ソケット接続で待機したり、データベース表をポーリングするなど)。プラグインは、サーバーJVMで実行される場合でも、サード・パーティーによって作成されていることがあります。このミドルウェア・アプリケーションには、(少なくとも) 2種類の内部ワーク・キューが存在します。プラグインから送られ外部のサーバーに送信されるのを待つメッセージのキュー (「発信メッセージ」キュー) と、外部のサーバーから戻され該当するプラグインに送信されるのを待つ応答のキュー (「着信応答」キュー) です。メッセージは、プラグイン・オブジェクトに対してincomingResponse() サービス・ルーチンを呼び出すことによって、発信元のプラグインまで戻されます。

プラグインからメッセージを受け取ると、そのメッセージは発信メッセージ・キューに入ります。発信メッセージ・キューのメッセージは、1つまたは複数のスレッドによって処理されます。スレッドはキューのメッセージを読み取り、その発信元を記録し、リモート・サーバー・アプリケーションにメッセージを送ります(例えば、Webサービス・インターフェースを介して)。最終的には、リモート・アプリケーションはWebサービス・インターフェースを介して応答を戻し、サーバーが、受け取った応答を着信応答キューに入れます。1つまたは複数の応答スレッドが、着信応答キューのメッセージを読み取り、それらを該当するプラグインへ送り、処理が完了します。

このアプリケーションには、発信要求用と着信応答用の2種類のメッセージ・キューと、おそらく、さまざまなプラグイン内に追加のキューがあります。さらに、サービス・スレッドもいろいろあります。発信メッセージ・キューから要求を読み取って外部サーバーに送信するもの、着信応答キューから応答を読み取ってプラグインへ送るもの、そしておそらく、ソケットやその他の外部要求元用のプラグイン内にもスレッドがいろいろあるでしょう。

必ずしも明確ではないスレッドの消滅

例えば、応答ディスパッチング・スレッドのような、これらのスレッドの1つが消滅した場合は、どうなるでしょうか。プラグインは、依然として新しいメッセージを送ることができるため、異常が起こったことにすぐには気付かないでしょう。メッセージはさまざまな入力元から依然として到着し、私たちのアプリケーションを通して外部サービスに送信されるでしょう。プラグインは、すぐに応答が戻ってくることを想定していないので、問題が発生していることにまだ気が付きません。受信された応答は徐々に列を作ります。応答がメモリーに保存されている場合は、最終的にメモリーを使い果たすことになります。そうでない場合でも、ある時点で、応答が送信されていないことに気付くでしょうが、システムの他の機能は正常に動きつづけているので、それに気付くまでにしばらく時間がかかるでしょう。

主なタスク処理機能が、単一のスレッドではなくスレッド・プールによって処理されるときは、スレッド・リークの発生に対してある程度の保険があります。これは、8つのスレッドで正常に動作するスレッド・プールは、おそらく、スレッドが7つになっても何とか仕事を処理してしまうからです。最初、違いはまったく認識できません。しかし、徐々に、おそらくわずかですが、システムのパフォーマンスが低下します。

サーバー・アプリケーションにおけるスレッド・リークの問題点は、外部からは必ずしも簡単に検出できないことにあります。ほとんどのスレッドは、サーバーのワークロードの一部や、おそらく特定のタイプのバックグラウンド・タスクのみを処理するので、実際にはプログラムに重大な障害が発生していても、ユーザーにはそれが正常に機能しているように見えることがあるのです。これは、スレッド・リークを引き起こす原因が必ずしもその証拠を残すわけではないという事実と相まって、びっくりするような、あるいは、不可思議なアプリケーションの振る舞いをもたらすことがあります。

スレッドの消滅の主な原因となるRuntimeException

スレッドは、キャッチされない例外またはエラーをスローしたときに消滅する可能性があります。あるいは、決して完了することのないI/O操作、またはnotify() が呼び出されないモニターで待機している場合に、簡単に停止してしまいます。予期しないスレッドの消滅の最も一般的な原因は、RuntimeException (NullPointerExceptionArrayIndexOutOfBoundsException など) のスローです。例に挙げたアプリケーションでRuntimeException がスローされやすいのは、プラグイン・オブジェクトでincomingResponse()を呼び出し、応答がプラグインに戻されるときです。プラグイン・コードがサード・パーティーによって作成されている場合や、アプリケーションの作成後に作成されている場合は、アプリケーション作成者は正しく動くかどうかをチェックすることはできません。万一、プラグインのいずれかがRuntimeException をスローしたときに応答サービス・スレッドが終了してしまうような仕掛になっているとしたら、1つのプラグインの障害がシステム全体を停止させてしまう可能性があることを意味します。不幸にして、この脆弱性は極めて一般に見受けられるものです。

チェックされる例外に対しては積極的なコード化が要求されますが (コンパイラーがそれを強制します) 、チェックされない例外については、その大部分が、ほとんどのJava開発者によって無視されます。単一スレッドのアプリケーションでは、未処理のRuntimeException の結果は明らかであり、それが発生した場所に関する明確なスタック・トレースがあります。これは、問題の通知とその修正に役立つ情報の両方を提供します。しかし、マルチスレッド化されたアプリケーションでは、スレッドは、チェックされない例外が原因で静かに消滅する可能性があり、ユーザーや開発者は、何が起こったのか、なぜ起こったのか分からず、頭をかきむしることになります。

例に挙げたアプリケーションの、要求および応答ハンドラー・スレッドなどのタスク処理スレッドは、基本的に、Runnable のような抽象化というバリアを介してサービス・メソッドを呼び出しているだけです。この抽象化のバリアの向こう側に何が起きているか分からない以上、チェックされない例外をスローすることはないだろうと想定してよいほどに、サービス・メソッドが行儀よく振る舞うかどうかは疑ってかかるべきです。サービス・ルーチンがRuntimeException をスローした場合は、呼び出しスレッドが、それをキャッチし、記録してキューの次の項目に進むか、または、スレッドを終了して再開します。(後者の選択肢は、RuntimeException またはErrorをスローした場合は、スレッドの状態に悪影響を与えた可能性もあるという想定から生じます。)

リスト1のコードは、例の着信応答スレッドのような、ワーク・キューのRunnable タスクを処理するスレッドの典型です。これには、チェックされない例外をスローするプラグインに対する防御がありません。

リスト1. RuntimeExceptionに対する防御のないワーカー・スレッド
private class TrustingPoolWorker extends Thread {
    public void run() {
        IncomingResponse ir;
        while (true) {
            ir = (IncomingResponse) queue.getNext();
            PlugIn plugIn = findPlugIn(ir.getResponseId());
            if (plugIn != null)
                plugIn.handleMessage(ir.getResponse());
            else
                log("Unknown plug-in for response " + ir.getResponseId());
        }
    }
}

このワーカー・スレッドをプラグイン・コードの障害に対してさらに堅固なものにするために、多くのコードを追加する必要はありません。ただ単に、RuntimeExceptionをキャッチして修正アクションを行いさえすれば、不十分な作りの1つのプラグインがサーバー全体を徐々に蝕んでいくことから、身を守ることができます。適切な修正アクションは、リスト2に示されているように、エラーを記録して単に次のメッセージに進むか、現在のスレッドを終了してそれを (TimerTask などのクラスを使用して) 再開し、問題を引き起こしたプラグインをアンロードしたりします。

リスト2. RuntimeExceptionに対する防御のあるワーカー・スレッド
private class SaferPoolWorker extends Thread {
    public void run() {
        IncomingResponse ir;
        while (true) {
            ir = (IncomingResponse) queue.getNext();
            PlugIn plugIn = findPlugIn(ir.getResponseId());
            if (plugIn != null) {
                try {
                    plugIn.handleMessage(ir.getResponse());
                }
                catch (RuntimeException e) {
                    // Take some sort of action; // - log the exception and move on
                    // - log the exception and restart the worker thread
                    // - log the exception and unload the offending plug-in
                }
            }
            else
                log("Unknown plug-in for response " + ir.getResponseId());
        }
    }
}

ThreadGroupによって提供されるチェックされない例外ハンドラーの使用

外部から手に入れたコードは、予想もしないRuntimeExceptionをスローしてくるものと身構えるアプローチに加え、ThreadGroup クラスのuncaughtException 機能を使用するのも賢明な方法です。ThreadGroup は、それほど便利ではありませんが、当分の間 (チェックされない例外処理がJDK 1.5でThread に追加されるまで) は、uncaughtException 機能のために不可欠です。リスト3は、ThreadGroup を使用して、チェックされない例外が原因でスレッドが消滅したことを検出する例を示しています。

リスト3. uncaughtExceptionによるスレッドの消滅の検出
public class ThreadGroupExample {
    public static class MyThreadGroup extends ThreadGroup {
        public MyThreadGroup(String s) {
            super(s);
        }
        public void uncaughtException(Thread thread, Throwable throwable) {
            System.out.println("Thread " + thread.getName() + " died, exception was: ");
            throwable.printStackTrace();
        }
    }
    public static ThreadGroup workerThreads = new MyThreadGroup("Worker Threads");
    public static class WorkerThread extends Thread {
        public WorkerThread(String s) {
            super(workerThreads, s);
        }
        public void run() {
            throw new RuntimeException();
        }
    }
    public static void main(String[] args) {
        Thread t = new WorkerThread("Worker Thread");
        t.start();
    }
}

スレッド・グループのスレッドが、チェックされない例外をスローしたために消滅した場合は、スレッド・グループのuncaughtException() メソッドが呼び出されます。これは、ログにエントリーを書き込むか、スレッドを再開するか、システムを再開するか、あるいは、必要と判断された修正アクションまたは診断アクションを行います。最低限、あるスレッドが消滅した場合に、すべてのスレッドがログ・メッセージを書き込めば、要求処理スレッドがどこに消えてしまったのか分からないということはなくなり、どのような問題がどこで起こったのかという記録が残ります。

まとめ

スレッドがアプリケーションから消えてしまうと、たいていは、(スタック) トレースを残さずに消えてしまうので、困惑してしまいます。多くのリスクと同様に、スレッド・リークを防止する最良の方法は、予防と検出の両方を行うことです。すなわち、外部コードを呼び出すときのようなRuntimeException がスローされやすい場所に注意し、スレッドが突然終了した場合はそれを検出するために、ThreadGroup によって提供されるuncaughtException ハンドラーを使用することです。


ダウンロード可能なリソース


関連トピック


コメント

コメントを登録するにはサインインあるいは登録してください。

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Java technology
ArticleID=224161
ArticleTitle=Javaの理論と実践: スレッドはどこへ消えた?
publish-date=09012002