iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Software Development

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

Day 17:try-catch 在協程裡為什麼有時候沒用

  • 分享至 

  • xImage
  •  

Day 16:CoroutineExceptionHandler,例外最後會被誰接住 結尾留下一個疑問:既然 CoroutineExceptionHandler 只在特定情境下才會被呼叫,並非萬用機制,那讀者原本在一般同步程式設計裡用得很熟練的 try-catch,寫進協程程式碼裡,是不是就能安心攔住所有例外。今天要正面回答這個問題。

try-catch 真的能安心用嗎

Day 16 已經誠實講清楚,CoroutineExceptionHandler 不是任何情境下都會生效的安全網,它只接住那些確定不會再被傳遞或等待的例外。這個限制留下一個很自然的追問,那讀者手邊最熟悉的 try-catch,會不會就是那個能補上缺口的工具。

答案沒有那麼單純。try-catch 本身依然有效,語法運作方式跟寫在一般同步程式碼裡完全相同,這一點不需要懷疑。問題出在協程世界裡有幾個容易被忽略的行為,會讓開發者誤以為 try-catch 失效,或者用錯了方式包覆它,導致意料之外的結果。今天就用兩個具體案例,逐一拆解這些容易踩雷的落差。

誤區一,把取消例外也一起吞掉了

回到多來源圖片下載與聚合工具。假設某個下載子協程內部,用 try-catch 包住整段下載邏輯,目的是捕捉網路連線失敗之類的例外,順手記一筆錯誤訊息:

val job = launch {
    try {
        val bytes = downloadImage(url)
        println("下載完成:${bytes.size} bytes")
    } catch (e: Exception) {
        logDownloadFailure(e)
    }
}

乍看之下沒什麼問題,捕捉例外、記錄錯誤,很像平常寫同步程式碼時的標準動作。但這裡的 catch (e: Exception) 接住的範圍太寬鬆了。當使用者中途取消整批下載,downloadImage 這個可取消點會依照 Day 10:協程取消不是強制中斷,是一場合作 定案的機制,透過拋出一個特定例外中止當下的執行流程。這個例外一路往上傳播時,會先經過這段 catch 區塊,而 Exception 這個型別涵蓋的範圍夠廣,剛好也把它接住了。如果 catch 區塊只是記個訊息,沒有重新拋出,這個例外就這樣被吞掉,不再繼續往上傳遞。

後果是協程明明已經被要求取消,卻沒有真正結束,執行流程反而繼續往下走,彷彿什麼事都沒發生過。

這正是 Day 10 建立的心智模型被意外破壞的具體案例。取消要求發出之後,協程本身並不會立刻停止,真正結束執行仰賴的是在可取消點拋出例外、讓這個例外沿著呼叫堆疊往上傳播,把還沒執行完的程式碼收乾淨。開發者用 try-catch 把這個例外攔下來卻沒有重新拋出,等於意外阻斷了取消訊號原本該有的傳遞路徑。協程沒有被強制中斷這回事,它靠的完全是例外傳播這條路,一旦這條路被半路攔截,結構化並發框架賴以運作的收斂保證也就跟著失效。

這裡要澄清一件事,問題不在於 try-catch 這個語法結構本身。開發者在使用時,沒有意識到協程的取消機制也是建立在例外傳播之上運作,才是問題所在。

正確的因應方向,是在捕捉例外時,先意識到協程執行環境裡可能出現的取消例外是特殊的一類,需要讓它繼續往上傳遞,而不是跟其他一般業務邏輯的錯誤混在一起處理。具體要用什麼型別判斷、寫成什麼語法,今天先不展開,重點是建立這個方向性的警覺。

誤區二,try 包住的範圍剛好漏掉了真正拋出例外的地方

第二個誤區的性質跟第一個相反,第一個是攔太多,這個則是根本沒攔到。同樣回到多來源圖片下載與聚合工具,假設開發者這樣寫:

try {
    scope.launch {
        val bytes = downloadImage(url)
        println("下載完成:${bytes.size} bytes")
    }
} catch (e: Exception) {
    logDownloadFailure(e)
}

這段程式碼的 try 區塊,包住的其實是「啟動一個下載子協程」這個呼叫本身。launch 這個呼叫幾乎立刻就會回傳,協程真正開始執行下載邏輯,是在稍後被排程執行的時候,早已離開了這段 try 區塊的執行範圍。如果 downloadImage 之後真的拋出例外,那個時間點,try-catch 早就執行完畢、退出作用範圍了,這段 catch 邏輯完全攔不到任何東西,例外會直接沿著這個子協程自己的路徑繼續往上走。

這正是 Day 16:CoroutineExceptionHandler,例外最後會被誰接住 建立的攔截限制,在這個具體情境裡的呈現。這裡討論的範圍同樣限於這個 launch 子協程本身沒有再被其他協程包住的情況;用 launch 啟動、且沒有被等待結果的協程,發生例外時真正該倚賴的,是掛載在 Context 裡的 CoroutineExceptionHandler,而不是在啟動它的那個地方外面包一層 try-catch。把這個限制換個角度講,就是今天這個誤區的樣貌,開發者以為自己的 try-catch 涵蓋了下載邏輯,實際上它只涵蓋了「啟動」這個瞬間,兩者之間隔著協程暫停與恢復的執行時機落差。

對照另一種情境會更清楚。如果這個協程是用 async 啟動、而且結果會被等待,情況就不一樣了:

val deferred = scope.async {
    downloadImage(url)
}

try {
    val bytes = deferred.await()
} catch (e: Exception) {
    logDownloadFailure(e)
}

這裡 try-catch 包住的是 await() 這個呼叫,例外正是在這裡被重新拋出的地方,所以才真正有機會被攔到。這裡只需要建立這個概念性對照,不需要展開完整的程式碼細節,重點是體會到同樣是 try-catch,包住的呼叫不同,實際攔截效果可以天差地遠。

一個簡明的判斷方向,而非死記規則

看完兩個誤區,很容易得出「協程裡的例外處理陷阱特別多」這種印象,但與其死記一條一條的陷阱清單,不如建立一個可以反覆套用的判斷方向。

想清楚這段程式碼裡的例外,實際會在哪個時間點、哪個呼叫堆疊上被拋出。try-catch 只能攔住它實際包覆範圍內、且執行流程確實會經過的例外,這件事在同步程式碼裡通常很直覺,程式碼由上往下執行,範圍看得一清二楚。但協程因為存在暫停與恢復,還有不被等待結果的執行路徑,這個「範圍」不一定和開發者直覺以為的程式碼區塊位置一致,誤區二正是最好的例子。

遇到不確定的情境時,可以回頭用 Day 16 建立的判斷,這個協程是否會被等待結果,來決定該依賴 try-catch 還是 CoroutineExceptionHandler。用 async 且結果會被等待,try-catch 包住等待結果的呼叫是合適的選擇。用 launch 且沒有人等待結果,CoroutineExceptionHandler 才是真正會被觸發的機制。兩者各自適用不同情境,實務上一個系統裡經常同時存在,各自負責自己涵蓋得到的那一塊。

例外處理終究離不開協程的取消本質

今天看到的兩個誤區都指向同一件事。協程程式碼裡的例外處理,終究不能脫離協程本身如何暫停、如何被取消、例外如何傳播這些底層行為去理解。單純套用一般同步程式設計對 try-catch 的直覺用法,容易在協程情境下踩坑,誤吞取消例外會讓協程該結束卻沒結束,包錯範圍會讓 catch 區塊形同虛設。

從 Dispatchers 談到今天,這個階段已經一路建立起協程執行環境與例外治理的主要工具。《Day 18:小結:協程的執行環境與例外治理工具箱總覽》 會把這些工具放在一起做個整理。


上一篇
Day 16:CoroutineExceptionHandler,例外最後會被誰接住
下一篇
Day 18:小結:協程的執行環境與例外治理工具箱總覽
系列文
異步程式設計修煉:Kotlin Coroutines 完全指南 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言