Kotlinコルーチンにおける非同期例外処理

目次

  1. はじめに
  2. コルーチンの基本
  3. 例外処理の基本メカニズム
  4. try-catchブロックの使用
  5. CoroutineExceptionHandlerの導入
  6. スーパーバイザースコープを用いた例外の封じ込め
  7. async/awaitと例外処理
  8. 複数のコルーチン間での例外伝播
  9. キャンセレーションと例外の関係
  10. ベストプラクティス
  11. まとめ

1. はじめに

Kotlinのコルーチンは非同期プログラミングを簡潔かつ効率的に行うための強力なツールです。しかし、非同期処理における例外処理は同期コードとは異なる考慮点があります。この記事では、コルーチンにおける例外処理のメカニズムを詳細に解説し、実践的なコード例を通じて理解を深めていきます。

2. コルーチンの基本

まず、コルーチンの基本的な構造を簡単に復習しましょう。

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {
        delay(1000L)
        println("コルーチン内の処理が完了しました")
    }
    
    println("メインスレッドは待機せずに次の処理へ進みます")
}

このコードでは、launchによって新しいコルーチンを作成し、非同期処理を開始しています。runBlockingはメインスレッドをブロックして、内部のコルーチンが完了するまで待機します。

3. 例外処理の基本メカニズム

コルーチン内で発生した例外は、コルーチンの親へと伝播します。これはJavaの通常のスレッドとは大きく異なる点です。コルーチン内で例外が発生すると、そのコルーチンはキャンセルされ、例外は親コルーチンへと伝播していきます。

import kotlinx.coroutines.*

fun main() = runBlocking {
    try {
        launch {
            println("子コルーチンを開始します")
            throw RuntimeException("エラーが発生しました")
        }
    } catch (e: Exception) {
        println("例外をキャッチしました: ${e.message}")
    }
    
    delay(100)
    println("この行は実行されますか?")
}

上記のコードでは、子コルーチン内で例外が発生しますが、親のtry-catchブロックではキャッチされません。なぜなら、launchは例外を非同期に伝播させるためです。結果として、アプリケーション全体がクラッシュします。

4. try-catchブロックの使用

コルーチン内での例外を適切に処理するには、例外が発生する可能性のあるコード部分をtry-catchブロックで囲む必要があります。

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {
        try {
            println("危険な処理を開始します")
            throw RuntimeException("予期せぬエラーが発生しました")
        } catch (e: Exception) {
            println("コルーチン内で例外をキャッチしました: ${e.message}")
        }
    }
    
    delay(100)
    println("メイン処理は正常に継続します")
}

このように、コルーチン内でtry-catchブロックを使用することで、例外を適切に処理し、アプリケーション全体がクラッシュすることを防ぐことができます。

5. CoroutineExceptionHandlerの導入

KotlinコルーチンはCoroutineExceptionHandlerという例外処理専用のメカニズムを提供しています。これを使用すると、より宣言的に例外処理を行うことができます。

import kotlinx.coroutines.*

fun main() = runBlocking {
    val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("CoroutineExceptionHandlerで例外をキャッチしました: ${throwable.message}")
    }
    
    val job = GlobalScope.launch(exceptionHandler) {
        println("コルーチンを開始します")
        throw RuntimeException("コルーチン内でエラーが発生しました")
    }
    
    job.join()
    println("メイン処理は正常に継続します")
}

CoroutineExceptionHandlerは、コルーチンビルダーのコンテキストとして渡すことができ、そのコンテキスト内で発生した例外を処理します。ただし、CoroutineExceptionHandlerrunBlockingasyncでは機能しないことに注意が必要です。

6. スーパーバイザースコープを用いた例外の封じ込め

通常、親コルーチンがキャンセルされると、その子コルーチンもすべてキャンセルされます。しかし、SupervisorJobを使用すると、子コルーチンの失敗が他の子コルーチンに影響を与えないようにすることができます。

import kotlinx.coroutines.*

fun main() = runBlocking {
    val supervisor = SupervisorJob()
    val scope = CoroutineScope(coroutineContext + supervisor)
    
    val job1 = scope.launch {
        delay(100)
        println("Job 1 is running")
        throw RuntimeException("Job 1 failed")
    }
    
    val job2 = scope.launch {
        delay(200)
        println("Job 2 is running")
    }
    
    job1.join()
    job2.join()
    
    println("いくつかのジョブが失敗してもスコープは生き続けます")
    
    supervisor.cancel() // スコープをクリーンアップ
}

または、supervisorScopeビルダーを使用して同様の効果を得ることもできます:

import kotlinx.coroutines.*

fun main() = runBlocking {
    supervisorScope {
        launch {
            delay(100)
            println("最初のタスクを実行中")
            throw RuntimeException("最初のタスクが失敗しました")
        }
        
        launch {
            delay(200)
            println("2番目のタスクは正常に実行されます")
        }
    }
    
    println("supervisorScopeは完了しました")
}

7. async/awaitと例外処理

asyncで開始されたコルーチンからの例外は、await()が呼び出されるまで伝播されません。これは「遅延された例外」と呼ばれる動作です。

import kotlinx.coroutines.*

fun main() = runBlocking {
    val deferred = async {
        println("非同期タスクを開始します")
        throw RuntimeException("非同期タスクが失敗しました")
    }
    
    try {
        deferred.await() // この時点で例外が再スローされる
    } catch (e: Exception) {
        println("await()で例外をキャッチしました: ${e.message}")
    }
}

この特性を活用することで、複数の非同期タスクを開始し、後でそれらの結果を処理する際に例外を適切に扱うことができます。

8. 複数のコルーチン間での例外伝播

複数のコルーチンが協調して動作する場合、例外の伝播はより複雑になります。以下の例で見てみましょう:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("例外がキャッチされました: ${throwable.message}")
    }
    
    val scope = CoroutineScope(Job() + exceptionHandler)
    
    scope.launch {
        launch {
            delay(100)
            throw RuntimeException("内部コルーチンでエラーが発生")
        }
        
        launch {
            delay(200)
            println("この行は実行されません - 親がキャンセルされるため")
        }
    }
    
    delay(500) // 例外処理を観察するための待機
}

この例では、内部コルーチンで発生した例外が親コルーチンに伝播し、同じ親を持つ他のコルーチンもキャンセルされます。

より複雑な例外伝播の制御には、coroutineScopesupervisorScopeを組み合わせて使用することもできます:

import kotlinx.coroutines.*

fun main() = runBlocking {
    try {
        supervisorScope {
            val job1 = launch {
                try {
                    coroutineScope {
                        launch {
                            delay(100)
                            throw RuntimeException("ネストされたコルーチンでエラー発生")
                        }
                    }
                } catch (e: Exception) {
                    println("Job1で例外をキャッチ: ${e.message}")
                }
            }
            
            val job2 = launch {
                delay(200)
                println("Job2は正常に実行されます")
            }
            
            job1.join()
            job2.join()
        }
    } catch (e: Exception) {
        println("supervisorScopeで例外をキャッチ: ${e.message}")
    }
    
    println("すべての処理が完了しました")
}

9. キャンセレーションと例外の関係

コルーチンのキャンセレーションは、CancellationExceptionという特殊な例外を通じて実装されています。この例外は通常の例外とは異なり、親コルーチンへは伝播されません。

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        try {
            repeat(1000) { i ->
                println("繰り返し $i")
                delay(100)
            }
        } catch (e: CancellationException) {
            println("コルーチンがキャンセルされました: ${e.message}")
            throw e // キャンセル例外は再スローする必要がある
        } finally {
            println("リソースのクリーンアップを行います")
        }
    }
    
    delay(300)
    job.cancelAndJoin()
    println("メイン処理を続行します")
}

キャンセレーション時のリソース解放などのクリーンアップ処理はfinallyブロックで行うべきです。また、CancellationExceptionをキャッチした場合は、再スローすることが推奨されています。

10. ベストプラクティス

以下に、Kotlinコルーチンでの例外処理に関するベストプラクティスをまとめます:

  1. 適切なスコープの使用: GlobalScopeの使用は避け、アプリケーションのライフサイクルに合わせた適切なスコープを使用する。
class MyViewModel : ViewModel() {
    private val viewModelScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
    
    fun fetchData() {
        viewModelScope.launch {
            try {
                val result = repository.getData()
                processResult(result)
            } catch (e: Exception) {
                handleError(e)
            }
        }
    }
    
    override fun onCleared() {
        viewModelScope.cancel()
        super.onCleared()
    }
}
  1. 例外を明示的に処理する: 例外が発生する可能性のある箇所では、明示的にtry-catchブロックを使用する。
suspend fun fetchUserData(): User {
    return try {
        api.getUserData()
    } catch (e: IOException) {
        // ネットワークエラーの処理
        User.createOfflineUser()
    } catch (e: HttpException) {
        // APIエラーの処理
        handleApiError(e)
        User.createErrorUser()
    }
}
  1. キャンセル可能な操作の適切な処理: キャンセル可能な操作では、キャンセル例外を適切に処理する。
suspend fun performCancellableOperation() {
    try {
        // キャンセル可能な操作
        while (isActive) {
            // 処理
        }
    } finally {
        // リソースのクリーンアップ
        closeResources()
    }
}
  1. 構造化された例外処理の利用: 例外処理を階層的に構造化し、適切なレベルで例外を処理する。
suspend fun processData() = coroutineScope {
    val data1 = async { fetchData1() }
    val data2 = async { fetchData2() }
    
    try {
        combineData(data1.await(), data2.await())
    } catch (e: DataException) {
        // データ処理に関する例外を処理
        handleDataException(e)
    }
}

11. まとめ

Kotlinコルーチンにおける非同期例外処理は、通常の同期コードとは異なる考慮点があります。コルーチンの階層構造、親子関係、キャンセレーションの挙動を理解し、適切な例外処理メカニズムを選択することが重要です。

この記事では、以下の重要なポイントを学びました:

  • コルーチン内での例外伝播のメカニズム
  • try-catchブロックの適切な使用方法
  • CoroutineExceptionHandlerの活用
  • SupervisorJobsupervisorScopeによる例外の封じ込め
  • async/awaitにおける遅延された例外の処理
  • 複数のコルーチン間での例外伝播の制御
  • キャンセレーションと例外の関係