Spring MVCでコルーチン(Kotlin)を使うこととなったため、調査しました。なかなか難しいなぁと思いました・・。
Coroutineとは何か?
途中で中断できる「処理のまとまり」のことです。中断すると制御が他の処理に移りますが、処理(routine)同士が制御を互いに移し合う様から、接頭辞「Co」がついています。
中断(suspend)とは何か?
Kotlinにおける「中断(suspend)」というのは、かなり乱暴な表現をすると、ある処理を実行しているスレッドが、通信処理など「待ち」となるような処理を開始した後で、実際に待つことをせず、待っている間に別の処理を実行する、ということを指します。待ちが終わると、同じ or 別のスレッドが続きの処理を再開します。待ちの間にボケーっと待っているわけではなく、他の仕事をやるということですね。いわゆるノンブロッキングというやつです。
逆に、「中断」しない場合というのは、待ちが終わるまでスレッドも待ち状態となり、待ちが終わったら同じスレッドが続きの処理を実行します。待ちの間にボケーっと待っているわけです。いわゆるブロッキングというやつです。
Spring MVCによるサーバサイド処理はこうなっている
公式サイトによると、以下の通り、Spring MVCではCoroutineがサポートされています。
Spring MVC および WebFlux アノテーション付き @Controller での中断機能のサポート
では、具体的にはどのような仕組みになっているのでしょうか?
Spring MVCにおける処理シーケンスは以下の通りです。
- Spring側は、Webサーバからリクエストを受け付けると、コールバックを指定してMonoと呼ばれるものを生成します。Monoとは、Reactive Streamsという仕様に出てくる「Publisher」という役割を実装したものです。Publisher(Mono)の中では非同期に処理が実行され、Spring側はPublisherをsubscribeします。Publisherの処理が終わると、SubscriberであるSpring側に通知され、それを受けてSpring側は呼び出し元にレスポンスしていきます。
- このコールバックの中でコルーチンが起動され、コルーチン内でControllerのsuspend functionが実行されます。コルーチン起動時にはDispatchers.Unconfinedが指定されていますので、Controllerのsuspend function内で起動する子コルーチンも、特段の指定がない限りは親コルーチンの設定であるDispatchers.Unconfinedを引き継ぎます。
- Controllerのsuspend functionでは、並列処理を行うためにasyncを呼び出し、子コルーチンを起動することが多いと思います。async呼び出しについては、以下の理解が大事と思います。
- asyncは定義をみると分かるように、CoroutinScopeの拡張関数です。コルーチンの処理ブロックにはレシーバ(this)としてCoroutineScopeオブジェクトが設定されていますので、実際にはthis.async()と呼び出せます。thisは省略できるので、async()と直接呼び出せるのです。
- 先述のとおり、asyncに特段の指定がない限り、子コルーチンは親コルーチンの設定であるDispatchers.Unconfinedを引き継ぎます。
- Dispachers.DefaultやDispatchers.IOを指定するといった選択肢もあり、それぞれ高CPU負荷の処理やIO待ちの発生する処理に適していると一般的には言われます。しかし、実際には無闇に指定すべきではありません。それらに最適なスレッド数には限りがあり、それらが枯渇した時点でブロッキングが発生してしまうためです。詳しくはこちらを参照ください。時期尚早な最適化はダメだよ、ということですね。
- asyncで起動した子コルーチンでは、親コルーチンを処理するスレッドがsuspendCoroutine or suspendCancellableCoroutineの呼び出しまでを実行し、続きの処理を別スレッドに任せます。自スレッドはasyncの戻り値であるDeferredを受け取り、async呼び出しの次の処理を実行していきます。DeferredはJobのサブクラスですから、つまるところJobの一種です。Deferredを操作することで、処理結果を受け取ったり、asyncで起動するコルーチンをキャンセルすっることができます。
- このようにControllerのスレッドはどんどんasyncでコルーチンを起動していき、どこかでそれらが終わるのをDeferred.await()で待ち、それらの処理結果を取得します。
- Controllerの処理が完了すると、Monoを介してSpring側に通知がいきます。先述した通り、通知を受けたSpring側は、呼び出し元にレスポンスしていきます(画面ならThymeleafなどに処理が移ります)。
何が嬉しいのか?
Spring MVCの場合、以下のメリットがあります。
- Spring側では、Contollerからの応答を待つためにスレッドがブロックされる、といったことは起こりません。Spring側はController側からのコールバックによる通知を受けてから、レスポンス処理にスレッドを回せば良いのです。Spring MVCとはいえ、Spring側からContollerを呼び出す部分だけはリアクティブな動きをするということです。
- Controller以降でasyncを呼び出す際は、親コルーチンを処理するスレッドがsuspendCoroutine or suspendCancellableCoroutineの呼び出しまでを実行し、続きの処理を別スレッドに任せるわけですから、Controllerを処理するメインともいえるスレッドはブロックされることなく、async以降の処理を継続できます。どんどんasyncで別コルーチンを起動していくことで、それらをシーケンシャルに呼び出すよりも明らかに性能が向上します。
- このような並列処理は、もちろんスレッドを多数起動することでも実現できますが、スレッドは1本あたり約2MBのメモリを消費するため、メモリ消費量が大変なことになります。一方、コルーチンでは各スレッドが上記のような「中断」を繰り返しながら細切れに処理をしていくため、少ないスレッドで同じことを実現できます。
- また、スレッドを多数起動した場合と比較したもう1つのメリットは、DBアクセスや通信処理などで「待ち」が発生する場合に、スレッドがブロックされず別の処理をできるため、CPUを余すところなく有効に活用できる、ということです。
エラーハンドリング
子コルーチンは、親コルーチンから起動された非同期処理であり、両者はある意味「別世界」で実行されます。子コルーチンでエラーが起きても、親コルーチンの世界とは関係ないわけです。
子コルーチンでエラーが起きても、親コルーチンでリカバリして子コルーチンを正常終了させることはできません。
例えば・・・
- launchで起動された子コルーチンで例外が発生しても、親コルーチンに伝播されません。CoroutineContextに設定されたCoroutineExceptionHandlerが、エラー時のコールバックとして実行されるだけです(主にログ出力。なお、デフォルトのハンドラは何もしてくれない)。親コルーチンは例外にアクセスすることすらできません(job.join()してCancellationExceptionをキャッチしてcauseとかを見ればいけるのかもしれませんが)。
- asyncで起動された子コルーチンで例外が発生すると、親コルーチンに伝播されます(Deferrdをawaitした時にスローされる)。親コルーチンは子コルーチンで起きた例外を知ることができます。子コルーチンのCoroutineContextにCoroutineExceptionHandlerが設定されていても、ハンドラに通知されません(ハンドラは動きません)。
Spring MVCでは、launchよりもasyncを使うことが多いでしょう。asyncで発生する例外をハンドリングする必要性が無い、というケースがほとんどのはずです。その場合は以下の制御で問題ないでしょう。
asyncは後述するcoroutineScope()で作成したCoroutinScopeで動作しますが、async内で例外がスローされると、そのコルーチンは異常終了したと見なされ、Structured concurrencyの仕組みに沿って、同Scope内で起動していたコルーチンが全てキャンセルされます。キャンセルされたawaitは即処理が終了され、そのコルーチン内でCancellationExceptionがスローされます。(ただし、長い計算処理の中でキャンセル状態のチェックをしていない場合、すぐに終了されません)。async.await()でスローされた例外はcoroutineScope()の外側に伝播されます。
このあたりの実装イメージについては、以下をご覧ください。
https://github.com/nyandora/kotlin-sample/blob/master/src/main/kotlin/ErrorOnAsyncBlock.kt
coroutineScope()の外側に伝播された例外は、特段のハンドリングをされない限りは、Spring側まで伝播され、最終的にはいつものようにInterceptorなどがログ出力やらシステムエラー画面の表示やらをやります。
コルーチンの各種APIを理解する
コルーチンに関連して色々なAPIがあり、初めはすごく混乱するのですが、これらを理解するためには「Structured concurrency」という概念を理解する必要があります。
コルーチンには生存期間があります。生存期間のことをCoroutineScopeと呼びます。変数にもスコープという生存期間がありますが、これと似たような概念です。
親コルーチンの生存期間の中に、子コルーチンの生存期間が包含されます。これは以下を意味します。
- 子コルーチンが完了して初めて、親コルーチンも完了することができます。
- 子コルーチンでエラー(CancellationExceptionを除く)が起こると、親コルーチンはキャンセルされ、他の子コルーチンもキャンセルされます。
すべてのコルーチンには生存期間、つまりCoroutineScopeがあるわけなので、コルーチンそのものより先に、CoroutineScopeが存在している必要があります。CoroutineScopeからコルーチンが生える感じです。CoroutineScopeの無いところから、いきなりコルーチンが開始されることは無いのです。このように、CoroutineScopeからコルーチンを生成されるため、launch/asyncといったコルーチンビルダーはCoroutineScopeの関数として定義されているのです。CoroutineScopeのインスタンスに対してlaunch/asyncしているわけです。
コルーチンを起動するときは、どのCoroutineScopeからコルーチンを生やし、どんな設定(CoroutineContext)で動かすか、を指定するわけですね。
この点を理解した上でAPIと向き合うと、APIについて腹落ちした理解ができてきます。
coroutineScope()
- 親コルーチンのCoroutineScopeの中にネストされたCoroutineScopeを作り、ネストされたCoroutineScopeから子コルーチンを生やして処理を実行します。設定(CorutineContext)は親コルーチンのものを引き継ぎます。この子コルーチンの中で孫コルーチンを起動した場合、Structured concurrencyに従って適切にキャンセル/エラーハンドリングされます。例えば、子コルーチンのある処理で例外がスローされた場合、子コルーチンはキャンセルされ、孫コルーチンもキャンセルされた後、親コルーチンに(coroutineScope()の外側に)その例外がスローされます。
- 親コルーチンはcoroutineScope()の中の処理が完了しないと、先に進めません。
- Spring MVCでは、画面に表示するデータをAPIで他システムからかき集めてくる、といったケースがあると思います。その場合はasyncを呼び出しまくって並列で各APIを呼び出し、それらのAPIの結果が出そろうまで待つ、といったことがあります。asyncをどのCoroutineScopeに対して呼び出すのか指定する必要がありますので、coroutineScope()でスコープを作り出す必要があります。
withContext()
- 現在のコルーチンを、設定(CoroutineContext)だけを変更して実行します。CoroutineScopeは現在のコルーチンが属しているものから変更されません。なので、withContextの引数blockに指定されるレシーバ(this)は、現在のコルーチンが属しているCoroutineScopeです。このCoroutineScopeからasyncとかでコルーチンを生やすわけですね。
- 現在のコルーチンはwithContext()の中の処理が完了しないと、先に進めません。
- 用途としては、パフォーマンスチューニングのためにDispatherを切り替える、くらいでしょうか・・?この用途を含め、Spring MVCにおいては出番がほぼ無いような気がします。
おわりに
新たに分かったことがあれば追記していこうと思います。できれば図でのご説明も追加したいところですね・・。
間違っているところがあれば、是非是非ご指摘いただきたいです!