Chapter 01無料公開

はじめに

Mori Atsushi
Mori Atsushi
2021.01.31に更新

昨今のアプリケーション開発において、非同期処理は欠かせないテクニックです。Androidを始めとしたクライアントアプリケーションは、画面の描画を妨げることなく処理する必要がありますし、バックエンドアプリケーションであっても、複数のリクエストを並行して処理することが要求されてきています。

Kotlinの公式ライブラリであるKotlin Coroutinesは、非同期処理を強力に支援してくれます。Multiplatformをサポートしており、Androidアプリ、サーバサイドKotlin、Kotlin Multiplatform Mobile、Kotlin/JS等、幅広い分野で活用することが可能です。

この本は、現在Kotlinを学んでおり、新たにKotlin Coroutinesに挑戦する方、既にKotlin Coroutinesに少し触れているが、より使いこなしたい方に向けた、Kotlin Coroutinesの全体を紹介するものです。Kotlin Coroutinesの基本的な考え方はもちろん、実践的な例や普段あまり触らないような細かいAPIまで触れています。この本を通じて、様々なアプリケーションやライブラリでKotlin Coroutinesを利用したモダンな非同期処理が実践されることを期待しています。

このChapterでは、Coroutinesの基本的な考え方や、なぜKotlinでCoroutinesが採用されているのかを歴史的な背景から解説します。ぜひ一緒に、Kotlin Coroutinesの世界に足を踏み入れましょう!

1.1. 最初のCoroutines

まずはこちらのコードをご覧ください。Kotlin Coroutinesを使うことで、非同期処理を簡単に扱うことができます。

import kotlinx.coroutines.*

fun main() {
    runBlocking {
        launch {
            println("1")
            delay(1000L)
            println("2")
        }
        launch {
            println("3")
        }
    }
}

runBlockinglaunchは後ほど説明するので、一旦は雰囲気を感じてもらえれば大丈夫です。実行結果はこのようになります。

1
3
2

23が逆に出力されていますね。通常の関数であれば、上から下に実行されます。

Kotlin Coroutinesを使うと、delay等の中断可能なメソッドで一旦suspend状態になり、他のブロックが実行されます。そして、再開可能になったタイミングで再開されます。

1秒のdelayのタイミングで一度中断されることで、2の前に3が出力されていたというわけです。この仕組みを使うことで、もっと複雑な非同期処理でも簡単に実現することができます。

1.2. なぜCoroutinesなのか

歴史的な背景から、KotlinではなぜCoroutinesという仕組みが選ばれているのか探っていきたいと思います。

非同期処理とスレッド処理

例えば、①データをサーバから取得し、②そのデータを加工し、③加工されたデータをローカルに保存する処理を考えます。実際にコードに書いてみるとこのようになるでしょう。

fun main() {
    val data = fetchData() // ①サーバからデータを取得
    val transformed = transform(data) // ②データを加工
    save(transformed) // ③データを保存
}

一見これで良さそうですが、それぞれの処理に時間にかかる場合、例えば画面(UI)がフリーズしてしまう等の問題が出てきます。その問題を解決するために行われる一つが、スレッド処理です。

fun main() {
    val thread = thread { // スレッドの開始
        val data = fetchData() // ①サーバからデータを取得
        val transformed = transform(data) // ②データを加工
        save(transformed) // ③データを保存
    }
    thread.join() // スレッドの処理終了を待つ
}

時間がかかる処理を別スレッドで実行することで、UIを更新しつつデータ処理を行うことができます。しかし、スレッドが増えていったとき、果たしてすべてのスレッドを管理しきれるでしょうか?また、待ち合わせ処理やエラーハンドリング等を含めると、爆発的に複雑になっていきます。そのため、一般的にスレッドを直接扱うことはしません。

Callbackの登場

スレッド処理の問題を解決するために、Callbackという解決方法があります。引数にlambda式やinterfaceを渡すことで、終了時の処理等を定義します。

- fun fetchData(): Data { /* ... */ }
+ fun fetchData(callback: (Data) -> Unit) { /* ... */ }
fun main() {
    fetchData {
        // データ取得完了時の処理
        // ...
    }
}

エラー処理等も以下のように書くことで実現することができます。

interface OnDataListener<T> {
    fun onSuccess(data: T)
    fun onFailure(e: Throwable)
}
fun fetchData(listener: OnDataListener<Data>) { /* ... */ }
fun main() {
    fetchData(object : OnDataListener<Data> {
        override fun onSuccess(data: Data) {
            // データ取得完了時の処理
            // ...
        }

        override fun onFailure(e: Throwable) {
            // エラー時の処理
            // ...
        }
    })
}

しかし、先程の例に上げた処理を全て記述してみると、かなり大変です。

fun main() {
    fetchData(object : OnDataListener<Data> { // ①サーバからデータを取得
        override fun onSuccess(data: Data) {
            transform(data) { // ②データを加工
                save(data, object : OnSaveListener<String> { // ③データを保存
                    override fun onSuccess() {
                        // 保存完了
                    }

                    override fun onFailure(e: Throwable) {
                        // 保存失敗
                    }
                })
            }
        }

        override fun onFailure(e: Throwable) {
            // データ取得失敗
        }
    })
}

処理を上から順に追うことができず、またカッコやインデントがかなり多くなってしまいました。こういったコードはその可読性の低さからCallback地獄と呼ばれ、避けられる対象となっています。

Features/Promises/Rxによる解決

上記の問題を解決するために、新たに提唱されたのがFeatures/Promises/Rxによる解決方法です。各々若干思想や挙動に違いがありますが、ここではRxKotlinを使って説明を行います。以下のようなコードになります。

- fun fetchData(callback: (Data) -> Unit) { /* ... */ }
+ fun fetchData(): Single<Data> { /* ... */ } 
fun main() {
    fetchData()
        .doOnSuccess { /* 成功時の処理 */ }
        .doOnError { /* エラー時の処理 */ }
}

エラー時の処理も見やすくなりましたね。また、先程の連続の処理も以下のように書けます。

fun main() {
    fetchData() // ①サーバからデータを取得
        .concatMap { transform(it) } // ②データを加工
        .concatMapCompletable { save(it) } // ③データを保存
        .doOnError { /* エラー時の処理 */ }
}

上から下に順番に実行されますし、インデントも増えず、かなり見やすくなりました。一方で、concatMapconcatMapCompletable等の大量のオペレータを把握しておく必要があります。

Coroutinesの威力

では、Kotlin Coroutinesではどのように書けるのでしょうか。なんと、非同期で扱いたいメソッドに対して、suspendという修飾子をつけるだけで、実現することができます。

- fun fetchData(): Single<Data> { /* ... */ } 
+ suspend fun fetchData(): Data { /* ... */ } 

まるで通常の同期処理のように、記述することができます。

suspend fun main() {
    val data = fetchData() // ①サーバからデータを取得
    val transformed = transform(data) // ②データを加工
    save(transformed) // ③データを保存
}

エラーハンドリングも、通常のtry-catchが使えます。

suspend fun main() {
    try {
        val data = fetchData()
        val transformed = transform(data)
        save(transformed)
    } catch (e: Throwable) {
        // エラー時の処理
    }
}

非同期処理であっても、限りなく同期処理に近い形で書ける というのがCoroutinesの特徴です。さらには、if文の条件式として利用したり、forEachの中で呼び出すこともできます。

suspend fun main() {
    if (fetchData() != null) {
        // ...
    }
}
suspend fun main() {
    fetchData().forEach {
        save(it)
    }
}

1.3. 軽量スレッドとは?

Kotlin Coroutinesはよく軽量スレッド、というように呼ばれます。通常のスレッドとは何が違うのでしょうか?

一般的なスレッドを使う場合、各々のスレッドは独立して動いています。これは、高い並列性を実現することができますが、スレッド毎に必要とするメモリは大きく、大量にスレッドを起動するとパフォーマンスが落ちていきます。


一般的なスレッド

例えば、以下のように大量にスレッドを起動しようとするとOutOfMemoryErrorが発生します。

fun main() {
    repeat(100_000) {
        thread {
            Thread.sleep(5000L)
            println(it)
        }
    }
}
0
1
…
67872
67873
java.lang.OutOfMemoryError: unable to create new native thread

一方で、Kotlin Coroutinesは単一のスレッドを再利用することができます。実行しているCoroutinesがdelay等の中断状態になると、他のCoroutinesを同じスレッドで実行します。これにより、並行に動作し、メモリを効率的に使うことができます。


Coroutinesとスレッド

図は1つのスレッドを2つのCoroutinesで共有していますが、実際には複数のスレッドから空いているスレッドを自動で割り当てることもできます。この仕組により、以下のようにかなりの数のCoroutinesを同時起動しても問題なく実行することができます。

fun main() {
    runBlocking {
        repeat(100_000) {
            launch {
                delay(5000L)
                println(it)
            }
        }
    }
}
0
1
…
99998
99999