解説(前半)
前回までの実装のおさらい
元々の「HttpServer.java」の実装では、クライアントからの接続要求を受けたスレッド自身がリクエストの読み込みとレスポンスの書き出しを行っていました。
for (Socket sock = accept(); sock != null; sock = accept()) { try { Request req = new Request(sock); if (req.path.equals("/quit")) { response(200, "OK", sock.getOutputStream()); break; } else { response(req, sock.getOutputStream()); } } catch (BadRequestException e) { if (debug) { e.printStackTrace(); } response(sock.getOutputStream(), e); } finally { sock.close(); } }
リストでは、ServerSocketに対してacceptを呼び出したスレッドがそのままacceptから返されたソケットに対して読み書きを行っています。なお実際のソケットに対する呼び出しはacceptメソッド、Requestクラスのコンストラクタ、responseメソッドで実行されます。
下図は、このプログラムに対して、2つのクライアントがリクエストを送った場合の動作を図示したものです。この場合、実際には2つのクライアントが同時にリクエストを送ったとしても、サーバー側がacceptするまでは処理が始まらないため、結果的に図のように逐次処理されることになります。

マルチスレッド化
シンプルなマルチスレッド化を最初に考えます。
元々の処理の問題点(本当に問題かどうかについては後でまた考察します)は、クライアントに対してサービスをシーケンシャルに与えるために、同時に複数のクライアントからアクセスされた場合に後回しにされたクライアントの待ち時間が大きくなることです。
しかし、少なくともCPUに関して言えば、ネットワークデバイスとの読み書きや送信対象のファイルの読み込み中はアイドル状態になります。この間に他のクライアントへのサービスを実行すれば、見かけ上同時に複数のクライアントへサービスを提供できるはずです。
下図はacceptの呼び出しによりクライアントとのインターフェイスに利用するソケットを取り出すスレッドをメインスレッド、クライアントインターフェイス用ソケットを読み書きするスレッドをクライアントスレッドとして示したものです。

図のようにクライアントからの接続要求の処理と実際のサービスを実行するスレッドを分離することで、クライアントは見かけ上並行してサービスを受けることができるようになります。このため、クライアントの待ち時間は短縮されることになります。
リストはこの方針に合わせて元のリストを修正したものです(アーカイブ内のソースは以下のリストを更に変形したものとなります――後述)。
authority = Authority.newAuthority(authtype); for (Socket sock = accept(); sock != null; sock = accept()) { clientService(sock); } void clientService(final Socket sock) { new Thread(new Runnable() { public void run() { try { try { Request req = new Request(sock); if (req.path.equals("/quit")) { response(200, "OK", sock.getOutputStream()); } else { response(req, sock.getOutputStream()); } } catch (BadRequestException e) { if (debug) { e.printStackTrace(); } response(sock.getOutputStream(), e); } } catch (IOException e) { e.printStackTrace(); } finally { try { sock.close(); } catch (IOException e) {} } } }).start(); }
リストでは、スレッドを生成するためにRunnableインターフェイスを実装した無名内部クラスを利用しています。ここで実際の処理をclientServiceという別メソッドに切り出しているのは、無名内部クラスからaccept結果のソケットをアクセスできるようにするためです。無名クラスからローカル変数(この例ではsock)にアクセスするためには、そのローカル変数をfinal変数として宣言する必要があります。しかし元の実装ではfor文の中でsockへの代入を行っているためfinal変数にはできません。
acceptの結果を格納したsockを無名内部クラスのインスタンスへ与えることです。しかしそのインスタンスは異なるスレッドで実行されることになるため、正確にいつ実行されるのかはわかりません。この結果、最初の
sock = accept();の結果として生成したスレッドが処理を開始し、sockで示された値を参照したときには、次のsock = accept();の結果が格納されてしまっているかも知れません。また、生成した無名内部クラスのインスタンスは元のメソッドの寿命よりも長く生存できます。これは、元のスレッドが処理を終了しメソッドから退出した場合にスタックが巻き戻され元のローカル変数が消滅した後でも、生成されたスレッドがそのローカル変数を参照可能でなければならないという矛盾した状態が必要なことを意味します。無名内部クラスから参照する変数が
finalであれば、途中から値が変更されないことが保証されます。変数の内容が変わらないということは、無名内部クラスのフィールドへその変数の内容をコピーしても矛盾が生じないことになります。また、そのように内容をコピーして無名内部クラス内のフィールドに保持していれば、元のスレッドがスレッドを生成したメソッドから退出してスタックが解放された後も元の値を参照できることになります。実際に無名内部クラスから
final宣言されたローカル変数を参照するコードを記述すると、コンパイラは無名内部クラス内にローカル変数の内容をコピーするためのフィールドを生成します。また、実行時には無名内部クラスのインスタンス生成時にローカル変数からフィールドへ内容がコピーされます。このように、無名内部クラスから参照するローカル変数を
finalとして途中で内容が変更できないように制限することで、呼び出し元のメソッドが呼び出し時点で設定したものと同一内容を無名内部クラスが参照できるようにしています。スレッドの生成方法
スレッドを生成し実行する方法の1つは、
Runnableインターフェイスを実装したクラスを定義するrunメソッドにスレッドで実行する処理を記述するThreadクラスのコンストラクタへ上で定義したオブジェクトのインスタンスを与える- 生成した
Threadクラスのインスタンスのstartメソッドを呼び出す
という手順を踏むことです。
もう1つの方法は次の手順となります。
Threadクラスを継承したクラスを定義するrunメソッドにスレッドで実行する処理を記述する- 上記のクラスのインスタンスを生成する
- 生成したインスタンスの
startメソッドを呼び出す
なお、runメソッドをオーバーライド(Runnableの実装の場合はrunメソッドを実装)する必要がある点から、誤ってstartメソッドの代わりにrunメソッドを実行するというバグパターンがあるので注意してください。
2種類の生成方法がある点について、J2SEのAPIドキュメントでは以下のような記述となっています。
- Threadクラス
- Runnableインターフェイス
Threadクラスを継承したサンプル例と、Runnableを実装したサンプル例をこの順で示す。Runnableの実装を強く示唆。クラスの継承はそのクラスの基本的な振る舞いを変更したり拡張したりしたい場合に行うものであり、処理を単に実行するためだけにThreadクラスのrunメソッドをオーバーライドするような継承方法は望ましくないという内容の記述。 両者を読み比べるとThreadクラスのドキュメントが単に可能なことを列挙してあるだけなのに対して、Runnableインターフェイスのドキュメントは実装デザインにまで踏み込んでいることがわかります。そして、おそらくRunnableインターフェイスに記述されている内容は誰にでも納得が行くものではないかと思われます。
問題は、スレッドを操作したい場合にAPIドキュメントの項目として最初に考え付く名前がThreadであり、まさにその名前のクラスがあり、かつそのクラスのドキュメントに掲載されている利用例がThreadの継承が最初であり、かつ後者の例を読むにはさらにRunnableインターフェイスという別のドキュメントの参照も必要となることから、実際にはThreadを継承してしまう例のほうが多いかも知れないことです。
しかし、確かにこの例で作成している無名内部クラスの責務は、クライアントのリクエストを読みレスポンスを返すHTTPプロトコルの実装であり、それは確かに実行可能です(is-a Runnable)が、別にThreadということではありません(is not a Thread)。そしてほとんどの場合、同様にRunnableとis-a関係は成立してもThreadとis-a関係になることはないでしょう。かと言ってThreadを継承したほうが特別に実装が簡単になるわけでもありません。したがって、Runnableを実装するのが良いと思います。
マルチスレッド化に効果があるかを考えてみる
スレッドを生成するという処理は、大雑把にはプロセスを生成するのと同じくらいシステムにとって負荷のかかる作業です。実際には、プログラムコードは既にプロセス内からアクセスできるように展開済みなのでプロセスの起動というほどではありませんが、それでもカーネルが持つスケジューリング用のテーブルの割り当てや、スタック用メモリーの割り当てなど、システムの実行をロックしなければ行えない作業が必要です。
したがって、理屈の上ではマルチスレッド化することでクライアントから並行アクセスできることになるため処理効率が上がるように見えますが、実際にはシングルスレッドによる処理よりも効率が落ちるという可能性も出てきます。
これについては実測してみなければ効果のほどはわかりません。CPUがIO待ち状態になれば他のスレッドを実行する余地が生じます。また、マルチコアCPUやマルチCPUシステムでは同時に複数のプロセス(スレッド)を実行できるため、処理効率が上がることが想像できますが、それ以前の段階でネットワークに対する負荷が高ければacceptが効率的に行われず、結局意味を持たない可能性も出て来るでしょう。
次の3つのグラフは、Jakarta JMeterを使った比較的いい加減なベンチマークテストの結果です。テストは、OS X 10.4.4が稼動するApple PowerBook (G4 1.5 GHz メモリーサイズ1GB)で実行している本記事のサンプルに対して、Pentium-M 1.7GHzが稼動するPCでJMeterを実行して行いました。この時のネットワークにはIEEE 802.11gを利用しています。また、すべての計測前に1000件のテストを10回実行して起動直後のばらつきの解消をしています。
このベンチマークテストでは、ソースアーカイブに同梱してある「/html/index.html」という非常に小さいサイズ(わずか386バイト)に対するリクエストを、10スレッドから要求した結果を計測しています。
このテストの特徴は、リクエストが単なるGETでサイズが小さく、クライアント処理用スレッドが読み込むファイルもキャッシュに収まる程度に非常に小さく、当然レスポンスのサイズも非常に小さいというものです。このような条件ではスレッド生成のオーバーヘッドが一番のボトルネックになるのではないかと筆者は予想しましたし、図からは事実ほぼそれに近い結果が出ています。しかし実際にスループットだけに着目するとスレッド生成のオーバーヘッドそのものが差となったのではなく、単純なマルチスレッド版のほうが他の版よりGCの発生頻度が高いために差が出たように見えました。仮に、任意に一番優秀な結果のグラフを取り出せば、3種類の実装方法のいずれも大して差は出なかったというのが実情です。
逆に、読み込むファイルが多数に渡り、またファイルサイズが平均して大きければスレッド生成のオーバーヘッドよりもファイル読み込み(ネットワーク書き出し)のオーバーヘッドが十分に大きくなることも考えられます。その場合には、マルチスレッド版(特にスレッドプール版)のスループットが上がると予想できます。実際に、どのような条件だとどのような結果になるのか、スレッドプール版であればスレッド数の変更がどのように結果に影響するかなど、事前に結果を予想してから実際に試してみるとおもしろいと思います。



