Kotlin のプレイグラウンドのコルーチンの概要

1. 始める前に

この Codelab では、優れたユーザー エクスペリエンスを提供するために Android デベロッパーが理解しておくべき重要なスキルである同時実行を紹介します。同時実行とは、アプリで複数のタスクを同時に行うことです。たとえば、アプリは、ユーザーの入力イベントに応答し、それに応じて UI を更新しながら、ウェブサーバーからデータを取得したり、デバイスにユーザーデータを保存したりできます。

アプリで同時に処理を行うには、Kotlin コルーチンを使用します。コルーチンを使用すると、コードブロックの実行を中断して後で再開できるため、その間に他の処理を行うことができます。コルーチンを使用すると、非同期コードを記述しやすくなります。つまり、あるタスクが終了してから次のタスクを開始する必要がなく、複数のタスクを同時に実行できます。

この Codelab では、Kotlin のプレイグラウンドの基本的な例を紹介し、非同期プログラミングに慣れるよう、コルーチンの実践演習を行います。

前提条件

  • main() 関数を使用して基本的な Kotlin プログラムを作成できること
  • 関数やラムダを含む、Kotlin 言語の基本的な知識

作成するアプリの概要

  • コルーチンの基本を学習して試すための短い Kotlin プログラム

学習内容

  • Kotlin コルーチンで非同期プログラミングを簡素化する方法
  • 構造化された同時実行の目的とそれが重要である理由

必要なもの

2. 同期コード

シンプルなプログラム

同期コードで進行中の概念上のタスクは、一度に 1 つだけです。これは順次の直線的なパスと考えることができます。あるタスクが完全に終了してから次のタスクを開始する必要があります。同期コードの例を次に示します。

  1. Kotlin のプレイグラウンドを開きます。
  2. このコードを、晴れの天気予報を表示するプログラムのコードに置き換えます。main() 関数では、まず Weather forecast というテキストを出力します。次に Sunny を出力します。
fun main() {
    println("Weather forecast")
    println("Sunny")
}
  1. コードを実行します。上のコードを実行すると、出力は次のようになります。
Weather forecast
Sunny

テキストを出力するタスクが完了してから実行が次のコード行に移るため、println() は同期呼び出しです。main() の各関数呼び出しは同期的であるため、main() 関数全体が同期的です。関数が同期か非同期かは、その構成要素で決まります。

同期関数は、タスクが完了した場合にのみ戻ります。そのため、main() の最後の print ステートメントが実行された後、すべての処理が完了します。main() 関数が戻り、プログラムが終了します。

遅延を追加する

ここで、晴れの天気予報を取得するには、リモートのウェブサーバーへのネットワーク リクエストが必要だとします。晴れの天気予報を出力する前に、コードに遅延を追加して、ネットワーク リクエストをシミュレートします。

  1. まず、コードの先頭で main() 関数の前に import kotlinx.coroutines.* を追加します。これにより、使用する関数が Kotlin コルーチン ライブラリからインポートされます。
  2. コードを変更して delay(1000) 呼び出しを追加します。この呼び出しで、main() 関数の残りの部分の実行が 1000 ミリ秒(1 秒)遅れます。この delay() 呼び出しを、Sunny の print ステートメントの前に挿入します。
import kotlinx.coroutines.*

fun main() {
    println("Weather forecast")
    delay(1000)
    println("Sunny")
}

delay() は、実際は Kotlin コルーチン ライブラリが提供する特別な suspend 関数です。main() 関数の実行はこの時点で中断(一時停止)し、指定した遅延時間(この場合は 1 秒)が経過すると再開されます。

この時点でプログラムを実行しようとすると、コンパイル エラー「Suspend function 'delay' should be called only from a coroutine or another suspend function」が発生します。

Kotlin のプレイグラウンドでコルーチンを学習するために、コルーチン ライブラリの runBlocking() 関数の呼び出しで既存のコードをラップできます。runBlocking() はイベントループを実行し、再開の準備ができたときに各タスクを中断したところから続行することで、複数のタスクを一度に処理できます。

  1. main() 関数の既存の内容を runBlocking {} 呼び出しの本体に移動します。runBlocking{} の本体は新しいコルーチン内で実行されます。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        delay(1000)
        println("Sunny")
    }
}

runBlocking() は同期的です。ラムダブロック内のすべての処理が完了するまで戻りません。つまり、delay() 呼び出しの処理が完了するまで(1 秒経過するまで)待機してから、Sunny print ステートメントの実行を続行します。runBlocking() 関数内のすべての処理が完了すると、関数が戻り、プログラムが終了します。

  1. プログラムを実行します。出力は次のとおりです。
Weather forecast
Sunny

出力は前と同じです。コードはまだ同期的です。直線的に動作し、一度に 1 つのことしか行いません。しかし、遅延のために長時間にわたって実行される点が異なります。

コルーチンの「コ」は、協調的という意味です。何かを待つために中断するとき、コードは連携して基となるイベントループを共有し、その間に他の処理を行えるようにします(「コルーチン」の「ルーチン」は、関数などの一連の命令を意味します)。この例の場合、delay() 呼び出しに到達するとコルーチンが中断します。コルーチンが中断している 1 秒間に(このプログラムに他の処理はありませんが)他の処理を実施できます。遅延時間が経過すると、コルーチンの実行が再開し、Sunny が出力されます。

関数の中断

気象データを取得するためのネットワーク リクエストを実行する実際のロジックが複雑になる場合は、ロジックを独自の関数に抜き出すことをおすすめします。コードをリファクタリングして効果を確認してみましょう。

  1. 気象データのネットワーク リクエストをシミュレートするコードを抜き出し、printForecast() という独自の関数に移します。runBlocking() のコードから printForecast() を呼び出します。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        printForecast()
    }
}

fun printForecast() {
    delay(1000)
    println("Sunny")
}

プログラムを実行すると、先ほどと同じコンパイル エラーが表示されます。suspend 関数はコルーチンまたは別の suspend 関数からしか呼び出せないため、printForecast()suspend 関数として定義します。

  1. printForecast() 関数宣言の fun キーワードの直前に suspend 修飾子を追加して、suspend 関数にします。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        printForecast()
    }
}

suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

delay() は suspend 関数でした。今回、printForecast() も suspend 関数になりました。

suspend 関数は通常の関数に似ていますが、中断して後で再開できます。そのために、suspend 関数は、この機能を利用できるようにする他の suspend 関数からしか呼び出すことができません。

suspend 関数には 0 個以上の中断ポイントを含めることができます。中断ポイントは、関数の実行を中断できる、関数内の場所です。実行が再開されると、コード内の最後に中断したところから再開して、関数の残りの部分に進みます。

  1. 練習として、コードの printForecast() 関数の宣言の下に、別の suspend 関数を追加します。この新しい suspend 関数を printTemperature() とします。天気予報の気温データを取得するためのネットワーク リクエストを行うように見せることができます。

関数内でも実行を 1000 ミリ秒遅らせ、気温値を摂氏 30 度のように出力します。エスケープ シーケンス "\u00b0" を使用して度記号 ° を出力できます。

suspend fun printTemperature() {
    delay(1000)
    println("30\u00b0C")
}
  1. main() 関数の runBlocking() コードから新しい printTemperature() 関数を呼び出します。コード全体を次に示します。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        printForecast()
        printTemperature()
    }
}

suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

suspend fun printTemperature() {
    delay(1000)
    println("30\u00b0C")
} 
  1. プログラムを実行します。出力は次のようになります。
Weather forecast
Sunny
30°C

このコードでは、まず printForecast() suspend 関数の遅延でコルーチンが中断し、その 1 秒間の遅延後に再開します。Sunny テキストが出力されます。printForecast() 関数は呼び出し元に戻ります。

次に、printTemperature() 関数が呼び出されます。このコルーチンは、delay() の呼び出しに到達すると中断し、1 秒後に再開して気温値を出力し終えます。printTemperature() 関数はすべての処理を完了して戻ります。

runBlocking() 本体にはこれ以上実行するタスクがないため、runBlocking() 関数が戻り、プログラムは終了します。

前述のように、runBlocking() は同期的であり、本体内の各呼び出しは順次呼び出されます。なお、適切に設計された suspend 関数は、すべての処理が完了してから戻ります。その結果、suspend 関数は順次実行されます。

  1. (省略可)このプログラムを遅延させて実行するためにかかる時間を確認する場合は、コードを measureTimeMillis() の呼び出しでラップすると、渡されたコードブロックの実行にかかった時間(ミリ秒)が返されます。この関数にアクセスするためにインポート ステートメント(import kotlin.system.*)を追加します。実行時間を出力し、1000.0 で割ってミリ秒を秒に変換します。
import kotlin.system.*
import kotlinx.coroutines.*

fun main() {
    val time = measureTimeMillis {
        runBlocking {
            println("Weather forecast")
            printForecast()
            printTemperature()
        }
    }
    println("Execution time: ${time / 1000.0} seconds")
}
suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

suspend fun printTemperature() {
    delay(1000)
    println("30\u00b0C")
} 

出力:

Weather forecast
Sunny
30°C
Execution time: 2.128 seconds

出力は、実行に約 2.1 秒かかったことを示しています(正確な実行時間は若干異なる可能性があります)。各 suspend 関数で 1 秒の遅延が発生するため、妥当なようです。

ここまでで、コルーチン内のコードがデフォルトで順次呼び出されることを確認しました。同時実行を行うには、明示的に指定する必要があります。その方法については、次のセクションで説明します。協調的なイベントループを利用して複数のタスクを同時に実行すると、プログラムの実行時間を短縮できます。

3. 非同期コード

launch()

コルーチン ライブラリの launch() 関数を使用して、新しいコルーチンを起動します。タスクを同時に実行するには、複数の launch() 関数をコードに追加して、複数のコルーチンを同時進行できるようにします。

Kotlin のコルーチンは、構造化された同時実行という重要なコンセプトに従っています。同時実行を明示的に要求しない限り(launch() を使用するなど)、コードはデフォルトで順次処理され、基となるイベントループと連携します。関数を呼び出す場合は、実装の詳細で使用したコルーチンの数に関係なく、関数が戻るまでにその処理を完全に終了することが前提となります。例外で失敗しても、例外がスローされると、関数から保留中のタスクはなくなります。したがって、例外がスローされても、処理が正常に完了しても、制御フローが関数から戻ればすべての処理が終了します。

  1. 前のステップのコードから始めます。launch() 関数を使用して、printForecast()printTemperature() の各呼び出しをそれぞれのコルーチンに移動します。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        launch {
            printForecast()
        }
        launch {
            printTemperature()
        }
    }
}

suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

suspend fun printTemperature() {
    delay(1000)
    println("30\u00b0C")
} 
  1. プログラムを実行します。出力は次のとおりです。
Weather forecast
Sunny
30°C

出力は同じですが、プログラムの実行が速くなったことにお気づきでしょうか。以前は、suspend 関数 printForecast() が完全に終了するまで待ってから printTemperature() 関数に移動する必要がありました。今回、printForecast()printTemperature() は、別々のコルーチン内にあるため同時に実行できるようになりました。

println(天気予報)ステートメントは、図の一番上のボックスにあります。その下に、下向き矢印があります。その下向き矢印から、printForecast() ステートメントを含むボックスを指す矢印のある右方向への分岐があります。さらに、その元の下向き矢印から、printTemperature() ステートメントを含む箱を指す矢印のある右方向の分岐がもう 1 つあります。

launch { printForecast() } 呼び出しは、printForecast() のすべての処理が完了する前に戻ることができます。これがコルーチンの優れている点です。次の launch() 呼び出しに移動して、次のコルーチンを開始できます。すべての処理が完了する前でも、launch { printTemperature() } も同様に戻ります。

  1. (省略可)プログラムがどれくらい速くなったのかを確認する場合は、measureTimeMillis() のコードを追加して実行時間を確認します。
import kotlin.system.*
import kotlinx.coroutines.*

fun main() {
    val time = measureTimeMillis {
        runBlocking {
            println("Weather forecast")
            launch {
                printForecast()
            }
            launch {
                printTemperature()
            }
        }
    }
    println("Execution time: ${time / 1000.0} seconds")
}

...

出力:

Weather forecast
Sunny
30°C
Execution time: 1.122 seconds

実行時間が約 2.1 秒から約 1.1 秒に短縮されたことがわかります。同時実行オペレーションを追加すると、プログラムの実行が速くなります。この時間測定コードを削除してから、次のステップに進んでください。

2 回目の launch() 呼び出しの後、runBlocking() コードの末尾より前に、別の print ステートメントを追加するとどうなるでしょうか?メッセージは出力のどこに表示されますか?

  1. runBlocking() コードを変更して、そのブロックの末尾より前に print ステートメントを追加します。
...

fun main() {
    runBlocking {
        println("Weather forecast")
        launch {
            printForecast()
        }
        launch {
            printTemperature()
        }
        println("Have a good day!")
    }
}

...
  1. プログラムを実行すると、次のように出力されます。
Weather forecast
Have a good day!
Sunny
30°C

この出力から、printForecast()printTemperature() の 2 つの新しいコルーチンが起動した後、Have a good day! を出力する次の命令に進めることがわかります。これは、launch() の「ファイア アンド フォーゲット(撃ちっぱなし)」の性質を表しています。launch() で新しいコルーチンを起動します。処理がいつ終了するのかを気にする必要はありません。

その後、コルーチンが処理を完了し、残りの出力ステートメントを出力します。runBlocking() 呼び出しの本体の処理(すべてのコルーチンを含む)がすべて完了すると、runBlocking() が戻り、プログラムが終了します。

これで、同期コードが非同期コードに変わりました。非同期関数が戻ったとき、タスクはまだ終了していない可能性があります。launch() の場合がそうでした。関数は戻りましたが、その処理はまだ完了していません。launch() を使用すると、コード内で複数のタスクを同時に実行できます。これは、開発する Android アプリで使用できる強力な機能です。

async()

実際のところ、予報と気温のネットワーク リクエストに要する時間は不明です。両方のタスクが完了したときに統一された天気予報を表示する場合、現在の launch() によるアプローチでは不十分です。そこで async() を使用します。

コルーチンの終了タイミングを重視しており、コルーチンからの戻り値が必要な場合は、コルーチン ライブラリの async() 関数を使用します。

async() 関数は Deferred 型のオブジェクトを返します。これは、準備ができたらそこに結果が入るという約束のようなものです。await() を使用して Deferred オブジェクトの結果にアクセスできます。

  1. まず、予報と気温のデータを出力するのではなく、String を返すように suspend 関数を変更します。関数名を printForecast()printTemperature() から getForecast()getTemperature() に更新します。
...

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}
  1. 2 つのコルーチンに launch() ではなく async() を使用するように runBlocking() コードを変更します。各 async() 呼び出しの戻り値を、forecasttemperature という変数に格納します。これらは、String 型の結果を保持する Deferred オブジェクトです(Kotlin の型推論により型の指定は省略できますが、async() 呼び出しによって返される内容が明確になるよう以下に記載します)。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        val forecast: Deferred<String> = async {
            getForecast()
        }
        val temperature: Deferred<String> = async {
            getTemperature()
        }
        ...
    }
}

...
  1. コルーチンの後半で、2 つの async() 呼び出しの後、Deferred オブジェクトに対して await() を呼び出すことで、それぞれのコルーチンの結果にアクセスできます。この場合、forecast.await()temperature.await() を使用して各コルーチンの値を出力できます。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        val forecast: Deferred<String> = async {
            getForecast()
        }
        val temperature: Deferred<String> = async {
            getTemperature()
        }
        println("${forecast.await()} ${temperature.await()}")
        println("Have a good day!")
    }
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}
  1. プログラムを実行すると、出力は次のようになります。
Weather forecast
Sunny 30°C
Have a good day!

ここでは、同時実行して予報と気温データを取得する 2 つのコルーチンを作成しました。それぞれが完了すると値を返しました。その後、2 つの戻り値を 1 つの print ステートメント Sunny 30°C にまとめました。

並列分解

この天気の例をさらに 1 歩進めて、コルーチンが処理の並列分解にどのように役立つのかを見てみましょう。並列分解では、問題を小さなサブタスクに分割して、並列に解けるようにします。サブタスクの結果が揃ったら、まとめて最終的な結果を出すことができます。

コードで、runBlocking() の本体から天気予報のロジックを抜き出して、Sunny 30°C という文字列の組み合わせを返す単一の getWeatherReport() 関数にします。

  1. コードで新しい suspend 関数 getWeatherReport() を定義します。
  2. この関数を、空のラムダブロックを指定した coroutineScope{} の呼び出しと結果が等しくなるように設定します。最終的に、天気予報を取得するロジックが含まれることになります。
...

suspend fun getWeatherReport() = coroutineScope {
    
}

...

coroutineScope{} は、この天気予報タスクのローカル スコープを作成します。このスコープ内で起動されたコルーチンは、このスコープ内でグループ化されます。このキャンセルと例外の意味については後述します。

  1. coroutineScope() の本体内で、async() を使用して 2 つの新しいコルーチンを作成し、それぞれ予報と気温データを取得します。この 2 つのコルーチンの結果を組み合わせて、天気予報文字列を作成します。そのためには、async() 呼び出しによって返された各 Deferred オブジェクトに対して await() を呼び出します。これにより、この関数から戻る前に、各コルーチンがその処理を完了し、結果を返すようになります。
...

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

...
  1. この新しい getWeatherReport() 関数を runBlocking() から呼び出します。コード全体を次に示します。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        println(getWeatherReport())
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}
  1. プログラムを実行すると、次の出力が表示されます。
Weather forecast
Sunny 30°C
Have a good day!

出力は同じですが、ここで注意すべき重要な点がいくつかあります。前述のように、coroutineScope() は、起動したコルーチンを含むすべての処理が完了した場合にのみ返されます。この場合、コルーチン getForecast()getTemperature() の両方が終了し、それぞれの結果を返す必要があります。その後、Sunny テキストと 30°C が組み合わされ、スコープから返されます。この Sunny 30°C という天気予報が出力され、呼び出し元は Have a good day! という最後の print ステートメントに進むことができます。

coroutineScope() は、関数が内部で処理を同時実行していても、coroutineScope はすべての処理が完了するまでは戻らないため、呼び出し元には同期オペレーションに見えます。

構造化された同時実行に関する重要な点は、複数の同時実行オペレーションを、単一の同期オペレーションにまとめることができるということです。同時実行は実装の詳細です。呼び出しコードの唯一の要件は、suspend 関数またはコルーチンであることです。それ以外には、呼び出しコードの構造で同時実行の詳細を考慮する必要はありません。

4. 例外とキャンセル

次に、エラーが発生する可能性がある状況や、処理がキャンセルされる可能性がある状況について説明しましょう。

例外の概要

例外とは、コードの実行中に発生する予期しないイベントです。このような例外に対処する適切な方法を実装すれば、アプリのクラッシュやユーザー エクスペリエンスへの悪影響を防ぐことができます。

以下に、例外があるために早く終了するプログラムの例を示します。このプログラムは、numberOfPizzas / numberOfPeople の除算によって 1 人が食べられるピザの枚数を計算することを目的としています。誤って、numberOfPeople の値を実際の値に設定し忘れたとしましょう。

fun main() {
    val numberOfPeople = 0
    val numberOfPizzas = 20
    println("Slices per person: ${numberOfPizzas / numberOfPeople}")
}

プログラムを実行すると、値をゼロで割ることはできないため、プログラムは計算例外でクラッシュします。

Exception in thread "main" java.lang.ArithmeticException: / by zero
 at FileKt.main (File.kt:4)
 at FileKt.main (File.kt:-1)
 at jdk.internal.reflect.NativeMethodAccessorImpl.invoke0 (:-2)

この問題は、numberOfPeople の初期値をゼロ以外の数値に変更することで簡単に解決できますが、コードが複雑になるにつれ、すべての例外の発生を予測して防ぐことができなくなるケースもあります。

コルーチンの 1 つが例外で失敗したらどうなるでしょうか。天気プログラムのコードを変更して確認しましょう。

コルーチンを伴う例外

  1. 前のセクションの天気プログラムから始めます。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        println(getWeatherReport())
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}

suspend 関数のいずれかで、意図的に例外をスローしてどのような影響があるかを確認します。これで、サーバーからデータを取得したときに予期しないエラーが発生することをシミュレートします。現実味のあるエラーです。

  1. getTemperature() 関数に、例外をスローするコード行を追加します。Kotlin の throw キーワードを使って throw 式を作成し、その後に Throwable から拡張された例外の新しいインスタンスを続けます。

たとえば、AssertionError をスローし、エラーの詳細を表すメッセージ文字列(throw AssertionError("Temperature is invalid"))を渡すことができます。この例外をスローすると、getTemperature() 関数の実行が停止します。

...

suspend fun getTemperature(): String {
    delay(500)
    throw AssertionError("Temperature is invalid")
    return "30\u00b0C"
}

また、他の getForecast() 関数が処理を完了する前に例外の発生を認識できるよう、getTemperature() メソッドの遅延を 500 ミリ秒に変更することもできます。

  1. プログラムを実行して出力を表示します。
Weather forecast
Exception in thread "main" java.lang.AssertionError: Temperature is invalid
 at FileKt.getTemperature (File.kt:24)
 at FileKt$getTemperature$1.invokeSuspend (File.kt:-1)
 at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith (ContinuationImpl.kt:33)

この動作を理解するには、コルーチンの間に親子関係があることを把握する必要があります。あるコルーチン(子)を別のコルーチン(親)から起動できます。コルーチンからコルーチンを起動するにつれて、コルーチンの階層全体を構築できます。

getTemperature() を実行するコルーチンと getForecast() を実行するコルーチンは、同じ親コルーチンの子コルーチンです。コルーチンの例外で見られる動作は、構造化された同時実行によるものです。子コルーチンのいずれかが例外で失敗すると、その例外は上方に伝播されます。親コルーチンがキャンセルされ、それによって他の子コルーチンがすべて(この場合は getForecast() を実行しているコルーチン)がキャンセルされます。最後に、エラーが上方に伝播されて、プログラムは AssertionError でクラッシュします。

try-catch 例外

コードの特定の部分が例外をスローする可能性があることがわかっている場合は、そのコードを try-catch ブロックで囲みます。例外をキャッチして、役に立つエラー メッセージをユーザーに表示するなど、アプリで適切に処理することができます。この仕組みのコード スニペットを以下に示します。

try {
    // Some code that may throw an exception
} catch (e: IllegalArgumentException) {
    // Handle exception
}

この手法は、コルーチンを使用した非同期コードでも有効です。try-catch 式で、コルーチンの例外をキャッチして処理できます。これは、構造化された同時実行では、シーケンシャル コードは依然として同期コードであるため、try-catch ブロックは想定どおりに機能するためです。

...

fun main() {
    runBlocking {
        ...
        try {
            ...
            throw IllegalArgumentException("No city selected")
            ...
        } catch (e: IllegalArgumentException) {
            println("Caught exception $e")
            // Handle error
        }
    }
}

...

例外処理に慣れるため、前に追加した例外をキャッチして出力するよう天気プログラムを変更します。

  1. runBlocking() 関数内で、getWeatherReport() を呼び出すコードを try-catch ブロックで囲みます。キャッチされたエラーを出力し、天気予報が利用できないというメッセージも出力します。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        try {
            println(getWeatherReport())
        } catch (e: AssertionError) {
            println("Caught exception in runBlocking(): $e")
            println("Report unavailable at this time")
        }
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(500)
    throw AssertionError("Temperature is invalid")
    return "30\u00b0C"
}
  1. このプログラムを実行すると、エラーが適切に処理され、プログラムは実行を正常に終了することができます。
Weather forecast
Caught exception in runBlocking(): java.lang.AssertionError: Temperature is invalid
Report unavailable at this time
Have a good day!

出力から、getTemperature() が例外をスローしていることがわかります。runBlocking() 関数の本体で、println(getWeatherReport()) 呼び出しを try-catch ブロックで囲みます。想定された種類の例外(この例では AssertionError)をキャッチしたら、"Caught exception" として出力し、その後にエラー メッセージ文字列を続けます。エラーを処理するため、もう 1 つの println() ステートメントで天気予報が利用できないこと(Report unavailable at this time)をユーザーに伝えます。

この動作は、気温を取得できなかった場合は(有効な予報を取得したとしても)天気予報がまったく表示されないことを意味します。

プログラムの動作に応じて、天気プログラムで例外を処理できた方法がもう 1 つあります。

  1. 気温を取得するために async() によって起動されたコルーチン内で try-catch 動作が実際に発生するよう、エラー処理を移動します。こうすれば、気温を取得できなかった場合でも、予報を出力できます。以下にコードを示します。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        println(getWeatherReport())
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async {
        try {
            getTemperature()
        } catch (e: AssertionError) {
            println("Caught exception $e")
            "{ No temperature found }"
        }
    }

    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(500)
    throw AssertionError("Temperature is invalid")
    return "30\u00b0C"
}
  1. プログラムを実行します。
Weather forecast
Caught exception java.lang.AssertionError: Temperature is invalid
Sunny { No temperature found }
Have a good day!

出力から、getTemperature() 呼び出しは例外で失敗しましたが、async() 内のコードがその例外をキャッチし、気温が見つからなかったことを示す String をコルーチンが返すことによって例外を適切に処理できたことがわかります。例外が発生しても天気予報は出力でき、Sunny の予報が表示されます。天気予報に気温はありませんが、その場所に、気温が見つからなかったことを説明するメッセージが表示されます。プログラムがエラーでクラッシュするよりも、ユーザー エクスペリエンスが向上します。

このエラー処理の手法については、async() がコルーチンの起動時のプロデューサーであると考えるとわかりやすいでしょう。await() は、コルーチンからの結果を消費するのを待機しているため、コンシューマーです。プロデューサーは、処理を行って結果を生成します。コンシューマーは、その結果を消費します。プロデューサーで例外が発生した場合、処理されないとコンシューマーがその例外を受け取るため、コルーチンは失敗します。ただし、プロデューサーが例外をキャッチして処理できれば、コンシューマーはその例外を認識せず、有効な結果とみなします。

参考までに getWeatherReport() コードをもう一度示します。

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async {
        try {
            getTemperature()
        } catch (e: AssertionError) {
            println("Caught exception $e")
            "{ No temperature found }"
        }
    }

    "${forecast.await()} ${temperature.await()}"
}

この場合、プロデューサー(async())は例外をキャッチして処理できるため、"{ No temperature found }" という結果 String を返すことができます。コンシューマー(await())はこの結果 String を受け取るため、例外の発生を認識する必要がありません。これが、コードで発生する可能性がある例外を適切に処理するためのもう 1 つの方法です。

例外は、処理されない限り、コルーチンのツリーの上方に伝播されることを説明しました。また、例外が階層のルートまで伝播される場合も注意することが重要です。アプリ全体がクラッシュする可能性があります。例外処理について詳しくは、コルーチン内の例外に関するブログ投稿と、コルーチンの例外処理についての記事をご覧ください。

キャンセル

例外と同様のテーマとして、コルーチンのキャンセルがあります。通常、このシナリオは、イベントが原因でアプリが開始済みの処理をキャンセルした場合は、ユーザー主導となります。

たとえば、ユーザーがアプリで気温の値に確認する必要がなくなったので、アプリで設定を選択したとしましょう。必要なのは天気予報(Sunny など)のみで、正確な温度は必要ありません。この場合は、現在気温データを取得しているコルーチンをキャンセルします。

  1. まず、以下の初期コード(キャンセルなし)から始めましょう。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        println(getWeatherReport())
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}
  1. 遅延の後に、予報のみが表示されるよう、気温を取得していたコルーチンをキャンセルします。coroutineScope ブロックの戻り値を天気予報の文字列のみに変更します。
...

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    
    delay(200)
    temperature.cancel()

    "${forecast.await()}"
}

...
  1. プログラムを実行します。この場合の出力は次のようになります。天気予報に含まれるのは予報の Sunny のみで、コルーチンがキャンセルされたため気温は出力されません。
Weather forecast
Sunny
Have a good day!

ここでは、コルーチンはキャンセルでき、キャンセルしても同じスコープ内の他のコルーチンには影響せず、親コルーチンはキャンセルされないことについて説明しました。

このセクションでは、キャンセルと例外がコルーチン内でどのように動作し、コルーチンの階層にどのように結び付いているかを確認しました。では、重要なすべての要素がどのように連携しているかを把握できるよう、コルーチンの背後にある正式なコンセプトについて説明しましょう。

5. コルーチンのコンセプト

処理を非同期または同時に実行する場合、処理の実行方法、コルーチンの存続期間、キャンセルされたかエラーで失敗した場合の動作などを決める必要があります。コルーチンは構造化された同時実行の原則に従います。つまり、メカニズムを組み合わせてコード内でコルーチンを使用する際に、こうしたことを決める必要があります。

ジョブ

launch() 関数でコルーチンを起動すると、Job のインスタンスが返されます。ジョブは、コルーチンへのハンドル(参照)を保持するため、そのライフサイクルを管理できます。

val job = launch { ... }

ジョブは、タスクが必要なくなったらコルーチンをキャンセルするなど、ライフサイクル、つまりコルーチンの存続期間を制御するために使用できます。

job.cancel()

ジョブを使用すると、ジョブのステータス(アクティブ、キャンセル、完了)を確認できます。コルーチンと、そのコルーチンが起動したコルーチンがすべての処理を完了していれば、ジョブは完了です。なお、コルーチンは別の理由(キャンセルや例外による失敗など)で完了することもありますが、その時点でジョブは完了したとみなされます。

また、ジョブはコルーチン間の親子関係も記録します。

ジョブ階層

あるコルーチンが別のコルーチンを起動する場合、新しいコルーチンから戻るジョブのことを、元の親ジョブの子といいます。

val job = launch {
    ...            

    val childJob = launch { ... }

    ...
}

この親子関係によってジョブ階層が形成され、各ジョブがジョブを起動できる、というようになります。

ジョブのツリー階層を示す図。階層のルートに親ジョブがあります。Child 1 Job、Child 2 Job、Child 3 Job という 3 つの子があります。Child 1 Job には Child 1a Job と Child 1b Job という 2 つの子があります。また、Child 2 Job には Child 2a Job という 1 つの子があります。最後に、Child 3 Job には Child 3a Job と Child 3b Job という 2 つの子があります。

子と親、さらに同じ親に属する他の子について、特定の動作を規定することになるため、この親子関係は重要です。この動作については、天気プログラムの前述の例で説明しました。

  • 親ジョブがキャンセルされると、その子ジョブもキャンセルされます。
  • job.cancel() で子ジョブがキャンセルされると、子ジョブは終了しますが、親ジョブはキャンセルされません。
  • ジョブが例外で失敗した場合、その例外で親がキャンセルされます。これを、エラーの上方伝播(親、親の親など)といいます。

CoroutineScope

コルーチンは通常、CoroutineScope で起動されます。これにより、管理されずに失われるコルーチンがなくなり、リソースが無駄になりません。

launch()async() は、CoroutineScope拡張関数です。スコープで launch() または async() を呼び出し、そのスコープ内に新しいコルーチンを作成します。

CoroutineScope はライフサイクルに関連付けられ、そのスコープ内のコルーチンの存続期間を設定します。スコープがキャンセルされると、そのジョブはキャンセルされ、キャンセルが子ジョブに伝播します。スコープ内の子ジョブが例外で失敗すると、他の子ジョブがキャンセルされ、親ジョブがキャンセルされて、例外は呼び出し元に再度スローされます。

Kotlin のプレイグラウンドの CoroutineScope

この Codelab では、プログラムの CoroutineScope を提供する runBlocking() を使用しました。また、coroutineScope { } を使用して getWeatherReport() 関数内に新しいスコープを作成する方法も学びました。

Android アプリの CoroutineScope

Android は、ActivitylifecycleScope)や ViewModelviewModelScope)など、ライフサイクルが明確に定義されたエンティティでコルーチン スコープをサポートします。これらのスコープ内で開始されるコルーチンは、対応するエンティティ(ActivityViewModel など)のライフサイクルに従います。

たとえば、lifecycleScope というコルーチン スコープを指定して Activity でコルーチンを開始するとします。アクティビティが破棄されると、lifecycleScope がキャンセルされ、その子コルーチンもすべて自動的にキャンセルされます。Activity のライフサイクルに従うコルーチンが望ましい動作かどうかを判断するだけで済みます。

今後取り組む Race Tracker Android アプリでは、コルーチンのスコープをコンポーザブルのライフサイクルに設定する方法について学びます。

CoroutineScope の実装の詳細

Kotlin コルーチン ライブラリで CoroutineScope.kt がどのように実装されているのかをソースコードで確認すると、CoroutineScope がインターフェースとして宣言され、CoroutineContext が変数として含まれていることがわかります。

launch() 関数と async() 関数は、そのスコープ内で新しい子コルーチンを作成し、子はスコープからコンテキストも継承します。コンテキストには何が含まれるのでしょうか。次はこれについて説明します。

CoroutineContext

CoroutineContext は、コルーチンが実行されるコンテキストに関する情報を提供します。CoroutineContext は基本的に要素を格納するマップであり、各要素に一意のキーがあります。コンテキストに含まれるフィールド(必須ではありません)の例を次に示します。

  • name - コルーチンを一意に識別するための、コルーチンの名前
  • job - コルーチンのライフサイクルを制御します
  • dispatcher - 適切なスレッドに処理をディスパッチします
  • exception handler - コルーチンで実行されるコードによってスローされる例外を処理します

コンテキストの各要素は、+ 演算子でまとめて追加できます。たとえば、1 つの CoroutineContext を次のように定義できます。

Job() + Dispatchers.Main + exceptionHandler

名前が指定されていないため、デフォルトのコルーチン名が使用されます。

コルーチン内で新しいコルーチンを起動すると、子コルーチンは親コルーチンから CoroutineContext を継承しますが、作成されたコルーチン専用のジョブが置き換えられます。また、変更するコンテキスト部分の launch() 関数または async() 関数に引数を渡すことで、親コンテキストから継承した要素をオーバーライドすることもできます。

scope.launch(Dispatchers.Default) {
    ...
}

CoroutineContext の詳細と、コンテキストが親から継承される仕組みについては、KotlinConf の講演動画をご覧ください。

ディスパッチャについて何度か言及していますが、その役割は、処理をディスパッチすること、またはスレッドに割り当てることです。スレッドとディスパッチャについて、さらに詳しく見ていきましょう。

ディスパッチャ

コルーチンはディスパッチャを使用して、実行に使用するスレッドを決定します。スレッドを開始し、なんらかの処理(コードの実行)を行い、それ以上の処理がなくなると終了します。

ユーザーがアプリを起動すると、Android システムは、アプリの新しいプロセスと 1 つの実行スレッド(メインスレッド)を作成します。メインスレッドは、Android システム イベント、画面の UI 描画、ユーザー入力イベントの処理など、アプリに関する多くの重要なオペレーションを処理します。そのため、アプリ用に記述するコードの大半はメインスレッドで実行される可能性があります。

コードのスレッド動作に関して理解すべき 2 つの用語として、ブロック非ブロックがあります。通常の関数は、処理が完了するまで呼び出し元のスレッドをブロックします。つまり、処理が完了するまで呼び出し元のスレッドを放棄しないため、その間は他の処理を実行できません。逆に、非ブロックコードは、特定の条件が満たされるまで呼び出し元のスレッドを放棄するため、その間に他の処理を実行できます。非ブロック処理の実行には、処理が完了する前に戻るため非同期関数を使用できます。

Android アプリの場合、実行にあまり時間がかからなければ、メインスレッドではブロックコードのみを呼び出します。メインスレッドをブロックしないようにして、新しいイベントがトリガーされたらすぐに処理を実行できるようにすることが目標です。このメインスレッドはアクティビティの UI スレッドであり、UI 描画や UI 関連のイベントを担当します。画面に変更がある場合、UI を再描画する必要があります。画面上のアニメーションなどについては、UI を頻繁に再描画して、滑らかに遷移して見えるようにする必要があります。長時間実行の処理ブロックをメインスレッドで実行する必要がある場合、画面の更新頻度が下がり、突然の遷移(「ジャンク」)や、アプリのハング、反応速度の低下が生じることがあります。

そのため、長時間実行の処理アイテムをメインスレッドから移動し、別のスレッドで処理する必要があります。アプリは 1 つのメインスレッドで開始されますが、複数のスレッドを作成して追加の処理を行うこともできます。こうした追加のスレッドを、ワーカー スレッドということがあります。長時間実行タスクがワーカー スレッドを長時間ブロックしても、その間にメインスレッドはブロックされず、ユーザーに対しアクティブに反応できるため、問題はありません。

Kotlin には次のような組み込みディスパッチャが用意されています。

  • Dispatchers.Main: このディスパッチャを使用すると、コルーチンはメインの Android スレッドで実行されます。このディスパッチャは主に、UI の更新や操作を処理し、迅速に処理を行うために使用します。
  • Dispatchers.IO: このディスパッチャは、メインスレッドの外部でディスクまたはネットワークの I/O を行う場合に適しています。たとえば、ファイルの読み書き、ネットワーク オペレーションの実行などです。
  • Dispatchers.Default: これは、launch()async() を呼び出したとき、コンテキストでディスパッチャが指定されていない場合に使用されるデフォルト ディスパッチャです。このディスパッチャを使用すると、メインスレッドの外部でコンピューティング負荷の高い処理を行うことができます。たとえば、ビットマップ画像ファイルの処理などです。

コルーチン ディスパッチャについて理解を深めるには、Kotlin のプレイグラウンドで次の例を試してください。

  1. Kotlin のプレイグラウンドにあるコードを次のコードに置き換えます。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        launch {
            delay(1000)
            println("10 results found.")
        }
        println("Loading...")
    }
}
  1. 次に、起動されたコルーチンの内容を withContext() の呼び出しでラップして、コルーチンが実行される CoroutineContext を変更し、特にディスパッチャをオーバーライドします。プログラムの残りのコルーチン コードに現在使用されている Dispatchers.Main ではなく、Dispatchers.Default を使用するように切り替えます。
...

fun main() {
    runBlocking {
        launch {
            withContext(Dispatchers.Default) {
                delay(1000)
                println("10 results found.")
            }
        }
        println("Loading...")
    }
}

withContext() は、それ自体が suspend 関数であるため、ディスパッチャの切り替えが可能です。指定されたコードブロックを新しい CoroutineContext を使用して実行します。新しいコンテキストは、親ジョブ(外側の launch() ブロック)のコンテキストからのものです。ただし、親コンテキストで使用されているディスパッチャは、ここで指定されたディスパッチャ(Dispatchers.Default)でオーバーライドされます。こうして、Dispatchers.Main による処理の実行から Dispatchers.Default の使用へと移行できます。

  1. プログラムを実行します。出力は次のようになります。
Loading...
10 results found.
  1. print ステートメントを追加し、Thread.currentThread().name を呼び出してスレッドを確認します。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("${Thread.currentThread().name} - runBlocking function")
                launch {
            println("${Thread.currentThread().name} - launch function")
            withContext(Dispatchers.Default) {
                println("${Thread.currentThread().name} - withContext function")
                delay(1000)
                println("10 results found.")
            }
            println("${Thread.currentThread().name} - end of launch function")
        }
        println("Loading...")
    }
}
  1. プログラムを実行します。出力は次のようになります。
main @coroutine#1 - runBlocking function
Loading...
main @coroutine#2 - launch function
DefaultDispatcher-worker-1 @coroutine#2 - withContext function
10 results found.
main @coroutine#2 - end of launch function

この出力から、ほとんどのコードがメインスレッドのコルーチンで実行されていることがわかります。ただし、withContext(Dispatchers.Default) ブロックのコード部分については、デフォルト ディスパッチャ ワーカー スレッド(メインスレッドではありません)のコルーチンで実行されます。withContext() が戻った後、コルーチンはメインスレッドでの実行に戻っています(出力ステートメント main @coroutine#2 - end of launch function のとおり)。この例では、コルーチンに使用されるコンテキストを変更することで、ディスパッチャを切り替えることができることを示しています。

メインスレッドで開始されたコルーチンがあり、特定のオペレーションをメインスレッドから移動する場合は、withContext を使用してその処理に使用するディスパッチャを切り替えることができます。利用可能なディスパッチャ(オペレーションの種類に応じて MainDefaultIO)から適切に選択します。処理は、その目的のために指定されたスレッド(またはスレッドプールというスレッド グループ)に割り当てることができます。コルーチンは自身を中断でき、ディスパッチャは再開方法にも影響を及ぼします。

Room と Retrofit(このユニットと次のユニット)などの人気のライブラリを扱う場合、ライブラリ コードがすでに Dispatchers.IO. のような代替コルーチン ディスパッチャを使用してこの処理を行っている場合は、ディスパッチャを自分で明示的に切り替える必要がない場合もあります。このような場合、これらのライブラリで公開されている suspend 関数は、すでにメインセーフである可能性があり、メインスレッドで実行されているコルーチンから呼び出すことができます。ワーカー スレッドを使用するディスパッチャへの切り替えは、ライブラリ自体が処理します。

これで、コルーチンの重要な部分と、コルーチンのライフサイクルと動作を形成する際に CoroutineScopeCoroutineContextCoroutineDispatcherJobs が果たす役割について大まかに確認できました。

6. まとめ

コルーチンに関する難しいトピックは以上です。コルーチンが非常に有用であることがわかりました。実行を中断し、基となるスレッドを他の処理に解放して、コルーチンを後で再開できます。これにより、コード内で同時実行オペレーションを実行できます。

Kotlin のコルーチン コードは、構造化された同時実行の原則に従います。デフォルトでは順次処理されるため、同時実行する場合はその旨を明示する必要があります(launch()async() を使用するなど)。構造化された同時実行では、複数の同時実行オペレーションを 1 つの同期オペレーションにまとめることができます。同時実行は実装の詳細です。呼び出しコードの唯一の要件は、suspend 関数またはコルーチンであることです。それ以外には、呼び出しコードの構造で同時実行の詳細を考慮する必要はありません。これにより、非同期コードが読みやすく、考えやすくなります。

構造化された同時実行では、アプリで起動された各コルーチンをトラッキングし、失われないようにします。コルーチンに階層を持たせることができます。タスクでサブタスクを起動し、さらにサブタスクを起動できます。ジョブは、コルーチン間の親子関係を維持し、コルーチンのライフサイクルを制御できます。

起動、完了、キャンセル、失敗の 4 つは、コルーチンの実行における一般的なオペレーションです。同時実行プログラムを簡単に管理できるようにするために、構造化された同時実行は、階層内の一般的なオペレーションの管理方法の基礎となる原則を定義します。

  1. 起動: 存続期間の境界が定義されたスコープでコルーチンを起動します。
  2. 完了: 子ジョブが完了するまで、ジョブは完了しません。
  3. キャンセル: このオペレーションは下方に伝播する必要があります。コルーチンがキャンセルされると、子コルーチンもキャンセルされる必要があります。
  4. 失敗: このオペレーションは上方に伝播する必要があります。コルーチンが例外をスローすると、親はすべての子をキャンセルし、自身をキャンセルして、親に例外を伝播させます。これは、エラーが検出されて処理されるまで続きます。これにより、コード内のエラーが適切に報告され、失われることはありません。

コルーチンに関する実践演習を行い、コルーチンの背後にあるコンセプトを理解することで、Android アプリで同時実行コードを記述する準備が整いました。非同期プログラミングにコルーチンを使用すると、コードが読みやすく、考えやすくなります。また、キャンセルと例外が発生した場合でもより堅牢になり、エンドユーザーにとってより最適で応答性の高いエクスペリエンスが提供されます。

まとめ

  • コルーチンを使用すると、新しいプログラミング スタイルを学習することなく、同時実行する長時間実行コードを作成できます。設計上、コルーチンの実行は順次行われます。
  • コルーチンは構造化された同時実行の原則に従い、処理が失われないようにし、存続期間に一定の境界があるスコープに関連付けられるようにします。同時実行を明示的に要求しない限り(launch()async() を使用するなど)、コードはデフォルトで順次処理され、基となるイベントループと連携します。関数を呼び出す場合は、実装の詳細で使用したコルーチンの数に関係なく、関数が戻るまでに(例外で失敗しない限り)その処理を完全に終了する必要があるということが前提となります。
  • suspend 修飾子は、実行を中断して後で再開できる関数をマークするために使用されます。
  • suspend 関数は、別の suspend 関数またはコルーチンからのみ呼び出すことができます。
  • 新しいコルーチンは、CoroutineScope の拡張関数 launch() または async() を使用して開始できます。
  • ジョブは、コルーチンのライフサイクルを管理し、親子関係を維持することで、構造化された同時実行を確保するための重要な役割を果たします。
  • CoroutineScope は、ジョブを通じてコルーチンの存続期間を制御し、子とその子にキャンセルやその他のルールを再帰的に適用します。
  • CoroutineContext は、コルーチンの動作を定義します。ジョブとコルーチン ディスパッチャへの参照を含むことができます。
  • コルーチンは、CoroutineDispatcher を使用して、実行に使用するスレッドを決定します。

詳細