Playframeworkとスレッド数

Playframeworkを少し大きめのスレッド数(50)で動かしていたら、自分の予想よりも大きくメモリを消費しました。
今回はその調査レポートです。
なお対象としたPlayのバージョンは1.2.5です。

Playのスレッド数のデフォルト値

まずPlayのスレッド数の設定について少し考察しておきます。

Playのスレッド数はapplication.confの「play.pool」というキーで設定されます。
このキーが省略された場合のデフォルト値は以下のようになります。

  • DEVモード - 1
  • PRODモード - 環境で利用可能なCPU数 + 1

DEVモードとPRODモードは開発環境と本番環境で設定を変更するためのフラグです。

このように開発時と本番で異なるデフォルト値が適用されますが、個人的にはこれらのデフォルト値はDEVモード、PRODモードどちらの場合においても適切ではないと考えています。
以下にそう考える理由を説明していきます。

DEVモードのデフォルト値「1」

スレッド数が1の場合同時に処理できるリクエスト数はひとつだけになります。つまりあるリクエストが実行中の間は他のリクエストは開始されずに待たされることになります。

このことは以下のようなリクエストハンドラを作成することで容易に確認できます。

    public static void ok() {
        System.out.println("ok");
        renderText("ok");
    }
    
    public static void sleep() {
        System.out.println("sleep start");
        try {
            Thread.sleep(60 * 1000);//1minutes
        } catch (InterruptedException e) {
        }
        System.out.println("sleep end");
        renderText("sleep");
    }

okに対するリクエストは通常は即座に返ってきますが、sleepが実行中の場合はその終了まで待たされます。

このように全てのリクエストをシリアライズしてひとつずつ実行する方式にはデバッグを容易にするというメリットがあります。しかし実際のWebアプリケーションでは常に複数のリクエストが同時並行的にやってきます。
複数のユーザーが同時にリクエストを発行する可能性があるのはもちろんですが、現代のWebアプリケーションでは一人のユーザーが開いているひとつの画面からAjaxによって複数のリクエストが同時に発行されることもままあります。

このことから本番環境がマルチスレッドで実行されるアプリケーションであるならば、開発環境がシングルスレッドあることのメリットはほとんどないと思います。逆に複数のリクエストで共通に使用するリソースのデッドロックが発生するなど開発環境では発生しない問題が本番環境だけで発生する可能性もあるので、原則的には開発環境であっても常にスレッド数はある程度大きな数字を設定しておくべきと考えています。(もちろんデバッグ目的で一時的にスレッド数を1にすることは問題ありません。)

ちなみにここでのスレッド数の設定はリクエストハンドラだけでなく静的なコンテンツにも適用されるので、Playのpublicフォルダから提供するイメージを多数含むようなページを返す場合にもパフォーマンス的に不利です。

PRODモードのデフォルト値「CPU数+1」

個人的な感想ですがPlayには非同期IOに対して過剰な期待があるように感じられます。
「IOをすべて非同期化すればCPU効率は最大化する」という理想があってそれを実現すべく実装が行われているような印象を受けるのです。(これはどこかに書かれているのを読んだことがあるのではなくて僕が勝手にそう思っているだけです。)

そうした理想を実現したシステムでは「CPU数+1」という設定値は確かに最適な値なのかもしれません。しかし実際にはすべてのIOを非同期で実現したシステムを作るのはほとんど不可能ではないかと思うのです。

例えばSalesforceやEvernoteなどの外部サービスを実行する場合、それらのサービスプロバイダーが提供するSOAPやRESTのWebAPIを叩きますが、それらはすべて同期IOです。
またJDBCを使用したデータベースアクセスも同期IOなので、そう考えると同期IOの全くない処理というのはほとんどないように思います。

そして同期IOがあるのであれば、必ずそこにはIOウェイトが発生するはずで、その間に他のスレッドが処理を進めることができるのであればその方が効率が良いです。

なのでPRODモードのスレッド数もデフォルト値をそのまま使用せずに環境や想定ユーザー数によって調整することが必要と考えています。

スレッド数を決める際に考慮する事項

こう書くと次にはそれでは最適なスレッド数はいくつなのか?という話になると思いますが、これはアプリケーションの処理内容や環境によって色々な要素が絡み合うので一概には言えません。
ざっと考えただけでも以下のような要素を考慮する必要があると思います。

  • CPU数 - CPU数に比してスレッド数があまりに多いとスレッドのスイッチが頻繁に発生することによって効率が低下する
  • メモリ - 同時並行的に処理するスレッドがそれぞれ、そこそこメモリを消費するものであるならばスレッド数が多ければそれだけ多くのメモリを消費する
  • 想定ユーザー数 - 同時アクセスが予想されるユーザー数よりも極端にスレッドが多くても無駄なだけだし、少なければ待ち行列が発生する
  • DBコネクション数 - DBの接続数が制限された環境の場合、接続可能なコネクション数を超えてスレッド数を増やしてもDBアクセスで結局待ち行列に入ることになる

などなど。

まぁ実際のところはこれらを厳密に計算するようなことはなくて20とか50とかに直観的に決めてますけど。(^^;;;
チューニングのやり方は色々あると思いますが、僕の場合は最初に大きめの値を設定して限界を超えていそうな要素があれば、そこを睨みつつ数値を調整するというやり方をとります。

Playでスレッド数を大きくした場合の注意点

さて前振りが長くなりましたが、今回スレッド数を50とした状態である程度メモリを消費すると思われる処理(=POIによるExcelファイルのパース)を繰り返し実行してみたところ実行毎に使用メモリが徐々に大きくなるという現象が確認できました。

最初はgcが実行されていないだけかと思いましたが、gcを実行してもほとんどメモリは解放されません。まさかメモリリークかよ!と憂鬱な気持ちでVisualVMで調査したところやはり使用済みの巨大オブジェクト(=パース後のExcelワークブック)がメモリに残り続けてました。(--

本気でメモリリークかと一瞬あせりましたが、よくよく調べてみると原因はThreadLocalからの参照でした。

リクエストハンドラでテンプレートを使用する場合renderメソッドに任意のオブジェクトを引数として渡せますが、ここで使用したオブジェクトはリクエストを抜けても次回スレッドが再利用するまでは参照が残り続けるようです。

スレッド数が50の場合、おそらく各スレッドがサイクリックに使いまわされると思うので最大50回分の引数オブジェクトがメモリに残ります。

今回の場合巨大オブジェクトを参照するメソッドばかりを繰り返し実行したのでメモリ消費が極端になったわけです。実際にはほとんどのメソッドは文字列や小さめのリストしか参照しないのでここまで極端な結果にはならないと思いますが、できればこれはフレームワーク側でリクエストを抜けた時に参照をクリアしてほしいですね。

コメント(4)