Kotlin Coroutine の特にキャンセルやエラーが伝播していく仕組みについて調べたことのまとめ。
Kotlin coroutineはキャンセルが伝播しますが、あまりイメージがつかめなかったので実際にluanchの内部実装を追って伝播する様子を見てみました。
- Coroutinesのバージョンはv1.5.2
- Kotlinのバージョンはv1.5.31
挙動を試すときは、公式のonline Kotlin playground を使用すると便利です。(import文を書けば)coroutineも使用できます。
[2022/4/10 追記]
Kotlin Coroutineに関するまとめの記事の第二弾を公開しました→Kotlin Coroutine のキャンセル周りの話②
[2022/7/4 追記]
- 内容に誤りがあったため「CoroutineContextとは」 「CoroutineContextの合成」の章を修正しました。
- ページ内リンクの貼り方が間違っていてクリックしても飛べない箇所があったので修正しました。
Kotlin Coroutine の基本
coroutineとは
軽量スレッドのこと。
みんなでスレッドを共有して、使用者が止まっている時(Thread.sleepにあたるときや別スレッドでの仕事の結果を待っている時)はほかの人が使うようにする。これにより新しくたてるスレッドの数や機会を減らしてリソースの削減を図れます。
coroutineを起動するには
coroutineを起動するにはCoroutineScopeが必要。
【例】
[CoroutineScopeのインスタンス].launch{
}
更に、起動したlaunchやasyncのラムダの中もCoroutineScope
なので、launchの中でも直接launchが呼べます。
[CoroutineScopeのインスタンス].launch{
// this = CoroutineScope
launch{ // OK!
}
}
親子関係
起動したlaunchやasyncの中のCoroutineScopeは、もとのCoroutineScopeとは別の新しいものですが、親子関係にあります。
=> 子CoroutineScopeと呼ぶことにします。
CoroutineScopeのつくり方
CoroutineContextを指定すると作れます。
![]() |
|---|
【例】
val scope = CoroutineScope(EmptyCoroutineContext)
scope.launc{
delay(1000)
}
CoroutineContextとは
[2022/7/4 修正済み]
-
CoroutineScopeの挙動を決めるもの。 - 1つの
CoroutineScopeは必ず1つのCoroutineContextに紐づいています。 - interfaceです。
- 内部interfaceとして
ElementとKey<e:Element>を持ち、ElementはCoroutineContextの子interfaceでもあります。 - 更に
ElementはKeyを内部フィールドとして持ちます。 -
CoroutineContext.Elementは種類ごとに各々ユニークなCoroutineContext.Keyを持っています。 -
CoroutineContext(.Element)同士は加算可能で、同じKeyを持つもの同士を足した場合は最後に足したものが勝ち、違うKeyを持つものを足すと両立します(Mapのような動き) - いろんな種類のCoroutineContext.Elementのmapになっています。
- 実際の
CoroutineScopeの挙動は紐づいているCoroutineContext=「CoroutineContext.Elementのmap」の中身により決まります。 - (
JobやCoroutineDispatcherなどはCoroutineContext.Elementを実装しています。)
![]() |
|---|
![]() |
|---|
※CoroutineContext.ElementもCoroutineContextを実装しています。
![]() |
|---|
CoroutineContextの代表的なもの
- Job : キャンセルや完了を待つときに使います。キャンセルやエラーの伝播も担当します。
- CoroutineDispatcher : 動くスレッドを決めます。
- CoroutineName : コルーチンの名前を設定できる。デバッグモードで使えるそうです。自分は使用したことがありません…
- CoroutineExceptionHandler : CoroutineScopeのなかでtry-catchされなかった例外をハンドリングするためのもの。
CoroutineContextの合成
[2022/7/4 修正済み]
- 前述の通り、
CoroutineContext(.Element)は+演算子で加算できます。 -
Keyの重複を許さない規則のmapである「CoroutineContext.Elementのmap」に別のCoroutineContext(.Element)を加算していくイメージ。 - +の右要素の
Key要素が左要素に含まれていない時→両方保たれる - +の右要素の
Key要素が左要素に含まれている時 →右要素のものが採用される
【例】
val context1 = Dispatchers.Main + CoroutineName("context1") // mainスレッドで動き、名前は「context1」
val context2 = Dispatchers.Main + Dispatchers.IO // IOスレッドで動く
キャンセルの伝播の仕組み
前提
coroutineは親CoroutineScopeで起動されたcoroutineがキャンセルされたときに子CoroutineScopeから起動されたcoroutineも自動でキャンセルされたり、子の中で例外が発生したときに同じ親を持つ子が自動でキャンセルされたりする仕組みがあります。(キャンセル・エラーの伝播)
誰が伝播を担うのか?
-
CoroutineContext.ElementのひとつであるJobが担っています。 - 前述のとおり、各
CorouineScopeは必ず1つのCoroutineContextに紐づいています。coroutineが起動したCoroutineScopeに紐づいたCoroutineContextのJob同士に親子関係がある時にエラーやキャンセルが伝播します。
伝播の方向
-
Job#cancelが呼ばれたことによるキャンセル(CancellationException発生) : 親→子 - それ以外のキャッチされなかった例外 : 子→親
その後受け取った親は自分の子をすべてキャンセルします。(CancellationException発生)
![]() |
|---|
CoroutineScope#cancel の実装
CoroutineScope#cancelというメソッドがあります。そのCroutineScopeの子や孫などをすべてキャンセルできます。
このメソッドも、内部的にはそのCoroutineScopeに紐づいたCoroutineContextのJob要素に対してcancel()を呼んでいます。
![]() |
|---|
CoroutineScopeの中で例外が発生した時
CoroutineScopeのなかで例外が発生し、キャッチされなかった時は紐づいたCroutineContextのJob要素が例外を受け取っています。
キャンセルの伝播は、
- 今cancel()を呼んだ、もしくはCoroutineScope#cancelを介してcancel()が呼ばれた Jobは誰と親子関係があるのか?
- ここで例外が発生した場合どのJobが受け取って、そのJobは誰と親子関係があるのか?
に注目すると、動きが追えます。
Job要素がないCoroutineContextから作成されたCoroutineScopeは?
val scope = CoroutineScope([CoroutineContextのインスタンス])とCoroutineScopeのインスタンスを作成した時、内部的には紐づけたCoroutineContextがJob要素を持たない時は新たにJobインスタンスを作成して渡されたCoroutineContextのインスタンスに加算しています。なのでこの場合もCoroutineScopeに紐づいたCoroutineContextはJob要素を持ちます。
![]() |
|---|
CorouitneScope#launchでのキャンセルの伝播の様子を内部実装から追ってみる
初めに、launch内で例外が発生した時の実際のコード出力を見てみる
【コード】
![]() |
|---|
【実行結果 出力】
1
Exception in thread "DefaultDispatcher-worker-2 @coroutine#3" java.lang.RuntimeException: error
at FileKt$main$2$1.invokeSuspend(File.kt:22)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:571)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:750)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:678)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)
error class kotlinx.coroutines.JobCancellationException
3
-
corouitne 3で発生した例外がcorouitne 1に伝播して、CancellationExceptionが発生しているのが分かると思います。 -
coroutine 1でdelayしている時にキャンセルの伝播があったので、try-catchでcatchできています。 - もし、
coroutine 1にtry-catchがなければ、その場でcoroutine 1はキャンセルされてしまい、println("3")は実行されません。
corouitne 3で発生した例外がcorouitne 1に伝播するということはcorouitne 3とcorouitne 1に親子関係があるということですが、どのように親子関係の構築を実現しているのでしょうか。
「今cancel()を呼んだ、もしくはCoroutineScope#cancelを介してcancel()が呼ばれた Jobは誰と親子関係があるのか?」「ここで例外が発生した場合どのJobが受け取って、そのJobは誰と親子関係があるのか?」に注目して、実際にどのようにキャンセル・エラーが伝播しているのかコードを見ていきたいと思います。
launchの内部実装を見てみる
![]() |
|---|
① コード部分だけ拡大
![]() |
|---|
1…普段launchに渡しているラムダ関数部。この中で例外が発生した時にどうなるのかを追うのが今回の最終目標です。
2…ラムダ関数部がcoroutine#startに引数として渡されています。
3…直前3行で作成されたインスタンスの入ったcoroutineという変数です。2で使用しています。
→coroutineという変数が何なのか見ていきます。
② 引き続きlaunchの実装
![]() |
|---|
4…何かしらのインスタンスを作成して、変数coroutineに入れています。launchの第2引数startの値によって作成するものは違うようですが、ともにnewContextという値を使用するようです。
5…4で使用しているnewContextを作成しています。newCoroutineContext(CoroutineContext)というメソッドを呼び出しているようです。引数には、launch呼び出し時の第1引数で渡されたCoroutineContextインスタンスを渡しているようです。
→newCoroutineContext(CoroutineContext)というメソッドの実装見ていきます。
③ newCoroutineContextの実装
![]() |
|---|
CorouitneScopeの拡張メソッドのようです。
④ newCoroutineContextメソッドの実装を細かく見ていきます。
![]() |
|---|
1…coroutineContext(interface CoroutineScopeに定義されているCoroutineContext。要するにこのCoroutineScopeに紐づいたCoroutineContextです。)と引数で渡されたcontextを加算して変数combinedに格納しています。contextが右側なので、同じKeyの要素を持つときはcontextの方が優先されます。(順番大事)
2…1で作成したcombineにデバッグモードの時は何かを加算したりして変数debugに格納しています。今回はデバッグモード時については考えないのでdebug==combinedです。
3…combinedがDispatchers.DefaultではなくContinuationInterceptor要素を持っていなかったら、debugにDispatchers.Defaultを加算して、それをreturn値とする。それ以外の場合はdebugをそのまま返しています。
※ちなみに、CoroutineDispatcherはContinuationInterceptorを実装しています。なので、「ContinuationInterceptor要素を持っていなかったら」は「実行スレッドが指定されていなければ…」という意味ととらえてよさそう。(正確には違うと思うけど。)
→以上をまとめると
newCoroutineContextメソッドは「現在のCoroutineScopeが紐づいているCoroutineContextに、引数で渡されたcontextを加算する。さらにそれが実行スレッド要素を持っていれば何もせずそのまま、持っていなければDispatchers.Defaultを加算して、return値とする。」メソッドのようです。
⑤ launchの実装に戻る
「② 引き続きlaunchの実装」で出てきたlaunchの実装に戻ります。
![]() |
|---|
「④ newCoroutineContextメソッドの実装を細かく見ていきます。」より、5で作成したval newContextはざっくり、「現在のCoroutineScopeが紐づいているCoroutineContextに、引数で渡されたcontextを加算して作成した新しいCoroutineContextインスタンス」であることが分かりました。
次に4で作成しているval coroutineについてみていきます。
![]() |
|---|
まず、6のstart.isLazyがtrueかfalseかということですが、7で設定されているstartのデフォルト値CoroutineStart.DEFAULTがtrue falseどちらなのかを見てみたいと思います。
CoroutineStart.DEFAULT
CoroutineStart#isLazy
ということで、デフォルトはstart.isLazy == false のようです。
今回は、start.isLazy == falseの時(8の囲いのところ)だけ見ていこうと思います。
(ちなみにstart.isLazy == trueの時に作成されるLazyStandaloneCoroutineですが、こちらもStandaloneCoroutineを継承しており、キャンセルの伝播という点ではstart.isLazy == false の時と動きに違いはありません。)
⑥ StandaloneCoroutine の実装
![]() |
|---|
実装はほぼなく、AbstractCoroutineというものの子クラスのようです。ということで、そちらを見ていきます。
⑦ AbstractCoroutine の実装
![]() |
|---|
※長すぎて以降スクリーンショットに入りきらず。コード実装の部分を拡大してみます。
![]() |
|---|
1…CoroutineContextのインスタンス。今回の場合、先ほど作成したval newContextが渡されてくるはずです。
2,3…2がtrueの時、3が実行されます。StandaloneCoroutineでは固定でtrueを設定しているので3は実行されます。3では、1で渡されたparentContext (== newContext)のJob要素をinitParentJobメソッドの引数に渡しています。
したがって次はinitParentJobの実装を見てみます。initParentJobはAbstractCoroutineが継承しているJobSupportのメソッドです。
⑧ JobSupport#initParentJobの実装
JobSupport の定義
![]() |
|---|
JobSupportもJobの子クラスなことが分かります。
![]() |
|---|
![]() |
|---|
引数で渡されたparent (==parentContext ==newContext)のattachChildメソッドを引数として自分自身を渡して実行しています。
したがって次はJob#attachChildメソッドの定義を見てみます。
⑨ Job#attachChildメソッドの定義
![]() |
|---|
定義はJobにされているが、実装はJobを実装したクラスが各々行う形のようですが、「渡されたChildJobを自身の子として紐づけ親子関係を作る」メソッドのようです。
例えば、Job()でインスタンス化したJobは、JobImplのインスタンスで、
![]() |
|---|
![]() |
|---|
JobSupportでは、attachChildメソッドが実装されています。
![]() |
|---|
ということでまとめると、Job#attachChildメソッドでは「渡されたChildJobを自身の子として紐づけ親子関係を作って」いるようです。
つまり、AbstractCoroutine、さらにはStandaloneCoroutineは、val newCoroutineを親とするJobでありCoroutineScopeのようです。
⑩ ここまでわかったことのまとめ
launchは、自身を起動したCoroutineScopeに紐づいているCoroutineContextと引数で渡されたCoroutineContextを加算し、それがCoroutineDispatcher要素を持たなければさらにDispatchers.Defaultを加算する。
そしてそれを親としたJobでありCoroutineScopeでもあるStandaloneCoroutineクラスのインスタンスcoroutineを作成して、launchの引数で渡されたラムダ関数blockおよびCoroutineStartインスタンスstartを引数にとってstartメソッドを呼ぶ。(coroutine.start(start,coroutine,block))
⑪ StandaloneCoroutine#startとは
最後、StandaloneCoroutine#startとは何をしていて、引数で渡されたラムダ関数の中で例外が発生したときにどのJobが受け取って、そのJobは誰と親子関係があるのか?を見ていきます。
まず、StandaloneCoroutine#startはAbstractCoroutine#startです。したがって、そちらの実装を見てみます。
![]() |
|---|
CoroutineStart#invokeが呼ばれているようです。
![]() |
|---|
第2引数のreceiverをレシーバーにして第1引数のblock(ラムダ関数)を実行するようです。
つまり、「blockで例外が発生したときに、そのエラーはreceiverが受ける」と考えてよさそうです。
おまけ
細かく見るのはここで力尽きてしまいましたが、以下少しだけさらに深いところまでコードを追っていきます。
launchのデフォルトは、DEFAULTになるので、赤枠の部分が実行されるはずです。赤枠の部分=(suspend (R) -> T)#startCoroutineCancellableの実装を見てみます。
(suspend (R) -> T)#startCoroutineCancellable
![]() |
|---|
この後は
![]() |
|---|
(suspend R.() -> T)#createCoroutineUnintercepted
![]() |
|---|
のように続いていきます。
⑫まとめ
さて、⑩⑪の内容をまとめると、
・launchは、自身を起動したCoroutineScopeに紐づいているCoroutineContextと引数で渡されたCoroutineContextを加算し、それがCoroutineDispatcher要素を持たなければさらにDispatchers.Defaultを加算する。
・そしてそれを親としたJobでありCoroutineScopeでもあるStandaloneCoroutineクラスのインスタンスcoroutineを作成して、launchの引数で渡されたラムダ関数blockおよびCoroutineStartインスタンスstartを引数にとってstartメソッドを呼ぶ。(coroutine.start(start,coroutine,block))
・coroutine.start(start,coroutine,block)では、blockで例外が発生した場合は第2引数のcoroutineがそのエラーを受け取る。
となります。
つまりlaunchで起動したcoroutineで例外が発生した場合
launch呼び出し時に渡されたCoroutineContextのJob要素またはlaunchを起動したCoroutineScopeに紐づいたCoroutineContextのJob
を親にしたCoroutineScopeかつJobであるStandaloneCoroutine
が例外を受け取ります。
→
CoroutineScopeの中で例外が発生した時 で提示した、キャンセルの伝播を追う時の着目点
・ 今cancel()を呼んだ、もしくはCoroutineScope#cancelを介してcancel()が呼ばれた Jobは誰と親子関係があるのか?
・ ここで例外が発生した場合どのJobが受け取って、そのJobは誰と親子関係があるのか?
= launch(に渡したラムダ関数)のなかで例外が発生した場合
・いったいどのJobが例外を受けとる?
・そのJobは誰と親子関係がある?
→
・いったいどのJobが例外を受けとる? → lauchの中で作ったStandaloneCoroutine。
・そのJobは誰と親子関係がある? → launch呼び出し時に渡されたCoroutineContextのJob要素またはlaunchを起動したCoroutineScopeに紐づいたCoroutineContextのJobを親にしている。
以上、これでlaunchに渡されたラムダの中で例外が発生したときに、エラーが伝播するために親子関係のあるJobが作成されラムダ関数が実行される流れを具体的に追えました。
コード例の場合のキャンセルの伝播の様子
初めにlaunch内で例外が発生した時の実際のコード出力を見てみる で出したコード例
![]() |
|---|
coroutine 3で例外が発生
→coroutine 3で発生した例外を受け取るのは、【coroutine 3のlaunchを起動したCoroutineScopeに紐づいたCoroutineContextのJob要素】=【coroutine 2に紐づいたCoroutineContextのJob要素】を親にしているStandaloneCoroutine
→親である【launchを起動したCoroutineScopeに紐づいたCoroutineContextのJob要素】=【coroutine 2に紐づいたCoroutineContextのJob要素】にエラーが伝播
→受け取った【coroutine 2に紐づいたCoroutineContextのJob要素】は、【coroutine 2のlaunchを起動したCoroutineScopeに紐づいたCoroutineContextのJob要素】=【scopeに紐づいたCoroutineContextのJob要素】を親にしているStandaloneCoroutine
→【scopeに紐づいたCoroutineContextのJob要素】にエラーが伝播
→もうひとつの【scopeに紐づいたCoroutineContextのJob要素】を親にしているStandaloneCoroutineに紐づいているCoroutineScopeであるcoroutine 1にキャンセルが伝播
→それがdelay (100L)実行中に起きるので、そこで例外が発生しcatch節で捕捉され、println("2")は実行されずprintln("1")が実行される
※ちなみに、実行時のコンソールログの
Exception in thread "DefaultDispatcher-worker-2 @coroutine#3" java.lang.RuntimeException: error
at FileKt$main$2$1.invokeSuspend(File.kt:22)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:571)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:750)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:678)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)
部分は、coroutine 3のCoroutineContext自体にエラーが捕捉されずに到達してしまったので、バックスレッド(coroutine 3のCoroutineContextに紐づいているDispatchers.Defaultスレッド)で例外が発生している旨のログが出ているのだと思われます。
launchに渡したラムダの中で例外が発生したときにキャンセルの伝播を防ぐためには
では、このコード例でエラーの伝播をさせずにcoroutine 1を正常系だけ通すにはどうしたらよいでしょうか?
⑫まとめの内容の
launch(に渡したラムダ関数)のなかで例外が発生した場合
・いったいどのJobが例外を受けとる? → lauchの中で作ったStandaloneCoroutine。
・そのJob派と誰と親子関係がある? → launch呼び出し時に渡されたCoroutineContextのJob要素またはlaunchを起動したCoroutineScopeに紐づいたCoroutineContextのJobを親にしている。
を念頭に置くと以下4つの方法があると思います。
方法①
coroutine 3で例外をthrowする部分をtry-catchで囲む
![]() |
|---|
【実行結果 出力】
1
2
3
方法②
coroutine 3を起動するlaunchに新しいJob(を含んだCoroutineContext)インスタンスを渡す
![]() |
|---|
【実行結果 出力】
1
Exception in thread "DefaultDispatcher-worker-1 @coroutine#3" java.lang.RuntimeException: error
at FileKt$main$2$1.invokeSuspend(File.kt:22)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:571)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:750)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:678)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)
2
3
※この場合、coroutine 3のCoroutineContext自体にエラーが捕捉されずに到達してしまうのは変わらないのでバックスレッド(coroutine 3のCoroutineContextに紐づいているDispatchers.Defaultスレッド)で例外が発生している旨のログは出ますが、キャンセルは伝播しないのでprintln("1") println("2") println("3")はすべて実行されています。
方法③
coroutine 2を起動するlaunchに新しいJob(を含んだCoroutineContext)インスタンスを渡す
![]() |
|---|
【実行結果 出力】
1
Exception in thread "DefaultDispatcher-worker-1 @coroutine#3" java.lang.RuntimeException: error
at FileKt$main$2$1.invokeSuspend(File.kt:22)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:571)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:750)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:678)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)
2
3
※この場合も、coroutine 3のCoroutineContext自体にエラーが捕捉されずに到達してしまうのは変わらないのでバックスレッド(coroutine 3のCoroutineContextに紐づいているDispatchers.Defaultスレッド)で例外が発生している旨のログは出ますが、キャンセルは伝播しないのでprintln("1") println("2") println("3")はすべて実行されています。
方法④
coroutine 1を起動するlaunchに新しいJob(を含んだCoroutineContext)インスタンスを渡す
![]() |
|---|
【実行結果 出力】
1
Exception in thread "DefaultDispatcher-worker-2 @coroutine#3" java.lang.RuntimeException: error
at FileKt$main$2$1.invokeSuspend(File.kt:22)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:571)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:750)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:678)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)
2
3
※この場合も、coroutine 3のCoroutineContext自体にエラーが捕捉されずに到達してしまうのは変わらないのでバックスレッド(coroutine 3のCoroutineContextに紐づいているDispatchers.Defaultスレッド)で例外が発生している旨のログは出ますが、キャンセルは伝播しないのでprintln("1") println("2") println("3")はすべて実行されています。
参考資料
https://kotlinlang.org/docs/coroutines-basics.html#scope-builder-and-concurrency
https://zenn.dev/at_sushi_at/books/edf63219adfc31
https://mahata.gitlab.io/post/2019-04-23-coroutines-kotlin/
https://speakerdeck.com/ntaro/kotlin-contracts-number-m3kt?slide=12
その他、Kotlin / Kotlin Coroutine の公式サイトなど
































