iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Software Development

異步程式設計修煉:Kotlin Coroutines 完全指南系列 第 19 篇

Day 18:小結:協程的執行環境與例外治理工具箱總覽

  • 分享至 

  • xImage
  •  

Day 17:try-catch 在協程裡為什麼有時候沒用 結尾提到,從 Dispatchers 談到那一天,這個階段已經一路建立起協程執行環境與例外治理的主要工具。今天要做的,就是把這些工具放在同一個情境裡走一次。

這六天其實一直在補齊同一個問題

從 Day 12:Dispatchers,協程實際跑在哪條執行緒上 到 Day 17:try-catch 在協程裡為什麼有時候沒用,陸續定案了 Dispatchers、Coroutine Context、withContext、SupervisorJob 與例外隔離、CoroutineExceptionHandler,加上 Day 17 對 try-catch 誤區的具體示範。

表面上是六個不同的主題,六天分別學了六件事,但它們其實一直圍繞著同一個問題:協程實際在哪裡執行,以及執行失敗時該如何被妥善治理。

今天不會出現任何新的英文名詞,也不會定案任何新規則。唯一的任務,是把這六天累積下來的內容放進同一個情境裡完整走一次,讓這些機制彼此搭配的樣子被具體看見,並且跟 Day 11:Scope、Structured Concurrency、Job、取消如何協同運作 已經整合過的階段二內容銜接起來。

六個工具,各自負責什麼

進入整合情境之前,先用最精簡的方式把六個角色的定位排一次隊,不重新展開任何一項的完整定義,只確認分工。

Dispatchers 負責決定協程實際運行在哪一種執行緒資源上,回答「這個協程跑在哪」,這是 Day 12:Dispatchers,協程實際跑在哪條執行緒上 定案的錨點。

Coroutine Context 負責把 Dispatcher、Job 等各自獨立的設定打包成協程隨身攜帶的一包環境設定,回答「這些設定是怎麼一起被攜帶的」,這是 Day 13:Coroutine Context,協程隨身攜帶的那包設定 定案的錨點。

withContext 負責在同一個協程內部動態切換這包設定裡的 Dispatcher,回答「執行到一半能不能換個環境繼續跑」,這是 Day 14:withContext 切換執行緒,你以為的暫停其實是搬家 定案的錨點。

SupervisorJob 與例外隔離負責決定子協程失敗時要不要向上波及其他兄弟協程,回答「一個協程失敗,其他協程要不要一起死」,這是 Day 15:一個協程失敗,其他協程要不要一起死,SupervisorJob 登場 定案的錨點。

CoroutineExceptionHandler 負責在協程確定失敗、且例外不會再被傳遞或等待時,提供一個最終攔截處理的位置,回答「沒人處理的例外最後去了哪裡」,這是 Day 16:CoroutineExceptionHandler,例外最後會被誰接住 定案的錨點。

Day 17 的誤區判斷提醒讀者,try-catch 的直覺用法在協程情境下有其限制,需要理解協程如何暫停、如何被取消、例外如何傳播,才能正確判斷該包覆的範圍,回答「我熟悉的例外處理工具在這裡還適用嗎,適用在哪裡」。

這一節只做角色複習,讀者若對細節生疏,回頭參照對應天數即可,今天不重複教學。

放進同一個情境,一次看完整套工具箱運作

延續一路使用的多來源圖片下載與聚合工具,今天把情境加厚成一個更完整的版本,同時容納幾件事:

  • 多個來源平行下載
  • 各自指定合適的 Dispatcher
  • 下載完成後切換到另一個 Dispatcher 做資料處理
  • 容許部分來源失敗而不波及其他來源
  • 失敗訊息最終被統一記錄下來

任務啟動時,第一個上場的是 Dispatchers。三個來源的下載子協程,本質是等待網路回應,依 Day 12 的判斷邏輯,適合指定 Dispatchers.IO。這個指定不是憑空生效的,它被打包進每個子協程攜帶的 Coroutine Context 裡,這是第二個上場的角色,Dispatcher、Job 等設定從啟動那一刻起就以一個整體的形式跟著協程走。

suspend fun aggregateAndProcess(urls: List<String>): List<ProcessedImage> =
    supervisorScope {
        val deferredResults = urls.map { url ->
            async(Dispatchers.IO) {
                val rawData = downloadImage(url) // 等待網路回應,交給 IO
                withContext(Dispatchers.Default) {
                    processImageData(rawData) // 換到 Default 做運算密集處理
                }
            }
        }
        deferredResults.mapNotNull {
            try {
                it.await()
            } catch (e: Exception) {
                null
            }
        }
    }

下載完成後,若還要對圖片資料做格式轉換這類運算密集處理,第三個角色 withContext 登場,讓同一個下載子協程不需要拆出新的身份,就能暫時搬到 Dispatchers.Default 把這段運算跑完,再自然搬回原本的環境。這個切換能夠平順發生,靠的正是第二個角色打下的基礎,withContext 傳入的新 Context 只覆寫了 Dispatcher 這一項,子協程在父子結構裡的位置完全沒有變動。

接下來換失敗登場。假設第二個來源逾時,第四個角色 SupervisorJob 決定了這個失敗會怎麼被對待。整個聚合任務包在 supervisorScope 底下,代表這裡使用的是 SupervisorJob,第二個下載子協程的失敗只停在它自己身上,不會沿著 Job 樹往上通知父範圍、再往下擴散取消,另外兩個來源的下載與後續處理完全不受影響,照常跑到完成。

但失敗不會就此消失。第二個來源那個失敗訊息,需要一個地方被妥善接住,這正是第五個角色 CoroutineExceptionHandler 該發揮作用的地方。

這裡要提醒一個範圍限制:CoroutineExceptionHandler 是否生效,判準在於這個協程是不是最外層、或直接掛載 handler 的協程,而不只是有沒有被等待結果,子協程的例外若還有父協程存在,會先委派給父協程處理。如果聚合流程裡另外還有一個負責背景記錄下載嘗試紀錄的協程,用 launch 啟動且沒有任何地方等待它的結果,這類協程適合掛載 CoroutineExceptionHandler 作為最終的錯誤記錄手段:

val handler = CoroutineExceptionHandler { _, exception ->
    logDownloadFailure(exception)
}

val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + handler)

scope.launch {
    recordDownloadHistory(url) // 純粹記錄,沒有任何地方等待這個協程的結果
}

至於上面聚合流程裡那段 try { it.await() } catch (e: Exception) { null },正是第六個角色該檢視的地方。這裡的 try-catch 包住的是 await() 這個真正會重新拋出例外的呼叫,屬於 Day 17 對照過的合適用法,捕捉範圍剛好對應例外實際浮現的位置。但如果開發者不小心把捕捉範圍放寬到整段下載邏輯,或者反過來只包住啟動 async 那一瞬間,就會分別踩到誤吞取消例外、或包錯範圍攔不到任何東西這兩個誤區。每一次寫下 try-catch,都值得停下來想一想,這段程式碼實際攔住的,到底是不是例外真正會被拋出的那個位置。

走完這整段流程,值得回頭呼應 Day 11:小結:Scope、Structured Concurrency、Job、取消如何協同運作 已經整合過的內容。這些下載子協程,依然活在 supervisorScope 劃出的明確邊界裡,跟父範圍之間依然是清楚的父子收斂關係,這件事完全沒有改變。差別在於,現在這套關係之上,又疊加了一層新的規則:每個協程實際跑在哪個 Dispatcher、執行到一半能不能切換環境、失敗要不要向上波及、沒被處理的例外去了哪裡。兩個階段的整合內容,是同一套心智模型的不同層次,而非互不相關的兩份清單。階段二回答的是協程怎麼被組織起來、怎麼收斂,階段三回答的是這個已經被組織好的協程,實際在什麼環境下執行、遇到失敗時怎麼被治理。

收斂成一份可以隨手取用的工具箱

把六天的內容走完一遍情境之後,可以收斂成一組寫協程程式碼時隨手可以自問的檢核方向。

這個協程的操作性質,適合哪一種 Dispatcher,是在等待、還是在運算。如果執行到一半需要換執行環境,該用 withContext 完成,還是誤以為只能重啟一個子協程。這群子協程之間的失敗,應該互相牽連,還是各自獨立,這決定了要不要引入 SupervisorJob。沒被其他機制處理掉的例外,有沒有一個統一掛載的 CoroutineExceptionHandler 會接住它。用 try-catch 包覆例外的時候,有沒有意識到協程的取消也仰賴例外傳播,包覆的範圍是不是真的對應到例外實際拋出的那個位置。

這份工具箱不是用來死記的規則清單,每次撰寫涉及協程執行環境或例外處理的程式碼時,都可以回頭套用這套思考順序。五個問題問過一輪,通常就能判斷這段程式碼在環境選用與失敗治理上,有沒有留下破綻。

接下來要問的是,如果任務不只回傳一個值呢

回頭檢視系列至今出現過的所有任務範例,下載一張圖片,或者聚合多個來源的結果,本質上都是執行一次、拿到一個結果的形式。即使中間經歷了暫停、取消、切換執行環境、例外處理,這些機制服務的最終目的,都是讓協程順利跑完一趟,交出單一一份結果。

這裡浮現一個目前為止完全沒有工具能優雅回答的新問題。假設一個任務的本質,需要隨著時間持續、依序回報多個值,而不是跑完一次就結束。舉例來說,一個下載任務如果不只是回報「完成了」這一個結果,而是要持續回報「目前下載到百分之多少」這種連續進度,前面六天建立的所有工具,Dispatchers 也好、SupervisorJob 也好,都是為了讓一次性的任務被妥善執行與治理而設計,沒有一個是為了持續產生多個值這種情境準備的。

這個問題會把系列帶進下一個階段,正式進入建立在協程之上的一種新型別,專門處理持續、依序發送多個值的資料流。今天先把這個命題本身立起來,具體怎麼設計、怎麼使用,留到下一篇正式展開。


上一篇
Day 17:try-catch 在協程裡為什麼有時候沒用
系列文
異步程式設計修煉:Kotlin Coroutines 完全指南 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言