Day 05:Coroutine Scope,協程住在哪裡、活多久 結尾留下一個問題:同一個 Scope 裡如果同時養著好幾個協程,其中一個先執行完,或者其中一個執行到一半出錯,這會不會影響到其他協程?它們彼此之間到底是各自獨立,還是存在某種牽連?今天要正式回答這個問題。
Kotlin 協程對這個問題的答案很明確:協程之間存在父子結構。在一個協程內部啟動的另一個協程,會成為它的子協程,這個結構關係會決定取消如何傳播、例外如何處理,彼此之間並非各自獨立的路人關係。
這套父子結構有一個正式名稱,今天要把它定案下來:Structured Concurrency,結構化並發。
簡單說,結構化並發規範的是協程的生命週期必須被限制在明確的父子範圍之內,父範圍知道自己底下有哪些子協程,也必須為它們的完整生命週期負責。這句話現在聽起來可能還有點抽象,但接下來會用具體情境把它攤開來看。
如果讀者寫過傳統的 Thread 並發程式碼,例如用 Executor 手動管理一批 Thread 物件,應該會對一種現象不陌生:啟動一條 Thread 去做某件事之後,呼叫端的邏輯可能提早結束,甚至丟出例外中斷了,但那條 Thread 完全不知道發生了這件事,它會照著自己原本的邏輯繼續跑下去。就算它辛辛苦苦算出結果,那個結果也早就沒有任何地方會去接收或使用了。
這種協程或執行緒繼續在背景遊蕩、沒人記得它還活著的現象,一般俗稱洩漏。
把這個現象放進訂單查詢與扣庫存服務的情境裡想像一次。假設查詢訂單的請求已經因為逾時,先回應使用者一個錯誤了,但背後負責確認庫存的那條執行緒或協程,如果沒有一套結構化的機制去約束它,很可能還在傻傻地等資料庫回應,繼續佔用連線、繼續消耗運算資源,去計算一個早就沒有人在等待的答案。使用者那邊什麼都不知道,系統這邊卻默默燒著資源。
結構化並發要解決的正是這個問題:讓協程的生命週期被父範圍完整掌握,不允許出現遊蕩在外、脫離管控的協程。父範圍存在的意義,不只是啟動子協程的入口,更是確保沒有一個子協程會被遺忘在某個角落繼續空轉。
把結構化並發拆解開來看,可以歸納成三條規則。這三條規則彼此環環相扣,共同構成同一套設計哲學的三個面向,接下來會逐條說明,並在下一節用完整情境把它們串起來看一遍。
規則一,父子關係。在一個協程或 Scope 內啟動的子協程,其存在會被父範圍完整掌握。換句話說,父範圍隨時知道自己底下正在跑著哪些子協程,不會出現父範圍完全不知情、子協程卻自顧自跑著的狀況。以訂單查詢與扣庫存服務為例,一個處理訂單請求的父範圍底下如果同時啟動了查詢訂單與確認庫存兩個子協程,這兩個子協程從誕生的那一刻起,就已經被登記在父範圍名下,並非憑空冒出來的獨立個體。
規則二,父範圍必須等待子協程。父範圍在自己結束之前,必須等待底下所有子協程都執行完畢,不能丟下還在執行中的子協程就自顧自先行結束。同樣以訂單查詢與扣庫存服務為例,如果父範圍同時發起查詢訂單與確認庫存兩個子協程,這個父範圍必須等這兩個子協程都各自有了結果,才算完整結束,不會出現查詢訂單協程都還沒跑完,父範圍卻已經逕自往下走、甚至提早回應使用者的情況。
規則三,取消與例外沿結構傳播。當父範圍被取消時,這個取消訊號會沿著父子結構往下傳播,所有子協程都會收到取消要求,不會有子協程對這件事一無所知、繼續執行下去。反過來,當某個子協程發生了沒有被處理的例外時,這個例外預設也會沿著結構往上傳播,可能導致父範圍與其他兄弟協程一併被取消。這裡先只建立這個傳播方向的認識,例外傳播的細節規則與客製化選項,例如某個子協程想要自己單獨處理例外、不要牽連兄弟協程,留給 《Day 08:Coroutine Context 與例外處理,協程出錯了誰負責》再展開。
這三條規則合在一起看,其實是同一件事的三個側面:父範圍對子協程的存在負責,因此才需要等待子協程完成;正因為存在這層負責關係,取消與失敗才會沿著這條結構線傳播,牽動的是整個協程家族的命運。
把三條規則套進同一個情境裡走一遍,會比死記文字定義來得清楚。假設一次訂單查詢與扣庫存的請求,對應著一個父範圍。這個父範圍底下同時啟動兩個子協程,一個負責查詢訂單詳情,一個負責確認庫存。下面的範例改用 coroutineScope { } 這個建構子來搭出父範圍,它本身就會建立一個新的父範圍,扮演的角色和 [[Day 05:Coroutine Scope,協程住在哪裡、活多久]] 裡的 scope 相同,都是協程的生命週期容器。
coroutineScope {
launch {
val order = findOrderById(orderId)
println("訂單查詢完成:${order?.status}")
}
launch {
val stock = checkStockAvailability(orderId)
println("庫存確認完成:$stock")
}
}
這裡的兩個 launch 掛在 coroutineScope { } 自己建立出來的父範圍上,同樣得依附在一個明確的生命週期容器上才能被呼叫,這一點與 Day 05 建立的語法心智模型是一致的。
先看正常路徑。查詢訂單詳情與確認庫存這兩個子協程各自運作,互不干擾,也不需要排隊等對方。當兩者都順利跑完,父範圍才算真正結束,接著才會用兩邊的結果組成一個完整的回應交給使用者。這正是規則二的具體樣貌,父範圍不會在其中一個子協程還沒完成時就搶先結束。
再看異常路徑。假設確認庫存的子協程在執行過程中發生了例外,可能是資料庫連線出了問題,也可能是庫存資料本身有異常。這個例外預設會沿著父子結構往上傳播到父範圍。父範圍收到這個例外之後,會意識到自己底下的協程家族出了狀況,於是往下對其他還在執行中的子協程送出取消訊號,這時候原本可能還在等待資料庫回應的查詢訂單詳情協程,就會被連帶取消,即使它本身完全沒有出錯。最終父範圍以失敗告終,回應使用者一個錯誤,查詢訂單那條協程也不會繼續傻傻跑完一個已經沒有意義的結果。
這裡只描述現象的走向,取消訊號實際上透過什麼機制送達子協程、例外傳播過程中有哪些可以攔截或客製化的地方,這些具體做法留給 《Day 08:Coroutine Context 與例外處理,協程出錯了誰負責》 處理。今天要記住的是這個因果關係本身:一個子協程的失敗,不會被父範圍當作沒發生過,它會牽動整個協程家族的命運。
今天確立了協程之間存在結構化的父子關係,這個結構決定了誰該等誰、誰的失敗會牽連誰。查詢訂單與確認庫存這兩個子協程,從啟動的那一刻起就被父範圍掌握著存在,父範圍必須等它們都有結果才能結束,一旦其中一個出了狀況,這個失敗會沿著結構傳播出去,不會被悶著不說。這是協程世界的秩序,也是今天想傳達的核心。
不過還有一個問題今天完全沒有碰。目前為止只談了協程之間誰該對誰負責,卻還沒討論過這些協程實際上是在哪一條執行緒上執行的。是不是每個協程都各自佔用一條獨立的執行緒,還是有辦法讓很多協程共用少數幾條執行緒運作?這個問題留到這裡先不回答。
《Day 07:Dispatchers,協程實際跑在哪條執行緒上》 會正式定案 Dispatchers,回答協程實際執行資源這個新的問題。
父範圍替整個協程家族負責的比喻很好懂。取消其實還需要子協程合作;若裡面用了 Thread.sleep 或不可取消的阻塞 I/O,可能不會立刻停下。後續會用 delay 與 Thread.sleep 對照,示範 cooperative cancellation 的差異嗎?
感謝回應 可以期待一下 有文章會講到