Day 12:Dispatchers,協程實際跑在哪條執行緒上 結尾留下一個問題:啟動協程時指定的 Dispatcher,具體是用什麼方式跟著協程一起被攜帶著走的,協程身上是不是還帶著其他類似的設定,而不只有 Dispatcher 這一項。今天要正式回答這個問題。
Day 12 只示範了怎麼在啟動協程時指定 Dispatcher,卻沒有講清楚這個設定被記在哪裡,又是透過什麼方式一路跟著協程走完它的生命週期。答案是,協程攜帶的其實不只是一個 Dispatcher,而是一整組上下文元素集合,這組集合有個正式名稱,叫做 Coroutine Context。這個詞從今天起定案為系列既定錨點,全系列後續一律使用 Coroutine Context 這個英文詞,不另外翻譯。Dispatcher 只是這組集合裡的其中一個元素,並不是協程唯一攜帶的設定。
這裡要回頭補一塊認知缺口。Day 09:Job,協程生命週期的控制把手 講過,每啟動一個協程,都會有一個對應的 Job 物件負責記錄它的執行狀態與生命週期。
當時把 Job 講成一個獨立存在的控制把手,但其實遺漏了一層事實,Job 本身正是 Coroutine Context 這個集合裡的其中一個元素。
每次啟動協程,除了決定它跑在哪條執行緒的 Dispatcher,還一併帶著代表它生命週期控制把手的 Job,兩者都只是 Context 這個容器裡各自負責不同職責的元素,先前分開學到的兩個概念,其實從一開始就活在同一個容器裡。
Coroutine Context 裡的元素彼此各自獨立,互不干涉。Dispatcher 只管執行緒排程;Job 只管生命週期控制,兩者互不過問對方的職責範圍。Context 扮演的角色,是把這些各司其職的元素打包成一份協程隨身攜帶的完整設定,讓協程不需要分別記住好幾個各自獨立的物件,只要帶著一份 Context,就等於帶齊了執行它所需要的所有環境資訊。
這是今天最核心的規則。Coroutine Context 的元素可以被組合在一起,例如同時指定一個 Dispatcher 與一個自訂名稱之類的元素,這些元素會一起打包成一份完整的 Context 跟著協程。
組合的語法很精簡,用一個運算子就能把兩個元素接在一起:
val context = Dispatchers.IO + CoroutineName("image-download")
如果組合時出現同一種類型的元素被指定了兩次,情況會不太一樣。假設先後指定了兩個不同的 Dispatcher:
val context = Dispatchers.IO + Dispatchers.Default
後面指定的會覆蓋前面的,同類型元素只會保留最後生效的那一個,這裡最終生效的是 Dispatchers.Default。這個「後蓋前」的規則,是理解 Day 14 即將示範的行為的關鍵前提,先在這裡把規則立好,具體怎麼運用留到下一篇。
拿多來源圖片下載與聚合工具具體對照一次。
一個父協程可能在 Dispatchers.IO 之下啟動,負責發送多個下載請求。
假設其中一個子協程完成下載後,緊接著要做一段運算密集的圖片後製處理,需要用 Dispatchers.Default 執行,等同於為這個子協程組合出一份新的 Context,其中的 Dispatcher 元素覆蓋了從父協程繼承來的設定。
至於 Context 裡與生命週期相關的部分,依然遵守 Day 7 定案的父子收斂關係,不會因為 Dispatcher 被覆寫就脫離結構化並發框架,父子之間該有的收斂關係一項也沒有少。
這裡只建立組合與覆寫的抽象規則,至於怎麼在執行到一半的協程內部做到覆寫這件事,留給 Day 14 正式示範。
組合與覆寫講完之後,還有一個問題要處理:一個子協程剛啟動的那一刻,它手上那份 Context 是從哪裡來的。
答案是,子協程預設會繼承父協程的 Context,除非在啟動子協程時特別指定了新的元素去覆寫其中某一項。舉例來說,子協程沒有特別指定 Dispatcher 時,會沿用父協程當下所在的 Dispatcher,不需要每次啟動都重新宣告一次。
可以借用隨身行李這個比喻幫助理解,行李裡裝著好幾樣各自獨立的物品,換掉其中一樣不影響其他物品,行李也可以整個被下一個人繼承後,只替換掉其中一件,其餘物品照舊留在裡面。這個比喻只是輔助直覺,實際運作仍要回到 Context 元素組合與繼承的規則本身來看。
這裡要明確澄清一件事,避免讀者誤解成 Context 的繼承規則是另外加掛上去的獨立機制。這個繼承規則與 Day 7 定案的 Structured Concurrency 框架是相容且互補的,並不是兩套各自運作的東西。父子協程之間的收斂關係管的是生命週期何時結束,父範圍結束時子協程必須一併結束,這件事今天完全沒有改變。Context 的繼承管的是子協程預設繼承什麼樣的執行環境設定,包含跑在哪條執行緒、代表生命週期的 Job 掛在哪一棵樹上。
兩者說的是同一套結構化並發體系底下不同面向的規則,一個管生死的收斂,一個管環境設定的傳遞,合起來才是協程父子關係的完整樣貌。
今天知道了協程攜帶的其實是一整組可以組合、可以互相覆寫的 Coroutine Context,Job 與 Dispatcher 都只是這組集合裡各自獨立的元素,子協程預設也會繼承父協程的這包設定,除非啟動時特別指定新的元素去覆寫其中一項。
但這裡浮現一個新問題。
如果一個協程已經在執行了,執行到一半才發現接下來這段程式碼需要換一個 Dispatcher 執行,難道只能重新啟動一個新的子協程,把原本那段邏輯拆開嗎?還是有辦法在同一個協程內部直接切換,不需要多開一個子協程來完成這件事?
下一篇會正式示範這個問題的答案:withContext。