iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
Software Development

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

Day 05:Coroutine Scope,協程住在哪裡、活多久

  • 分享至 

  • xImage
  •  

Day 04:小結:把 Thread 模型與協程模型放在一起比一次》 結尾留下一個疑問:訂單查詢與扣庫存服務裡,有些步驟適合同時發起,一旦一個請求裡需要同時或接續啟動不只一個協程,這些協程各自活多久,誰該負責確認它們都執行完畢,或者在必要時把它們一起收掉?今天要正式回答這個問題,答案是 Coroutine Scope。

一個協程需要一個家

前四天看到的協程,都只是單一函式呼叫層級的行為。suspend fun findOrderById 暫停、恢復,整個過程發生在一次呼叫裡,讀者還沒看過「啟動一個新的協程」這個動作長什麼樣子。

但只要問題從「一個函式怎麼暫停」推進到「同時處理多個各自獨立的操作」,就會冒出一個新的問題:這個新啟動的協程,該歸誰管?

Kotlin 協程對這件事有一個明確的規定:每一個協程都必須在某個 Coroutine Scope 裡啟動,不存在遊蕩在外、沒有歸屬的協程。可以先把 Coroutine Scope 想成協程的家,協程一旦被啟動,就住進了某個特定的家裡,這個家決定了它的生命週期邊界,也決定了誰有資格管它的生死。這裡借用「家」這個比喻只是為了幫助理解,實際上 Coroutine Scope 管理的對象是協程本身,跟 Android 的 Activity 生命週期或 Spring Bean 生命週期管理的對象並不是同一回事,兩者的規則不能直接套用。

launch 與 async,協程是怎麼被啟動的

要啟動一個新的協程,最常見的做法是呼叫 launchasync 這兩個建構子。兩者都有一個共同的前提:呼叫時必須處於某個 Coroutine Scope 的環境下,離開了 Scope,這兩個字根本無法被呼叫。今天只需要知道這兩個建構子存在、都需要 Scope 才能運作,兩者回傳值的差異留到後面章節視需要再展開,這裡先點到為止。

把這件事放回訂單查詢與扣庫存服務的情境裡看,會比較具體。假設一次請求進來,同時需要「查詢訂單」與「確認庫存」這兩個各自獨立的操作,兩者互不依賴對方的結果,沒有理由排隊一個做完才做下一個。這種情境下,合理的做法就是各自 launch 一個協程,讓兩件事同時進行。這兩個協程雖然做的事情不同,但都必須歸屬於同一個 Coroutine Scope。

scope.launch {
    val order = findOrderById(orderId)
    println("訂單查詢完成:${order?.status}")
}

scope.launch {
    val stock = checkStockAvailability(orderId)
    println("庫存確認完成:$stock")
}

這段範例刻意寫得很小,只想呈現一件事:launch 是掛在 scope 這個物件上呼叫的,少了這個 scope,這兩行程式碼根本寫不出來。這裡的 scope 具體怎麼被建立、跟 Spring Boot 的請求處理流程怎麼整合,不是今天要處理的問題,先看懂「呼叫 launch 需要一個 Scope 掛著」這件事就夠了。

值得強調的是,這個限制是 Kotlin 協程在設計上刻意做出來的,不是遺漏或疏忽。如果 launch 可以脫離任何 Scope 被隨意呼叫,啟動出來的協程就會變成一個沒有人負責、也沒有人知道它何時該結束的存在,這正是 Coroutine Scope 想從一開始就避免的狀況。

Scope 決定了協程活多久

Coroutine Scope 本身也有自己的生命週期。當一個 Scope 被取消或結束時,在這個 Scope 裡啟動的協程也會被要求跟著結束。

這是協程與傳統 Thread 一個很大的行為差異。

一條傳統 Thread 一旦被啟動,通常沒有一個天然的機制會在某個「父層」結束時自動要求它跟著停止,它會照著自己的邏輯一路跑到底,除非有人明確寫程式碼去中斷它。協程的世界裡,Scope 提供了這樣一個天然的機制。

把這件事放回訂單查詢的情境想像一次。假設一次訂單查詢請求對應一個 Coroutine Scope,如果這個請求因為使用者提早關閉連線,或者處理時間拉得太長而逾時,理論上這個 Scope 底下所有還在執行中的協程,也該跟著被收拾掉,而不是繼續在背景默默跑完。畢竟使用者已經不在等這個結果了,讓協程繼續算下去,只是白白浪費運算資源去產生一個沒有人會用到的答案。

這裡先只建立「Scope 結束、協程也該跟著結束」這個方向性的認識就好,取消訊號實際上怎麼從 Scope 傳播到協程內部、協程收到取消要求後該怎麼回應,這些細節今天先不展開,《Day 06:Structured Concurrency,為什麼協程不能亂長亂放》與後續談逾時機制的篇幅會再回來處理。

在 Spring Boot 應用程式裡,Scope 從哪裡來

把這個抽象概念拉回真實的後端服務情境。在一個典型的 Spring Boot 應用程式裡,處理一次請求時,合理的做法是讓這個請求相關的所有協程共用同一個 Coroutine Scope,讓這個 Scope 的生命週期與這個請求的生命週期綁在一起。請求開始,Scope 跟著建立,請求結束或中斷,Scope 也跟著收掉,底下掛著的協程自然一併被處理。

一次請求對應一個 Coroutine Scope

這裡只想先建立這層概念性的對照,實際在 Spring MVC 的 Controller 裡該怎麼取得或建立這個 Scope,是 《Day 10:在 Spring MVC 裡寫 suspend function,能動但有代價》才會處理的技術細節,今天不深入。可以先簡短提醒一件事:不同的 Scope 建立方式,會帶來完全不同的生命週期行為。如果選擇一個全域、長期存在的 Scope,裡面啟動的協程生命週期就不會自動跟著任何一次請求結束,反過來如果每個請求各自擁有獨立的 Scope,協程的生死就會緊緊跟著那次請求走。這個選擇本身就是一個重要的設計決策,具體該怎麼選、在程式裡的哪個位置建立,留給後續章節逐步展開。

同一個家裡的協程們,彼此是什麼關係

今天定案了 Coroutine Scope:協程必須歸屬於某個 Scope 才能被啟動,Scope 結束時,協程也該跟著結束。訂單查詢與扣庫存服務裡,同時發起查詢訂單與確認庫存這兩個協程,就是靠各自 launch 進同一個 Scope 完成的。

但今天留下一個新的疑問,而且這個疑問無法迴避。同一個 Scope 裡,如果同時養著好幾個協程,其中一個先執行完了,或者其中一個執行到一半發生錯誤,這會不會影響到同一個 Scope 裡的其他協程?它們彼此之間,到底是各自獨立、互不相干,還是存在某種說不清楚的牽連?今天先把這個問題留在這裡,不給答案。

《Day 06:Structured Concurrency,為什麼協程不能亂長亂放》會正式定案 Structured Concurrency,結構化並發,解釋父子協程之間的收斂規則,回答今天留下的這個問題。


上一篇
Day 04:小結:把 Thread 模型與協程模型放在一起比一次
系列文
Spring Boot + Kotlin 協程高併發,後端開發新選擇6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言