iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Software Development

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

Day 09:Job,協程生命週期的控制把手

  • 分享至 

  • xImage
  •  

Day 08:coroutineScope 與 supervisorScope,兩種收斂行為的差異 結尾留下一個問題:coroutineScope 與 supervisorScope 在失敗傳播上表現出不同行為,但這背後總得有個具體機制,負責追蹤協程彼此的狀態與關係。今天要正面回答這個問題。

父子關係總得靠點什麼記下來

Day 7 建立了父子協程收斂的標準規則,Day 8 進一步對照出兩種失敗傳播的行為差異。這兩天的內容有一個共同的盲點,全部停留在「行為描述」的層次。「父範圍結束,子協程要跟著結束」「失敗要不要向上傳播」,這些都是現象,不是機制。程式不會因為一句口頭規則就自動知道該通知誰、該等待誰,總要有個實際存在的物件,把這些關係具體記錄下來。

答案是 Job。

每啟動一個協程,Kotlin 都會為它建立一個對應的 Job 物件,代表這個協程的執行狀態與生命週期控制把手。

回頭看 launch 的回傳值,原來就是它

Day 04:launch、async、runBlocking,三種 Coroutine Builder 該選誰 曾經提過,呼叫 launch 會得到一個回傳值,當時只是點到為止,沒有展開細節。現在可以正式把這個回傳值補齊:它就是一個 Job 物件。

val job: Job = launch {
    downloadImage(url)
}

拿到這個 Job 之後,可以用它查詢協程目前的執行狀態。常見的查詢方式包括確認協程是否還在執行中、是否已經正常完成、是否已經被取消,這幾種狀態組合起來,讓開發者能夠掌握一個協程目前的生死情況,不需要自己額外設計一套追蹤機制。

這件事乍聽平常,卻補上了 Day 4 留下的一個小缺口。當時之所以沒有深入談這個回傳值,是因為那個階段讀者連協程本身的基本行為都還不熟悉,硬要塞進 Job 的概念只會模糊掉當天的重點。現在有了 Day 7 的父子收斂規則與 Day 8 的失敗傳播對照打底,回頭看這個回傳值,意義就完全不同了。它不只是一個可有可無的查詢介面,而是接下來要談的父子關係得以被具體記錄下來的基礎,Job 不只記錄自己的狀態,也記錄自己與其他 Job 之間的關聯。

Job 如何具體記錄父子關係

這是今天最核心的段落。每個新啟動的協程,它的 Job 會自動與啟動它的那個協程的 Job 建立父子連結,多個這樣的連結串起來,就形成一棵 Job 樹。這棵樹的結構,正是 Day 7 談到的父子協程收斂關係在實作層面的具體樣貌。父 Job 結束或被取消時,會沿著這棵樹去要求所有子 Job 一併結束,結構化並發的規則能夠被落實,靠的正是這套追蹤機制。

回到多來源圖片下載與聚合工具,重新看一次 Day 8 出現過的範例:

suspend fun aggregateDownloadStrict(urls: List<String>) = coroutineScope {
    val deferredResults = urls.map { url ->
        async { downloadImage(url) }
    }
    deferredResults.awaitAll()
}

coroutineScope 建立的區塊本身對應一個父 Job,裡面用 async 啟動的每一個下載動作,各自對應一個子 Job,這些子 Job 全部掛在同一個父 Job 底下,構成一棵以聚合任務為根的 Job 樹。可以借用族譜的概念幫助理解這種結構,父節點與子節點層層相連,一棵樹上的每個節點都清楚知道自己的上一層是誰。這個比喻只是輔助直覺,實際運作仍要回到 Job 樹本身的行為來看。

Day 8 那個範例裡,假設三個來源中第二個逾時失敗,實際發生的事情是這個下載子協程對應的 Job 進入失敗狀態,這個失敗訊號沿著 Job 樹往上傳給父 Job,父 Job 接著沿著同一棵樹往下,把第一個與第三個還在進行中的子 Job 一併要求結束。這正是「一人失敗,全家共赴難」這句描述背後具體發生的事,過程沿著明確的樹狀結構逐層傳遞,並非某種模糊的連鎖反應。

換成 Day 8 另一個用 supervisorScope 包裹的版本,Job 樹的結構其實沒有改變,一樣是一個父 Job 底下掛著三個子 Job。真正不同的地方,只在於父 Job 收到子 Job 失敗這個訊號之後,選擇不沿著樹狀結構往下傳遞取消要求給其他兄弟 Job。樹本身還是同一棵樹,差別只在於失敗訊號要不要繼續往下傳。這也解釋了為什麼 Day 8 特別強調 supervisorScope 依然遵守父範圍結束時子協程必須一併結束的規則,因為那條規則管的是樹的結構本身,跟失敗要不要傳播是兩件事。

這裡要明確澄清一件事,避免讀者誤解成 Job 樹是額外加掛上去的新功能。

Job 樹不是獨立於結構化並發框架之外的另一套機制,它是結構化並發這個規則得以被具體實作出來的手段。Day 7 講的父子收斂規則描述的是「應該發生什麼」,Job 樹回答的是「這件事實際上是怎麼被記錄與執行的」,兩者說的是同一件事的兩個層面,不是兩套彼此獨立的東西。

Job 還能做什麼,查詢與主動控制

Job 除了被動記錄協程的狀態,也是一個開發者可以主動操作的控制把手。持有一個協程的 Job,之後可以在任何需要的時間點查詢它的狀態,也可以主動對它發出取消要求。

val job = launch {
    downloadImage(url)
}

if (userCancelledDownload) {
    job.cancel()
}

以多來源圖片下載與聚合工具為例,如果需求是要提供一個「使用者可以手動中止整次下載」的功能,持有代表這次下載任務的 Job,在使用者按下中止按鈕的當下對這個 Job 發出取消要求,會是實現這個功能的合理方向。這件事乍看之下和前面談到的父子收斂規則有點呼應,使用者主動取消對應的往往是最外層那個父 Job,取消要求發出之後,理論上會沿著 Job 樹往下影響所有還在進行中的子下載。至於這個要求發出之後,協程內部實際如何回應,今天先不展開,這裡只建立方向性認識。

另外,Job 也提供等待協程真正完成的方式,讓開發者能在需要確保某個協程確實執行完畢之後,才繼續往下執行後續邏輯。舉例來說,如果聚合下載工具在下載完成後還要接著做一次寫入快取的動作,確保下載任務真的結束再進行寫入,會是比較穩妥的做法。這部分同樣先停在概念層級,具體的 API 用法留到用得上的時候再細講,今天的重點是建立「Job 不只能查、還能主動操作」這個認識。

取消之後,協程是立刻停止還是需要配合

今天把 Job 這個抽象了兩天的概念,落實成一個具體的物件。它記錄協程的執行狀態,串起父子協程的樹狀關係,也是開發者手上可以主動查詢與操作的控制把手。Day 7 的父子收斂規則、Day 8 的失敗傳播差異,說到底都是 Job 樹在背後運作的結果。

但這裡浮現一個新問題。今天知道了可以透過 Job 對一個協程發出取消要求,這個要求發出之後,協程接下來會怎麼樣?是像拔掉電源一樣,說停就停,還是這中間存在某個環節,需要協程自己願意配合,取消才能真正完成?今天先把這個疑問留在這裡,不急著給答案。

《Day 10:協程取消不是強制中斷,是一場合作》 會正式處理這個問題,把協程取消實際發生的過程講清楚。答案會指向一個和直覺不太一樣的方向,取消要求發出之後,協程並不會立刻停下,中間還有一段需要協程自己主動配合才能完成的過程。


上一篇
Day 08:coroutineScope 與 supervisorScope,兩種收斂行為的差異
下一篇
Day 10:協程取消不是強制中斷,是一場合作
系列文
異步程式設計修煉:Kotlin Coroutines 完全指南 共 15 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言