Kotlin はじめてのコルーチン

0. はじめに

18年10月にKotlinのコルーチンがexperimentalからstableになりました。 遅ればせながら、コルーチンを触ってみました。

この記事は、これからコルーチンを学習する人向けの記事です。

*Kotlin1.3、 kotlinx-coroutines1.0.1の環境です。 *Kotlinが初めての方は、こちらで気軽に試せるので触ってみてください。先頭にimport kotlinx.coroutines.*を忘れずに。

1. コルーチンとは

Wikipediaから引用します。

コルーチン(英: co-routine)とはプログラミングの構造の一種。サブルーチンがエントリーからリターンまでを一つの処理単位とするのに対し、コルーチンはいったん処理を中断した後、続きから処理を再開できる。

どういうことなのか。簡単なプログラムを例にして説明をします。

fun main() {
    /* ここからコルーチン */
    println("start foo")
    時間のかかる処理
    println("end foo")
    /* ここまでコルーチン */

    println("bar")
}

例えば"start foo"から"end foo"をコルーチンとして実行することで、時間のかかる処理のタイミングでmainスレッドがその処理を中断し、中断中は別の処理をすることができます。 ここでは、中断中は"bar"を表示させることにします。 よって、出力結果をこのようなります。

start foo
bar
end foo

2.初めてのコルーチン

それでは実際にコルーチンを作成して、スレッドが中断して再開するところをみてみます。 作成するプログラムは、1.コルーチンとはのプログラムに、コルーチンを適用します。説明通りの結果になるか確認します。

コルーチンを作成するにはコルーチンビルダーというものを使います。 コルーチンビルダーには様々ありますが、ここではもっともシンプルなlaunch関数を使います。 使い方は簡単です。launch関数にコルーチンとして実行するラムダを渡します。

fun main() {
    GlobalScope.launch {
        println("start foo")
        delay(1000)
        println("end foo")
    }
    println("bar")
}

これで"start foo"から"end foo"まではコルーチンとして実行されます。

なお、GlobalScopedelay関数はあとで説明します。 delay関数Thread.sleepメソッドのようなものだと現時点では思っておいてください。 「時間のかかる処理」をdelay関数で代替しています。引数として中断したい時間をミリ秒単位で指定できます。

結果はこのようになります。("start foo"が表示されないこともあります。)

bar
start foo

想定した出力結果になりませんでした。 まず、"bar"が先に表示されてしまいました。 これはlaunch関数がコルーチンの実行をスケジュール化だけして、処理を先に進めてしまうからです。 また、"end foo"が表示されませんでした。原因は、"end foo"から処理を再開をする前にmain関数からリターンして、プログラム自体が終了してしまうからです。

launch関数では、mainスレッドの実行を止めることできないので、何か工夫が必要です。 launch関数の代わりにrunBlocking関数というコルーチンビルダーを使うことにします。 runBlocking関数は、コルーチンが完了するまで呼び出し出し元のスレッドを停止させるコルーチンビルダーです。

fun main() {
    runBlocking {
        println("start foo")
        delay(1000)
        println("end foo")
    }
    println("bar")
}

当然ですが、これでも期待した出力結果にはなりません。

start foo
end foo
bar

なぜならrunBlocking関数をコールした時点で、コルーチンの処理が終わるまで呼び出し元のスレッドがブロックされるからです。(出力結果として想定したものではありませんが、delay関数のポイントで中断および再開はしています。)

それでは先のlaunch関数と組み合わせたらどうなるでしょうか。

fun main() {
    runBlocking {
        launch {
            println("start foo")
            delay(1000)
            println("end foo")
        }
        delay(500)
        println("bar")
    }
}

先述したようにlaunch関数はコルーチンの実行をスケジュール化して処理を先に進めてしまうので、"start foo"が表示される前に"bar"が表示されてしまいます。 これを防ぐために"bar"の直前にdelay(500)を置きます。 (前回と違い、launch関数を呼び出す際にGlobalScopeがない理由はあとで説明します。)

結果はこのようになりました。

start foo
bar
end foo

想定した出力結果になりました。

どのスレッドで各々が実行されているか調べてみましょう。 また、少しだけKotlinっぽく書いてみます。

fun main() = runBlocking {
    launch {
        println("$threadName:start foo")
        delay(1000)
        println("$threadName:end foo")
    }
    delay(500)
    println("$threadName:bar")
}

val threadName: String
    get() = Thread.currentThread().name
main:start foo
main:bar
main:end foo

中断する前の処理、中断中の処理、中断から再開した処理、全てmainスレッドで実行されていることが確認できました。

なお、このプログラムは2回中断が発生しています。

launchコルーチンのスケジュール化 → delay(500)で中断(1回目) → launchの実行開始 → delay(1000)で中断(2回目) → delay(500) から再開 → delay(1000)から再開

3.中断はいつ発生するのか

コルーチンの実行が中断され、そして再開される様子を見ることができましたが、中断とはどういう時に発生するのでしょうか。 ドキュメントにこのような記載があります。

Suspending functions can be used inside coroutines just like regular functions, but their additional feature is that they can, in turn, use other suspending functions, like delay in this example, to suspend execution of a coroutine. サスペンド関数は、コルーチンの中で通常の関数のように使えます。通常の関数との違いは、サスペンド関数はコルーチンの実行を中断するために、他のサスペンド関数を使うことです。(この例のdelayのように)

サスペンド関数という新しい用語が出てきました。サスペンド関数とはこのように関数の先頭にsusupend修飾子がついた関数のことです。

suspend fun hoge()

このドキュメントによるとサスペンド関数をコールすることで中断が発生するようです。 確かにdelay関数の定義にもこのようにsuspend修飾子がついています。

public suspend fun delay(timeMillis: Long)

それでは、delay関数のように中断を起こすサスペンド関数を作成してみましょう。 せっかくなので、中断から再開するときに値を返すサスペンド関数を作成してみます。 今回は4096bitで表現可能な素数を返すgetPrimeNumber関数を作成します。

getPrimeNumber関数の利用側はこのようにします。

fun main() = runBlocking {
    println("$threadName:start runBlocking")
    launch {
        println("$threadName:start launch")
        val prime = getPrimeNumber()
        println("$threadName:prime number = $prime")
        println("$threadName:end launch")
    }
    delay(500)
    println("$threadName:end runBlocking")
}

大体の流れは、 getPrimeNumber関数をコールしたらmainスレッドはコルーチンを中断 → その間に"end Blocking"を表示 → 素数が求め終わったら、素数を表示させるところから再開 です。

次に、サスペンド関数であるgetPrimeNumber関数はどう作成すればいいのでしょうか。 まずは、素数を求めるコードを書く必要がありますが、BigInterger.probablePrimeという素数を求めるのに便利なメソッドがあります。 このメソッドの詳しい使い方は割愛しますが、BigInterger.probablePrime(4096, Random())素数(正確には「おそらく素数」)を返してくれます。私の手元のマシンでは呼び出してから返ってくるまでに10秒程度かかりました。

次に実際に中断を起こすコードを書いていきます。

suspend fun getPrimeNumber() = BigInterger.probablePrime(4096, Random())

このように書ければシンプルですが、このようにしてもgetPrimeNumber関数で中断されず、mainスレッドが素数を求めるために停止してしまいます。

スレッドを中断させるにはsuspendCoroutine関数をコールする必要があります。 suspendCroutine関数はこのように定義されています。この関数もサスペンド関数です。

inline suspend fun  suspendCoroutine(
    crossinline block: (Continuation) -> Unit
): T

ラムダが受け取るContinuationインターフェースにはこのような拡張関数が定義されています。

fun  Continuation.resume(value: T)

このresumeメソッドをコールすることで、コルーチンが再開します。

それでは中断はいつ発生するのでしょうか。 あえて、resumeメソッドをコールせず、このようにして実行してみてください。

suspend fun getPrimeNumber() {
    println("$threadName:hoge")
    suspendCoroutine {
        println("$threadName:fuga")
    }
    println("$threadName:piyo")
}

結果はこのようになります。

main:start runBlocking
main:start launch
main:hoge
main:fuga
main:end runBlocking

また、このプログラムは永遠に終了しません。なぜなら、コルーチンが再開しないためです。

この結果をみると、"fuga"の後に"end ranBlocking"が表示されているので、"fuga"を表示後、つまりsuspendCoroutine関数に渡したラムダの実行終了後に中断が発生していることがわかります。 これが中断が発生するタイミングです。

今度は、中断が発生後、約1秒経過してからresumeメソッドをコールして再開してみます。

suspend fun getPrimeNumber() {
    println("$threadName:hoge")
    suspendCoroutine { cont ->
        println("$threadName:fuga")
        Thread {
            Thread.sleep(1000)
            cont.resume(1234)
        }.start()
    }
    println("$threadName:piyo")
}

中断から再開しました。

main:start runBlocking
main:start launch
main:hoge
main:fuga
main:end runBlocking
main:piyo
main:prime number = kotlin.Unit
main:end launch

また、getPrimeNumber関数素数を返さないのでkotlin.Unitと表示されてしまっています。

それではgetPrimeNumber関数素数を返すように変更します。resumeメソッドに渡した値がsuspendCoroutine関数の戻り値になるので、このように書けます。

suspend fun getPrimeNumber(): BigInteger = suspendCoroutine { cont ->
    Thread {
        cont.resume(BigInteger.probablePrime(4096, Random()))
    }.start()
}

これで、先ほどの結果でkotlin.Unitとなっていた箇所に素数が表示されます。

目的である中断の発生タイミングについて、確認できました。

4.コルーチンビルダーについて少し詳しく

これまでで、launch関数runBlocking関数の2つのコルーチンビルダーを使いました。 この2つ以外にも様々なコルーチンビルダーが提供されています。 例えば、先ほど素数を求めるために作成したgetPrimeNumber関数ですが、withContext関数というコルーチンビルダーを使うとこのように書けます。

suspend fun getPrimeNumber() = withContext(Dispatchers.Default) {
        BigInteger.probablePrime(4096, Random())
    }

このコルーチンビルダーは値を返すことができます。 また、第一引数に値を指定することで、コルーチンを実行するスレッドを切り替えています。

実用的なコルーチンビルダーは他にもありますが、この記事ではそれらを紹介しません。 ここでは、この記事でまだ触れていない重要な2つの内容について説明します。

  1. コルーチンビルダーは、中断可能な世界へのエントリーポイントのようなもの
  2. コルーチンスコープが必要なコルーチンビルダー

中断可能な世界へのエントリーポイント

まずは1つ目です。 サスペンド関数としてgetPrimeNumber関数を作成し、コールすることでコルーチンが中断されることを見ましたが、このようなコードはコンパイルエラーになります。

fun main() {
    val primeNumber = getPrimeNumber()
}

理由は、サスペンド関数はサスペンド関数もしくはサスペンドラムダからしかコールできないというルールがあるからです。 通常のラムダとサスペンドラムダの違いは、関数と同様にsuspend修飾子の有無です。 例えば、launch関数の定義はこのようになっています。

fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext, 
    start: CoroutineStart = CoroutineStart.DEFAULT, 
    block: suspend CoroutineScope.() -> Unit
): Job (source)

blockの型をみるとsuspend修飾子がついているのがわかります。これはサスペンドラムダを受け取ることを表しています。 このようにコルーチンビルダーはサスペンドラムダを受けることで中断可能な世界へのエントリーポイントを提供しています。

コルチーンスコープ

次に2つ目のコルチーンスコープについて。 launch関数の定義を見ていただくと、launch関数CoroutineScopeインターフェースの拡張関数として定義されているのがわかります。

fun CoroutineScope.launch(..)

よって、launch関数をコールするにはCoroutineScopeインスタンスが必要です。 最初の方のコードでGlobalScope.launchと書いていたのはそのためです。 GlobalScopeCoroutineScopeインターフェースを実装したインスタンスです。

object GlobalScope : CoroutineScope

launch関数をコールするにはCoroutineScopeインスタンスが必要ですが、 runBlocking関数に渡すラムダ内ではGlobalScope.launch{..}ではなく、シンプルにlaunch{..}と書けます。 この理由はrunBlocking関数の定義をみるとわかります。

fun  runBlocking(
    context: CoroutineContext = EmptyCoroutineContext, 
    block: suspend CoroutineScope.() -> T
): T (source)

blockのレシーバの型はCoroutineScopeとなっています。 これが理由で、runBlocking関数に渡すラムダ内では、GlobalScope.launchと書く必要がなかったのです。

レシーバ付きラムダに馴染みがない方のために、少し補足します。 あえてthisを使って書くとこのようになります。

runBlocking {
    this.launch {..}
}

このthisは、runBlocking関数が作成したCoroutineScopeインスタンスを参照しています。

補足

コルーチンスコープが導入されたのはkotlinx-coroutines0.26.0からです。 0.26.0がマークされたのが18年9月です。0.26.0より古いバージョンを前提に書かれた記事ではGlobalSopeがないコードを見ることがあるかもしれません。

// 0.26.0より前
fun main() {
    launch {..}
}

// 0.26.0以降
fun main() {
    GlobalScope.launch {..}
}

5.終わりに

予定ではCoroutineScopeCoroutineContextJobについても書くつもりでしたが、記事が長くなってしまったので、全く触れられませんでした。 コルーチンを使った実用的なコードも同様です。

コルーチンを勉強をしている身ではありますが、何かの機会があれば、それらについても書いてみたいと思います。

この記事が、これからコルーチンを初める方に少しでも役に立てば幸いです。