【Android】Coroutine周りの仕組みについて理解する

KotlinのCoroutine周りは苦手ですが業務で必要な知識のため、coroutine、dispatcher、scheduler、jobなどの仕組みについても理解したいと思います。

Coroutineの仕組み

コルーチンは止めたり再開したりできる軽量な非同期処理の単位です。

コルーチンはよく軽量スレッドと呼ばれますがCoroutineとスレッドは違います。スレッドはOSが管理する実行単位でコルーチンはKotlinランタイムが管理する実行単位です。

Coroutineはスレッドの中で動く存在でスレッドの利用効率を爆上げする仕組みとなります。

Kotlin
launch {
    doSomething()
}

上記の{}の中がコールチンとして実行される処理単位ですが、それを包む実行コンテナがコールチンとなります。

コルーチンの一時停止

コルーチンの中でsuspend関数が呼ばれるとコルーチンは一時停止します。その際スレッドは止まらず、呼び出し元の処理や別のコルーチンが実行され、非同期処理の実行が完了するとsuspendしていた部分から処理が再開されます。

CoroutineContext

コルーチンの実行環境を定義する要素の集合で、コルーチンの実行ルールをまとめて持つ設定オブジェクトです。

コンテキストの中には下記の代表的な4つの情報が入っています。

① Dispatcher(どのスレッドで動くか)

②Job(コルーチンのライフサイクルを管理する)

③CoroutineName(デバッグ用のコルーチン名)

④ExceptionHandler(例外処理ルール)

コルーチンはただの止まれる関数でしかなく、Contextが初めてどこで動きいつ終わるかなどを決めます。

CoroutineScope

コルーチンスコープはコルーチンコンテキストを保持し、そこからコルーチンを起動する主体でルートとなる存在です。コルーチンはこのスコープに生かされていると言えます。

先ほどのコルーチンコンテキストと合わせて関係を整理すると下記のようになります。

Kotlin
CoroutineScope
    └ CoroutineContext
            ├ Dispatcher
            ├ Job
            ├ 名前
            └ 例外処理

            Coroutine起動

コルーチンスコープの定義としてはコルーチンコンテキストを持っている存在かどうかだけです。

①コルーチンの発生地点になる

コルーチンは必ずコルーチンスコープから生まれるため、スコープなしではコルーチンは起動できません。もしスコープがなかった場合コルーチンがどこで生まれ、いつ終わるか、誰が責任を持つのかが不明となります。

②ライフサイクル管理

スコープはその中で生まれたコルーチンをまとめて管理します。そのためスコープが死ぬと中のコルーチンが全部キャンセルされます。

CoroutineDispatcher

ここからはコルーチンコンテキストの内部について入っていきます。まずはディズパッチャについてです。

コールチンディスパッチャはコルーチンをどのスレッドで実行するかを決める仕組みです。コルーチンは自分でスレッドを選びません、ディスパッチャがスレッドを選びます。

また、ディスパッチャ指定はコルーチンがどこで生きるかを決めるため起動時に決められるのか、起動中に移動するかで指定方法が異なります。

Dispatcherの指定方法

①コルーチン起動時に指定する

Kotlin
launch(Dispatchers.IO) {
}

コルーチン全体をIOスレッド群で実行するという意味となります。

②起動中に指定する

Kotlin
launch {
  withContext(Dispatchers.IO)
}

今のコルーチンの中で一時的に実行環境を切り替えるという意味となります。

というのもwithContextでは新しいコルーチンは作成せずに現在利用しているコルーチンを継続利用して実行します。もしwithContextを利用しない場合は再度launchするなどしてコルーチンを作成する必要がありネスト地獄になってしまいます。

withContextは親コンテキストを基に新しいCoroutineContextを合成し、そのContextで既存Coroutineを再開する仕組みです。Dispatcherを指定した場合その結果として実行スレッドが切り替わる可能性があります。

また、大事なのがwithContextはsuspend関数ということです。withContextの実行でIOディスパッチャで実行する必要がある場合はsuspendする可能性があります。

Scheduler

KotlinのコールチンAPIにはScheduler という公開クラスは存在しませんが、概念レイヤーとしてよく扱われています。

実装としては主にCoroutineDispatcherの内部実装で利用されている仕組みとなります。

ディスパッチャとの違いを表すなら下記になります。

Dispatcher

どのプール・どのスレッド群を使うか決める

Scheduler

その中で誰を先に動かすか決める

CoroutineのスケジューリングThreadスケジューリングと違い、OSの上にもう一段スケジューラが乗っているような感じです。これが軽量スレッドと呼ばれる理由の核心です。

Kotlin
withContext → Dispatcher変更
Dispatcher → Schedulerにタスク登録
Scheduler → 実行タイミング決定

Coroutine → suspend
Scheduler → 1秒後に再開予約
1秒後
Scheduler → Dispatcherへ再実行依頼
Dispatcher → スレッドに載せる

以上のことからSchedulerとはコルーチンをいつ動かすかを決める実行管理エンジンで、Dispatcherとはコルーチンをどこで動かすかを決める実行環境指定ということです。

Job

最後にJobについてです。

Jobはコルーチンと関連しており、コルーチンの実行状態とライフサイクルを管理する存在です。

Kotlin
コルーチン生成された

コルーチン実行中

コルーチン完了 or コルーチンキャンセル

上記のコルーチンの状態遷移を全部握っているのがJobです。

コルーチンを起動すると必ず裏でJobが1つ生成されます。

Kotlin
Coroutine = 実行される処理
Job = その処理の管理者

そのためJobはコルーチンコンテキストの一部です。

Jobが何をしているか

①状態管理

②キャンセル制御

③親子関係の管理

特に③が重要でコルーチンを別コルーチンの中でlaunchすると子のJobも作成される形となります。

Kotlin
親Job
   └ 子Job

木構造 = 構造化並行処理(Structured Concurrency)

親Jobかキャンセルされると子Jobもキャンセルされます。逆に子が失敗したら親にエラーが伝播されます。

withContextでのJobについて

withContextでは親のJobを利用することになるので親がキャンセルされるとそれを利用しているwithContextにも影響が及ぶ

launchはコルーチンの作成時にJobを返す

launchはコルーチンを返すのではなくその制御を行うためのJobを返します。 これはコルーチンの途中停止、再開、キャンセル、例外伝播などを安全に管理できるように設計されているためです。

Kotlin
// 実務でよくやるパターン
job.cancel()
job.join()

job.canccel()

上記のコードでjob.canncelはコルーチンの終了をさせます。

canncel時に止まるポイントとしてはsuspendしている場合で、同期的な処理の場合は止まりにくい。

job.join()

そのJobが終わるまでsuspendします。またjoinはsuspend関数のためスレッドをブロックしません。

Coroutineの停止について

コルーチンのキャンセルは2層構造となっている

① Jobの状態(キャンセル済みフラグ)

② 例外による制御フロー停止

上記の2つが合わさって初めて停止が成立する

まずjob.cancelが呼ばれるとjob.isActiveがfalseとなり、その次にコルーチンがsuspendした瞬間にCancellationExceptionが投げられます。

Kotlin
try {
    delay(1000)
} catch (e: Exception) {
    // 握りつぶし
}

上記の例でcanncel時の挙動を見てみます。

時系列を流れとすると下記となります

Kotlin
delay が CancellationException を投げる

catch(Exception) が捕まえる

例外が消える

Coroutineは続行

この時Jobはすでにキャンセル状態になっているので死んでいるのに動いているゾンビ状態であると言えます。

ではエラーを握りつぶさなかった場合を考えてみます

Kotlin
CancellationException 発生

呼び出し元へ伝播

さらに上へ

Coroutine終了

コルーチンの正常終了は呼び出し元へ伝搬して初めて正常に終了されます。

おわりに

これでコルーチン周りの概要が理解できたので実務でコードを読む分には困らなさそうです。