🏝️

KotlinのCoroutineを試しに使ってみる

2023/08/10に公開

KotlinにはCoroutineと呼ばれるものがあるので、今回はこちらを使ってみます。

ドキュメントはこちらの公式ガイドを参考にしました。

https://kotlinlang.org/docs/coroutines-guide.html

Coroutine とは?

一通りKotlinで試したあとに分かったのですが、Kotlin固有の言葉ではないようです。wikiによるとCoroutineはSubroutineとは異なり中断・再開できる処理のまとまりということらしいです。

https://ja.wikipedia.org/wiki/コルーチン

KotlinのCoroutineによって、Kotlinのコードを中断・再開可能な単位に構成することができるようになります。それだけではなく、中断したCoroutineが再開するスレッドは異なっても良いためCPUのコアを効率的に使用でき、スループットの向上が期待できます。IO待ちが発生する状況や大量の計算を非同期処理したい場合に役立ちそうですね。

少しの期間触ってみて感じたことは、次の3要素がKotlin Coroutineにおける主要登場人物であることです。

  • Coroutine Builder
  • Suspend Function
  • Coroutine Scope

Hello, Coroutine!

例えば次のように書きます。

package booookstore.playground

import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class HelloCoroutineTest {

    @Test
    fun sayHello() {
        var message = ""
        runBlocking { //#1
            launch { //#2
                delay(100L) //#3
                message += "World. " // <2>
            }
            message += "Hello, " // <1>
        }
        message += "from coroutine." // <3>

        assertEquals("Hello, World. from coroutine.", message)
    }

}

#1 runBlocking はCoroutineBuilderというものです。Builderと呼ばれるとおりCoroutineを作成します。 runBlock はNone-Coroutineの世界からCoroutineの世界へのエントリポイントです。{ } の内部ではCoroutineを自由に生成することができます。Blockingと呼ばれるとおり、Coroutineの実行完了まで現在のスレッドをブロックします。言い換えるとCoroutineの実行が完了するまでスレッドは次のコードを実行しません。

#2 launch もCoroutineBuilderの一つです。そう、CoroutineBuilderは複数登場しそれぞれ役割が少々違います。 launch はCoroutineを新しく生成しますが現在のスレッドをブロックしません。suspend関数によって実行が中断されると現在のスレッドを開放します。開放されたスレッドは他の仕事をすることができます。

#3 delay はsuspend関数の一つです。suspend関数は現在のCoroutineを中断する関数です。ここでは0.1秒Coroutineを中断します。0.1秒後(正確には0.1秒後Coroutineがいずれかのスレッドに割り当てられてから)再開します。

上記のプログラムは <1> <2> <3> の順序でmessage変数にアクセスし、最後のアサーションに成功します。

大雑把なイメージにすると以下となります。

CoroutineBuilder

CoroutineBuilderはCoroutineを新しく作成します。runBlockinglaunchasync はそれぞれ代表的なCoroutineBuilderです。

CoroutineBuilder 戻り値 計算結果
launch Job 計算結果を返さない
async Deffered 計算結果を返す
runBlocking 任意 計算結果を返す

CoroutineBuilderがCoroutineを作成するイメージはこんな感じになります。

launchJob を返します。 join を使うことでCoroutineの完了を待機できます。

package booookstore.playground

import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class JobTest {

    @Test
    fun job() = runBlocking {
        var message = ""
        val job = launch {
            delay(100L)
            message += "hello"
        }
        job.join()
        assertEquals("hello", message)
    }

}

Job はCoroutineの計算結果を持っていません。つまり、 launch は計算結果を返す必要のないCoroutineを生成します。

一方で asyncDeffered を返します。DefferedJobと同様にjoinにより完了を待機できます。Coroutineの完了後に getCompleted を呼び出せば計算結果を受け取れます。

package booookstore.playground

import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

@OptIn(ExperimentalCoroutinesApi::class)
class AsyncTest {

    @Test
    fun async() = runBlocking {
        val deferred = async {
            delay(100L)
            "hello"
        }
        deferred.join()
        assertEquals("hello", deferred.getCompleted())
    }

}

JobDeffered が複数ある場合はリストにしてすべての完了を待機することもできます。

次は Job が複数ある場合のケースです。

package booookstore.playground

import kotlinx.coroutines.delay
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class JobTest {

    @Test
    fun job() = runBlocking {
        var message = ""
        val jobA = launch {
            delay(100L)
            message += "hello"
        }
        val jobB = launch {
            delay(200L)
            message += ",world"
        }
        listOf(jobA, jobB).joinAll()
        assertEquals("hello,world", message)
    }
}

次は Deffered が複数ある場合のケースです。

package booookstore.playground

import kotlinx.coroutines.*
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

@OptIn(ExperimentalCoroutinesApi::class)
class AsyncTest {

    @Test
    fun async() = runBlocking {
        val deferredA = async {
            delay(100L)
            "hello"
        }
        val deferredB = async {
            delay(100L)
            "world"
        }
        listOf(deferredA, deferredB).joinAll()
        assertEquals("hello", deferredA.getCompleted())
        assertEquals("world", deferredB.getCompleted())
    }

}

サンプルコードでは伝わりづらいですがCoroutineを任意の数作成することはよくあることだと思うので上記のやり方を覚えておくと良さそうです。

Suspend function

後述するCoroutineScope内でSuspend functionを呼び出すことができます。さらに、他のSuspend functionからもSuspend functionを呼び出すことができます(以下のサンプルコードを参照)。Suspend functionは中断可能な関数で、例えばネットワーク通信やIOの処理などで処理を中断します。例えば上記の例だと delay はSuspend functionです。

Suspend functionによって中断されるとCoroutineは現在のスレッドを開放します。このスレッドの開放がパフォーマンスにおいて重要な点になります。Coroutine無しでIO処理などを行うとスレッドをブロックするためその間スレッドは何も仕事をせず待機する事になります。一方、Coroutineの場合スレッドを開放するので、開放されたスレッドは他の処理に割り当てることができます。

Suspend functionを使用するコードを関数に切り出す場合、その関数もまたSuspend functionと名乗る必要が有り、先頭に suspend と宣言します。

次のコードでは2つのSuspend functionを定義しています。Suspend functionからSuspend functionを呼び出すことができるため、 message1message2 を呼び出しています。この例では合計で0.2秒関数が中断されます。 message1 は単に message2 を呼び出しているだけなのでdelayで指定した時間の合計が中断される時間の合計と一致するというわけです。

package booookstore.playground

import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class SuspendFunctionTest {

    @Test
    fun suspendFunctionTest() = runBlocking {
        val message = message1()
        assertEquals("hello world", message)
    }

    private suspend fun message1(): String {
        delay(100L)
        val message = message2()
        return "hello$message"
    }

    private suspend fun message2(): String {
        delay(100L)
        return " world"
    }

}

一方で下記のようにすることで0.1秒の中断で済みます。2回のSuspend functionの呼び出しをそれぞれ別々のCoroutineで行っているので一つのスレッドが片方が中断されている間にもう片方の処理を継続できるからです。

    @Test
    fun suspendFunction2Test() = runBlocking {
        val deferredA = async { message2() }
        val deferredB = async { message2() }
        assertEquals(" world world", deferredA.await() + deferredB.await())
    }

CoroutineScope

CoroutineScopeは名前から想像できる通り、Coroutineが存在できるスコープのことになります。Coroutineが存在するならば、必ずCoroutineScopeも同時に存在することになります。CoroutineScope自体は概念ではなく、インターフェイスとして存在します。

launchasync といった関数は CoroutineScope に対する拡張関数として定義されています。なので、Coroutineを作成する launchasync はCoroutineScope内にて呼び出すことが強制されます。また、これら2つの関数は CoroutineScope をレシーバーとして持つ関数を引数で受け取ります。2つの関数はCoroutineBuilderなのでCoroutineを作成し、その上で何をするのかを引数で受け取ることができるというわけですね。

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

ここまでの話だと「Coroutineには一つのCoroutineScopeしか存在しないので意識する必要がないのでは?」と思うかもしれません。しかし、 coroutineScope 関数を使うことで一つのCoroutineの中に複数のCoroutineScopeを持つことができます。CoroutineScopeを作成する関数をScopeBuilderと言います。

例えば、次の例では2つのCoroutineScopeが1つのCoroutineの中に存在しています。

package booookstore.playground

import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

@Suppress("NonAsciiCharacters")
class CoroutineScopeTest {

    @Test
    fun coroutineScope() = runBlocking {
        val message = secondCoroutineScope()
        assertEquals("hello", message)
    }

    private suspend fun secondCoroutineScope(): String = coroutineScope {
        async {
            delay(100L)
            "hello"
        }.await()
    }

}

図にすると以下のようなイメージになります。

runBlocking もScopeBuilderです。runBlocking coroutineScope はどちらもCoroutineの実行が終わるまで呼び出し元の処理を止めますが、 runBlocking は元なるスレッドを開放しません。一方、 coroutineScope はスレッドを開放します。このような違いから runBlocking はCoroutineの世界へのエントリポイントとしての役割を持っています。main関数やテストの中で呼び出す想定をしています。Coroutineの世界で runBlocking を呼び出してしまうと元となるスレッドを開放してくれないため、Coroutineであるべきメリットを握りつぶしてしまうことになります。Coroutineの世界では coroutienScope を使うべきです。

代表的な関数の違い

ここまで登場した関数の違いを整理しました。

関数 元となるスレッドを開放 呼び出し元はCoroutine完了を 新しくCoroutineを作成
launch する 待たない する
async する 待たない する
runBlocking しない 待つ する
coroutineScope する 待つ しない
GitHubで編集を提案

Discussion