Day 09:Job,協程生命週期的控制把手 結尾留下一個問題:對一個 Job 發出取消要求之後,協程是不是立刻停下。這個問題後來由 Day 10:協程取消不是強制中斷,是一場合作 接手處理。但其實還有一個更早、跨度更長的問題,一路懸在那裡沒有解決。Day 08:coroutineScope 與 supervisorScope,兩種收斂行為的差異 當時只停在現象層級觀察 supervisorScope 為什麼能讓失敗不外溢,並明白說了具體機制留到今天才會揭曉。
今天就是把這筆帳算清楚的日子。
Day 8 用同一組多來源圖片下載與聚合工具,對照出兩種行為。
coroutineScope 底下,只要有一個子協程失敗,這個失敗會向上傳播,連累同一範圍內其他還在下載中的兄弟協程一併取消。
supervisorScope 底下,同樣的失敗只會讓那一個子協程自己結束,其他來源照樣下載到完成。當時只交代了現象,機制部分留了一句話:supervisorScope 底層真正依賴的東西,會在 Day 15 正式揭曉。
答案是 SupervisorJob。supervisorScope 之所以能做到失敗不外溢,關鍵在於它內部使用的 Job 屬於一種特殊類型,SupervisorJob,而非一般的 Job。這個詞從今天起正式定案,全系列後續談到失敗隔離時,一律沿用這個名稱。
要看懂 SupervisorJob 特別在哪裡,得先把一般 Job 的預設行為講清楚,當成對照的基準。
Day 09:Job,協程生命週期的控制把手 已經定案 Job 樹的概念,每啟動一個協程,都會有一個對應的 Job,多個 Job 之間透過父子關係串成一棵樹。一般情況下,當某個子 Job 因為例外而進入失敗狀態,它會主動通知自己的父 Job。父 Job 收到這個通知後,不會置之不理,而是把自己也標記為失敗,並依循 Structured Concurrency 的父子收斂規則,沿著這棵樹往下,要求所有其他子 Job 一併取消。
這整套流程正是 Day 7、Day 8 觀察到的 coroutineScope 標準行為,落在 Job 層級的具體實現。向上通知、再向下擴散取消,這個過程是一般 Job 之間父子關係預設內建的行為,開發者不需要額外撰寫任何程式碼去觸發它,也沒有辦法用一般手段關掉它。這是結構化並發框架的預設立場,失敗代表事情出了問題,與其讓其他協程繼續做可能已經沒有意義的工作,不如趁早收斂整個範圍。
這是今天最核心的部分。SupervisorJob 的特殊之處在於,當它底下某個子 Job 因為例外而失敗時,SupervisorJob 不會把這個失敗視為自己也需要跟著失敗的理由,也不會沿著樹往下擴散取消其他子 Job。失敗僅止於那一個子 Job 本身,其餘兄弟 Job 完全不受影響,繼續正常執行到各自結束。
回到 Day 8 那個用 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 建立的範圍內部使用的正是 SupervisorJob,第二個下載子協程失敗時,這個 SupervisorJob 收到失敗通知,選擇不把自己標記為失敗,也不往下擴散取消要求,另外兩個下載子協程對應的 Job 完全不知道發生過這件事,只管繼續跑到完成。最終聚合出來的結果,只是少了失敗的那一份,其餘照常送達。這正是 Day 8 那段程式碼底層實際發生的事,只是當時還沒有名字可以指稱它。
把一般 Job 與 SupervisorJob 的差異換個角度想,會更容易記住。一般 Job 底下的子協程,比較像一般家庭裡一個成員出了意外,全家一起承擔後果;SupervisorJob 底下的子協程,比較像一群約定好各自為自己負責的隊友,一人出狀況,其他人照常完成自己手上的工作。這個比喻只是輔助直覺,實際運作仍要回到樹狀結構與失敗通知的傳遞規則來看。
這裡有一件事必須講得非常明確,避免產生誤解。SupervisorJob 依然遵守 Day 07:Structured Concurrency,為什麼協程不能亂長亂放 定案的父子收斂規則。如果父範圍本身被取消,或者父範圍正常執行完畢,所有子協程依然必須一併結束,這件事沒有任何改變。SupervisorJob 改變的只有一件事,子協程失敗時要不要向上傳播、進而擴散取消其他兄弟協程,除此之外的收斂邏輯,跟一般 Job 完全一樣。換句話說,SupervisorJob 並未讓子協程脫離結構化並發框架,它只是這個框架內可以選用的一種失敗隔離策略,選了它,父子之間該有的收斂關係一項都不會少,只是少了失敗訊號的上傳這一條路徑。
到這裡容易產生一個常見誤解,以為既然失敗不會波及其他兄弟協程,這件事就算解決了。並不是這樣。SupervisorJob 讓失敗不外溢,但那個失敗的子協程依然確實地失敗了,這個失敗需要在某個地方被妥善接住,SupervisorJob 本身沒有負責這件事。
回到聚合下載工具的情境,即使第二個來源下載失敗不影響其他來源繼續下載,開發者仍然需要知道這個來源到底為什麼失敗,才能決定要不要提示使用者部分結果不完整,要不要把錯誤記錄下來供之後排查,甚至判斷是不是該安排重試。SupervisorJob 只解決了「不波及其他協程」這個問題,完全沒有解決「這個失敗訊息該被誰接住」這個問題。如果開發者誤以為引入 SupervisorJob 就等於妥善處理了例外,很可能就此放著那個失敗訊息不管,錯誤資訊靜靜地消失,往後排查問題時完全無跡可尋。隔離失敗跟解決失敗,是兩件不同的事,前者只是不讓災情擴大,後者才是真正回應了問題本身。
今天正式回收了 Day 8 留下的懸念,supervisorScope 底層依賴的正是 SupervisorJob,它阻斷了子 Job 失敗向上傳播的路徑,同時完整保留了父子收斂的規則,父範圍結束時,子協程照樣得一起結束。這是本階段第二個高認知負荷的觀念錨點日,把行為層級的觀察,正式提升到機制層級的解釋。
但這也帶出一個新的疑問。知道了 SupervisorJob 能讓一個子協程的失敗不波及其他兄弟協程,那個失敗的例外訊息本身,如果沒有人主動處理,它最終究竟去了哪裡。協程系統會不會有一個統一的地方,負責接住這些沒人處理的例外,避免它就這樣憑空消失。這個疑問今天先留在這裡,不急著給答案。
《Day 16:CoroutineExceptionHandler,例外最後會被誰接住》 會正式定案這個問題的答案,把例外訊息最終的去向講清楚。