ひだまりソケットは壊れない

ソフトウェア開発に関する話を書きます。 最近は主に Android アプリ、Windows アプリ (UWP アプリ)、Java 関係です。

まじめなことを書くつもりでやっています。 適当なことは 「一角獣は夜に啼く」 に書いています。

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 でコルーチンを構造化しやすくなった

コルーチンの構造化をやりやすくするために、launchasync といったコルーチンビルダーが 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 を紐づけることで、そのライフサイクルの中で管理したいコルーチンを扱いやすくなります。

コルーチン、いい感じに使っていきましょう。