iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Software Development

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

Day 10:協程取消不是強制中斷,是一場合作

  • 分享至 

  • xImage
  •  

Day 09:Job,協程生命週期的控制把手 結尾留下一個問題:透過 Job 對協程發出取消要求之後,這個協程接下來會怎麼樣,是像拔掉電源一樣說停就停,還是中間還有一段需要協程自己配合的過程。今天要正式回答這個問題。

發出取消要求之後,然後呢

Day 9 已經確立 Job 是可以主動操作的控制把手,持有一個協程的 Job,就能在任何時間點對它呼叫取消。但那篇文章刻意在這裡停住,沒有繼續往下講,取消要求發出之後,協程實際上發生了什麼事。

答案跟大多數人的第一直覺不太一樣。取消要求發出,並不會讓協程立刻停止執行。協程的取消其實需要協程本身在某些特定時間點主動檢查這個要求,並且願意配合結束,這種模型有一個正式名稱,Cooperative Cancellation,協程取消機制。

如果協程從不檢查,取消要求就只是被已讀不回

先用一個具體反例,把「取消不是立刻生效」這件事講清楚。

回到多來源圖片下載與聚合工具。假設某個下載子協程內部,除了呼叫網路請求之外,還包了一段純粹的資料處理迴圈,例如把下載回來的每一筆像素資料逐一比對、逐一運算。這段迴圈本身完全沒有呼叫任何 suspend function,也沒有任何暫停動作。

val job = launch {
    var checksum = 0L
    for (pixel in rawPixels) {
        checksum += heavyCompute(pixel)
    }
    println("運算完成:$checksum")
}

job.cancel()

呼叫 job.cancel() 之後,這段迴圈並不會憑空消失。它會繼續一筆一筆跑完 rawPixels,直到自然結束為止,取消要求對它幾乎沒有造成任何立即影響。這個結果對還沒建立正確心智模型的人來說,往往相當意外,明明已經喊了取消,程式卻毫無反應地繼續埋頭運算。

這背後有明確的設計考量。Kotlin 協程刻意不採用類似強制中斷 Thread 那種做法,讓程式停在任意一個不可預期的狀態,可能是資源已經打開一半、檔案寫到一半,卻被硬生生截斷,留下沒有正確收尾的爛攤子。與其冒這種風險,協程選擇要求協程本身參與整個取消流程,讓結束之前有機會做必要的處理。

這正是 Day 9 結尾疑問的答案。取消要求發出之後,先被記錄成一個狀態,掛在對應的 Job 上,但這個狀態本身不會主動打斷任何正在執行的程式碼。真正結束執行,需要協程在某個時間點自己檢查這個狀態,然後決定配合結束。這個過程可以借用一個生活化的畫面理解:請同事先停下手邊工作,但對方正埋頭忙著一件事,沒有抬頭看到訊息,於是繼續把手上的事做完。取消要求就這樣被已讀不回,直到對方剛好抬起頭來查看,才有機會真正停下。這個比喻只是輔助直覺,實際運作靠的是接下來要談的可取消點機制,不是靠某種類似人際互動的自覺。

可取消點,協程主動確認要不要繼續

既然取消不會自動生效,那協程究竟是在什麼時間點抬頭看一眼這個取消狀態的?

答案是可取消點。多數官方提供的 suspend function,特別是涉及延遲等待,或是等待其他協程回傳結果的操作,本身內建了取消檢查的邏輯。

執行到這些操作時,它們會主動確認自己所屬的 Job 是否已經被要求取消,如果是,就會提前結束,不再繼續往下執行。這些天生就會檢查取消狀態的操作,就是可取消點。

同樣回到多來源圖片下載與聚合工具,把前面那個反例,改成真正呼叫網路請求的版本:

val job = launch {
    val bytes = downloadImage(url)
    println("下載完成:${bytes.size} bytes")
}

job.cancel()

downloadImage 這類真正會暫停、等待網路回應的 suspend function,天然就是一個可取消點。如果使用者中途取消整次下載,正在這個點上暫停等待的協程,能夠察覺自己所屬的 Job 已經被要求取消,於是提前結束,不會傻傻地繼續等到網路回應真的送達,才發現自己其實早就不被需要了。這和前一段那個純運算迴圈形成清楚對照,差別不在於程式碼寫得好不好,而在於執行過程中有沒有經過任何一個懂得檢查取消狀態的點。

至於前面那種純運算、完全沒有天然可取消點的迴圈,並非完全無解。開發者可以主動在迴圈內加入一個檢查取消狀態的判斷,讓每次疊代都確認一下是否該提前結束。這裡只建立這個方向性的認識,具體要呼叫什麼判斷、寫成什麼樣的語法,留到後續實際需要動手處理長時間運算場景時再細講,今天的重點是理解「可取消點決定了取消要求何時被看見」這件事本身。

取消之後,程式碼是怎麼被打斷的

可取消點檢查到取消狀態之後,具體是怎麼讓協程真正停下來的?

協程在可取消點察覺自己被要求取消時,會透過拋出一個特定的例外,中止當下的執行流程。這個例外沿著呼叫堆疊往上傳播,把這個協程原本還沒執行完的程式碼一路結束掉。這正是可取消點主動配合結束的具體實現方式,取消訊號最終是靠一次例外拋出來真正生效的。這裡只建立現象層級的理解,這個例外完整的類別階層,以及遇到它該怎麼處理,屬於後續階段例外治理的範疇,今天先不展開。

這件事也讓 Day 7 與 Day 9 建立的框架完整銜接起來。一個父協程被要求取消時,取消訊號會沿著 Day 9 定案的 Job 樹往下傳遞,通知每一個子 Job 自己也被要求取消。但通知歸通知,每個子協程真正結束執行,仍然仰賴各自在執行過程中經過某個可取消點,主動檢查並配合拋出例外中止自己。取消訊號傳遞到位,跟協程實際結束執行,中間隔著這一段合作式檢查的過程。

這正是 Structured Concurrency 框架與協程取消機制搭配運作的具體樣貌。取消機制並非獨立於結構化並發之外的另一套技巧,它正是讓 Day 7 那條「父範圍結束、子協程必須一併結束」的規則得以確實生效的其中一環。Job 樹負責把取消訊號傳遞到每一個該通知的角落,可取消點與例外拋出負責讓每個協程真正把自己收乾淨,兩者合起來,結構化並發的收斂保證才算真正落地。

這四個機制原來一直都在互相搭配

今天把協程取消這件事講得具體了一些。取消要求發出之後,先被記錄成 Job 上的一個狀態,接著沿著 Job 樹往下傳遞給每一個子 Job,但協程真正結束執行,仍然仰賴協程本身在可取消點主動檢查這個狀態,透過拋出例外配合結束,這整段過程就是協程取消機制的完整樣貌。

這也讓最近幾天陸續定案的錨點,在這裡對上了同一個焦點。取消訊號要傳遞到誰,靠的是 Job 樹;子協程結束的範圍要收斂在哪裡,靠的是 Structured Concurrency 界定的父子關係;而這一切要從哪裡開始運作,最初就取決於 Coroutine Scope 一開始劃定的歸屬邊界。四個看似分開介紹的概念,其實一直都在同一套框架底下互相搭配。

明天會把這幾天陸續定案的這幾個錨點放在一起,完整看一次它們是怎麼協同運作的,替結構化並發這個階段做一次收尾整理。


上一篇
Day 09:Job,協程生命週期的控制把手
下一篇
Day 11:Scope、Structured Concurrency、Job、取消如何協同運作
系列文
異步程式設計修煉:Kotlin Coroutines 完全指南 共 15 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言