サナギわさわさ.json

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

Swift2のProtocol Extensionsとクラス継承を比較する

プログラムを書く時はできるだけ頭を使いたくないので、当然コードもできるだけ共通化して使いまわしたいわけです。

今まで自分はiOS開発ではクラス継承を使って処理の共通化を行っていたのですが、Swift2ではProtocol Extensionsを使って処理の共通化を行うのが良いらしいので、今回はそれについて調べてみました。

TL;DR

  • クラス継承で処理を共通化していた時は仕様変更のたびに親クラスが肥大化したり、子クラスの挙動が親クラスに依存して分かりにくくなったりするのが辛かったが、Protocol Extensionsでは複数のProtocolを使って実装を共通化できるので見通しが良くなりそう

  • 逆にSwift2でProtocol Extensionsよりクラス継承を使うべきケースを知りたい(誰か教えてください)

Protocol Extensionsの概要

SwiftのProtocolとはインターフェースみたいなもので、Protocol Extensionsとは、本来実装を持たないProtocolに対してメソッドの実装を追加できる機能です。Protocolは複数継承することができるので、これによってコードの再利用・共通化を柔軟に行うことができるようになります。

クラス継承との比較

せっかくなので従来のクラス継承を使った場合と比べてみます。 要件として、ViewControllerA,ViewControllerBで共通の処理が存在する場合を考えてみます。

まずは、ロード時に現在メンテナンス中かどうかをサーバーに確認し、必要に応じてポップアップを表示する共通処理を実装してみます。

クラス継承を用いる場合

共通処理をBaseViewControllerに記述し、 ViewControllerA,ViewControllerBからBaseViewControllerを継承します。

class BaseViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        checkMaintenance()
    }
    
    func checkMaintenance() {
        API.checkMaintenance() { result in
            //show popup if needed
        }
    }
}

class ViewControllerA: BaseViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
    }
}

class ViewControllerB: BaseViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
    }
}

Protocol Extensionsを用いる場合

MaintenanceCheckableというProtocolを作り、Protocol Extensionsで共通処理を実装します。その後ViewControllerA,ViewControllerBからMaintenanceCheckableを継承します。

protocol MaintenanceCheckable {
    func checkMaintenance()
}

extension MaintenanceCheckable where Self: UIViewController {
    func checkMaintenance() {
        API.checkMaintenance() { result in
            //show popup if needed
        }
    }
}

class ViewControllerA: MaintenanceCheckable {
    override func viewDidLoad() {
        super.viewDidLoad()
        self.checkMaintenance()
    }
}

class ViewControllerB: MaintenanceCheckable {
    override func viewDidLoad() {
        super.viewDidLoad()
        self.checkMaintenance()
    }
}

この時点だと別にどちらの実装でも良さそうに見えます。では仕様変更が発生し、

  • ロード時にアクセス解析ログをサーバに送る処理を行う
  • メンテナンス中かどうかの確認はViewControllerBでは行わない

という実装をする必要が出てきた場合を考えてみます。

クラス継承を用いる場合

共通処理及び条件分岐をBaseViewControllerに追加します。まだ大丈夫ですが、この調子で仕様が増えていくとBaseViewControllerの処理が肥大化して見通しが悪くなる恐れがあります。また、子クラスの実装者が親クラスが何をやっているか把握し辛く新規メンバーの学習コストが増す気がします。

class BaseViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        sendAccessLog()
        if Mirror(reflecting: self).subjectType !=  ViewControllerB.self {
            checkMaintenance()    
        }        
    }
    
    func checkMaintenance() {
        API.checkMaintenance() { result in
            //show popup if needed
        }
    }
    
    func sendAccessLog() {
        API.sendAccessLog() { result in
         }
    }
}

class ViewControllerA: BaseViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
    }
}

class ViewControllerB: BaseViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
    }
}

Protocol Extensionsを用いる場合

LogSendableというProtocolを新しく作り、Protocol Extensionsで共通処理を実装します。
その後ViewControllerA,ViewControllerBの継承にLogSendableを追加し、ViewControllerBの継承からMaintenanceCheckableを外します。
処理をProtocolごとに細かく分ける事で見通しが良くなり、継承しているProtocolを見ればどのような特徴を持つクラスなのかも分かります。

protocol LogSendable {
    func sendAccessLog()
}

extension LogSendable where Self: UIViewController {
    func sendAccessLog() {
        API.sendAccessLog() { result in
         }
    }
}

protocol MaintenanceCheckable {
    func checkMaintenance()
}

extension MaintenanceCheckable where Self: UIViewController {
    func checkMaintenance() {
        API.checkMaintenance() { result in
            //show popup if needed
        }
    }
}

class ViewControllerA: MaintenanceCheckable, LogSendable {
    override func viewDidLoad() {
        super.viewDidLoad()
        self.sendAccessLog()
        self.checkMaintenance()
    }
}

class ViewControllerB: LogSendable {
    override func viewDidLoad() {
        super.viewDidLoad()
        self.sendAccessLog()        
    }
}

まとめ

  • クラス継承で処理を共通化すると親クラスが肥大化して辛い事に加え、子クラスの挙動が分かりにくくなり新規メンバーの学習コストが増す
  • Protocol Extensionsを用いて細かいProtocolを複数継承して処理を記述する事で肥大化が避けられ、挙動も継承Protocolを見れば大体分かるようになる
  • 逆にどんな場合にProtocolよりクラス継承を使うべきなのでしょうか?(だれか教えてください)

参考

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

(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は非常に魅力的だが、イベント駆動型である事を忘れてブロッキング処理を書きまくると死ぬ

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

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

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

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

Play2のPromiseと非同期処理について調査した

PlayFrameWorkはイベント駆動型を基本としているため、全ての処理は非同期で実行される事が前提となっています。 これによって単一スレッドで複数のリクエストを捌く事ができます。

非同期のHTTP呼び出しを実現するPlay WS APIを使って外部のWebサイトにアクセスするコードを例にして、非同期処理でクライアントからのリクエストがどう扱われるか見てみます。

非同期処理でのリクエスト挙動

非同期で外部Webサイトにアクセスするコントローラ
public class TestController extends Controller {

    public static F.Promise<Result> nonBlockingUrlAccess() {
        final String uniqueId = UUID.randomUUID().toString();
        Logger.debug("request start : " + uniqueId);

        F.Promise<WS.Response> resultPromise = WS.url("http://yahoo.co.jp").get();
        return resultPromise.map(new F.Function<WS.Response, Result>() {
            @Override
            public Result apply(WS.Response response) throws Throwable {
                Logger.debug("return result : " + uniqueId);
                return Results.ok();
            }
        });
    }
}

このコントローラに対して5箇所から同時にアクセスした場合、以下のようなログが吐かれます。

request start : 61f6a075-7b23-4547-a8f8-e108e0aa9b82
request start : 249b3fd0-80fa-4e02-9702-fc46580ff3a5
request start : c98bc9cb-9f5a-43ac-8bb4-190dab9431fe
request start : d0d79bd4-ecdd-4b31-9fc5-aa13ceb41c29
request start : 512300bc-1757-4543-9004-39362ec1fbea
return result : 249b3fd0-80fa-4e02-9702-fc46580ff3a5
return result : 512300bc-1757-4543-9004-39362ec1fbea
return result : 61f6a075-7b23-4547-a8f8-e108e0aa9b82
return result : d0d79bd4-ecdd-4b31-9fc5-aa13ceb41c29
return result : c98bc9cb-9f5a-43ac-8bb4-190dab9431fe

また、処理時間は以下のようになります。

Concurrency Level:      5
Time taken for tests:   1.033 seconds
Complete requests:      5
Failed requests:        0
Requests per second:    4.84 [#/sec] (mean)
Time per request:       1033.332 [ms] (mean)
Time per request:       206.666 [ms] (mean, across all concurrent requests)

Promiseを使って非同期に外部Webサイトにアクセスする事で、1スレッドで複数のリクエストを同時に受け付け、結果が返ってきたものから順番にレスポンスを返せている事が分かります。

このように、基本的に非同期処理を行っている限り問題は無いのですが、実際にコードを書いているとDBアクセスなどでどうしても同期処理を行わなければいけない場合があります。

という訳で同期処理を普通に行った際にクライアントからのリクエストがどう扱われるか見てみます。

同期処理でのリクエスト挙動

重い同期処理を模擬したコントローラ
public class TestController extends Controller {

    public static Result blockingHeavy() throws InterruptedException {
        final String uniqueId = UUID.randomUUID().toString();
        Logger.debug("request start : " + uniqueId);
        Thread.sleep(1000);
        Logger.debug("return result : " + uniqueId);
        return ok();
    }
}

このコントローラに対して5箇所から同時にアクセスした場合、以下のようなログが吐かれます。

request start : 5c0fb9e8-875e-4192-957a-20bc102554df
return result : 5c0fb9e8-875e-4192-957a-20bc102554df
request start : e28064e5-231b-43f5-9c44-99551e278491
return result : e28064e5-231b-43f5-9c44-99551e278491
request start : 0cf300fb-be2c-4ff0-8809-38e9ed13e725
return result : 0cf300fb-be2c-4ff0-8809-38e9ed13e725
request start : 80f5e280-6d51-438c-91da-edf94173d021
return result : 80f5e280-6d51-438c-91da-edf94173d021
request start : 167c95a5-bcd2-4501-9411-f9adf46f9874
return result : 167c95a5-bcd2-4501-9411-f9adf46f9874

また、処理時間は以下のようになります。

Concurrency Level:      5
Time taken for tests:   5.029 seconds
Complete requests:      5
Failed requests:        0
Requests per second:    0.99 [#/sec] (mean)
Time per request:       5029.434 [ms] (mean)
Time per request:       1005.887 [ms] (mean, across all concurrent requests)

先ほどとは違いリクエストは順列に1件ずつ処理されており、先に届いたリクエストが処理されるまで後のリクエストは処理されていません。同期処理を普通に書くとリクエストを同時に捌けなくなってしまう事が分かります。

※本来Playは「マルチスレッド + イベント駆動型」なので同期処理を普通に書いてもスレッド数ぶんは同時に捌けますが、今回は説明のためスレッド数を1に設定しています。

同期処理をPromiseでラップする

では同期処理をPromiseでラップしてみるとどうでしょうか?Promiseでラップされた処理は非同期に実行されるので、これで同期処理でも同時に捌けるようになるはずです。

重い同期処理をPromiseでラップしたコントローラ
public class TestController extends Controller {

    public static F.Promise<Result> blockingHeavyWithPromise() {
        final String uniqueId = UUID.randomUUID().toString();
        Logger.debug("request start : " + uniqueId);
        F.Promise<Result> resultPromise = F.Promise.promise(new F.Function0<Result>() {
            @Override
            public Result apply() throws Throwable {
                Logger.debug("promise start : " + uniqueId);
                Thread.sleep(1000);
                Logger.debug("process end : " + uniqueId);
                return ok();
            }
        });

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

このコントローラに対して5箇所から同時にアクセスした場合、以下のようなログが吐かれます。

request start : 676db307-d8a9-4639-853c-e52026cca17c
request start : 28dcc477-4d96-483c-b6ed-50771b48bad6
request start : 7ff15ce5-ce17-4db7-98a9-71b9f068fb76
request start : e15ddb60-4efb-47da-b008-4bfc8f69a4bc
promise start : ae1432ba-bbe4-4f22-b724-26fd919334c9
process end : ae1432ba-bbe4-4f22-b724-26fd919334c9
promise start : 676db307-d8a9-4639-853c-e52026cca17c
process end : 676db307-d8a9-4639-853c-e52026cca17c
promise start : 28dcc477-4d96-483c-b6ed-50771b48bad6
process end : 28dcc477-4d96-483c-b6ed-50771b48bad6
promise start : 7ff15ce5-ce17-4db7-98a9-71b9f068fb76
process end : 7ff15ce5-ce17-4db7-98a9-71b9f068fb76
promise start : e15ddb60-4efb-47da-b008-4bfc8f69a4bc
process end : e15ddb60-4efb-47da-b008-4bfc8f69a4bc
return result : ae1432ba-bbe4-4f22-b724-26fd919334c9
return result : 676db307-d8a9-4639-853c-e52026cca17c
return result : 28dcc477-4d96-483c-b6ed-50771b48bad6
return result : 7ff15ce5-ce17-4db7-98a9-71b9f068fb76
return result : e15ddb60-4efb-47da-b008-4bfc8f69a4bc

また、処理時間は以下のようになります。

Concurrency Level:      5
Time taken for tests:   5.102 seconds
Complete requests:      5
Failed requests:        0
Requests per second:    0.98 [#/sec] (mean)
Time per request:       5101.675 [ms] (mean)
Time per request:       1020.335 [ms] (mean, across all concurrent requests)

リクエストの受け付け自体は同時に行えていますが、今度はPromiseの実行部分が詰まっているように見えます。処理時間はPromiseでラップする前と変わりませんし、これでは意味がありません。

Promiseでラップ=新規スレッド立ち上げではない

PlayでPromiseを作成する際には暗黙的にGlobalのExecutionContextが渡されており、Promiseの処理はそのExecutionContextによって実行されます。

詳しくは下記のサイトにまとまっていますのでそちらをご覧ください。非常に参考になります。

mashi.hatenablog.com

要するに、ただPromiseを使って同期処理をラップしただけでは非同期処理にはならないという事らしいです。よく見たらPlayの公式documentationにも似たような事が書いてありますね。

ThreadPools

このため、Future でブロッキングコードをラップするという誘惑に駆られるかもしれないことに注意してください。こうすることではノンブロッキングになりませんし、ブロッキングが他のスレッド内で起こるかもしれません。使用しているスレッドプールがブロッキングを扱うための十分なスレッドを持っていることを確かめる必要があります。

別ExecutionContextを明示してPromiseでラップする

長くなりましたが、同期処理をPromiseでラップしたい際にはメインで使っているものとは別のExecutionContextを明示して渡す、というのが正しそうです。Playでは複数のExecutionContextをapplication.confで定義できます。

Promiseに渡す用のExecutionContextを定義し、それを使ってPromiseを作成してみます。

play {

    akka {
        event-handlers = ["akka.event.slf4j.Slf4jEventHandler"]
        loglevel = WARNING

        actor {

            default-dispatcher = {
                fork-join-executor {
                    parallelism-min = 1
                    parallelism-max = 1
                }
            }
            
            promises-dispatcher = {
                fork-join-executor {
                    parallelism-min = 10
                    parallelism-max = 10
                }
            }
        }
    }
}
重い同期処理を別ExecutionContextを明示してPromiseでラップしたコントローラ
public class TestController extends Controller {

    public static F.Promise<Result> blockingHeavyWithPromise() {
        ExecutionContext executionContext = Akka.system().dispatchers().lookup("play.akka.actor.promises-dispatcher");
        final String uniqueId = UUID.randomUUID().toString();
        Logger.debug("request start : " + uniqueId);
        F.Promise<Result> resultPromise = F.Promise.promise(new F.Function0<Result>() {
            @Override
            public Result apply() throws Throwable {
                Logger.debug("promise start : " + uniqueId);
                Thread.sleep(1000);
                Logger.debug("process end : " + 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;
            }
        });
    }
}

このコントローラに対して5箇所から同時にアクセスした場合、以下のようなログが吐かれます。

request start : 8b458e3e-a60b-467b-ae03-868115e71942
promise start : 8b458e3e-a60b-467b-ae03-868115e71942
request start : b0d248e2-5bd4-43d4-9bb5-0e3cd575dfd6
request start : 7de18236-a6f7-44b9-b427-8e1126ace14e
promise start : b0d248e2-5bd4-43d4-9bb5-0e3cd575dfd6
request start : d2f7df3a-fddb-4aaa-af3f-3888f15c0979
promise start : 7de18236-a6f7-44b9-b427-8e1126ace14e
promise start : d2f7df3a-fddb-4aaa-af3f-3888f15c0979
request start : b448a9f2-cb15-4547-b79d-e5b2627e6a9e
promise start : b448a9f2-cb15-4547-b79d-e5b2627e6a9e
process end : b0d248e2-5bd4-43d4-9bb5-0e3cd575dfd6
process end : d2f7df3a-fddb-4aaa-af3f-3888f15c0979
process end : 8b458e3e-a60b-467b-ae03-868115e71942
process end : 7de18236-a6f7-44b9-b427-8e1126ace14e
return result : 8b458e3e-a60b-467b-ae03-868115e71942
return result : b0d248e2-5bd4-43d4-9bb5-0e3cd575dfd6
process end : b448a9f2-cb15-4547-b79d-e5b2627e6a9e
return result : d2f7df3a-fddb-4aaa-af3f-3888f15c0979
return result : b448a9f2-cb15-4547-b79d-e5b2627e6a9e
return result : 7de18236-a6f7-44b9-b427-8e1126ace14e

また、処理時間は以下のようになります。

Concurrency Level:      5
Time taken for tests:   1.083 seconds
Complete requests:      5
Failed requests:        0
Requests per second:    4.62 [#/sec] (mean)
Time per request:       1082.881 [ms] (mean)
Time per request:       216.576 [ms] (mean, across all concurrent requests)

リクエストも同時に捌けていますし、Promiseも詰まっていません。処理時間も問題ありませんし、やっと求めていた動きになりました。

結論

Playで同期処理をPromiseでラップする時は、明示的に別のExecutionContextを渡すべき

色々理解不足の点もありますので、何かおかしなところありましたら教えてください!