KotlinのCoroutine周りは苦手ですが業務で必要な知識のため、coroutine、dispatcher、scheduler、jobなどの仕組みについても理解したいと思います。
目次
Coroutineの仕組み
コルーチンは止めたり再開したりできる軽量な非同期処理の単位です。
コルーチンはよく軽量スレッドと呼ばれますがCoroutineとスレッドは違います。スレッドはOSが管理する実行単位でコルーチンはKotlinランタイムが管理する実行単位です。
Coroutineはスレッドの中で動く存在でスレッドの利用効率を爆上げする仕組みとなります。
launch {
doSomething()
}上記の{}の中がコールチンとして実行される処理単位ですが、それを包む実行コンテナがコールチンとなります。
コルーチンの一時停止
コルーチンの中でsuspend関数が呼ばれるとコルーチンは一時停止します。その際スレッドは止まらず、呼び出し元の処理や別のコルーチンが実行され、非同期処理の実行が完了するとsuspendしていた部分から処理が再開されます。
CoroutineContext
コルーチンの実行環境を定義する要素の集合で、コルーチンの実行ルールをまとめて持つ設定オブジェクトです。
コンテキストの中には下記の代表的な4つの情報が入っています。
① Dispatcher(どのスレッドで動くか)
②Job(コルーチンのライフサイクルを管理する)
③CoroutineName(デバッグ用のコルーチン名)
④ExceptionHandler(例外処理ルール)
コルーチンはただの止まれる関数でしかなく、Contextが初めてどこで動きいつ終わるかなどを決めます。
CoroutineScope
コルーチンスコープはコルーチンコンテキストを保持し、そこからコルーチンを起動する主体でルートとなる存在です。コルーチンはこのスコープに生かされていると言えます。
先ほどのコルーチンコンテキストと合わせて関係を整理すると下記のようになります。
CoroutineScope
└ CoroutineContext
├ Dispatcher
├ Job
├ 名前
└ 例外処理
↓
Coroutine起動コルーチンスコープの定義としてはコルーチンコンテキストを持っている存在かどうかだけです。
①コルーチンの発生地点になる
コルーチンは必ずコルーチンスコープから生まれるため、スコープなしではコルーチンは起動できません。もしスコープがなかった場合コルーチンがどこで生まれ、いつ終わるか、誰が責任を持つのかが不明となります。
②ライフサイクル管理
スコープはその中で生まれたコルーチンをまとめて管理します。そのためスコープが死ぬと中のコルーチンが全部キャンセルされます。
CoroutineDispatcher
ここからはコルーチンコンテキストの内部について入っていきます。まずはディズパッチャについてです。
コールチンディスパッチャはコルーチンをどのスレッドで実行するかを決める仕組みです。コルーチンは自分でスレッドを選びません、ディスパッチャがスレッドを選びます。
また、ディスパッチャ指定はコルーチンがどこで生きるかを決めるため起動時に決められるのか、起動中に移動するかで指定方法が異なります。
Dispatcherの指定方法
①コルーチン起動時に指定する
launch(Dispatchers.IO) {
}コルーチン全体をIOスレッド群で実行するという意味となります。
②起動中に指定する
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の上にもう一段スケジューラが乗っているような感じです。これが軽量スレッドと呼ばれる理由の核心です。
withContext → Dispatcher変更
Dispatcher → Schedulerにタスク登録
Scheduler → 実行タイミング決定
↓
Coroutine → suspend
Scheduler → 1秒後に再開予約
1秒後
Scheduler → Dispatcherへ再実行依頼
Dispatcher → スレッドに載せる以上のことからSchedulerとはコルーチンをいつ動かすかを決める実行管理エンジンで、Dispatcherとはコルーチンをどこで動かすかを決める実行環境指定ということです。
Job
最後にJobについてです。
Jobはコルーチンと関連しており、コルーチンの実行状態とライフサイクルを管理する存在です。
コルーチン生成された
↓
コルーチン実行中
↓
コルーチン完了 or コルーチンキャンセル上記のコルーチンの状態遷移を全部握っているのがJobです。
コルーチンを起動すると必ず裏でJobが1つ生成されます。
Coroutine = 実行される処理
Job = その処理の管理者そのためJobはコルーチンコンテキストの一部です。
Jobが何をしているか
①状態管理
②キャンセル制御
③親子関係の管理
特に③が重要でコルーチンを別コルーチンの中でlaunchすると子のJobも作成される形となります。
親Job
└ 子Job木構造 = 構造化並行処理(Structured Concurrency)
親Jobかキャンセルされると子Jobもキャンセルされます。逆に子が失敗したら親にエラーが伝播されます。
withContextでのJobについて
withContextでは親のJobを利用することになるので親がキャンセルされるとそれを利用しているwithContextにも影響が及ぶ
launchはコルーチンの作成時にJobを返す
launchはコルーチンを返すのではなくその制御を行うためのJobを返します。 これはコルーチンの途中停止、再開、キャンセル、例外伝播などを安全に管理できるように設計されているためです。
// 実務でよくやるパターン
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が投げられます。
try {
delay(1000)
} catch (e: Exception) {
// 握りつぶし
}上記の例でcanncel時の挙動を見てみます。
時系列を流れとすると下記となります
delay が CancellationException を投げる
↓
catch(Exception) が捕まえる
↓
例外が消える
↓
Coroutineは続行この時Jobはすでにキャンセル状態になっているので死んでいるのに動いているゾンビ状態であると言えます。
ではエラーを握りつぶさなかった場合を考えてみます
CancellationException 発生
↓
呼び出し元へ伝播
↓
さらに上へ
↓
Coroutine終了コルーチンの正常終了は呼び出し元へ伝搬して初めて正常に終了されます。
おわりに
これでコルーチン周りの概要が理解できたので実務でコードを読む分には困らなさそうです。