iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Software Development

Spring Boot + Kotlin 協程高併發,後端開發新選擇系列 第 10

Day 09:小結:四個核心觀念如何在一次請求中協同運作

  • 分享至 

  • xImage
  •  

Day 08:Coroutine Context 與例外處理,協程出錯了誰負責 結尾把 Coroutine Scope、Structured Concurrency、Dispatchers、Coroutine Context 四個名詞攤開來看了一次,但也留下一句話:這四塊拼圖各自看起來都理解了,但它們如何在同一次真實請求中協同運作,是接下來要做的整合工作。今天就是把這句話兌現的一天。

四個觀念,先擺在桌上看一眼

過去五天分別定案了四個觀念。

Coroutine Scope 決定協程住在哪裡、活多久,在 Day 05:Coroutine Scope,協程住在哪裡、活多久 定案。Structured Concurrency,結構化並發,決定父子協程之間誰對誰負責,在 Day 06:Structured Concurrency,為什麼協程不能亂長亂放 定案。Dispatchers 決定協程實際跑在哪一組執行緒資源上,Day 07:Dispatchers,協程實際跑在哪條執行緒上 定案。Coroutine Context 是把 Job、Dispatcher 這些設定收攏起來的容器,同時也補齊了例外如何沿結構傳播,Day 08:Coroutine Context 與例外處理,協程出錯了誰負責 定案。

四個觀念都認得了,但分開五天學下來,腦中大概還是四張各自獨立的卡片,還沒被排進同一條時間軸。

今天要做的事情很單純:用同一個訂單查詢與扣庫存服務的請求,從進來到回應結束,把這四張卡片放進同一段時間軸裡,看它們是怎麼一起運作的,而不是各講各的。

一次請求,從進來到回應結束

想像一次訂單查詢請求進到系統裡,目標是同時查詢訂單詳情、確認庫存,然後把兩邊結果組成回應交還給使用者。這正是 Day 06:Structured Concurrency,為什麼協程不能亂長亂放Day 08:Coroutine Context 與例外處理,協程出錯了誰負責 三天反覆使用的那個情境,今天延續下去。

請求進來的那一刻,第一件發生的事情是建立一個 Coroutine Scope。這個 Scope 的生命週期會跟這次請求的生命週期綁在一起,請求還在處理,Scope 就存在,請求結束或中斷,Scope 也該跟著收掉。

有了這個 Scope,接下來系統在裡面同時發起兩個子協程,一個查詢訂單詳情,一個確認庫存。

這兩個子協程一旦被 launch 出去,就已經被父範圍登記在案,這是 Day 6 定案的結構化並發關係,父範圍知道自己底下有誰,也必須等兩者都有結果才能真正結束,不會有一邊還沒跑完,回應就先送出去的狀況。

coroutineScope {
    launch(Dispatchers.IO) {
        val order = findOrderById(orderId)
        println("訂單查詢完成:${order?.status}")
    }
    launch(Dispatchers.IO) {
        val stock = checkStockAvailability(orderId)
        println("庫存確認完成:$stock")
    }
}

這段骨架同時看得到 Scope、兩個子協程、Dispatcher 指定,四個觀念裡有三個已經在同一段程式碼裡現身了。

接著往下看,這兩個子協程真正執行運算、或是等待資料庫回應的那段時間,各自帶著自己的 Dispatcher,決定要被排到哪一組執行緒資源上。這裡刻意寫成兩者都指定 Dispatchers.IO,呼應 Day 7 建立的認識,查詢訂單詳情與確認庫存這類會等待資料庫回應的操作適合被排在這組資源上,而且協程的數量與底層執行緒的數量,從一開始就是兩件互不綁定的事,就算同時有大量這樣的請求湧入,也不代表系統會憑空冒出等量的作業系統執行緒。

再往下走一步,如果確認庫存這個子協程執行到一半發生例外,例如資料庫連線出了問題,這個例外不會靜靜地消失,也不會直接被外層某個 try-catch 接住。它會先讓這個子協程對應的 Job 標記為失敗,接著沿著 Job 維護的父子結構往上傳播到父範圍,這是 Day 8 定案的機制。

父範圍收到這個失敗之後,會對還在執行中的查詢訂單詳情子協程送出取消訊號,即使那個協程自己完全沒有問題,也會被連帶收掉。

整條時間軸走到這裡,四個觀念已經完整地在同一次請求裡輪流登場,而且彼此之間的因果關係清清楚楚:Scope 框住了這次請求的邊界,結構化並發決定了兩個子協程誰對誰負責,Dispatcher 決定了它們實際借用哪組執行緒,Context 底下的 Job 則是撐起例外傳播與取消的那個具體機制。

一次訂單查詢請求的完整時間軸

少了任何一塊,情況會有多糟

假設拿掉其中一塊,這個請求的行為會退化成什麼樣子,我們可以一起想看看。

如果沒有 Coroutine Scope 的約束,查詢訂單詳情與確認庫存這兩個協程有可能變成沒人管的遊蕩協程,請求早就結束或逾時了,它們卻還在背景默默跑著,沒人知道該什麼時候讓它們停下來。

如果沒有結構化並發的收斂規則,確認庫存那邊的失敗可能被父範圍完全忽略,查詢訂單詳情那個協程繼續傻傻跑完,算出一個已經沒有意義的結果,白白浪費運算資源。

如果沒有 Dispatchers 負責調度執行緒資源,每個協程很可能退化成變相的一對一執行緒模型,喪失掉協程原本用少數執行緒撐住大量併發的優勢,Day 04:小結:把 Thread 模型與協程模型放在一起比一次 建立的那個對照就會整個站不住腳。

三個假設情境擺在一起,其實在說同一件事:四個觀念不是各自獨立的加分項,缺了任何一塊,這次請求的行為都會走樣。

協程的世界打穩了,接下來要跟誰打交道

Day 5 到今天這五天,建立的是協程本身的世界觀。協程怎麼被管理、彼此之間怎麼互相影響、實際跑在哪裡、身上又帶著什麼資訊,這些都是協程內部世界的規則,今天把它們排進同一條時間軸,確認了這套規則在一次真實請求裡是怎麼協同運作的。

但這些協程終究要活在一個真實的 Spring Boot 應用程式裡。它要接收 HTTP 請求,要跟 Controller 這類既有機制搭配運作,這件事目前為止完全還沒有正面處理過。

前五天談的 Scope、結構化並發、Dispatcher、Context,全部都發生在協程自己的世界裡,一個 suspend fun 怎麼被 Controller 呼叫、Scope 又該從哪裡建立才符合一次 HTTP 請求的生命週期,這些問題都還沒有答案。

《Day 10:在 Spring MVC 裡寫 suspend function,能動但有代價》 會正式進入生態整合期,第一站就是在 Spring MVC 裡寫 suspend function

能動,但有代價。


上一篇
Day 08:Coroutine Context 與例外處理,協程出錯了誰負責
系列文
Spring Boot + Kotlin 協程高併發,後端開發新選擇10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言