Kotlinのコルーチン: launch、async、scope

1. 概要

コルーチンはKotlinが非同期コードを処理する方法で、コールバック地獄やスレッドのブロッキングを必要としません。この記事では、launch、async、suspend fun、およびCoroutineScopeがどのように機能し、いつ何を使うかについて説明します。この記事を読み終えると、きれいな並行実行コードを書くことができ、コルーチンのライフサイクルを制御し、JUnit 5でテストする方法が分かるようになります。

2. 必要な依存関係

コルーチンはKotlin標準ライブラリに含まれていないため、kotlinx-coroutines-coreを追加する必要があります。

<dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-coroutines-core</artifactId>
    <version>1.8.1</version>
</dependency>
<dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-coroutines-test</artifactId>
    <version>1.8.1</version>
    <scope>test</scope>
</dependency>

Spring Bootを使用している場合、spring-boot-starter-webfluxまたはspring-boot-starterモジュールには、コントローラーでsuspend funをサポートするKotlin拡張機能が含まれています。

3. suspend fun: コルーチンの基本単位

suspendは、スレッドをブロックせずに一時停止(suspend)できる関数をマークするキーワードです。

suspend fun fetchOrder(orderId: Long): Order {
    delay(200) // I/O呼び出しをシミュレート。実スレッドはブロックされない
    return Order(orderId, "PENDING")
}

data class Order(val id: Long, val status: String)

delay()は組み込みのsuspend関数で、Thread.sleep()とは異なり、待機中にスレッドを保持しません。suspend funは、別のsuspend funから、またはコルーチンビルダー(launch、async、runBlocking)内からのみ呼び出すことができます。

注意: 通常のfun関数(suspendではない)から直接suspend funを呼び出すとコンパイルエラーが発生します。これはKotlinが非同期コンテキストを明示的に宣言することを強制するメカニズムです。

4. launch: fire-and-forget コルーチン

launchは新しいコルーチンを作成し、計算結果を返さずにJobを返します。結果が必要ない「実行して忘れる」場合に使用します。

fun main() = runBlocking {
    val job: Job = launch {
        println("注文の処理を開始します。スレッド: ${Thread.currentThread().name}")
        val order = fetchOrder(1L)
        println("注文ステータス: ${order.status}")
    }
    println("launchが返されました。コルーチンは並行実行しています。")
    job.join() // コルーチンが完了するまで待機
}
launchが返されました。コルーチンは並行実行しています。
注文の処理を開始します。スレッド: main
注文ステータス: PENDING

runBlockingはmain内でコルーチンを実行するためのスコープを作成します(デモやテストにのみ使用します。実運用環境のコードでは使用しません)。launchは現在のスレッドをブロックしません。コルーチンをスケジュールして即座に返します。job.join()は、コルーチンが完了するのを待つポイントです。

5. async: 結果を返すコルーチン

asyncはlaunchに似ていますが、Deferred<T>(結果を取得するために.await()を呼び出す「promise」)を返します。

suspend fun fetchCustomer(customerId: Long): String {
    delay(150)
    return "Customer-$customerId"
}

suspend fun loadOrderDetail(orderId: Long, customerId: Long): String = coroutineScope {
    val orderDeferred = async { fetchOrder(orderId) }
    val customerDeferred = async { fetchCustomer(customerId) }

    val order = orderDeferred.await()
    val customer = customerDeferred.await()
    "Order ${order.id} (${order.status}) の $customer"
}

2つのasyncコマンドは並行実行され、順序付けられていません。ここでの総待機時間は約200ms(200msと150msの最大値)であり、350msではありません。await()は結果を取得するためにsuspendするポイントです。各asyncの直後にawait()を呼び出すのではなく、分離して呼び出さないと、コードは順序付けられて実行され、並行実行の利点が失われます。

// 間違い: 順序付けられて実行されます。asyncの意味が失われます。
val order = async { fetchOrder(orderId) }.await()
val customer = async { fetchCustomer(customerId) }.await()

注意: 上記は構文エラーではありませんが、asyncを使用する目的を台無しにします。各await()は作成直後にブロックされるため、2つのコルーチンは決して同時に実行されません。

6. CoroutineScope: コルーチンのライフサイクルを管理するもの

すべてのコルーチンはCoroutineScopeに属する必要があります。スコープが決定します: コルーチンがどのくらい生きるか、いつ取り消すか、例外がどのように伝播するか。

class OrderService {
    private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())

    fun processOrderAsync(orderId: Long) {
        serviceScope.launch {
            val order = fetchOrder(orderId)
            println("注文 ${order.id} を処理しました")
        }
    }

    fun shutdown() {
        serviceScope.cancel() // このスコープ内で実行されているすべての子コルーチンをキャンセルします
    }
}

serviceScopeはクラスのフィールドとして保持されます。これはオブジェクトが使用されなくなった後も「孤立した」コルーチンが実行し続けるのを防ぐ方法です。SupervisorJob()により、1つの子コルーチンが失敗してもほかの子コルーチンがキャンセルされません(Job()の場合、1つが失敗するとすべての兄弟がキャンセルされます)。

6.1. coroutineScope vs CoroutineScope

coroutineScope { ... }(suspend関数、小文字)とCoroutineScope(...)(新しいスコープを作成するコンストラクタ、大文字)を混同しないでください。

suspend fun loadDashboard(): String = coroutineScope {
    // coroutineScopeは、内部のすべての子コルーチンが完了するまで待機してから
    // 返されます。親コルーチンのコンテキストを使用します。
    val a = async { fetchOrder(1L) }
    val b = async { fetchCustomer(2L) }
    "${a.await()} - ${b.await()}"
}

coroutineScopeは、複数の子コルーチンをグループ化するためのsuspend関数ビルダーで、構造化された並行実行を保証します: 関数は内部のすべてのasync/launchが完了した場合にのみ返されます(またはそのいずれかが失敗し、すべてがキャンセルされます)。独立した新しいスコープを作成するのではなく、現在のコンテキストを継承します。これは、セクション6のCoroutineScope(...)とは異なり、関数が終了しても自動的にキャンセルされない、独立して存続するスコープを作成します。

7. Dispatcher: コルーチンがどのスレッドで実行されるか

Dispatcherはコルーチンが実行される場所を決定します。JavaでExecutorを選択するのと似ています。

suspend fun readConfigFile(path: String): String = withContext(Dispatchers.IO) {
    // Dispatchers.IOはブロッキングI/O(ファイルの読み取り、JDBC呼び出しなど)に使用されます
    File(path).readText()
}

suspend fun heavyCompute(data: List<Int>): Long = withContext(Dispatchers.Default) {
    // Dispatchers.DefaultはCPUバウンドな作業に使用されます
    data.sumOf { it.toLong() * it }
}

withContextは現在のコルーチンを別のdispatcherで実行するように切り替え、完了後に元のdispatcherに自動的に戻します。ブロッキングコード(JDBC、ファイルI/O)を呼び出す必要があり、Dispatchers.DefaultまたはDispatchers.Main(Android上)のメインスレッドを占有したくない場合に非常に便利です。

Dispatcher 用途
Dispatchers.Default CPU計算(解析、ソート、計算)
Dispatchers.IO ブロッキングI/O(ファイル、JDBC、ネットワークブロッキング)
Dispatchers.Main UIの更新(Android/JavaFXでのみ利用可能)
Dispatchers.Unconfined 稀に使用。最初の呼び出しスレッドで実行します

8. コルーチン内の例外処理

launchとasyncでの例外伝播は異なります。これは最も一般的なバグの原因です。

fun main() = runBlocking {
    // launch: 例外は即座にスローされます。キャッチしないとスコープがクラッシュする可能性があります
    val job = launch {
        try {
            throw IllegalStateException("注文が存在しません")
        } catch (e: IllegalStateException) {
            println("launchで例外をキャッチしました: ${e.message}")
        }
    }
    job.join()

    // async: 例外はDeferredに保持され、await()を呼び出したときだけスローされます
    val deferred = async {
        throw IllegalStateException("顧客が存在しません")
    }
    try {
        deferred.await()
    } catch (e: IllegalStateException) {
        println("asyncで例外をキャッチしました: ${e.message}")
    }
}
launchで例外をキャッチしました: 注文が存在しません
asyncで例外をキャッチしました: 顧客が存在しません

asyncの場合、.await()を呼び出し忘れると、Deferredがガベージコレクション回収されるか、どこかでawait()を呼び出すまで、例外は静かに「飲み込まれ」ます。これは本番環境でデバッグが難しいバグを引き起こしやすいです。

9. コルーチンのテストを書く

kotlinx-coroutines-testでrunTestを使用して、実際のdelayを待つことなくsuspend関数をテストします。

class OrderServiceTest {

    @Test
    fun `fetchOrderはステータスPENDINGを返す`() = runTest {
        val order = fetchOrder(1L)

        assertThat(order.id).isEqualTo(1L)
        assertThat(order.status).isEqualTo("PENDING")
    }

    @Test
    fun `loadOrderDetailは2つのコルーチンを並行実行する`() = runTest {
        val result = loadOrderDetail(orderId = 1L, customerId = 2L)

        assertThat(result).contains("Order 1")
        assertThat(result).contains("Customer-2")
    }
}

runTestは自動的に仮想時間を使用します。fetchOrder内のdelay(200)により、テストが実際に200ms遅くなることはなく、ロジックが再開されるポイントまでスキップされます。これが、ユニットテストでrunBlockingを使用してコルーチンをテストすべきでない理由です。実際の時間を待つため、テストスイートはテストの数に応じて遅くなります。

10. よくあるミス

  • GlobalScopeを使用する: コルーチンはアプリケーション全体の存続期間生き、ライフサイクルに従ってキャンセルされません。これはリークを引き起こします。常にセクション6のようにクラス/コンポーネントに特定のスコープを作成してください。
  • 本番コード内でrunBlockingを呼び出す: 現在のスレッドを実際にブロックし、コルーチンの目的を台無しにします。main()、テスト、またはブロッキングコードとsuspendコードを接続する必要がある場所でのみ使用してください。
  • asyncでawait()を忘れる: 例外は飲み込まれ、コルーチンは実行されますが、誰も結果を待つことはありません。無駄であり、エラーをトレースするのが難しいです。
  • CPU計算にDispatchers.IOを混在させる: Dispatchers.IOは大幅に大きなスレッドプール(I/O待機に最適化)を備えています。計算集約的なタスクに使用するとスケジューリング効率が低下します。

注意: サービスに真の並行実行が不要な場合(数回のI/Oを順序付けて呼び出すだけ)、コルーチンは過度に複雑かもしれません。async/launchが不要で、順序付けて呼び出す単純なsuspend fun関数の方が、より明確で保守しやすい場合があります。

11. 結論

suspend funは基本単位で、launchは結果が不要な場合に使用し、asyncは並行実行が必要でawait()で結果を取得する場合に使用します。CoroutineScopeはライフサイクルを決定します。常にスコープを特定のコンポーネントに関連付け、GlobalScopeを避け、構造化された並行実行を制御するためにcoroutineScope/SupervisorJobを優先的に使用します。これら3つの概念をしっかり理解することで、Kotlinでほとんどの非同期コードを安全かつテストしやすく書くことができます。

kotlincoroutinesconcurrencyandroidspring-boot