Day 15:一個協程失敗,其他協程要不要一起死,SupervisorJob 登場 結尾留下一個疑問:知道了 SupervisorJob 能讓一個子協程的失敗不波及其他兄弟協程,那個失敗的例外訊息本身,如果沒有人主動處理,最終究竟去了哪裡。今天要正式回答這個問題。
Day 15 講清楚了隔離失敗跟解決失敗是兩件不同的事,SupervisorJob 只負責前者,不負責後者。那個失敗訊息如果沒人接手,總不能就這樣憑空消失。Kotlin 協程確實提供了一個可以掛載在協程上下文中、專門用來攔截未被捕捉例外的最終處理機制,這就是 CoroutineExceptionHandler。這個詞從今天起正式定案為系列既定錨點,全系列後續談到例外治理時,一律沿用這個英文詞,不另外翻譯。
聽到「最終處理機制」這幾個字,很容易以為又要學一個全新的獨立系統。事實不是這樣。Day 13:Coroutine Context,協程隨身攜帶的那包設定 已經定案,協程攜帶的其實是一整組可以組合、可以互相覆寫的 Coroutine Context,Dispatcher 與 Job 都只是這組集合裡各自獨立的元素。CoroutineExceptionHandler 正是可以被放進這包設定裡的另一種元素,跟 Dispatcher 或 Job 一樣,可以在啟動協程時用同一個組合運算子一併帶進去:
val handler = CoroutineExceptionHandler { context, exception ->
println("捕捉到未處理例外:$exception,來自 $context")
}
val scope = CoroutineScope(Dispatchers.IO + handler)
CoroutineExceptionHandler 並非獨立於協程執行環境之外的額外機制,它是 Context 這包設定裡新增的其中一種元素,跟著協程一起被攜帶、一起被組合、一起被繼承。
角色定位也可以先講清楚一半。當一個協程最終確定要因為例外而失敗,而且這個失敗不會再被其他機制攔截或等待,掛載在 Context 裡的 CoroutineExceptionHandler 就會被呼叫,拿到這個例外做最後的處理,例如記錄一筆錯誤訊息。至於「確定不會再被攔截或等待」具體是什麼意思,正是下一節要誠實面對的重點。
這是今天最核心,也最需要誠實面對的部分。
CoroutineExceptionHandler 主要攔截的是那些已經確定不會再被向上傳遞、也不會再被其他協程等待結果的例外。典型情境是透過 launch 啟動、而且沒有任何地方會呼叫等待它結果的協程。一旦這種協程內部拋出未被處理的例外,這個例外就會被系統視為最終失敗,交給 CoroutineExceptionHandler 處理,開發者不需要另外寫任何攔截程式碼。
val handler = CoroutineExceptionHandler { _, exception ->
logDownloadFailure(exception)
}
val scope = CoroutineScope(SupervisorJob() + handler)
scope.launch {
recordDownloadHistory(url) // 純粹記錄,沒有任何地方等待這個協程的結果
}
這裡要誠實提醒一個重要限制。如果協程是透過 async 啟動、而且結果會被等待,這種情境下產生的例外,通常會在等待結果的那個地方被拋出,而不是直接交給 CoroutineExceptionHandler 處理。這裡先建立這個限制存在的方向性認識就好,不展開所有情境組合的完整判斷規則,避免流於條列規則失去重點。
還有一個範圍需要先講清楚,避免誤會被過度延伸。今天討論的「是否被等待結果」這個判準,範圍限於最外層、或直接掛載 CoroutineExceptionHandler 的那個協程本身。如果一個協程本身是另一個協程的子協程,即使它自己沒有被 .join() 或 .await() 等待,它的例外仍然會先委派給父協程處理,不會直接交給自己身上掛的 handler,這是巢狀 launch 情境下的實際判準,今天先點出這個範圍限制,不展開完整的巢狀情境規則。
把這個限制具體放回多來源圖片下載與聚合工具的情境會更好理解。假設系統裡有一個協程,純粹負責在背景記錄每一次下載的嘗試紀錄,這個協程沒有任何地方會等待它的結果,這類協程就適合掛載 CoroutineExceptionHandler 作為最終的錯誤記錄手段,一旦寫入紀錄時發生例外,系統會自動把它接住並記下來。但如果是負責下載某一個來源、下載結果會被其他協程等待並拿去聚合的協程,這種協程發生例外時的處理方式,就不能只依賴 CoroutineExceptionHandler,因為這個例外很可能根本不會走到那裡,而是直接出現在等待結果的那一端。
可以借用一個比喻幫助建立直覺:CoroutineExceptionHandler 有點像大樓最外層的總機,只接聽那些沒有被任何分機接起的來電,分機自己接起的電話,總機完全不會插手。這個比喻只是輔助理解攔截範圍的方向,實際運作仍要回到「例外會不會被等待」這個判斷標準本身。
除了知道它攔截什麼,實務上也常有人疑惑該把它掛在哪個位置。簡單提醒一個方向:CoroutineExceptionHandler 通常掛載在最外層、負責統籌整個範圍的協程或 Scope 上,會比掛載在個別子協程上更有意義。原因不難理解,它的目的是作為最終的安全網,統一接住這個範圍內沒有被其他機制處理掉的例外,而不是要求每個子協程都各自重複設定一次。
這裡只建立這個方向性建議,不展開完整的掛載規則與所有例外情境的組合,保持概念層級即可。真正動手設計一個系統的例外治理策略時,還有更多細節需要考量,但那已經超出今天要建立的心智模型範圍。
今天正式定案了 CoroutineExceptionHandler,它是掛載在 Coroutine Context 裡的一種元素,用來接住那些確定不會再被傳遞或等待的例外,典型情境是沒有人等待結果的 launch 協程。同時也誠實建立了它的攔截限制,async 且結果會被等待的協程,例外通常改由等待結果的那一端接手,它並非任何情境下都會生效的萬用安全網。
這裡浮現一個新的疑問。既然 CoroutineExceptionHandler 只在特定情境下才會被呼叫,並非萬用機制,那讀者原本在一般同步程式設計裡用得很熟練的 try-catch,寫進協程程式碼裡,是不是就能安心攔住所有例外?這個疑問今天先留在這裡,不急著給答案。
《Day 17:try-catch 在協程裡為什麼有時候沒用》 會用具體的示範情境揭曉這個問題的答案:try-catch 在協程裡,有時候為什麼會沒用。