[Kotlin Tutorials 22] 協程中的例外處理

2023-06-08 09:00:52

協程中的例外處理

Parent-Child關係

如果一個coroutine丟擲了異常, 它將會把這個exception向上拋給它的parent, 它的parent會做以下三件事情:

  • 取消其他所有的children.
  • 取消自己.
  • 把exception繼續向上傳遞.

這是預設的例外處理關係, 取消是雙向的, child會取消parent, parent會取消所有child.

catch不住的exception

看這個程式碼片段:

fun main() {
    val scope = CoroutineScope(Job())
    try {
        scope.launch {
            throw RuntimeException()
        }
    } catch (e: Exception) {
        println("Caught: $e")
    }

    Thread.sleep(100)
}

這裡的異常catch不住了.
會直接讓main函數的主程序崩掉.

這是因為和普通的例外處理機制不同, coroutine中未被處理的異常並不是直接丟擲, 而是按照job hierarchy向上傳遞給parent.

如果把try放在launch裡面還行.

預設的例外處理

預設情況下, child發生異常, parent和其他child也會被取消.

fun main() {
    println("start")
    val exceptionHandler = CoroutineExceptionHandler { _, exception ->
        println("CoroutineExceptionHandler got $exception")
    }
    val scope = CoroutineScope(Job() + exceptionHandler)

    scope.launch {
        println("child 1")
        delay(1000)
        println("finish child 1")
    }.invokeOnCompletion { throwable ->
        if (throwable is CancellationException) {
            println("Coroutine 1 got cancelled!")
        }
    }

    scope.launch {
        println("child 2")
        delay(100)
        println("child 2 throws exception")
        throw RuntimeException()
    }

    Thread.sleep(2000)
    println("end")
}

列印出:

start
child 1
child 2
child 2 throws exception
Coroutine 1 got cancelled!
CoroutineExceptionHandler got java.lang.RuntimeException
end

SupervisorJob

如果有一些情形, 開啟了多個child job, 但是卻不想因為其中一個的失敗而取消其他, 怎麼辦? 用SupervisorJob.

比如:

val uiScope = CoroutineScope(SupervisorJob())

如果你用的是scope builder, 那麼用supervisorScope.

SupervisorJob改造上面的例子:

fun main() {
    println("start")
    val exceptionHandler = CoroutineExceptionHandler { _, exception ->
        println("CoroutineExceptionHandler got $exception")
    }
    val scope = CoroutineScope(SupervisorJob() + exceptionHandler)

    scope.launch {
        println("child 1")
        delay(1000)
        println("finish child 1")
    }.invokeOnCompletion { throwable ->
        if (throwable is CancellationException) {
            println("Coroutine 1 got cancelled!")
        }
    }

    scope.launch {
        println("child 2")
        delay(100)
        println("child 2 throws exception")
        throw RuntimeException()
    }
    Thread.sleep(2000)
    println("end")
}

輸出:

start
child 1
child 2
child 2 throws exception
CoroutineExceptionHandler got java.lang.RuntimeException
finish child 1
end

儘管coroutine 2丟擲了異常, 另一個coroutine還是做完了自己的工作.

SupervisorJob的特點

SupervisorJob把取消變成了單向的, 只能從上到下傳遞, 只能parent取消child, 反之不能取消.
這樣既顧及到了由於生命週期的結束而需要的正常取消, 又避免了由於單個的child失敗而取消所有.

viewModelScope的context就是用了SupervisorJob() + Dispatchers.Main.immediate.

除了把取消變為單向的, supervisorScope也會和coroutineScope一樣等待所有child執行結束.

supervisorScope中直接啟動的coroutine是頂級coroutine.
頂級coroutine的特性:

  • 可以加exception handler.
  • 自己處理exception.
    比如上面的例子中coroutine child 2可以直接加exception handler.

使用注意事項, SupervisorJob只有兩種寫法:

  • 作為CoroutineScope的引數傳入: CoroutineScope(SupervisorJob()).
  • 使用supervisorScope方法.

把Job作為coroutine builder(比如launch)的引數傳入是錯誤的做法, 不起作用, 因為一個新的coroutine總會assign一個新的Job.

例外處理的辦法

try-catch

和普通的例外處理一樣, 我們可以用try-catch, 只是注意要在coroutine裡面:

fun main() {
    val scope = CoroutineScope(Job())
    scope.launch {
        try {
            throw RuntimeException()
        } catch (e: Exception) {
            println("Caught: $e")
        }
    }

    Thread.sleep(100)
}

這樣就能列印出:

Caught: java.lang.RuntimeException

對於launch, try要包住整塊.
對於async, try要包住await語句.

scope function: coroutineScope()

coroutineScope會把其中未處理的exception丟擲來.

相比較於這段程式碼中catch不到的exception:

fun main() {
    val scope = CoroutineScope(Job())
    scope.launch {
        try {
            launch {
                throw RuntimeException()
            }
        } catch (e: Exception) {
            println("Caught: $e")
        }
    }
    Thread.sleep(100)
}

沒走到catch裡, 仍然是主程序崩潰.

這個exception是可以catch到的:

fun main() {
    val scope = CoroutineScope(Job())
    scope.launch {
        try {
            coroutineScope {
                launch {
                    throw RuntimeException()
                }
            }
        } catch (e: Exception) {
            println("Caught: $e")
        }
    }

    Thread.sleep(100)
}

列印出:

Caught: java.lang.RuntimeException

因為這裡coroutineScope把異常又重新丟擲來了.

注意這裡換成supervisorScope可是不行的.

CoroutineExceptionHandler

CoroutineExceptionHandler是例外處理的最後一個機制, 此時coroutine已經結束了, 在這裡的處理通常是報告log, 展示錯誤等.
如果不加exception handler那麼unhandled exception會進一步往外拋, 如果最後都沒人處理, 那麼可能造成程序崩潰.

CoroutineExceptionHandler需要加在root coroutine上.

這是因為child coroutines會把例外處理代理到它們的parent, 後者繼續代理到自己的parent, 一直到root.
所以對於非root的coroutine來說, 即便指定了CoroutineExceptionHandler也沒有用, 因為異常不會傳到它.

兩個例外:

  • async的異常在Deferred物件中, CoroutineExceptionHandler也沒有任何作用.
  • supervision scope下的coroutine不會向上傳遞exception, 所以CoroutineExceptionHandler不用加在root上, 每個coroutine都可以加, 單獨處理.

通過這個例子可以看出另一個特性: CoroutineExceptionHandler只有當所有child都結束之後才會處理異常資訊.

@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception -> 
        println("CoroutineExceptionHandler got $exception") 
    }
    val job = GlobalScope.launch(handler) {
        launch { // the first child
            try {
                delay(Long.MAX_VALUE)
            } finally {
                withContext(NonCancellable) {
                    println("Children are cancelled, but exception is not handled until all children terminate")
                    delay(100)
                    println("The first child finished its non cancellable block")
                }
            }
        }
        launch { // the second child
            delay(10)
            println("Second child throws an exception")
            throw ArithmeticException()
        }
    }
    job.join()
}

輸出:

Second child throws an exception
Children are cancelled, but exception is not handled until all children terminate
The first child finished its non cancellable block
CoroutineExceptionHandler got java.lang.ArithmeticException

如果多個child都丟擲異常, 只有第一個被handler處理, 其他都在exception.suppressed欄位裡.

fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception ->
        println("CoroutineExceptionHandler got $exception with suppressed ${exception.suppressed.contentToString()}")
    }
    val job = GlobalScope.launch(handler) {
        launch {
            try {
                delay(Long.MAX_VALUE) // it gets cancelled when another sibling fails with IOException
            } finally {
                throw ArithmeticException() // the second exception
            }
        }
        launch {
            delay(100)
            throw IOException() // the first exception
        }
        delay(Long.MAX_VALUE)
    }
    job.join()
}

輸出:

CoroutineExceptionHandler got java.io.IOException with suppressed [java.lang.ArithmeticException]

單獨說一下async

async比較特殊:

  • 作為top coroutine時, 在await的時候try-catch異常.
  • 如果是非top coroutine, async塊裡的異常會被立即丟擲.

例子:

fun main() {
    val scope = CoroutineScope(SupervisorJob())
    val deferred = scope.async {
        throw RuntimeException("RuntimeException in async coroutine")
    }

    scope.launch {
        try {
            deferred.await()
        } catch (e: Exception) {
            println("Caught: $e")
        }
    }

    Thread.sleep(100)
}

這裡由於用了SupervisorJob, 所以async是top coroutine.

fun main() {

    val coroutineExceptionHandler = CoroutineExceptionHandler { coroutineContext, exception ->
        println("Handle $exception in CoroutineExceptionHandler")
    }

    val topLevelScope = CoroutineScope(SupervisorJob() + coroutineExceptionHandler)
    topLevelScope.launch {
        async {
            throw RuntimeException("RuntimeException in async coroutine")
        }
    }
    Thread.sleep(100)
}

當它不是top coroutine時, 異常會被直接丟擲.

特殊的CancellationException

CancellationException是特殊的exception, 會被例外處理機制忽略, 即便丟擲也不會向上傳遞, 所以不會取消它的parent.
但是CancellationException不能被catch, 如果它不被丟擲, 其實協程沒有被成功cancel, 還會繼續執行.

CancellationException的透明特性:
如果CancellationException是由內部的其他異常引起的, 它會向上傳遞, 並且把原始的那個異常傳遞上去.

@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception ->
        println("CoroutineExceptionHandler got $exception")
    }
    val job = GlobalScope.launch(handler) {
        val inner = launch { // all this stack of coroutines will get cancelled
            launch {
                launch {
                    throw IOException() // the original exception
                }
            }
        }
        try {
            inner.join()
        } catch (e: CancellationException) {
            println("Rethrowing CancellationException with original cause")
            throw e // cancellation exception is rethrown, yet the original IOException gets to the handler
        }
    }
    job.join()
}

輸出:

Rethrowing CancellationException with original cause
CoroutineExceptionHandler got java.io.IOException

這裡Handler拿到的是最原始的IOException.

Further Reading

官方檔案:

Android官方檔案上連結的部落格和視訊:

其他: