LoginSignup
3

More than 1 year has passed since last update.

Spring MVCにおけるCoroutine(Kotlin)をきちんと理解する

Last updated at Posted at 2021-04-30

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においては出番がほぼ無いような気がします。

おわりに

新たに分かったことがあれば追記していこうと思います。できれば図でのご説明も追加したいところですね・・。

間違っているところがあれば、是非是非ご指摘いただきたいです!

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3