Day 10:協程取消不是強制中斷,是一場合作 結尾提到一件事:Job 樹負責把取消訊號傳遞到每個角落,Structured Concurrency 界定子協程結束的範圍該收斂在哪裡,而這一切最初都是從 Coroutine Scope 劃定的歸屬邊界開始運作。今天要做的,就是把這句話展開成一個完整的畫面。
從 Day 06:Coroutine Scope,協程住在哪裡、活多久 到 Day 10:協程取消不是強制中斷,是一場合作,陸續定案了 Coroutine Scope、Structured Concurrency、Job、協程取消機制四個錨點,中間 Day 08:coroutineScope 與 supervisorScope,兩種收斂行為的差異 又補上了兩種內建 Scope 在失敗傳播上的對照。
表面上看起來是五個各自獨立的主題,五天分別學了五件事,但它們其實一直圍繞著同一個問題:協程的生命週期,究竟是怎麼被結構化地管理起來的。
今天不會出現任何新的英文名詞,也不會定案任何新規則。唯一的任務,是把這五天累積下來的內容放進同一個情境裡,完整走一次,讓這些機制彼此搭配的樣子被具體看見。之前每一天為了聚焦單一觀念,範例都刻意保持精簡,一次只示範一個角色。今天要做相反的事,把角色全部請回舞台,看它們同時上場時是怎麼分工的。
在進入整合情境之前,先用最精簡的方式把四個角色的定位排一次隊,不重新展開任何一個的完整定義,只確認分工。
Coroutine Scope 負責回答「這個協程住在哪裡」。它界定協程的歸屬環境與生命週期邊界,沒有 Scope,協程連啟動都做不到,這件事 Day 06:Coroutine Scope,協程住在哪裡、活多久 已經講得很清楚。
Structured Concurrency 結構化並發負責回答「協程之間是什麼關係」。父範圍結束,子協程必須一併結束,這是 Day 07:Structured Concurrency,為什麼協程不能亂長亂放 定下的標準規則。這條規則底下還可以再細分,Day 08:coroutineScope 與 supervisorScope,兩種收斂行為的差異 對照過兩種可調整的失敗傳播策略,一個是一人失敗全家收攤,一個是各自努力互不牽連,但兩者都沒有脫離父子收斂這個大框架。
Job 負責回答「這一切是靠什麼被記錄下來的」。抽象的父子關係總得有個具體物件承接,Day 09:Job,協程生命週期的控制把手 說明了每個協程都對應一個 Job,多個 Job 之間的父子連結串成一棵樹,Day 7 與 Day 8 談的收斂規則與傳播差異,都是這棵樹在背後運作的結果。
協程取消機制負責回答「取消真正發生時,協程是怎麼配合結束的」。Day 10:協程取消不是強制中斷,是一場合作 講清楚取消並非拔電源式的強制中斷,它是一場需要協程自己在可取消點主動檢查、主動配合的過程。
四個角色各司其職,讀者若對任何一個的細節感到生疏,可以回頭參照對應的那一天講了什麼。
延續前面幾天一直在用的多來源圖片下載與聚合工具,今天把情境稍微加厚一點,同時容納三件事:多個來源平行下載、其中一個來源逾時失敗、使用者中途按下取消整批下載。這三件事湊在一起,剛好足夠讓四個錨點輪番上場。
任務啟動的那一刻,第一個登場的是 Coroutine Scope。一次聚合下載任務,會先在某個 Scope 裡展開,這個 Scope 劃出了這次任務的生命週期邊界,接下來所有動作都發生在這個邊界之內,不存在遊蕩在外的協程。
suspend fun aggregateDownload(urls: List<String>): List<String> = coroutineScope {
val deferredResults = urls.map { url ->
async { downloadImage(url) }
}
deferredResults.awaitAll()
}
coroutineScope 建立的區塊本身就是這個 Scope,裡面用 async 平行啟動的每一個下載動作,都是這個範圍底下的子協程。它們之間並非各自獨立、互不相干的個體,而是遵守 Structured Concurrency 的父子收斂規則,這是第二個登場的角色。這些下載子協程共用同一個父範圍,父範圍要等所有子協程都收斂,才會被視為真正完成。
這些父子關係具體由誰記錄下來,答案是第三個角色,Job。
coroutineScope 這個父範圍本身對應一個父 Job,async 啟動的每一個下載動作各自對應一個子 Job,全部掛在同一個父 Job 底下,構成一棵以這次聚合任務為根的 Job 樹。這棵樹此刻正安靜地記錄著,誰是誰的子協程,目前各自處在什麼狀態。
接著情境開始出狀況。假設三個來源裡,第二個網址連線逾時,這裡不展開逾時本身怎麼被偵測到的技術細節,只需要知道結果是這個下載子協程失敗了。
依 Day 7 建立的標準行為,這個失敗會沿著剛才那棵 Job 樹往上傳,父 Job 收到訊號後,接著往下要求第一個與第三個還在進行中的子 Job 一併結束。這正是第四個角色,協程取消機制,該上場的時刻。取消訊號雖然已經沿著 Job 樹傳遞到每個子協程手上,但每個子協程能不能真正停下來,取決於它此刻是否走到了可取消點。downloadImage 這類真正在等待網路回應的 suspend function,天生就是可取消點,正在這個點上暫停等待的下載協程,會察覺自己被要求取消,於是提前結束,不會傻傻等到一個早就不被需要的回應送達。
再加一層,如果失敗還沒發生,使用者就先按下取消整批下載的按鈕,流程其實是同一套。使用者的取消動作對應到最外層那個父 Job 收到一次主動的取消要求,接著同樣沿著 Job 樹往下傳遞給每一個子 Job,每個下載子協程一樣要走到各自的可取消點,才能真正配合結束。不管取消訊號是因為某個來源失敗而自動觸發,還是使用者主動按下按鈕,傳遞路徑與收斂方式完全一致,差別只在於訊號最初是從哪裡冒出來的。
整段流程走完會發現,沒有任何一步是額外附加上去的技巧。
從協程被啟動的那一刻起,Scope 劃邊界、Structured Concurrency 定關係、Job 記狀態、取消機制管收尾,四件事早就同時在背景運作,只是先前為了讓每一天都能聚焦單一觀念,才把它們拆開分五天介紹。
走完整合情境,我們可以把這五天的內容濃縮成一句話:協程永遠活在某個 Scope 界定的範圍裡,以父子關係結構化地被組織起來。
這層關係由 Job 具體記錄,當結束或取消發生時,訊號沿著這層關係傳遞,但協程本身仍需主動配合才能真正停下來。
這句話值得留在腦中隨時調用。日後看到任何跟協程生命週期有關的程式碼,都可以用這句話反推回去檢查:這個協程有沒有活在一個明確的 Scope 裡,還是憑空冒出來的;它跟其他協程之間是不是清楚的父子收斂關係,還是意外脫離了掌控;取消或結束發生時,訊號有沒有辦法沿著 Job 樹傳到它身上;就算訊號傳到了,它有沒有機會走到一個可取消點去配合結束。
幾個問題問完,一段協程程式碼寫得穩不穩,通常就有答案了。
這幾天建立的框架,回答的都是協程何時暫停、何時恢復、被誰持有、被誰取消,這條時間軸上的問題。
但還有一個同樣重要的問題完全還沒碰過:這些協程實際上是在哪一條執行緒上運行的。寫在同一段程式碼裡的協程,會不會其實分散在不同執行緒上執行,這件事至今還沒有討論到。
接下來的階段,會從協程實際執行的環境開始,一路談到上下文如何組成,以及例外該如何被妥善治理。結構化並發地基已經打穩,明天起要往下挖的,是這棟建築實際蓋在哪塊土地上。