😽

Kotlin Coroutine 入門1: 起動と suspend

2020/09/22に公開

Kotlin 標準の並行プログラミング API である coroutine を理解[1]したのでまとめました。

本家のガイド は包括的で(上から読めば)丁寧に書いてあるのですが、実際に自分が読んだ際には理解に結構苦労したので、少し別のアプローチでの入門になります。

Coroutine と suspend 関数

簡単な例

以下、簡単な使用例として通信処理を伴う処理を考えます。同期的に呼び出すとこうなると思います。

// メインスレッドで実行
fun runMain() {
    val data = Weathers.tomorrow()
    println("明日の天気: ${data}")
}

fun Weathers.tomorrow(): String {
    Thread.sleep(2000) // 長い処理を表すための仮実装
    return "晴れ"
}

▶️ 実行してみる

これを実行すると、2秒後に 明日の天気: 晴れ が出力されます。一方 coroutine を使うと以下のように書く事ができます[2]

// メインスレッドで実行
fun runMain(): Job = viewModelScope.launch {
    val data = Weathers.tomorrow()
    println("明日の天気: ${data}")
}

suspend fun Weathers.tomorrow(): String {
    delay(2000) // 長い処理を表すための仮実装
    return "晴れ"
}

▶️ 実行してみる

viewModelScope.launch { … } を呼び出す事で、非同期で処理が開始されます。suspend, delay などの見慣れないキーワードが一部追加されていますが、主な記述内容は同期的な呼び出しと同じです。

最初の同期的な書き方では2秒間スレッドがブロックされてしまうという問題があります。この間ユーザーの操作がフリーズしてしまうのでアプリケーションでこのコードを書くのは現実的ではありません。一方 couroutine を使うと結果が出るまでの2秒間ユーザーは普通に操作ができます。便利!! 🥳🥳

処理の中断(suspend)

上のような差ができる理由は coroutine ではスレッドをブロックする代わりに処理を「中断」するからです。

最初の例では Thread.sleep() により2秒間メインスレッドを占有(ブロック)していて、その間他の処理がしたくてもメインスレッドが使えません。一方 coroutine の例で使った delay() 関数は2秒間ブロックするのではなく、メインスレッドを2秒間解放してからメインスレッドを再取得して処理を続行します。開放中は他の処理にスレッドを活用できます。これを「中断(suspend)」と呼びます。

フレームワークやライブラリの機能を使って同じような事を実現する事ためにはコールバック関数を用意する必要があるため、coroutine と比べると周りくどい書き方になりがちです。以下は Android による例です。

// メインスレッドで実行
fun runMain() {
    // 結果をコールバックを渡して呼び出し
    Weathers.tomorrow { data ->
        println("明日の天気: ${data}")
    }
}

fun Weathers.tomorrow(callback: (String) -> Unit): Unit {
    // 2秒後に処理を呼び出すようタイマーを設定する。
    // callback 処理はメインスレッドで行われるが、それまでの間スレッドは解放される
    val handler = Handler()
    handler.postDelayed({ callback("晴れ") }, 2000)
}

▶️ 実行してみる

delay() のように中断が起こる関数を「suspend 関数」と呼びます。通常の関数は中断しない(できない)ので、suspend 関数を呼び出す事ができません。 suspend 関数を呼びたい場合は launch の中で呼び出すか、関数に suspend キーワードを付けて suspend 関数にする必要があります。

CoroutineScope

coroutine を使った例では viewModelScope に対して launch { … } を呼び出す事で coroutine を起動していました。coroutine を起動する機能を持つオブジェクトは「CoroutineScope」と呼ばれます。

CoroutineScope は coroutine の起動だけではなく起動した coroutine を適切に終了する役割を担っています。例えば Android の KTX で提供されている[3] viewModelScope は、画面を閉じた時に coroutine を自動でキャンセルしてくれます。 Android でユーザー操作による処理を実行する場合は viewModelScope から起動すると良いでしょう。

一方で Kotlin の標準で用意されている GlobalScope もあります。こちらは処理が自動でキャンセルされません。一般的には自動でキャンセルを行ってくれる scope を使った方が良いとされます[4]

coroutine を手動でキャンセルする事も可能です。 launch { … } メソッドの結果に対し cancel() を呼び出すとキャンセルする事ができます。

fun runMain(): Job = viewModelScope.launch {
    val data = Weathers.tomorrow()
    println("明日の天気: ${data}")
}

val job = runMain()
// 個別にジョブをキャンセルする。特に必要な場合のみ使う。
job.cancel()

// viewModelScope をキャンセルすれば viewModelScope
// から起動したジョブを一斉にキャンセルできる。
runMain()
runMain()

// ここでジョブを一斉にキャンセル
// 大抵はフレームワーク側でやってくれるので不要
viewModelScope.cancel()

▶️ 実行してみる

Coroutine は難しくない

このように、coroutine の基本は「launch() で非同期処理を開始し、中断する関数には suspend をつける」というとてもシンプルなものです。並行プログラミングをする上で見落としがちなキャンセル処理も、CoroutineScope が適切にやってくれるようになっています。

他のプラットフォームにおける Promise のような概念もあるのですが、特に必要ない限りは suspend 関数で済ませるのが簡単で問題も少ないです。

とはいえ suspend 関数が充実していない段階では、suspend 関数だけで済ませるのは中々難しいと思います。Kotlin では従来の処理を suspend 関数にする方法をいくつか用意しているので、それを紹介します。

suspend 関数への変換

ブロック処理 → suspend 関数

一定期間待つような単純な処理であれば delay() という組み込みの suspend 関数を使えば実現できました。しかしそう言った suspend 関数がない場合は従来のブロックする処理から suspend 関数を作る必要があります。

一番最初の例にあったブロック版の関数を考えてみましょう。

fun Weathers.tomorrow(): String {
    Thread.sleep(2000) // 長い処理を表すための仮実装
    return "晴れ"
}

以下のように別のスレッドに切り替える事で、ブロック処理を suspend 関数に変換する事ができます。

suspend fun Weathers.tomorrow(): String = withContext(Dispatchers.IO) {
    Thread.sleep(2000) // 長い処理を表すための仮実装
    "晴れ"
}

withContext(Dispatchers.IO) { … } で別のスレッドを使って実行し、withContext() の処理が終わるまでの間メインスレッドを開放する事ができます。

標準では Dispatches.IO(IO処理用) の他に Dispatchers.Main(メインスレッド[5])や Dispatchers.Default(計算用)があります。また、独自でスレッドプールを作る事もできます。

スレッドの切り替えについての詳細は、公式ドキュメントの Coroutine Context and Dispatchers に書かれてあります。

コールバック関数 → suspend 関数

すでに非同期処理用にコールバック形式の関数がある場合は、どうでしょう。先ほどの Handler を使った例を改善してみます。

fun Weathers.tomorrow(callback: (String) -> Unit): Unit {
    // 2秒後に処理を呼び出すようタイマーを設定する。
    // callback 処理はメインスレッドで行われるが、それまでの間スレッドは解放される
    val handler = Handler()
    handler.postDelayed({ callback("晴れ") }, 2000)
}

suspendCoroutine { … } を使う事で suspend 関数に変換する事ができます。

suspend fun Weathers.tomorrow(): String = suspendCoroutine { c ->
    // 2秒後に処理を呼び出すようタイマーを設定する。
    // callback 処理はメインスレッドで行われるが、それまでの間スレッドは解放される
    val handler = Handler()
    handler.postDelayed({ c.resume("晴れ") }, 2000)
}

ただし、suspendCancellableCoroutine を使った方が望ましいです。

suspend fun Weathers.tomorrow(): String = suspendCancellableCoroutine { c ->
    // キャンセル対応用コード1: コールバック処理を一旦変数で持つ
    val callback = Runnable {
        // キャンセル対応用コード2: キャンセルされている場合は何もしない
        if (c.isActive) c.resume("晴れ")
    }

    val handler = Handler()
    // キャンセル対応用コード3: coroutine のキャンセルが起きた時に Handler 側もキャンセルする
    c.invokeOnCancellation { handler.removeCallbacks(callback) }

    // 2秒後に処理を呼び出すようタイマーを設定する。
    // callback 処理はメインスレッドで行われるが、それまでの間スレッドは解放される
    handler.postDelayed(callback, 2000)
}

▶️ 実行してみる

ちょっと長くなってしまいましたが、これは主にキャンセル対応のためのコードがあるからです。coroutine は任意のタイミングでキャンセルする事ができるので、キャンセルされた時に元の非同期関数の処理もキャンセルする事が望ましいです。また、処理の要所要所でキャンセルされていないか(isActive が true か)チェックする必要もあります。

キャンセルと協調的マルチタスキング

「キャンセル対応」というのが出てきました。 suspend 関数へ変換する処理を適切に作るためには、coroutine のキャンセルの仕組みを理解した方が良いでしょう。

実は、CoroutineScope 等に対してキャンセルを呼び出してもいきなり実行中の処理が打ち切られるわけではありません。実際に打ち切られるポイントは限られており、またキャンセルに従わないで処理を続行する事も可能です。

キャンセルされた処理が実際に打ち切られるポイントは、主に「suspend 関数を呼んだタイミング」です。キャンセル対応された suspend 関数[6]はキャンセル対応しています)を呼び出した場合、CancellationException が呼ばれます。投げられた CancellationException をキャッチせずに coroutine を終了すれば、キャンセル処理が完了します。

そのため、suspend 関数が CancellationException を投げるかもしれない事を想定するだけでキャンセル対応できます。一方で、ブロック処理を suspend 関数を作るようなケースだと、isActiveyield でキャンセルが要求されているか確認したりするなどして、明示的にキャンセル対応しないと最後まで処理を続けてしまいます。

キャンセルについての詳細は、公式ドキュメントの Cancellation and Timeouts に書かれてあります。

まとめ

ここまで Kotlin の coroutine の基本、特に起動と中断について解説しました。「時間のかかる処理をスレッドをブロックせずに非同期で行いたい」といった用途であれば、今回の内容で大体事足りるのではないかと思います。

次回は並列処理を行う際の話や、Kotlin の coroutine で特徴的な structured concurrency について解説したいと思います。

https://zenn.dev/wm3/articles/aa85d6cc7aa0a8146863

脚注
  1. 入門読んで Hello, world! ができるようになった程度という意味。Flow も分かってないし…。 ↩︎

  2. Android と KTX を使った場合の書き方。 ↩︎

  3. Android の場合は viewModelScope の他に lifecycleScope が用意されています(2020/03月時点)。 ↩︎

  4. Kotlinの標準ライブラリ開発のリーダーである Roman Elizarov さんによるThe reason to avoid GlobalScope に GlobalScope を避けるべき理由が書かれてあります。 ↩︎

  5. Dispatchers.Main の実装はプラットフォーム側が用意するもので、素の Kotlin では使用できません。 ↩︎

  6. 標準の suspend 関数(delay()など)は全てキャンセル対応しています。 ↩︎

Discussion