Day 06:Coroutine Scope,協程住在哪裡、活多久 結尾留下一個問題:同一個 Scope 裡同時養著好幾個協程,其中一個出事,會不會連累其他協程。它們之間到底是各自獨立,還是存在某種說不清楚的牽連。今天要正式回答這個問題。
先快速接上昨天的疑問。一個 Coroutine Scope 底下往往不只掛一個協程,這些協程彼此之間如果毫無關係,各憑本事執行到結束,那 Scope 存在的意義也就只剩下「共用同一個生命週期邊界」這麼薄弱的一層。實際情況比這個更嚴謹。
Kotlin 協程要求協程之間必須維持明確的父子關係。在一個協程內啟動另一個協程,這個新啟動的協程就會成為前者的子協程,而不是一個獨立、互不相干的個體。這種把協程的生命週期限制在明確父子範圍內的管理方式,有一個正式的名字:Structured Concurrency,結構化並發。
在理解結構化並發具體規定了什麼之前,不妨先想像一下反面情境,一個沒有這種約束的世界會長什麼樣子。
假設協程之間完全沒有父子關係的限制,一個函式裡隨手啟動的協程,理論上可以在呼叫它的函式早就執行完畢、回傳結果、甚至整個呼叫堆疊都已經消失之後,還繼續在背景默默運作。沒有人知道它還活著,也沒有任何機制負責在需要的時候把它收掉。這種脫離掌控、四處遊蕩的協程,正是結構化並發要防止的東西。
這種情況的風險相當具體。最直接的是資源洩漏,一個已經沒有人在等待結果的下載協程,可能還在持續佔用網路連線,持續消耗頻寬,白白浪費資源去換一個永遠不會被使用的結果。更難處理的是可預測性的崩壞,當程式需要結束,或使用者主動取消某個操作時,這些遊蕩協程並不會自動跟著收尾,導致整個程式的行為變得難以推理。除錯時更是災難,你很難靠靜態閱讀程式碼就確認,此刻背景到底還有哪些協程在運作,因為它們的存在早已脫離了原本啟動它們的那段程式碼的掌控範圍。
結構化並發正是為了杜絕這種情況而存在。把協程的生命週期強制綁定在明確的父子範圍內,開發者就不再需要手動一一追蹤每個協程的生死,這件事交給協程的執行模型自動處理。
這是今天最核心的規則,也是後續多天內容都會回頭引用的基準,值得把它講得具體一點。
規則本身並不複雜:一個協程內啟動的子協程,其生命週期被限制在父協程的範圍之內。 父協程如果因為正常完成、被取消、或發生例外而結束,所有尚未完成的子協程都必須一併結束。這件事是自動且強制的,不需要開發者手動逐一去追蹤或取消每一個子協程,這正是結構化並發帶來的收斂保證。
拿多來源圖片下載與聚合工具具體想像一次。
假設一次聚合下載任務對應一個父協程,這個父協程用 async 同時啟動好幾個子協程,各自負責下載不同來源的圖片。如果這次聚合任務因為使用者取消,或某種例外情況而提前結束,理論上這些還在進行中的下載子協程都應該被一併收拾掉,而不是視而不見,繼續各自在背景默默跑完,各自佔用網路資源去下載已經沒有人需要的結果。
suspend fun aggregateDownload(urls: List<String>) = coroutineScope {
val deferredResults = urls.map { url ->
async { downloadImage(url) }
}
deferredResults.awaitAll()
}
這段範例裡,coroutineScope 建立的區塊本身就是父協程的範圍,裡面用 async 啟動的每一個下載動作,都是這個父範圍底下的子協程。它們共用同一個收斂邊界,這正是父子關係最單純的樣貌。
同樣重要的是這條規則的反向版本:父協程要等到所有子協程都完成之後,才會真正被視為完成。 這也是為什麼呼叫 async 之後接著呼叫 awaitAll,父協程實質上會停留在原地,直到每一個子協程都拿到結果或都結束為止,才會繼續往下走。這個等待行為並非 awaitAll 恰好被設計成這樣,而是結構化並發約束下自然會出現的結果,父協程本來就不該在子協程還沒收斂之前,宣稱自己已經完成。
這裡只建立一個標準行為:子協程失敗,預設會向上影響整個父範圍,導致父協程與其他兄弟協程一併結束。 至於這個標準行為是否永遠成立,有沒有例外情況可以讓某個子協程的失敗不波及其他協程,這是明天要正式對照的內容,今天先把這個預設值立穩。
到這裡可能會有一種印象,覺得結構化並發是一種需要開發者刻意遵守的良好習慣,就像寫程式要記得關檔案、記得釋放資源一樣,屬於自律範疇。這個印象需要修正。
結構化並發並非一份寫在文件裡、靠開發者自覺遵守的建議清單,它是協程 API 本身在語法層面就強制要求的行為。要啟動一個協程,必須存在一個可以歸屬的 Scope,這件事 Day 06:Coroutine Scope,協程住在哪裡、活多久 已經提過,找不到 Scope 就無法通過編譯。這個限制從一開始就杜絕了「遊蕩協程」出現的可能性,開發者根本沒有機會寫出一個完全脫離父子關係、自由漂浮的協程,因為語言層面就不允許這種寫法存在。
可以借用一個生活化的畫面幫助理解這種收斂責任的性質:父母對未成年子女負有監護責任,家庭這個單位一旦解散,不會有人放任孩子自己流落在外,總得有人接手安置。
父子協程之間的收斂關係也是類似的邏輯,父範圍結束時,子協程不會被放著不管,一定會有一個明確的收尾動作發生。這個比喻只是幫助建立直覺,實際運作靠的是協程執行模型本身的規則,不是靠某種類似親情的自覺。
這個觀念的重要性值得說得再明白一點。接下來系列談到協程如何被取消、一個協程失敗要不要連累其他協程、甚至後面談到 Flow 資料流的行為,全部都建立在結構化並發這個框架之上。
往後遇到任何跟協程生命週期有關的機制,都可以回頭用今天建立的這套父子收斂框架去理解,這些機制是在同一套規則底下運作的具體變化,不是各自獨立、需要分別死背的技巧。
今天把「協程之間存在父子收斂關係」這件事講得相當具體,父範圍結束,子協程要跟著結束;父協程要等所有子協程收斂,才算真正完成。但這裡有一個問題被刻意留白。
這套規則要能夠落實,終究需要某個具體的物件或機制,把「誰是誰的子協程」「目前執行到什麼狀態」這些資訊實際記錄下來。光靠一句「父子關係」的口頭描述,程式並不會自動知道該去通知誰、該去等待誰。這個問題今天先不回答。
明天會先看兩種內建的 Scope 建構函式,在子協程失敗時,各自呈現出什麼樣不同的收斂行為,替今天建立的標準規則找到第一個對照組。再往下一步,《Day 09:Job,協程生命週期的控制把手》會正式定案這個問題的答案,講清楚父子關係究竟是靠什麼被具體記錄與追蹤的。