スレッドプールの大きさは?
少し前に友人が,Skypeで連絡してきました。64プロセッサマシン上で稼働して,1日に数回,30,000程度のスレッドをローンチするJVMクラスタについて聞きたいというのです。300,000以上のスレッドが動作するため,カーネル時間の相当部分がその管理に費やされて,結果としてアプリケーションが不安定になっているのだそうです。このような場合は明らかに,クライアントがスレッド数を決めるのを止めさせて,スレッドプールを用意して,スレッド数からクライアントの実行を絞り込むようにする必要があります。
これは極端ですが,スレッドプールが必要な理由を示す一例です。しかしスレッドプールがあったとしても,データ紛失やトランザクションの失敗などによってユーザに苦痛を与えることはあります。あるいはプールのサイズが大きすぎたり小さすぎたりすれば,アプリケーションが安定性を失ってしまうかも知れません。適切なサイズのスレッドプールは,ハードウェアやアプリケーションが無理なくサポートできる範囲内で,最大限の要求が可能なものにすることが必要なのです。言い方を代えるとすれば,処理能力に余裕があるならばリクエストをキューに待たせたくない一方で,管理能力を越えたリクエストをローンチしたくもないのです。それならば,スレッドプールのサイズは一体いくつにすればよいのでしょう?
"推測せずに計測せよ(measure don’t guess)" のマントラに従えば,まず最初に問題となっているテクノロジを確認する必要があります。その次には,どのような計測方法が適当か,それをシステムに適用するにはどうすればよいか,などを検討します。ある程度の数学的知識も動員しなければならないでしょう。スレッドプールがひとつのキューに結合された複数のサービスプロバイダであることを考えれば,これがLittleの法則をモデル化したひとつのシステムであることが分かります。どういうことなのか,詳しく見てみましょう。
Littleの法則
Litteの法則とは「システムにおけるリクエストの数は,リクエストの到着率と個々のリクエストに要するサービス平均時間の積に等しい」というものです。この法則は,私たちの毎日の生活の中ではごく普通のことです。これが1950年代まで提唱されなかったこと,1960年代になるまで証明されなかったことの方が,逆に驚きだと言えるでしょう。Littleの法則の実例をひとつ,ご紹介しましょう。行列に並んでいて,あとどのくらい時間が掛かるのか知りたいと思ったことはありませんか? おそらくは列に何人並んでいるのかを考えて,それからキューの先頭にいる人がサービスを受けるのに掛かる時間をざっと見ることでしょう。それから2つの数を掛け合わせて,行列に並ぶ時間を見積もるはずです。もし列の長さを見る代わりに,新しく列に加わる人の頻度を観察して,それをサービス時間と掛け合わせるなら,列にいるか,あるいはサービスを受けている人の平均数が分かるでしょう。
この他にも "サービスを受けるのを待つ人が,キューの中に立っている平均時間はどれくらいか?" といったような疑問に,Littleの法則で答をだすといった内容のゲームは,他にもたくさんあります。
図1. Littleの法則
同じようにLittleの法則は,スレッドプールサイズの決定にも使うことができます。私たちがしなければならないのは,リクエストの到着率と,サービスに必要な平均時間を測定することです。そうすれば,これらの値をLittleの法則に挿入して,システムの平均要求数が計算されます。その数値がスレッドプールのサイズよりも小さければ,その結果に従ってプールのサイズを小さくすることが可能なのです。逆に計算結果がスレッドプールのサイズよりも大きい時には,問題はもう少し複雑になります。
実行中のリクエストより待ち状態のリクエストの数が多い場合,まず最初に判断しなければならないのは,もっと大きなスレッドプールをサポートするのに十分な容量がシステムにあるかどうかです。そのためには,アプリケーションのスケール能力を制限している可能性が最も高いリソースが何なのか,突き止めなくてはなりません。今回の記事ではCPUがそうなのですが,実際の状況ではそれ以外の可能性も大いにあることに注意してください。スレッドプールのサイズを拡大するのに充分なキャパシティがあるなら話は簡単ですが,そうでなければ他の選択肢として,アプリケーションをチューニングするか,ハードウェアを追加するか,あるいはその2つを組み合わせて行う必要があります。
実際の例
リクエストをソケット経由で受信して実行するという典型的なワークフローを念頭において,先ほどのテキストを検討してみましょう。実行プロセスにおいては,クライアントに処理結果を返送するまでの間,データやその他のリソースが必要になります。私たちが行ったデモのサーバでは,CPU集約的なタスクの実行か,データベースあるいは外部データからのデータ抽出のいずれかを任意に実行するという,一定の作業ユニットの実行でこのワークロードをシミュレートすることにします。CPU集約的なアクティビティは,円周率や2の平方根など,無理数の小数点以下の桁数を計算することでシミュレートします。外部データソースへのコールはThread.sleepで代用します。サーバ中のアクティブなリクエスト数を一定に制限するために,スレッドプールを使用します。
java,util.concurrent(j.u.c)に対してスレッドプールが行う操作をモニタするためには,独自の計測手段が必要になります。現実的にはj.u.c.ThreadPoolExecutorにアスペクトでコードを挿入するか,あるいはASMなどバイトコード操作テクニックを使用して手を加えることになると思いますが,今回の目的ではj.u.c.ThreadPoolExecutorを手書きで拡張して,必要なモニタ機能を挿入することで,この話題には踏み込まないことにします。
public class InstrumentedThreadPoolExecutor extends ThreadPoolExecutor {
// リクエスト時間をすべて記録する
private final ConcurrentHashMap timeOfRequest =
new ConcurrentHashMap<>();
private final ThreadLocalstartTime = new ThreadLocal ();
private long lastArrivalTime;
// 他の変数は AtomicLong および AtomicInteger@Override
protected void beforeExecute(Thread worker, Runnable task) {
super.beforeExecute(worker, task);
startTime.set(System.nanoTime());
}@Override
protected void afterExecute(Runnable task, Throwable t) {
try {
totalServiceTime.addAndGet(System.nanoTime() - startTime.get());
totalPoolTime.addAndGet(startTime.get() - timeOfRequest.remove(task));
numberOfRequestsRetired.incrementAndGet();
} finally {
super.afterExecute(task, t);
}
}@Override
public void execute(Runnable task) {
long now = System.nanoTime();numberOfRequests.incrementAndGet();
synchronized (this) {
if (lastArrivalTime != 0L) {
aggregateInterRequestArrivalTime.addAndGet(now - lastArrivalTime);
}
lastArrivalTime = now;
timeOfRequest.put(task, now);
}
super.execute(task);
}
}
リスト1. InstrumentedThreadPoolExecutorの主要部分
このリストには,修正コードの中で自明でない部分が含まれています。今回のサーバではThreadPoolExectorの代わりにこれを使用します。データを収集するために,3つの主要なメソッドをオーバーライドします。beforeExecute,実行,afterExecute,そして MXBean によるデータ公開,という処理順序です。これらのメソッドがどのように動作するのかを見ていきましょう。
Executorにリクエストを渡すのには,executeメソッドが使用されます。従ってこれをオーバーライドすれば,初期タイミングの収集が可能になります。リクエスト間のインターバルの記録もここで行います。lastArrivalTimeをリセットするための処理も必要です。なお状態変数は共有されるので,一連のアクティビティはsynchronizedで同期する必要があります。最後にExecutorスーパークラスに処理を渡します。
名前が示すとおり,executeBeforeはリクエストが実行される直前に起動されるメソッドです。この時点までリクエストが蓄積されていた時間全体が,リクエストがプール内に待機していた時間,いわゆる "待ち時間 (dead time)" です。この後,afterExecuteがコールされるまでの時間が,Littleの法則の対象となる "サービス時間" であると見なされます。開始時間はスレッドローカルに保存することにします。afterExecuteメソッドでは "プール待機時間","リクエスト処理時間" の計算を実行した上で,"リクエスト退却(retire)" を登録します。
MXBeanにも,InstrumentedThreadPoolExecutorで計数したパフォーマンスカウンタを報告する機能が必要になります。これはExecutorServiceMonitorメソッドの仕事にしましょう (リスト2を参照)。
public class ExecutorServiceMonitor
implements ExecutorServiceMonitorMXBean {public double getRequestPerSecondRetirementRate() {
return (double) getNumberOfRequestsRetired() /
fromNanoToSeconds(threadPool.getAggregateInterRequestArrivalTime());
}public double getAverageServiceTime() {
return fromNanoToSeconds(threadPool.getTotalServiceTime()) /
(double)getNumberOfRequestsRetired();
}public double getAverageTimeWaitingInPool() {
return fromNanoToSeconds(this.threadPool.getTotalPoolTime()) /
(double) this.getNumberOfRequestsRetired();
}public double getAverageResponseTime() {
return this.getAverageServiceTime() +
this.getAverageTimeWaitingInPool();
}public double getEstimatedAverageNumberOfActiveRequests() {
return getRequestPerSecondRetirementRate() * (getAverageServiceTime() +
getAverageTimeWaitingInPool());
}public double getRatioOfDeadTimeToResponseTime() {
double poolTime = (double) this.threadPool.getTotalPoolTime();
return poolTime /
(poolTime + (double)threadPool.getTotalServiceTime());
}public double v() {
return getEstimatedAverageNumberOfActiveRequests() /
(double) Runtime.getRuntime().availableProcessors();
}
}
リスト2. ExecutorServiceMonitorの関連部分
リスト2には「キューがどのように利用されているか」という疑問に答えるための実装の,自明でない処理部分が示されています。getEstimatedAverageNumberOfActiveRequestsメソッドがLittleの法則の実装であることに注目してください。このメソッドでは返却率(retirement rate),すなわちシステムから除去されるリクエストを測定した速度を,リクエストをサービスするための平均時間で乗することによって,システムに存在するリクエストの平均数を求めています。この他に関連するメソッドとしては,getRatioOfDeadTimeToResponseTime() と getRatioOfDeadTimeToResponseTime() があります。それではいくつかの実験を行って,これらの数値が相互に,あるいはCPU使用率にどのように関係するのかを見ていきましょう。
実験1
動作を確認するため,最初の実験は意図的に自明なものにしています。具体的には "サーバスレッドプールのサイズ" を1に設定して,ひとつのクライアントが前述したリクエストを30秒周期で繰り返し発行する,というものです。
(画像をクリックして拡大)
図2 1クライアント,1サーバスレッドでの実行結果
図2の画像は,VisualVM MBean ViewとグラフィカルなCPU使用率モニタのスクリーンショットから取ったものです。VisualVM(http://visualvm.java.net/) は,パフォーマンスのモニタリングやプロファイリング用の独自ツールを開発するオープンソースプロジェクトですが,このツールの利用については,本記事の範囲外です。簡単に言うとMBean viewとは,PlatformMBeansServerに登録されているすべてのJMX MBeanにアクセスするための,オプションのプラグインです。
実験に話を戻すと,スレッドプールのサイズは1でした。 クライアントのループ処理を考えれば,アクティブなリクエストの平均数も1になるように思われます。しかしながら,クライアントがリクエストの再発行するのにいくらかの時間が必要なので,1よりわずかに小さな値になるはずです。実際に予想どおり,0.98になりました。
次の値RatioOfDeadTimeToResponseTimeは,0.1%程度に位置しています。アクティブなリクエストが1つしかなく,プールサイズもそれと同じですから,予想ではこの値は0になるはずです。それに反して0にならないのは,タイマの配置場所やタイマ精度に関するエラーのためかも知れません。ただし値は充分に小さいので,無視しても問題ないでしょう。CPU利用率から,使用されているコア数が1より小さいことが分かりました。つまりこれは,より多くのリクエストを処理する余裕がまだある,ということです。
実験2
第2の実験では,スレッドプールのサイズは1のままとして,クライアントの同時リクエスト数を10まで引き上げます。予想したとおり,CPUの利用率は変化しませんでした。同時に実行されるスレッド数は1で変わっていないからです。しかしながら,完了するリクエストの数は増加しました。理由として可能性が高いのは,アクティブなリクエストの数が10になったため,クライアントがリクエストの再発行のために待機する必要がなくなったことです。これによって常に1つのリクエストがアクティブになる一方で,キューの長さは9になるはずです。もうひとつ明らかになったのは,応答時間に対する待ち時間の時間が変わらず90%近くになっていることです。この数値から,クライアント側から見た全応答時間の90%は,スレッドの待ち時間に起因するということになります。レイテンシを削減する上で最大の問題は待ち時間であって,CPUのロードには余裕があることから,プールのサイズを大きくして,より多くのリクエストを取り込んでも問題のないことが分かります。
(画像をクリックして拡大)
図3 10クライアントリクエスト,1サーバスレッド時の実行結果
実験3
対象機のコア数が4なので,スレッドプールサイズを4にセットして同じ実験を試してみることにします。
(画像をクリックして拡大)
図4 10クライアントリクエスト,4サーバスレッド時の実行結果
今回も同じく,リクエストの平均数が10になっているのがわかります。違っているのは,完了するリクエスト数が2,000強にまで急増している点です。それに伴ってCPU稼働率も向上していますが,それでも100%には至っていません。全応答時間に対する待ち時間の比率は60%と依然高く,改善の余地を残していることが分かります。ここでもスレッドプールのサイズを拡大すれば,CPU能力の余剰を取り入れることができるでしょうか?
実験4
この最終テストは,プールを8スレッドに設定して実施します。結果を見ると,期待どおりであることが分かります。リクエストの平均数は10弱,待ち時間は19%弱,そして完了するリクエスト数は再び大きく改善されて3,621にまで達しました。ただしCPU使用率も100%近くになっていて,今回の負荷条件で達成可能な改善が終わりに近付いていることが見て取れます。さらにこの結果から,8が理想的なプールサイズであることも分かります。もうひとつの結論として,さらに待ち時間を削減する必要があるならば,CPUを増設するか,あるいはアプリケーションのCPU利用効率を改善する以外の方法はない,ということにもなります。
(画像をクリックして拡大)
図5 10クライアントリクエスト,8サーバスレッド時の実行結果
理論と現実
今回の実験に対してはさまざまな反論もあると思います。そのひとつはおそらく,内容があまりに単純だという点でしょう。現実にある大規模なアプリケーションでは,スレッドがひとつ,あるいはコネクションプールのスレッドがひとつということはあり得ないからです。実際に,採用している通信テクノロジごとにもうひとつのプールを用意しているアプリケーションはたくさんあります。例えばアプリケーションがサーブレットによって処理されるHTTPリクエスト,JMS経由のリクエストに加えて,JDBCコネクションプールも持っているとしましょう。この場合,サーブレットエンジン用のスレッドプールと,JMSとJDBC接続用それぞれのコネクションプールが存在することになります。このようなケースで行うべきなのは,それらすべてを単一のスレッドプールで扱うことでしょう。これはつまり,到着率とサービズ時間を集約して処理したい,という意味でもあります。 システム全体を集約してLittleの法則を適用する研究により,全体でのスレッド数を判断することが可能になります。それができれば次のステップとして,各スレッドグループにそれを分配すればよいのです。分配のロジックとしては数あるテクニックの中から,パフォーマンス要件に見合ったものを選んで採用すればよいでしょう。個々のハードウェア要件に基づいてスレッドプールサイズのバランスをとる,というテクニックもそのひとつです。ただし,JMS経由で着信するリクエストを通じてクライアントに優先順位を付けることもありますから,その方向でプールのバランスを図ることが重要な場合もあります。その場合には当然ですが,必要なハードウェアの違いを考慮に入れた上で,プール毎のサイズを調整しなければなりません。
もうひとつ考慮が必要なのは,今回の定式化が,システムの平均リクエスト数に注目したものであることです。さまざまな理由から,90パーセンタイルのキュー長を知りたい場合があります。この値を使用することで,到着率の自然変動を扱うための余裕量がより多く獲得できるのです。この数値を求める方法は非常に複雑ですが,平均的に言うとおそらくは,バッファを20%拡張するのと同じ程度の効果があります。この方法を実行する場合は,多数のスレッドをハンドルするのに充分な能力が確保されていることを確認してください。例えば今回の例では8スレッドがCPUの限界だったので,それ以上スレッドを増やした場合にはパフォーマンス低下が始まる可能性がありました。最終的にはスレッドプールよりも,カーネルの方がずっと効率的なスレッド管理ができるのです。私たちは8スレッドを越える拡張にはメリットがないと推測したのですが,実際に計測してみればそうではなくて,システムが潜在的に吸収可能な余裕がまだあったのかも知れません。言い換えるならば,能力を越えた実行 (ストレステストのような) をテストしてみて,ユーザレスポンスやスループットにどう影響するのかを試すべきでしょう。
結論
スレッドプールを適切に設定することは簡単ではありませんが,ロケット科学ほど難しい訳でもありません。問題の背景にある数学はよく理解されていますし,毎日の生活の中に満ちあふれて入るという意味では,かなり直感的な部分さえあります。不足しているのは,合理的な選択を行うための計測結果なのです(j.u.c.ExecutorServiceがその証拠です)。適切なセッティングの取得はなかなか面倒で,正確な科学というよりもバケツ科学といった趣きのものです。そうではあっても,ほんの少し面倒な時間を受け入れれば,予想した以上のワークロードによって不安定化したシステムを扱うことの頭痛から,あなたを救ってくれるのです。
著者について
Kirk Pepperdine 氏は,Javaのパフォーマンスチューニングの分野に特化した独立系コンサルタントです。パフォーマンスチューニングに関するセミナの作成経験もあるため,世界的に広く受信されています。セミナでは,Javaのパフォーマンス問題のトラブルシューティングにおいて,チームの有効性向上のために使用されている,パフォーマンスチューニング手法を提供しています。2006年にはJava Championに選ばれた氏は,多数の出版物でパフォーマンスに関する記事を執筆すると同時に,DevoxxやJavaONEなど多くのカンファレンスでパフォーマンスに関する講演も行っています。氏はまた,パフォーマンスチューニング情報に関するリソースで有名なサイトである,Java Performance Tuningの創設にも協力しています。