タケハタのブログ

プログラマの生き方、働き方、技術について雑多に書いていくブログです。

とりあえずKotlin Coroutinesを触ってみた

はじめに

前回の記事でKotlin1.3のRC版を使ってKotlin Coroutinesを導入する手順を書いたのですが、その翌日にKotlin 1.3が正式版としてリリースされて意味がなくなったので、今回はその正式版を使ってコルーチンを超基本的なところを触ってみます。
Kotlinの公式ドキュメントにあるCoroutines Guideのサンプルコードを参考に使っていますが、これを元に個人的にゴチャゴチャいじりながら整理した内容を書いているので、Kotlin Croutinesについて知りたい方の参考になれば。

Gradleの設定

まずGradle。 正式版になったので下記だけでOKです。

group 'KotlinCoroutineSample'
version '1.0-SNAPSHOT'

buildscript {
    ext.kotlin_version = '1.3.10' // Kotlinのバージョンを1.3.10に

    repositories {
        jcenter()
    }

    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}

apply plugin: 'kotlin'

repositories {
    jcenter()
}

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
    compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
    compile "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.0.0" // kotlinx-coroutines-coreも正式版を設定
}

compileKotlin {
    kotlinOptions.jvmTarget = "1.8"
}

Kotlinのバージョンは既に 1.3.10 になっています。

launch

まずKotlin Coroutinesで最初に紹介される launch を使った形の実装。

import kotlinx.coroutines.*

fun main() {
    GlobalScope.launch {
        delay(1000L)
        println("World!")
    }
    println("Hello,")
}

GlobalScope.launch で囲ったブロックの中がコルーチンになります。
delay は後で説明を書きますが、ここでは1秒待機しているくらいに思っておいてください。
実行すると

Hello,

とだけ表示されます。 コルーチンの中が非同期で実行されているので、後ろの Hello, だけが表示されて処理が終わっています。
ただ、これではCoroutineの中の println("World!") が実行されないまま終わってしまうので、

import kotlinx.coroutines.*

fun main() {
    GlobalScope.launch {
        delay(1000L)
        println("World!")
    }
    println("Hello,")
    Thread.sleep(2000L)
}

と最後に2秒の待機を入れると、

Hello,
World!

と表示されました。

runBlocking

runBlockingは、その中で作られたコルーチンの処理が終わるまで実行をブロックするものです。
一旦下記を実行してみてください。

import kotlinx.coroutines.*

fun main() {
    runBlocking {
        delay(1000L)
        println("World!")
    }
    println("Hello,")
}

単純に上のrunBlockingの中の「1秒待ってWorld!を出力する」が実行された後、下の Hello, が出力されたと思います。

World!
Hello,

これだと意味がよく分からないと思うので、下記のように runBlocking の中で 下記のようにlaunch を使ってみます。

fun main() {
    runBlocking {
        launch {
            delay(1000L)
            println("World!") // ②
        }
        println("Hello,") // ①
    }
    println("success") // ③
}

println("Hello,") もrunBlocking節の中に入れ、最後に外でsuccessという文字を出力しています。
すると

Hello,
World!
success

と、コメントで書いてある番号の順番に出力されます。
これは

①launchのdelayを待ってる間に実行され Hello! が出力される
②delayで1秒待った後 World! が出力される ③runBlocking内のコルーチン(launch)の処理が終わるのを待ち success が出力される

という流れになっています。
ちょっと非同期処理っぽくなってきましたね。 ちなみにこの launch を呼ぶ時に GlobalScope. を付けていませんが、もし付けてしまうと多分runBlockingと別のスコープ(ここあんま理解できてない)として扱われてしまうので、終了を待ってくれず

Hello,
success

と出力されて終わってしまうので気をつけてください。

Job#join()

launchJob という型のインスタンスを返します。
下記の様にインスタンスを受け取り、 join という関数を呼ぶと、その地点でコルーチンの終了を待ってくれます。

fun main() {
    runBlocking {
        val job = launch { // ①コルーチン開始
            delay(1000L)
            println("World!")
        }
        println("Hello,")
        job.join() // ②①のコルーチンが終わるまで待つ
        println("success")
    }
}

実行結果は

Hello,
World!
success

job.join でコルーチンの終了を待ち、最後のsuccessという文字列を出力しています。
ただこれだとさっきの結果と変わらなくて分かりづらいと思うので、下記のようにしてみます。

fun main() {
    var sum = 0
    runBlocking {
        val job = launch {
            delay(1000L)
            for (i in 1..10) {
                sum += i
            }
        }
        job.join()
        sum *= 2 // コルーチンの終了を待った後sumを2倍にする
    }
    println("sum=${sum}")
}

1〜10の数値を足し合わせて、2倍にして出力するというプログラムです。
実行結果は

sum=110

となります。
for文での足し算が終わるのを待った後、2倍して runBlocking の外でその結果が出力されているのが分かります。
ピンと来ない方は job.join() の行を消して実行してみると、結果がおかしくなるので分かると思います。

async/await

コルーチンを使うもう一つの方法として、async/awaitがあります。
asyncawait は、 launchJob#join の使い方と似ています。
前述している launch のサンプルコードも下記の様に書き換えられます。

fun main() {
    runBlocking {
        val result = async { // ①コルーチン開始
            delay(1000L)
            println("World!")
        }
        println("Hello,")
        result.await() // ②①のコルーチンが終わるまで待つ
        println("success")
    }
}

launch と何が違うかというと、 async は結果の値を受け取ることができます。
例えば下記のように書くと

fun main() {
    runBlocking {
        val result = async {
            delay(1000L)
            var sum = 0
            for (i in 1..10) {
                sum += i
            }
            sum // コルーチンの結果としてsumを返す
        }
        println("計算中")
        println("sum=${result.await()}") // コルーチンの結果を出力する
    }
}

実行結果として

計算中
sum=55

と表示されます。
async の結果(この場合はsumの値)をresultという変数で受け取り、 result.await() を呼ぶことで async の処理が終わるのを待って、その結果を返してくれます。
(非同期であることを分かりやすくするために、間に「計算中」という出力を入れています)
なので実行終了を待って結果を受け取り、それに対してなにか処理をしたい時などはこちらの方が便利です。

また、下記のように並列で処理することもできます。

fun main() {
    runBlocking {
        val result1 = async {
            var sum = 0
            for (i in 1..10) {
                delay(1000)
                sum += i
            }
            sum // コルーチンの結果としてsumを返す
        }
        val result2 = async {
            var sum = 0
            for (i in 11..20) {
                delay(500)
                sum += i
            }
            sum // コルーチンの結果としてsumを返す
        }
        println("計算中")
        println("sum=${result1.await() + result2.await()}") // コルーチンの結果を出力する
    }
}

1〜10を足し合わせた結果と、11〜20を足し合わせた数値を足し合わせた結果を足し算して出力しています。
実行結果は下記になります。

計算中
sum=210

result1とresult2で delay の時間をあえてずらしていますが、ちゃんと両方の終了を待って結果が出力されているのが分かると思います。

並列処理のパフォーマンス

ここまで基本的な呼び出し方を書いてきましたが、最後にちょっと並列処理について書きます。
コルーチンは「軽量なスレッド」と説明されています。
下記は公式ドキュメントに載っているサンプルコードですが、10万のコルーチンを一斉に起動しても一瞬で実行が終わります。

fun main(args: Array<String>) = runBlocking<Unit> {
    repeat(100_000) { // 10万のコルーチンを起動する
        launch {
            delay(1000L)
            print(".")
        }
    }
}

これをスレッドで実行すると

fun main(args: Array<String>) = runBlocking<Unit> {
    repeat(100_000) { // 10万のスレッドを起動する
            thread {
            Thread.sleep(1000L)
        }
    }
}

僕のPCでは何回か試行して大体40秒くらいかかっていました。
ちなみに最近たまたまPCを買い替えてスペックの高いマシンを使っているので、PCスペック次第ではもっとかかるし、なんなら返ってこないと思います。
サーバーサイド(というかバックエンド)エンジニアとしては、コルーチンの使い道はバッチプログラムでの並列処理とかかなと思っているところなんで、もっと重い処理を並列実行とかしてどのくらい早いか、今度試してみます。

今度書こうと思っていること

  • コルーチンの中断について
  • 重めのバッチ処理でのコルーチンのパフォーマンス

「コルーチンの中断」は言葉の意味がやっと理解できてきたので、その理解しづらかった部分とかサスペンド関数のこととか書こうと思います(重要な要素なので)。
前述したバッチ処理でのパフォーマンスのことも試したら書きます。
あとはコルーチン以外の1.3の機能も触らないと・・・