《Day 07:Dispatchers,協程實際跑在哪條執行緒上》 結尾留下一個問題:Dispatcher 其實只是協程隨身攜帶的其中一項設定,那協程執行時還帶著哪些其他資訊?今天要正式回答這個問題,並補上一件更重要的事,Day 06:Structured Concurrency,為什麼協程不能亂長亂放 當時刻意留白的例外傳播細節。
Dispatcher 決定協程在哪一組執行緒資源上執行,但它終究只是協程身上眾多設定之一。協程實際攜帶的,是一整組設定的集合,這組集合有個正式名稱:Coroutine Context。這個詞從今天開始正式定案,後續全系列一律使用這個英文原文,不另外翻譯。
Coroutine Context 可以想成一個容器,裡面裝著這個協程執行時需要知道的各種資訊。Dispatcher 是其中一個元素,決定執行緒資源;另一個同樣重要的元素是 Job,決定協程的生命週期狀態與父子關係。今天不會窮舉 Context 裡所有可能的元素種類,
先建立「這是一個容器,Dispatcher 只是其中一格」這個概念性印象就夠了,剩下的元素會在後續章節依需要陸續出場。
Day 06:Structured Concurrency,為什麼協程不能亂長亂放 講的結構化並發,聽起來像是協程之間某種默契或約定,但實際上這套秩序需要一個具體的東西去記錄與維護,這個東西就是 Job。
每個協程都對應著一個 Job,這個 Job 記錄著協程目前的執行狀態,例如執行中、已完成、已取消,同時也維護著父子 Job 之間的關聯。Day 06:Structured Concurrency,為什麼協程不能亂長亂放 說「父範圍知道自己底下有哪些子協程」,這句話在實作層面的具體樣貌,就是父協程的 Job 持有著一份對子協程 Job 的引用,子 Job 誕生的那一刻就被登記在父 Job 底下,不是憑空冒出來的獨立個體。
把 Day 06:Structured Concurrency,為什麼協程不能亂長亂放 那段敘事性描述換成機制說明,會更清楚:當一個父範圍被取消時,實際發生的事情是父 Job 進入取消狀態,這個狀態沿著它與子 Job 之間的關聯往下傳播,逐一通知每個子 Job 該結束了。取消不是某種抽象的「氛圍感染」,它是一條沿著 Job 樹狀結構明確傳遞的訊號。
這裡不打算展開 Job 物件上有哪些可呼叫的方法,那份清單不是今天的重點,容易把讀者的注意力從「Job 是結構化並發的機制載體」這個核心認知,拉往一堆零散的 API 細節。今天只需要記住一件事:結構化並發不是一種默契,它是靠 Job 這個具體物件、以及物件之間的父子關聯撐起來的。
寫慣傳統 Java 例外處理的讀者,很容易帶著一個假設走進協程的世界:在 launch 啟動的協程外面包一層 try-catch,應該就能像處理一般函式呼叫那樣,捕捉到協程裡面發生的例外。
這個假設在協程的世界裡並不成立。
先看一段容易讓人踩坑的寫法:
fun handleOrderRequest(orderId: String) {
try {
coroutineScope {
launch {
val order = findOrderById(orderId)
println("訂單查詢完成:${order?.status}")
}
launch {
val stock = checkStockAvailability(orderId) // 這裡拋出例外
println("庫存確認完成:$stock")
}
}
} catch (e: Exception) {
println("捕捉到例外:${e.message}") // 這行很可能永遠不會被印出來
}
}
寫這段程式碼的人多半期待,只要確認庫存那個協程出了狀況,外層的 catch 就會接住它。但 launch 啟動的協程屬於「發射後不管」的模式,它不會把例外直接丟回呼叫端這條路徑,而是走 Day 06:Structured Concurrency,為什麼協程不能亂長亂放 規則三描述的那條路:例外沿著 Job 的父子結構往上傳播。
回到 Day 06:Structured Concurrency,為什麼協程不能亂長亂放 已經走過一次的情境。查詢訂單詳情與確認庫存是同一個父範圍底下的兩個子協程。如果確認庫存那個子協程拋出例外,而這個例外沒有被妥善處理,它會先讓確認庫存這個子協程的 Job 標記為失敗,接著沿著父子關聯往上傳給父 Job。父 Job 收到這個失敗之後,會依照結構化並發的規則,對其他還在執行中的兄弟協程送出取消訊號,這時候查詢訂單詳情那個協程就會被連帶取消,即使它自己從頭到尾都沒有出任何問題。整段流程走到最後,父範圍本身也會以失敗告終,外層那個 try-catch 頂多接到一個被重新拋出的例外,接住的時間點和接住的內容,都和原本想像的「直接捕捉到子協程裡的例外」有落差,實務上也常出現前述範例那樣完全接不到的情況。
那正確的處理位置在哪裡?
基本方向是把例外處理往內收,收到真正知道自己在做什麼的那個協程建構子層級,或者交給專門設計來處理協程例外的機制去承接,而不是寄望在最外層用一個籠統的 try-catch 概括承受整個協程家族的失敗。具體要在哪個建構子層級處理、要用哪種機制承接,牽涉到的語法變化不少,這裡先停在方向性的理解,下一節會透過一個實務情境,順帶看到其中一種具體做法。
Day 06:Structured Concurrency,為什麼協程不能亂長亂放 建立的預設規則,是一個子協程失敗會沿結構往上傳播,可能連累其他兄弟協程一起被取消。但這只是預設行為,不是唯一選項,有些情境並不希望這種連坐發生。
例如訂單服務在確認庫存之後,可能還要同時發送好幾個非關鍵的通知,一個發給倉儲系統、一個發給行銷系統。這些通知彼此獨立,其中一個發送失敗,不該讓另一個原本可以順利送達的通知也一起被取消。這種「子協程之間應該互不影響」的情境,可以透過 SupervisorJob 這種機制調整預設的例外傳播行為,讓它只由發生問題的那個子協程自行承擔失敗,不再往上牽連兄弟協程。
這裡只需要知道有這樣一種機制存在,也知道它調整的是「例外要不要在兄弟協程之間互相牽連」這一個環節,不需要展開完整的程式碼或所有客製化選項,那已經超出今天要處理的核心範圍。更重要的是理解這件事的定位:SupervisorJob 是對預設行為的例外情況客製化,不是在否定 Day 06:Structured Concurrency,為什麼協程不能亂長亂放 建立的結構化並發規則。預設行為與客製化選項是互補關係,多數情境下父子之間的連帶失敗依然是合理且必要的保護機制,只有在明確需要子協程互不影響的少數情境,才需要換上這種客製化手段。
從 Day 05:Coroutine Scope,協程住在哪裡、活多久 到今天,依序建立了 Coroutine Scope、Structured Concurrency、Dispatchers、Coroutine Context 四個核心觀念。
Scope 決定協程住在哪裡、活多久,結構化並發決定協程之間誰對誰負責。
Dispatchers 決定協程實際跑在哪條執行緒上。
Coroutine Context 則是把 Job、Dispatcher 這些設定收攏在一起的完整容器。
今天也補上了例外如何沿著 Job 結構傳播、以及如何用 SupervisorJob 客製化這個傳播行為。
這四塊拼圖各自看起來都理解了,但它們如何在同一次真實請求中協同運作,是接下來要做的整合工作。
《Day 09:小結:四個核心觀念如何在一次請求中協同運作》 會用同一個請求情境,把 Scope、Structured Concurrency、Dispatchers、Coroutine Context 四者放在一起完整看一次。