上一章看的是怎麼把單一任務的例外攔下來,避免影響其他兄弟。這一章換個方向:當例外沒有被攔下來、或是我們本來就想透過取消(Cancellation)一次把多個任務停掉時,結構化併發底下會發生什麼事?
例外帶動的批次取消#
回到 Day 10 那個父 Coroutine 帶兩個子 Coroutine 的範例:
fun launchExceptionFun1() {
val job = launch {
launch {
try {
delay(Long.MAX_VALUE)
} finally {
println("First children are cancelled")
}
}
launch {
delay(100)
println("Second child throws an exception")
throw RuntimeException()
}
}
job.join()
}當第二個子 Coroutine 拋出 RuntimeException 後:
- 父 Coroutine 的 Job 接到例外,把同層其他子 Coroutine 一起取消。
- 第一個子 Coroutine 正在
delay(Long.MAX_VALUE)等待,被取消後會走進finally,印出First children are cancelled。
換句話說,Job 管理的範圍內,只要有一個 Coroutine 出錯,其他還沒完成的兄弟就會一併被取消。對於整批工作「缺一不可」的情境,這個行為其實是想要的:既然其中一步壞了,繼續跑剩下的也沒意義。
CoroutineExceptionHandler 統一處理未攔截的例外#
例外往父層傳上去之後,最終會落到 CoroutineExceptionHandler。這也是 Coroutine context 的一個 Element:
class Day10 {
private val coroutineExceptionHandler = CoroutineExceptionHandler { _, exception ->
println("CoroutineExceptionHandler got $exception")
}
private val scope = CoroutineScope(coroutineExceptionHandler)
suspend fun launchWithException() {
val job = scope.launch {
launch {
try {
delay(Long.MAX_VALUE)
} finally {
println("First children are cancelled")
}
}
launch {
delay(100)
println("Second child throws an exception")
throw RuntimeException()
}
}
job.join()
}
}CoroutineExceptionHandler 既是介面、也是同名的工廠函式。實作大致長這樣:
public inline fun CoroutineExceptionHandler(
crossinline handler: (CoroutineContext, Throwable) -> Unit
): CoroutineExceptionHandler =
object : AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler {
override fun handleException(context: CoroutineContext, exception: Throwable) =
handler.invoke(context, exception)
}scope 中發生未被攔截的例外,會經由 handleException 傳入 handler,讓我們做集中處理。
launch 與 async 的差異#
CoroutineExceptionHandler 並不是萬用的攔截器:
- launch(與 actor)裡未被攔截的例外,會往父 Coroutine 傳,最終由 CoroutineExceptionHandler 收。
- async(與 produce)的例外不會走這條路,而是延遲到呼叫
await()(或對應的取值方式)時拋給呼叫端。
舉例:
suspend fun asyncException(): Int {
val deferred1 = scope.async {
delay(100)
10
}
val deferred2 = scope.async {
delay(200)
throw RuntimeException("Incorrect")
}
return deferred1.await() + deferred2.await()
}執行時,CoroutineExceptionHandler 不會收到這個 RuntimeException。處理方式是把呼叫端用 try-catch 包起來:
fun main() = runBlocking {
val day10 = Day10()
try {
val result = day10.asyncException()
println("$result")
} catch (e: RuntimeException) {
println("${e.message}")
}
}這裡的差別根源於 launch 與 async 對「結果」的態度:launch 沒有結果,例外只能向上傳;async 的結果在 Deferred 裡,例外就附在結果上,由取值的人接手。理解這個差別後,例外要在哪裡 try-catch 就比較清楚了。
如何避免「整批被取消」#
如果不希望某個任務出錯就把整批都帶下水,可以從幾個方向處理:
- 在可能出錯的地方就近用 try-catch 攔下來(等同於把例外消化掉)。
- 對 async 的結果用 try-catch 包住
await(),這樣只有取值的呼叫端會看到例外。 - 換成 SupervisorJob:讓兄弟之間的失敗彼此獨立,不會因為一個出錯就連累其他。
最後一種留到下一章詳細介紹。
小結#
- 在 Job() 管理的 scope 中,未被攔截的例外會把同層其他兄弟一起取消,呈現「批次取消」的效果。
- CoroutineExceptionHandler 是 Coroutine context 的一個 Element,能集中處理 launch 內未被攔截的例外。
- async 的例外不會走 CoroutineExceptionHandler,必須在呼叫
await()的地方用 try-catch。
原文出處#
- 原書/iThome:https://ithelp.ithome.com.tw/articles/10264460