読者です 読者をやめる 読者になる 読者になる

サナギわさわさ.json

サナギさんとキルミーベイベーとプログラミングが好きです

PlayFrameworkをただの静的型付けMVCだと思って本番稼動させると死ぬ

プログラミング PlayFramework

(3/15 : タイトル修正しました。wは小文字ですね、すみません・・・)

PlayFrameworkが流行り始めてから割と経ちますので、そろそろ正式採用しようと考える方も多いのではないかと思います。

強力な静的型付けで守られたPlayは、ミッションクリティカルなシステムや数万行を超える大規模システムの構築に特に向いているような気がします。

また、Servletを使っていないのに加えてMVC構造がベースなので、今までRailsなどで開発をしていた人でもシームレスに移行できると思います。

しかし、忘れてはならないのがPlayのアーキテクチャ全ての処理が非同期で行われることを前提としているという事です。

ここを忘れてPlayをただの強力な静的型付けで守られたMVCフレームワークとだけ考えて開発を進めてしまうと、本番環境で稼動させた時にパフォーマンスが上がらずに困ることになるかもしれません。今回はそのあたりについて書こうと思います。

Playのアーキテクチャ

今回言いたいことはPlay公式のスレッドプール周りのDocumentation最初の数行にほぼ全て書いてあります。 https://www.playframework.com/documentation/ja/2.1.x/ThreadPools

重要だと思うところをいくつか引用しておきます。

Play framework は、下から上まで、非同期な web フレームワークです。

play-core 内の IO はブロックされないので、伝統的な web フレームワークと比較して、スレッドプールは低目に調整されています。

ブロッキング IO や、潜在的に多くの CPU を集約して実行可能なコードを書きたいと考えた場合に、どのスレッドプールがその処理を実行しているかを知り、それに応じて調整する必要があります。これを考慮に入れずにブロッキング IO を行うと、Play framework のパフォーマンスは貧弱になり得ます。

伝統的なwebフレームワークと比較して、スレッドプールは低目に調整されています。という箇所が特に注意すべきところですね。

これだけで説明は十分と言えば十分なのですが、やや説明不足な気もするのでもう少し詳しく書きます。

Webサーバのアーキテクチャとしては、主に以下のようなものがあります。

  • 1リクエストあたり1プロセスで捌くマルチプロセス型(LL言語に多い)
  • 1リクエストあたり1スレッドで捌くマルチスレッド型(JavaServletなど)
  • イベントループを利用して複数リクエストを1スレッドで捌くイベント駆動型(Node.jsなど)

このあたりに関しては色々な方が説明記事を書いてくださっているので、割愛します。

yuuki.hatenablog.com

などで説明されているのでそちらをご覧ください。

この中ではPlayはマルチスレッド + イベント駆動型でして、 CPUコア数分のスレッドを作ってそのスレッドの中でイベント駆動型でリクエストを処理するというのがデフォルト挙動です。

ここで重要なのは、イベント駆動型では基本的に全ての処理を非同期で行う必要があり、今までの開発と同じ感覚で書いてると死ぬという事です。今まで普通のJavaで開発していた方がPlayに移行した場合、知らずにブロッキング処理を使ってしまう事もあるんじゃないかなと思います。少なくとも僕は最初知らずに使ってました。

PlayでブロッキングI/Oを使った時の挙動

ではPlayでブロッキングI/Oを使った時の実際の挙動を見てみましょう。以下のようなブロッキングI/Oを模擬したコントローラを作り、Apache Benchで同時に10リクエストを行ってみます。なお、結果を分かりやすくするためにPlayのスレッド数は1に設定してあります。(デフォルトではスレッド数 = CPU数)

ブロッキングI/Oを模擬したコントローラ
public class TestController extends Controller {
    public static Result blockingHeavyProcess() throws InterruptedException{
        Thread.sleep(3000);
        return ok();
    }

    public static Result lightProcess() throws InterruptedException{
        Thread.sleep(10);
        return ok();
    }
}
GET /block  controllers.TestController.blockingHeavyProcess()
GET /light  controllers.TestController.lightProcess()
スレッド数を1に設定(application.confに記述)
default-dispatcher = {
    fork-join-executor {
        parallelism-factor = 1.0
        parallelism-max = 1
    }
}
軽いリクエストだけ実行
ab -n 10 -c 10 http://localhost:9000/_api/light
Concurrency Level:      10
Time taken for tests:   0.134 seconds
Requests per second:    74.53 [#/sec] (mean)
Time per request:       134.171 [ms] (mean)
重いリクエストの実行中に軽いリクエストを実行
ab -n 10 -c 10 http://localhost:9000/_api/block
Concurrency Level:      10
Time taken for tests:   30.242 seconds
Requests per second:    0.33 [#/sec] (mean)
Time per request:       30242.090 [ms] (mean)

ab -n 10 -c 10 http://localhost:9000/_api/light
Concurrency Level:      10
Time taken for tests:   29.416 seconds
Requests per second:    0.34 [#/sec] (mean)
Time per request:       29415.664 [ms] (mean)

この通り、重いリクエストがあった場合、軽いリクエストの方まで詰まってしまうという事が分かります。現実で例えると、SQLへ接続する処理が重くなると、MemCachedに接続する処理まで重くなってしまうという感じでしょうか。

これはPlayが1つのスレッドで複数のリクエストを捌くイベント駆動モデルだからです。重い処理の方がスレッドを占有してしまっているので後から実行された軽い処理の方も詰まってしまいます。

そもそもブロッキングI/Oを行わなければ良いのですが、実際のプログラムでブロッキングI/Oを全く行わないというのはあまり現実的ではありません。というわけで何らかの方法でブロッキングI/Oを行っても処理が詰まらないようにする必要があります。

解決策1 : スレッド数自体を増やす

Playはマルチスレッド + イベント駆動モデルです。なので、ブロッキングI/Oが避けられない場合はスレッド数自体を増やしてしまうというのが最も単純な解決策です。

というわけで、デフォルトではCPU数ぶんしか生成されないスレッド数を15にして、先ほどと同じテストを行ってみます。

スレッド数を15に設定(application.confに記述)
default-dispatcher = {
    fork-join-executor {
        parallelism-min = 15
        parallelism-max = 15
    }
}
重いリクエストの実行中に軽いリクエストを実行
ab -n 10 -c 10 http://localhost:9000/_api/block
Concurrency Level:      10
Time taken for tests:   3.088 seconds
Requests per second:    3.24 [#/sec] (mean)
Time per request:       3088.092 [ms] (mean)

ab -n 10 -c 10 http://localhost:9000/_api/light
Concurrency Level:      10
Time taken for tests:   0.036 seconds
Requests per second:    276.31 [#/sec] (mean)
Time per request:       36.191 [ms] (mean)

このように全体のスレッド数を増やす事で、重いリクエストがいくつか生じた際でもある程度誤魔化せます。実際のスレッド数をいくつにするか、というのは実際のマシンスペックや負荷状況を見て決めていただければ良いかなと思います。

この方法は非常に単純ですがある程度の効果が出ます。しかし、重い処理と軽い処理が同一のスレッドを使っているという点では根本解決にはなっておらず、結局設定したスレッド数以上の重いリクエストが来た場合は軽いリクエストも詰まってしまいます。

また、イベント駆動モデルを使ってC10K問題に対応しようとしているPlayでスレッドを多数生成してしまうのは何やら申し訳ない気持ちになります。

というわけで別の解決策も考えてみましょう。

解決策2 : ブロッキングI/Oは別のスレッドでやる

重いリクエストが来た際でも軽いリクエストに影響が出ないようにするには、別々のスレッドを使うのが良さそうです。

PlayではPromise生成時に明示的に別のExecutionContextを渡す事で、別々のスレッドで処理を行う事ができます。
詳しくは別記事で書いていますのでそちらをご覧ください。

kakakazuma.hatenablog.com

また、そもそもExecutionContextとは何ぞやという話に関してはこちらの記事で詳しく説明されていますのでご覧ください。

mashi.hatenablog.com

ブロッキング処理を別のスレッドで行うコントローラ
public static F.Promise<Result> blockingHeavyProcess() {
    final String uniqueId = UUID.randomUUID().toString();
    Logger.debug("process Start : " + uniqueId);

    ExecutionContext executionContext = Akka.system().dispatchers().lookup("play.akka.actor.heavy-promises-dispatcher");
    F.Promise<Result> resultPromise = F.Promise.promise(new F.Function0<Result>() {
        @Override
        public Result apply() throws Throwable {
            Logger.debug("promise Start : " + uniqueId);
            long start = System.nanoTime();
            Thread.sleep(3000);
            long end = System.nanoTime();
            Logger.debug("processTime : " + (end - start) / 1000000 + "msec : " + uniqueId);
            return ok();
        }
    },executionContext);

    return resultPromise.map(new F.Function<Result, Result>() {
        @Override
        public Result apply(Result result) throws Throwable {
            Logger.debug("return result : " + uniqueId);
            return result;
        }
    });
}

public static F.Promise<Result> lightProcess() throws InterruptedException{
    final String uniqueId = UUID.randomUUID().toString();
    Logger.debug("process Start : " + uniqueId);

    ExecutionContext executionContext = Akka.system().dispatchers().lookup("play.akka.actor.light-promises-dispatcher");
    F.Promise<Result> resultPromise = F.Promise.promise(new F.Function0<Result>() {
        @Override
        public Result apply() throws Throwable {
            Logger.debug("promise Start : " + uniqueId);
            long start = System.nanoTime();
            Thread.sleep(10);
            long end = System.nanoTime();
            Logger.debug("processTime : " + (end - start) / 1000000 + "msec : " + uniqueId);
            return ok();
        }
    },executionContext);

    return resultPromise.map(new F.Function<Result, Result>() {
        @Override
        public Result apply(Result result) throws Throwable {
            Logger.debug("return result : " + uniqueId);
            return result;
        }
    });
}
別のExecutionContextを設定
heavy-promises-dispatcher = {
    fork-join-executor {
        parallelism-min = 10
        parallelism-max = 10
    }
}

light-promises-dispatcher = {
    fork-join-executor {
        parallelism-min = 10
        parallelism-max = 10
    }
}
重いリクエストの実行中に軽いリクエストを実行
ab -n 10 -c 10 http://localhost:9000/_api/block
Concurrency Level:      10
Time taken for tests:   3.168 seconds
Requests per second:    3.16 [#/sec] (mean)
Time per request:       3167.555 [ms] (mean)

ab -n 10 -c 10 http://localhost:9000/_api/light
Concurrency Level:      10
Time taken for tests:   0.042 seconds
Requests per second:    236.01 [#/sec] (mean)
Time per request:       42.371 [ms] (mean)

ブロッキングI/Oを明示的に別のスレッドで行う事で、詰まった際の影響をそのスレッド内だけにとどめる事ができます。用途別でスレッドを分けると良さげです。(SQLアクセス用スレッド、memcachedアクセス用スレッド、ノンブロッキング処理用スレッドetc...)
基本的にはこちらの解決策の方が綺麗な気がします。ただ用途別にスレッド分けるのが面倒くさい時はとりあえず全体のスレッド数を増やしてしまうのもありかなとは思います。

結論

  • Playの強力な静的型付けや書きやすいMVCは非常に魅力的だが、イベント駆動型である事を忘れてブロッキング処理を書きまくると死ぬ

  • ブロッキング処理を書く時は以下のどちらかをやると良い

    • 用途別にスレッドを分けて処理を書く(詰まりにくいシステムになって素敵だがやや面倒)

    • 全体のスレッド数を増やしてしまう(愚直だが楽)

まだまだ知らない事が多いので、突っ込みなどいただけると助かります!