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 の公式サイトなど