上一章看的是怎麼把單一任務的例外攔下來,避免影響其他兄弟。這一章換個方向:當例外沒有被攔下來、或是我們本來就想透過取消(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