《Day 07:Structured Concurrency,為什麼協程不能亂長亂放》 建立了一個標準規則:子協程失敗,預設會向上影響整個父範圍,導致父協程與其他兄弟協程一併結束。這個規則講得很篤定,但留了一句伏筆,這個標準行為是不是永遠成立,有沒有辦法讓某個子協程的失敗不波及其他協程。
今天就來對照這個問題的答案。
Day 7 的核心規則只用一句話就能重述:父範圍結束時,不管是正常完成、被取消,或者發生例外,所有子協程都必須一併結束,子協程失敗預設也算在這個「必須一併結束」的觸發條件之內。
Kotlin 協程提供了兩個內建的 Scope 建構函式,coroutineScope 與 supervisorScope,兩者都遵守結構化並發的父子收斂規則,父範圍結束或被取消,子協程一樣要跟著結束。
差異只出現在一個具體問題上:子協程失敗這件事,要不要向上傳播,連帶取消同一範圍內其他還在進行中的兄弟協程。
今天用同一組下載情境,把這個差異攤開來看。
回到多來源圖片下載與聚合工具。假設目標是把好幾個來源的圖片全部下載回來,湊成一組完整結果才算數,任何一個來源失敗,這次聚合就沒有意義。
suspend fun aggregateDownloadStrict(urls: List<String>) = coroutineScope {
val deferredResults = urls.map { url ->
async { downloadImage(url) }
}
deferredResults.awaitAll()
}
這段程式碼跟 Day 7 出現過的範例幾乎一樣,用 coroutineScope 包住一組平行下載。假設 urls 裡有三個來源,其中第二個網址失效,連線逾時直接拋出例外,coroutineScope 的預設行為是讓這個失敗立刻傳播出去,取消同一範圍內第一個與第三個還在進行中的下載子協程,即使那兩個來源原本可能會順利下載成功。
這個行為背後的設計理由並不難理解。如果最終目的是要聚合所有來源的結果,某一個來源已經確定失敗,整份聚合結果本來就已經不完整,繼續讓其他協程跑下去,換來的成功結果最後也派不上用場,白白消耗網路資源與時間。及早取消,把資源省下來,是更划算的選擇。coroutineScope 這種「要嘛全部成功、要嘛一起失敗」的行為,適合彼此結果互相依賴、任一失敗就讓整體結果失去意義的情境。
這正是 Day 7 建立的標準行為,具體落在一個 API 上的樣子。coroutineScope 沒有做任何額外加工,忠實遵守結構化並發預設的失敗傳播規則,子協程失敗,父範圍跟著收斂,兄弟協程一併結束。
換一個情境。如果把多來源圖片下載與聚合工具的目標改成:即使某些來源下載失敗,仍然希望盡量把成功的來源結果聚合起來呈現給使用者,剛才那組用 coroutineScope 寫成的程式碼就不再適用了。一個來源失敗,會連帶錯殺其他原本會成功的協程,使用者原本可以拿到兩張圖片,結果因為第三個來源逾時,一張都拿不到。
這時候 supervisorScope 是更合適的選擇。
suspend fun aggregateDownloadLenient(urls: List<String>): List<String> = supervisorScope {
val deferredResults = urls.map { url ->
async { downloadImage(url) }
}
deferredResults.mapNotNull {
try {
it.await()
} catch (e: Exception) {
null
}
}
}
同樣是平行下載三個來源,換成 supervisorScope 包裹之後,第二個來源逾時失敗,不會連累第一個與第三個還在進行中的下載子協程,它們各自繼續執行到完成,最後聚合出來的結果只是少了失敗的那一份,而不是全軍覆沒。
這裡有一件事需要特別提醒,避免讀者產生誤解。
supervisorScope 依然是結構化並發框架下的產物,它一樣遵守「父範圍結束或被取消時,所有子協程必須一併結束」這條規則,如果呼叫 aggregateDownloadLenient 的外層協程整個被取消,裡面這三個下載子協程照樣得跟著收尾,這件事沒有任何改變。
唯一不同的地方,只在於子協程失敗這件事本身,不會自動觸發父範圍或其他兄弟協程的取消。這仍然是結構化並發框架內可調整的一種失敗傳播策略,而非脫離這個框架另起爐灶的獨立機制。
至於 supervisorScope 底層具體是靠什麼機制做到「失敗不外溢」這件事,今天先不展開,這裡只建立現象層級的認識,完整機制留到後面幾天再回來處理。
與其死記兩個函式名稱的差異,不如建立一個判斷架構。
面對一組要平行執行的子協程,先問自己一個問題:這些子協程的結果彼此是否互相依賴,任一失敗是否會讓整體結果失去意義?
如果答案是肯定的,選 coroutineScope,讓失敗自然向上傳播,及早收斂整個範圍,不必浪費資源在一份注定不完整的結果上。
如果子協程之間本質上是各自獨立的任務,一個失敗不該影響其他任務的完成,選 supervisorScope,讓每個子協程照自己的步調跑完。
拿聚合下載工具完整對照一次。如果業務需求要求所有來源都必須成功才算完成一次有效的聚合,任何一個來源掛掉都該讓整批取消重來,這時候適合 coroutineScope。如果業務需求容許部分來源失敗,只要把成功的部分呈現出來就有價值,例如相簿功能寧可少一張圖也不要整批空白,這時候適合 supervisorScope。判斷的關鍵始終落在子協程之間的關係上,而不是哪個函式看起來比較新或比較常見。
今天看到 coroutineScope 與 supervisorScope 在失敗傳播行為上的具體差異,一個讓失敗向上蔓延,一個讓失敗留在原地,不打擾兄弟協程。兩者都沒有脫離 Day 7 建立的父子收斂框架,差別純粹在於失敗這件事要不要被當成觸發取消的訊號。
不過這裡留下一個問題,值得追問下去。這個差異的背後,協程之間到底是用什麼具體機制在追蹤彼此的狀態,判斷該不該互相影響?光靠「向上傳播」或「不傳播」這種行為描述,還沒有回答協程內部究竟怎麼記錄「誰是誰的子協程」「目前發生了什麼事」。
明天的《Day 09:Job,協程生命週期的控制把手》 會正式定案這個問題的答案,把協程生命週期背後的控制把手攤開來看。至於 supervisorScope 底層真正依賴的機制,會留到 《Day 15:一個協程失敗,其他協程要不要一起死,SupervisorJob 登場》 正式揭曉。今天先把這個現象層級的對照印象記住,具體的機制留給後面幾天慢慢補齊。