😺

Kotlinで非同期処理の実装: コルーチンを整理📝

に公開

Kotlinで非同期処理の実装

コルーチンをなんとなく使っていたので、理解のためにも
実際に動かしながら整理してみました👀

コルーチンとは

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

Coroutines basics
To create applications that perform multiple tasks at once, a concept known as concurrency, Kotlin uses coroutines. A coroutine is a suspendable computation that lets you write concurrent code in a clear, sequential style. Coroutines can run concurrently with other coroutines and potentially in parallel.
On the JVM and in Kotlin/Native, all concurrent code, such as coroutines, runs on threads, managed by the operating system. Coroutines can suspend their execution instead of blocking a thread. This allows one coroutine to suspend while waiting for some data to arrive and another coroutine to run on the same thread, ensuring effective resource utilization.

  • Kotlin のコルーチンは、「中断可能な計算単位」であり、
    同期処理のように順序立ててシンプルに書きながら並行処理を実現できる仕組み
    のこと。
  • コルーチン =「軽量なスレッド」ではなく「中断できる計算の単位」のことであり、
    中断と再開を通常の関数の中に組み込んでいく仕組みのこと。
  • Kotlinでの実装方式:コンパイラがsuspend関数をステートマシンに変換する
  • suspend といった修飾子などは Kotlin の言語機能に組み込まれているが、
    Kotlin Coroutines は Kotlin の言語機能ではなくライブラリとして提供されている。

https://github.com/Kotlin/kotlinx.coroutines

そもそもコルーチンとスレッドの違いは何??

比較項目 スレッド (Thread) コルーチン (Coroutine)
管理主体 OS(カーネルレベル) Kotlinランタイム(ユーザーレベル)
作成コスト 高い(OSリソースを割り当て) 低い(軽量オブジェクト)
同時実行 並列(CPUコアごとに実行) 並行(同一スレッド内でも切り替え)
コンテキストスイッチ OSによる強制切替(重い) Kotlinランタイムによる協調切替(軽い)
停止方法 スレッドブロック(CPUを占有) suspend により中断(スレッドを解放)
最大数 数千〜数万程度 数十万〜数百万でも可能
実行単位 OSスレッド単位でスケジュール スレッド上で動くタスク単位でスケジュール

スレッドはOSによって管理される実行単位。
各スレッドは独自のスタックメモリ(通常1MB程度)を持つ。
スレッドの作成・切り替えはOSカーネルが行うため、コストが高いです。
対してコルーチンは、OSレベルのスケジューリングに依存しない、
言語レベルの「軽量」な並行実行の仕組みである。

なぜコルーチンは「軽い」のか

なぜスレッドは「重い」のか

  • メモリ: 1スレッドあたり約1MBのスタックが必要
  • OS介入: スレッドの作成・切り替えにOSカーネルの処理が必要
  • コンテキストスイッチ: レジスタ保存・復元などのオーバーヘッド

なぜコルーチンは「軽い」のか

  • メモリ: 1コルーチンあたり数十バイト〜数KB
  • ランタイム管理: Kotlinランタイムが管理、OSを介さない
  • 中断・再開: 関数呼び出しレベルの軽さ

Coroutineがどのように動くか

コルーチンを作成するには、以下の要素が必要だ。

用語 詳細
suspend 中断可能な関数、ブロッキングせずに処理を一時停止
コルーチンビルダー launch, async, runBlocking など。
コルーチンを起動するための関数
Dispatcher コルーチンが実行されるスレッドを制御する仕組み (Default, IO, Main)
CoroutineScope コルーチンのライフサイクル管理。親子関係で安全に管理

1. suspend関数:処理の中断と再開

  • 中断と再開が可能な関数

  • suspend 関数は他の suspend 関数内か、
     コルーチンビルダー(launch, async等)の中からしか呼び出せない
    (通常の関数は中断しない(できない)ので、suspend 関数を呼び出す事ができない。)

  • 通常の関数から呼ぶには、コルーチンスコープが必要
    ->コルーチンスコープは後述します

  • suspendはKotlin言語機能の一部だが、ほとんどのコルーチン機能はkotlinx.coroutinesライブラリを通じて利用できる。

suspend fun fetchData(): String {
    delay(1000L)  // 1秒待機、スレッドは開放される
    return "データ取得完了"
}

// ❌ NGパターン
fun normalFunction() {
    fetchData() // コンパイルエラー!
}

// ✅ OKパターン1: suspend関数内
suspend fun anotherSuspendFunction() {
    fetchData() // OK
}

// ✅ OKパターン2: コルーチンビルダー内
fun normalFunction() {
    CoroutineScope(Dispatchers.IO).launch {
        fetchData() // OK
    }
}

2. CoroutineScope

CoroutineScopeは、コルーチンの「実行環境」を提供する箱のようなものだ。

  1. ライフサイクル管理: コルーチンの開始・終了・キャンセルを管理
  2. 親子関係: コルーチン同士の親子関係を構築
  3. 例外処理: 未処理例外の伝播を制御
  4. リソース管理: メモリリークを防ぐ

"構造化並行性(Structured Concurrency)"

  • 親スコープがキャンセルされると、すべての子コルーチンも自動的にキャンセル
    • メモリリークやリソースリークを防ぐ

スコープの作成方法

// 1. CoroutineScope() - 汎用スコープ
val scope = CoroutineScope(Dispatchers.Default)

// 2. MainScope() - UIアプリケーション用
val mainScope = MainScope() // Dispatchers.Mainがデフォルト

// 3. 既存のスコープを使用
runBlocking {
    launch { } // このブロック内がスコープ
}

3. コルーチンビルダー関数

コルーチンビルダー関数は、実行するコルーチンを定義するsuspendラムダを受け取る関数。

コルーチンビルダー 用途 特徴
CoroutineScope.launch() バックグラウンド処理を起動 結果を返さない
CoroutineScope.async() 並列処理を起動 結果を返す
runBlocking() ブロッキング実行 現在のスレッドをブロック
withContext() コンテキストを変更して実行 一時的なコンテキスト変更
coroutineScope() 新しいスコープを作成 構造化並行性を提供

コルーチンビルダー関数は、
実行するためにCoroutineScope(コルーチンの「実行環境」を提供するもの)が必要だ。

CoroutineScopeは以下の2つの方法で取得できる:

  1. 既存のスコープを使用: runBlocking { }coroutineScope { }のブロック内で、そのブロックがスコープになる
     → launch()async()はCoroutineScopeの拡張関数なので、スコープがある場所で呼び出す必要がある。
  2. 新しいスコープを作成: CoroutineScope(Dispatchers.Default)のように明示的にスコープを作る

3-1. launch

関数シグネチャ

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

基本的な特徴

  • 戻り値: Jobオブジェクト(結果は返さない)
  • 実行方式: Fire-and-forget(投げっぱなし)
  • 用途: バックグラウンドでの非同期処理(戻り値を気にしない処理に適している)
  • スレッド: デフォルトでは親スコープのDispatcherを使用
補足:Job とは

補足:Job とは

Job とはなんらかの作業を表す単位で、開始や完了などの状態がある。
launch や async などのコルーチンビルダーで作成したコルーチンは Job であり、
Jobはコルーチンの「実行中の処理」を表すハンドルといえる。

launchのノンブロッキング動作を確認
以下の挙動を見ていきたいと思う。

  • launch呼び出し後、メイン処理がすぐに継続されること
  • delay()中にスレッドが他の処理に使われること
  • 単一スレッドでも複数のコルーチンが協調動作すること
fun demonstrateLaunch() = runBlocking {
    println("\n🚀 === launchの例 ===")
    println("📝 [${Thread.currentThread().name}] メインスレッド開始")
    
    val job = launch {
        println("🔄 [${Thread.currentThread().name}] launchコルーチン開始")
        fetchUserData("123")
        println("✅ [${Thread.currentThread().name}] launchコルーチン完了")
    }
    
    println("📝 [${Thread.currentThread().name}] launch呼び出し後、処理継続")
    delay(500) // 0.5秒待機
    println("📝 [${Thread.currentThread().name}] メイン処理継続中...")
    
    job.join() // コルーチンの完了を待つ
    println("📝 [${Thread.currentThread().name}] 全ての処理完了")
}

suspend fun fetchUserData(userId: String): String {
    println("🔄 [${Thread.currentThread().name}] fetchUserData開始: $userId")
    delay(1000) // 1秒待機(ネットワーク呼び出しをシミュレート)
    println("✅ [${Thread.currentThread().name}] fetchUserData完了: $userId")
    return "User-$userId"
}

実行結果:

🚀 === launchの例 ===
📝 [http-nio-8080-exec-1] メインスレッド開始
📝 [http-nio-8080-exec-1] launch呼び出し後、処理継続
🔄 [http-nio-8080-exec-1] launchコルーチン開始
🔄 [http-nio-8080-exec-1] fetchUserData開始: 123
📝 [http-nio-8080-exec-1] メイン処理継続中...
✅ [http-nio-8080-exec-1] fetchUserData完了: 123
✅ [http-nio-8080-exec-1] launchコルーチン完了
📝 [http-nio-8080-exec-1] 全ての処理完了

3-2. async

関数シグネチャ

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

基本的な特徴

  • 戻り値: Deferred<T>オブジェクト(結果を返す)
  • 実行方式: 並列処理(結果を待つ)
  • 用途: 並列処理で結果が必要な処理に適している
  • スレッド: デフォルトでは親スコープのDispatcherを使用
補足:Deferred とは

Deferredとは、非同期処理の結果を表すオブジェクトで、Jobを継承している。
asyncで作成したコルーチンはDeferredを返し、await()で結果を取得できる。

asyncの並列処理動作を確認
以下の挙動を見ていきたいと思う。

  • async呼び出し後、メイン処理がすぐに継続されること
  • 複数のasyncが並列に実行されること
  • await()で結果を取得できること
suspend fun fetchUserData(userId: String): String {
    println("🔄 [${Thread.currentThread().name}] fetchUserData開始: $userId")
    delay(1000) // 1秒の非同期処理をシミュレート
    println("✅ [${Thread.currentThread().name}] fetchUserData完了: $userId")
    return "User-$userId"
}

fun demonstrateAsync() = runBlocking {
    println("\n🚀 === asyncの例 ===")
    println("📝 [${Thread.currentThread().name}] メインスレッド開始")
    
    val deferred1 = async {
        println("🔄 [${Thread.currentThread().name}] async1コルーチン開始")
        fetchUserData("123")
    }
    
    val deferred2 = async {
        println("🔄 [${Thread.currentThread().name}] async2コルーチン開始")
        fetchUserData("456")
    }
    
    println("📝 [${Thread.currentThread().name}] async呼び出し後、処理継続")
    delay(500)
    println("📝 [${Thread.currentThread().name}] メイン処理継続中...")
    
    val result1 = deferred1.await()
    val result2 = deferred2.await()
    
    println("📝 [${Thread.currentThread().name}] 結果1: $result1")
    println("📝 [${Thread.currentThread().name}] 結果2: $result2")
    println("📝 [${Thread.currentThread().name}] 全ての処理完了")
}

実行結果:

🚀 === asyncの例 ===
📝 [http-nio-8080-exec-1] メインスレッド開始
📝 [http-nio-8080-exec-1] async呼び出し後、処理継続
🔄 [http-nio-8080-exec-1] async1コルーチン開始
🔄 [http-nio-8080-exec-1] fetchUserData開始: 123
🔄 [http-nio-8080-exec-1] async2コルーチン開始
🔄 [http-nio-8080-exec-1] fetchUserData開始: 456
📝 [http-nio-8080-exec-1] メイン処理継続中...
✅ [http-nio-8080-exec-1] fetchUserData完了: 123
✅ [http-nio-8080-exec-1] fetchUserData完了: 456
📝 [http-nio-8080-exec-1] 結果1: User-123
📝 [http-nio-8080-exec-1] 結果2: User-456
📝 [http-nio-8080-exec-1] 全ての処理完了

3-3. runBlocking

関数シグネチャ

fun <T> runBlocking(
    context: CoroutineContext = EmptyCoroutineContext,
    block: suspend CoroutineScope.() -> T
): T

基本的な特徴

  • 戻り値: ブロック内の処理の結果を直接返す
  • 実行方式: 現在のスレッドをブロックして、コルーチンが完了するまで待機
  • 用途: main関数やテストコードでコルーチンを実行する際に使用
補足:runBlockingの特殊性

runBlockingは他のコルーチンビルダーと異なり、CoroutineScopeの拡張関数ではなく通常の関数。
そのため、コルーチンスコープがない場所(main関数など)から直接呼び出せる。
ただし、現在のスレッドをブロックするため、
コルーチンの「ノンブロッキング」という利点が失われる。

4.Dispatcher

  • コルーチンが実行されるスレッドを制御する仕組みで、コルーチンの「実行先スレッド」を決める

  • 「スレッドそのもの」ではなく、「コルーチンをどのスレッド上で動かすかのルール」

  • 同じDispatcher上で複数コルーチンを起動すると、少数のスレッドで効率的に並行実行される

  • 主な種類:

Dispatcher 用途 特徴
Dispatchers.Default CPU バウンド処理 CPU コア数に基づいたスレッドプール
Dispatchers.IO I/O バウンド処理 ブロッキング I/O に最適化、多くのスレッドを生成
Dispatchers.Main Android UI スレッド メインスレッドで安全に UI 更新
Dispatchers.Unconfined どこでも 最初は呼び出し元スレッドで開始、途中で適切なスレッドに移動

Discussion