kotlinx.coroutines 0.26.0 でコルーチンの構造化 (structured concurrency) がやりやすくなった
この記事は Recruit Engineers Advent Calendar 2018 の 10 日目の記事です。 今日は Kotlin におけるコルーチンの構造化された並行性 (structured concurrency) やコルーチンスコープ (coroutine scope) についてです。
昨日は古川陽介さんの 『R-ISUCON Winter 2018 解説記事 | リクルートテクノロジーズ メンバーズブログ』 でした。 明日は masahiro331 さんの予定です。
前書き : コルーチンについて
Kotlin のコルーチンについては下記記事に書きましたので、参照ください。 (Kotlin 1.1 のころの情報なので少し古くなっています。)
雑に言うと 「コルーチンとは軽量のスレッド (のようなもの) である」 という理解で良いと思います。
kotlinx.coroutines 0.26.0 での並行性モデルの変更
少し前の話ですが、Kotlin 1.3 がリリースされ、コルーチンが fully stable になりました *1。 さらにその少し前、kotlinx.coroutines 0.26.0 にて並行性モデルが大きく変更されました。
私は Kotlin 1.2 のころからコルーチンを使っていたのですが、Kotlin 1.3 にマイグレーションするにあたってこの並行性モデルの変更に少しはまってしまったので、調べたことを書き残しておきます。
kotlinx.coroutines 0.26.0 よりも前
kotlinx.coroutines 0.26.0 より前は、下記のようなコードを実行すると、標準出力には 「Bar」 しか表示されず 「Foo」 は表示されませんでした。
import kotlinx.coroutines.experimental.delay import kotlinx.coroutines.experimental.launch import kotlinx.coroutines.experimental.runBlocking fun main(args: Array<String>) = runBlocking { launch { delay(1000) println("Foo") } println("Bar") }
なぜなら、launch
で起動されたコルーチンはグローバルスコープのコルーチンであり、その終了を待たずに処理が runBlocking
を抜けてしまい、(アクティブなコルーチンが存在しても) プロセスが殺されてしまうからです。 ガイドでは、「グローバルスコープのコルーチンはデーモンスレッドのようなもの」 と表現されています。
Active coroutines that were launched in GlobalScope do not keep the process alive. They are like daemon threads.
kotlinx.coroutines/coroutines-guide.md at 0.26.0 · Kotlin/kotlinx.coroutines · GitHub
これを避けて、launch
の処理を最後まで終わらせるためには、val job = launch { ... }; job.join()
という感じで明示的にコルーチンの終了を待ったり、下記のように明示的にコルーチンコンテキストを指定して、launch
で起動されるコルーチンが runBlocking
のコルーチンの子になるようにするなどの必要がありました。
fun main(args: Array<String>) = runBlocking { launch(kotlin.coroutines.experimental.coroutineContext) { delay(1000) println("Foo") } println("Bar") }
すなわち、明示的に記述しなければコルーチンは構造化されませんでした。 この挙動での問題として、issue では下記の例が書かれています。
suspend fun loadAndCombineImage(name1: String, name2: String): Image { val image1 = async { loadImage(name1) } val image2 = async { loadImage(name2) } return combineImages(image1.await(), image2.await()) }
上記のコードでは、image1.await()
が例外を送出した場合に、image2
のコルーチンがキャンセルされずに放置されてしまいます。 仮に async(coroutineContext)
を使ったとしても、親コルーチンが広すぎて適切ではありません。 本来は、loadAndCombineImage
で一つのスコープを形成し、画像読み込み処理をそのスコープの子にする、という風にコルーチンを構造化する必要があります。
kotlinx.coroutines 0.26.0 でコルーチンを構造化しやすくなった
コルーチンの構造化をやりやすくするために、launch
や async
といったコルーチンビルダーが CoroutineScope
の拡張関数に変更されたり、coroutineScope
という関数が導入されたりしました。
例えば、下記のように書くと、launch
で起動されるコルーチンは自動的に runBlocking
のコルーチンの子になるので、処理は launch
のコルーチンの終了を待ってから runBlocking
を抜けるようになります。
fun main(args: Array<String>) = runBlocking { launch { delay(1000) println("Foo") } println("Bar") }
loadAndCombineImage
の例については、下記のように書くことで loadAndCombineImage
の処理に対応するコルーチンスコープが形成され、例外送出時には自動的に子の画像読み込み処理がキャンセルされるようになります。
suspend fun loadAndCombineImage(name1: String, name2: String): Image = coroutineScope { val image1 = async { loadImage(name1) } val image2 = async { loadImage(name2) } combineImages(image1.await(), image2.await()) }
便利ですね。
グローバルスコープのコルーチンを起動したい場合には GlobalScope
オブジェクトを使用します。
Kotlin 1.3 へのマイグレーション時にはまったところ
私が Kotlin 1.3 にマイグレーションするときにはまったのは、もともとグローバルスコープのコルーチンを起動していた箇所が、意図せずに別のコルーチンの子になるように変更されてしまった箇所でした。 雑な例ですが、もともと kotlinx.coroutines 0.25.0 で下記のようなコードを書いていたとします。
launch { val v = async { ... } }
kotlinx.coroutines 0.26.0 以降にマイグレーションする際には、
GlobalScope.launch { val v = GlobalScope.async { ... } }
という風に書き換えないといけません。 上記の例の場合、launch
メソッドの方は他のコルーチンスコープに属していないためマイグレーション時にコンパイルエラーが発生するので GlobalScope
のつけ忘れをすることはないのですが、上記の例の async
メソッドのようにもともと他のコルーチンスコープの中で使われているコルーチンビルダーについては GlobalScope
を付けなくてもコンパイルエラーにならないため、GlobalScope
のつけ忘れをしてしまって意図せぬ挙動になる可能性があります。 私はこれにはまりました。
皆様もお気を付けください。
ガイド
本記事では kotlinx.coroutines 0.26.0 での変更点を主に説明しましたが、新たにコルーチンを学ぶ場合は下記のようなガイドを読むと良いと思います。
また、CoroutineScope
のドキュメントも参考になります。 Android の Activity のようにライフサイクルが明確に定まっているものに CoroutineScope
を紐づけることで、そのライフサイクルの中で管理したいコルーチンを扱いやすくなります。
コルーチン、いい感じに使っていきましょう。