解説(後半)
スレッドプールの実装
スレッドプールは、スレッドを使い捨てにせずプールしておき、必要となった時点で再利用するスレッドの利用方法です。スレッドプールを実装する場合、どのようにパラメータを与えるかが実装上の肝となります。
たとえば、最初に示した単純なマルチスレッド版では、clientServiceというメソッドの中でスレッドを生成していました。このため、クライアントとのインターフェイスに利用するソケットはclientServiceへの引数として与えるだけで済みます。しかし、スレッドプールを利用する場合にはこのような手法は取れません。なぜなら、ソケットを与えたいスレッドとプール上のスレッドは(当然ですが)異なるスレッドのため、メソッドの引数のようにスタックを介して与えるわけにはいかないからです。
HttpServerの修正
最初に、実際のソースアーカイブ上の「HttpServer.java」のリストを以下に示します。シングルスレッド版、マルチスレッド版、スレッドプール版を並存できるようにクライアントとのインターフェイス処理の制御部をClientServiceという別のクラスに切り出し、それを利用するように変更しています。
public void service(String authtype) throws IOException { authority = Authority.newAuthority(authtype); ClientService svc = ClientService.newService(this); for (;;) { Socket sock = accept(); if (sock == null) { break; } svc.doService(sock); } svc.stop(); }
ClientServiceが実行するのは、クライアントとのインターフェイスではなくインターフェイス処理の制御だという意味は、インターフェイスそのもの――つまりリクエストの読み込みやレスポンスの書き出しは元のHttpServerクラスの実装(後続のrequestAndResponseメソッド)を利用するということです。そのため、ClientServiceがHttpServer自身を呼び出すことができるように、newServiceというファクトリメソッドに自分自身のインスタンス(this)を与えています。またスレッドプール版ではプールしたスレッドの後始末が必要となるため、最後にstopメソッドを呼び出してクリーンアップ処理を実行しています(スレッドプール版以外の実装ではnoopです)。
次に示すrequestAneResponseメソッドがリクエスト/レスポンス処理をClientServiceから呼び出せるように分離したものです。クライアントとのインターフェイスに利用するソケット(メソッドの引数として与えられるもの)のcloseは呼び出し元の責務としています。
public void requestAndResponse(Socket sock) throws IOException { try { Request req = new Request(sock); if (req.path.equals("/quit")) { response(200, "OK", sock.getOutputStream()); close(); } else { response(req, sock.getOutputStream()); } } catch (BadRequestException e) { if (debug) { e.printStackTrace(); } response(sock.getOutputStream(), e); } }
クライアントインターフェイス制御
クライアントとのインターフェイスを制御するクラス(ClientService.java)のソースを以下に示します。
static ClientService newService(HttpServer svr) { httpServer = svr; String type = System.getProperty(THREAD_TYPE_KEY, THREAD_TYPE_POOL); switch (type.charAt(0)) { case 'p': if (svr.isDebugEnabled()) { System.out.println("service type=thread pool"); } return new PooledClientServiceImpl(); case 'm': if (svr.isDebugEnabled()) { System.out.println("service type=multi thread"); } return new MtClientServiceImpl(); default: if (svr.isDebugEnabled()) { System.out.println("service type=single thread"); } return new StClientServiceImpl(); } }
ファクトリメソッドでは、システムプロパティから生成するタイプを判定します。ここに示されたように、シングルスレッド版はStClientServiceImpl、マルチスレッド版はMtClientServiceImpl、スレッドプール版はPooledClientServiceImplというクラス名です。なお、これらのクラスはいずれもClientServiceクラスのstaticネステッドクラスとして定義しています。
以下に示すのは、もっとも単純なStClientServiceImplのリストです。
static class StClientServiceImpl extends ClientService { StClientServiceImpl() { } /** * 呼び出し元のスレッドでそのまま実行する。 */ public void doService(Socket sock) { try { httpServer.requestAndResponse(sock); } catch (IOException e) { e.printStackTrace(); } finally { try { sock.close(); } catch (IOException e) { e.printStackTrace(); } } } }
StClientServiceImplの場合、HttpServerが呼び出すdoServiceメソッドの中でHttpServer#requestAndResponseを呼び出し、最後にソケットをクローズします。
それではスレッドプール版はどのような実装となっているでしょうか。
static class PooledClientServiceImpl extends MtClientServiceImpl { static final int MAX_THREAD = 3; List threads; volatile List clients;
スレッドプール版では2つのListを利用します。1つはスレッドを管理するためのListでthreadsという名前です。もう1つはクライアントとのインターフェイスソケットを格納するためのListでclientsという名前です。メインスレッドとプール上のスレッドはclientsを介してインターフェイスします。
なお、このプログラムでは処理を簡略化しているためthreadsというリストはほとんど利用していません。本来であれば、このリストを利用して、プール上のスレッドに対して状態を問い合わせたり強制的に例外を発生させたりすることになります。
PooledClientServiceImpl() {
clients = new ArrayList();
threads = Collections.synchronizedList(new ArrayList());
for (int i = 0; i < MAX_THREAD; i++) {
threads.add(newThread());
}
}
コンストラクタでは上記の2つのリストを作成します。このうち、2つ以上のメソッドを同期して呼び出す必要があるclientsについては(いずれにしろ同期ブロックを利用するため)通常のListとして作成していますが、そうではないthreadsについてはCollections.synchronizedListメソッドを利用して同期リストを作成します。複数のスレッドを利用する場合、同時に参照されるメモリは同期する必要があります。そのため、複数のスレッドから更新と参照が行われる可能性があるコレクションなどは、必ず前者のように常に同期ブロック内で利用するか、さもなければ後者のように同期コレクションを生成してそれを利用するようにします。
public void doService(Socket sock) { synchronized(clients) { clients.add(sock); clients.notifyAll(); } }
HttpServerから呼び出されるdoServiceメソッドでは、clientsを同期し、ソケットを追加した後にこのオブジェクトで待ち状態となっているスレッドを実行させます。
なお、現実のスレッドプールではこのような処理の過程で、実際にスレッドが生存しているかの確認や――仮に例外で終了したことに気づかなければ永遠にクライアントからのリクエストが処理されないだけではなく、ソケットがクローズされないままどんどんリストに追加されて行きリソース不足によりプロセスが異常終了することが考えられます――、あるいは必要であればスレッドの追加実行などを行うことになります。
void stop() { synchronized(clients) { for (Iterator i = clients.iterator(); i.hasNext();) { try { ((Socket)i.next()).close(); } catch (IOException e) { // 無視 } } clients.clear(); for (int i = 0; i < threads.size(); i++) { clients.add(null); } clients.notifyAll(); } }
同様にHttpServerから呼び出されるstopメソッドでは、現在リストに登録されているすべてのソケット(プールされたスレッドの処理が間に合わなかった場合などを想定しています)をクローズした後に、スレッド数分のnullを追加しスレッドを再実行します。ここでnullは、スレッドに対する処理終了のセンチネルの意味を持ちます。
Thread newThread() {
Thread t = new Thread(new Runnable() {
public void run() {
for (;;) {
try {
Socket sock = null;
synchronized(clients) {
while (clients.size() == 0) {
clients.wait();
}
sock = (Socket)clients.remove(0);
}
if (sock == null) {
break;
}
threadService(sock);
} catch (Exception e) {
e.printStackTrace();
}
}
threads.remove(Thread.currentThread());
}
});
t.start();
return t;
}
}
コンストラクタから呼び出されるnewThreadメソッドでは、スレッドを生成してプールします。また、無名内部クラスを利用しているため、スレッドで行う処理もここで定義しています。
プールされたスレッドは、基本的にはループで状態の変化(具体的にはメインスレッドからの処理依頼の受信)を待ちます。このような待ち状態を作る最も望ましい手段は、オブジェクトのモニターに対するwaitメソッドの呼び出しです。ここでは、すでにメインスレッド側の処理(clientsを同期し、ソケットを追加し、notifyAllを呼び出す)から分かるように、clientsを同期し、ソケットが存在しなければwaitを呼び出して待ち状態となります。waitを呼び出すと該当オブジェクトのモニターが一時的に解放されるため、同期状態が解消されます。このため、synchronizedブロック内に留まっているにも関わらずメインスレッドがclientsを同期することができるようになります。
ここで重要な点は、while (clients.size() == 0)の部分です。このようにスレッドが待ち状態に利用するオブジェクトに対する呼び出しは、再実行させる側はnotifyAllを利用して全スレッドを実行の対象とし、再実行される側はwhileのようなループによる条件判断を利用することが推奨されます。そうではなく、ifによる1回の条件判断とnotifyによる単一スレッドの再実行を選択する場合は、あらゆる面からスレッドの動作をシミュレートしてみて本当に問題がないかを確認するようにしてください。
まとめ
スレッドは正しく利用すれば見かけ上(と限定されるものではありませんが)の実行効率の向上につながります。また、今後のCPUの発展方向はマルチコア化にあるように、サービスを提供するプログラムではマルチスレッド化が重要かも知れません。この場合は見かけ上ではなく実際に実行効率が上がる可能性が高くなります。
しかし、実際にはスレッドを増やしたからといって処理効率が上がるわけではありません。すべてはアプリケーションの処理特性などに依存します。たとえばこの記事で扱っている簡易HTTPサーバーがせいぜい最大5クライアントに対して、それほど大きくも無いサイズのファイルの送信だけを実行するのであれば、JMeterの結果から見えるようにマルチスレッド化することはプログラムの複雑さが増して単にバグを入れる機会を増やしているだけかも知れません。
その一方で、マルチスレッドをプログラムから制御するメカニズムを知ることは重要です。それは最初に書いたように今後のCPUの発展方向に合っているからです。実際にはフレームワークを利用するだけで自分自身では直接スレッドを生成することは無いとしても、インスタンスを複数のスレッドで共有する場合のフィールドアクセスの考慮、ローカル変数の意義、staticフィールド、同期など考えなければならない点は多々あります。たとえば単にStrutsを使うだけと言ってもActionクラスを実装するには複数のスレッドから共有されるインスタンスということを意識する必要があります。
マルチスレッドプログラムのメカニズムを理解するには実際に動かしてみて動作を納得するまで追ってみることが一番です。
本記事は、種々の実装を試せるように個々の処理を分離しています。いろいろな実装方法を試してみると良いと思います。
