iT邦幫忙

2026 iThome 鐵人賽

DAY 29
0
Software Development

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

Day 28:協程常見陷阱總盤點,這些坑我們都踩過

  • 分享至 

  • xImage
  •  

Day 27:收斂進一個具名專案,圖片下載工具完整版,順便看看 Android 與 Ktor 怎麼用 結尾留下一個疑問,圖片下載工具完整版示範的都是正確組合起來的樣子,但實務開發中,是不是也存在一些看起來能動、卻埋著隱患的寫法。今天正面回答這個問題。

回顧,正確用法看完了,換個角度看問題

Day 27 把前面 26 天的觀念收斂進同一個具名專案,平行下載乾淨地收斂、例外隔離確實生效、測試快速又可靠,看起來已經是一份完整的正面示範。但正面示範容易讓人誤以為現實世界的程式碼也都長這樣,真實情況往往不是這樣。

今天要盤點的,是協程開發中幾類最容易反覆出現的陷阱。這些陷阱不是新機制,也不需要學新的 API,每一個都會對照它具體違反了系列前面哪一天定案的哪一條規則。這麼做的目的很明確,讀者看完今天的內容,不該只是記住「不要這樣寫」這幾個字,而是要能指出這樣寫錯在哪個心智模型的環節上。這正是本篇刻意採取反面角度的原因,也是整個系列走到這個階段該具備的能力,看到問題程式碼的當下,知道它踩中了系列哪一條已經建立好的規則。

陷阱一,隨手用 GlobalScope 啟動協程

先看第一類陷阱的典型樣貌。假設在圖片下載工具完整版裡,某個畫面想在使用者點擊某張縮圖時,順手多下載一份高解析度版本供稍後使用,開發者圖方便,隨手寫了這樣的程式碼:

fun onThumbnailClicked(url: String) {
    GlobalScope.launch {
        downloadHighResVersion(url)
    }
}

這段程式碼能動,畫面點下去,高解析度版本確實在背景默默下載完成。問題不在「它能不能動」,問題在於這個協程被啟動的方式,從一開始就注定脫離了任何有意義的收斂關係。

回到 Day 06:Coroutine Scope,協程住在哪裡、活多久 定案的核心心智模型,每一個協程都必須歸屬於某個 Coroutine Scope,Scope 結束,協程也該跟著結束。GlobalScope 這個名字本身就說明了問題,它是一個貫穿整個應用程式生命週期的範圍,幾乎等於沒有範圍。用它啟動的協程,不會因為畫面關閉、請求結束而跟著收斂,因為根本沒有任何一個有意義的父範圍會在恰當的時機把它一併清理掉。

再往下看,Day 07:Structured Concurrency,為什麼協程不能亂長亂放 定案的結構化並發規則講得更明確,協程之間必須維持明確的父子關係,一個協程的存在應該能被某個更上層的範圍追蹤與管理。用 GlobalScope 啟動的協程完全不具備這種父子關係,它是一個孤兒,畫面早就銷毀了,這個下載任務卻可能還在背景繼續佔用網路連線與運算資源,沒有人知道它什麼時候該結束,也沒有人能一次把它連同畫面的其他資源一起收斂掉。

正確的方向不難理解,協程應該啟動在與它邏輯上相關聯的 Scope 之下,讓它的生命週期自然跟隨這個範圍的結束而收斂。如果這個下載任務邏輯上屬於這個畫面,就該用這個畫面對應的 Scope 啟動它;如果它其實是一個獨立於畫面之外、系統層級的背景任務,也該有一個明確代表這類任務生命週期的 Scope 來管理,而不是丟給一個等同於沒有邊界的全域範圍。這裡只需要指出方向,Coroutine Scope 該怎麼建構、怎麼綁定生命週期,Day 6 已經完整交代過,不再重講一次。

陷阱二,忘記結構化,協程長成一片各自為政的散兵

第二類陷阱藏得比第一類更隱蔽,因為它通常不會出現在單一協程身上,而是出現在多個協程彼此的關係裡。回到圖片下載工具完整版的多來源平行下載邏輯,假設有人這樣寫:

fun downloadAllLoosely(urls: List<String>) {
    urls.forEach { url ->
        CoroutineScope(Dispatchers.IO).launch {
            downloadOne(url)
        }
    }
}

表面上看,這段程式碼確實達成了平行下載的效果,每個來源都各自啟動一個協程,並行執行,速度也不差。問題出在每一次呼叫 CoroutineScope(Dispatchers.IO),都建立了一個全新、互不相干的範圍,這些協程之間沒有任何共同的父 Scope,也就沒有 Day 09:Job,協程生命週期的控制把手 定案的那種父子 Job 關係。它們是一群各自獨立存在的散兵,剛好在同一時間點被啟動,僅此而已。

這種寫法的代價,要等到需要整體取消的那一刻才會現形。使用者中途按下取消整次下載,理想情況下應該有一個共同的父 Job,取消要求只要沿著這棵 Job 樹往下傳遞,所有子協程就會一併收到取消訊號而結束執行,這正是 Day 7 建立的結構化並發規則、以及 Day 9 講清楚的 Job 樹追蹤機制原本該提供的保證。但這裡沒有這樣一棵樹,開發者被迫得自己另外維護一份清單,手動記下每一個協程各自的 Job 或某種控制把手,取消時得逐一走訪這份清單,一個一個發出取消要求,稍有遺漏,就有協程繼續在背景默默跑著沒人管。

正確的方向,是讓這些平行下載的協程都在同一個父 Scope 之下,用 coroutineScope 或 supervisorScope 這類建構方式啟動,讓它們自然形成一棵以這次下載任務為根的 Job 樹。往後不管是要整體取消,還是要等待所有來源都完成,都能依賴這棵樹狀結構一次到位,不需要開發者自己額外維護一份平行清單。這裡同樣只指出方向,Job 樹具體如何被建立與追蹤,Day 9 已經拆解得很清楚。

陷阱三,例外被悄悄吞掉,錯誤發生了卻沒人知道

第三類陷阱換了個機制,但破壞力一點也不小,而且這次不需要另外假想一個新函式,直接看 downloadOne 本身就有反面示範的空間。Day 27 定案的版本,catch 依序區分了 CancellationException、TimeoutCancellationException、一般 Exception 三段,各自對應不同的處理方式。假設當初圖方便,沒有做這層區分,而是把 downloadOne 寫成這樣:

private suspend fun downloadOne(url: String): DownloadResult =
    try {
        withTimeout(5_000) {
            val raw = fetchBytes(url)
            withContext(cpuDispatcher) { decode(raw) }
        }
        DownloadResult.Success(url)
    } catch (e: Exception) {
        DownloadResult.Failure(url, e)
    }

只看回傳型別,這個版本跟定案版本一樣都是 DownloadResult,downloadAll 呼叫起來也不會出現任何編譯錯誤,某個來源逾時或下載失敗時,一樣會得到一筆 DownloadResult.Failure,聚合結果看起來完全正常。問題出在這個籠統的 catch (e: Exception) 同時接住了兩種性質完全不同的例外,卻用同一種方式處理。

第一個問題,CancellationException 也會落入這一段 catch。真正的重點在於,協程的取消終究是靠在可取消點拋出 CancellationException、讓它沿著呼叫堆疊往上傳播才完成的,Day 10:協程取消不是強制中斷,是一場合作 已經定案過這個機制。一旦 downloadAll 呼叫端決定取消整次下載,這個取消訊號傳到 downloadOne 內部時,會被這裡的 catch (e: Exception) 攔下來,包裝成一筆 DownloadResult.Failure 後若無其事地回傳,沒有照規則重新拋出。取消訊號就此斷在這一層,父層完全感知不到這個子協程其實已經被取消,supervisorScope 也就等不到它該有的收斂行為。

第二個問題,即使排除取消訊號不談,TimeoutCancellationException 與 fetchBytes 或 decode 拋出的一般例外,也被這段 catch 用同一種粗淺的方式接住,全部轉換成型態相同的 DownloadResult.Failure。表面上看,兩者都成功轉換成失敗結果,沒有程式當掉,但逾時、網路錯誤、解碼失敗背後代表的是完全不同的成因,一旦全部用同一段程式碼籠統接住,往後想從記錄或監控中回頭分辨究竟是哪一種失敗,會發現這些差異早在這一層就被抹平,真正的失敗訊息其實是被悄悄蓋掉了。

Day 17:try-catch 在協程裡為什麼有時候沒用 已經點出這個誤區的本質,範圍過大的 try-catch 除了掩蓋掉真正的失敗原因,還可能意外捕捉到協程取消時依賴的例外。Day 27 定案的三段式 catch 之所以刻意把 CancellationException 獨立放在最前面、只做 throw e,正是為了避免這裡示範的問題重演。再往前對照 Day 16:CoroutineExceptionHandler,例外最後會被誰接住 定案的分工,例外處理原本就不該是一句籠統的 catch 打天下。該向上傳播、讓呼叫端決定如何應對的例外,要讓它能夠傳播;該被明確轉換成失敗結果的例外,也該依照成因分開處理,而不是每個下載函式都各自寫一段又寬又淺的 try-catch,把所有例外一視同仁地吞掉。正確的方向,就是 downloadOne 目前定案的那三段式寫法,這裡不重新展開完整的例外治理教學,Day 16 與 Day 17 已經把該講的都講過了。

這幾類陷阱背後,其實是同一件事沒做到

三類陷阱講完,很容易把它們當成三條各自獨立的注意事項死記下來,但這樣理解會錯過真正重要的東西。GlobalScope 濫用動到的是 Scope,忘記結構化動到的是 Job,例外吞噬動到的是例外傳播路徑,三個機制表面上互不相干,背後卻指向同一件事。開發者為了圖一時方便,讓某段協程程式碼脫離了系列一路強調的結構化並發框架,不管是脫離明確的生命週期範圍、脫離父子收斂關係,還是讓例外的傳播路徑被不當截斷,本質上都是在破壞同一套設計精神。

這裡可以借用一個比喻,結構化並發像是一棟建築物的地基,Scope 決定地基劃在哪個範圍,Job 樹決定樑柱之間怎麼互相支撐,例外傳播路徑決定出了狀況時警報訊號怎麼一路傳到看得到的地方。地基蓋歪了,上面裝潢得再漂亮,遇到地震還是最先出問題的那一戶。今天看到的三類陷阱,都是在地基上偷工減料的結果,平時看不出異狀,一旦遇到取消、失敗這類真正的考驗,代價就會一次浮現。

有了這個共通點,實務上遇到協程程式碼,可以自問三個方向。這個協程有沒有明確隸屬於某個有意義的 Scope,而不是掛在一個貫穿全域的範圍下。如果需要整體取消或等待多個協程完成,這些協程之間有沒有建立起該有的父子關係,還是各自為政。例外發生時,這段程式碼是讓它該傳播的傳播、該記錄的記錄,還是籠統地一口吞掉。這三個問題本身不需要死背,只要記得結構化並發這個核心精神,自然就能推導出來。

結尾,知道了這些坑,一個完整的專案要怎麼確保沒踩中

今天盤點的三類陷阱,GlobalScope 濫用、忘記結構化、例外被悄悄吞掉,各自看起來都不難避免,甚至看完今天的內容,可能會覺得只要多留意一下就好。但一個具備多來源平行下載、資料流回報進度、例外處理這些真實複雜度的完整專案,要怎麼確保自己從頭到尾都沒有不小心踩中任何一個,光是逐段檢查程式碼夠嗎,還是需要一次端到端的完整驗證。

這個疑問今天先留在這裡。下一篇要做的,正是對圖片下載工具完整版進行一次全系列整合驗收,跑一次完整的多來源下載情境,看看前面 27 天累積下來的一切,擺在一起能不能真正撐得住。


上一篇
Day 27:收斂進一個具名專案,圖片下載工具完整版,順便看看 Android 與 Ktor 怎麼用
下一篇
Day 29:全系列整合驗收,跑一次完整的多來源下載情境
系列文
異步程式設計修煉:Kotlin Coroutines 完全指南 共 31 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言