iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Software Development

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

Day 07:Dispatchers,協程實際跑在哪條執行緒上

  • 分享至 

  • xImage
  •  

Day 06:Structured Concurrency,為什麼協程不能亂長亂放 結尾留下一個問題:確立了協程之間誰該對誰負責之後,這些協程實際上到底是在哪一條執行緒上執行的?是不是每個協程都各自佔用一條獨立的執行緒,還是有辦法讓很多協程共用少數幾條執行緒運作?今天要正式回答這個問題。

暫停之後,協程去哪裡恢復

答案要從更早之前的一句話說起。Day 03:第一個 suspend function,協程到底暫停了什麼 曾經提到,協程在等待期間,執行緒被釋放去處理其他請求,等結果回來後,協程可能在原本的執行緒上恢復執行,也可能在另一條可用的執行緒上恢復執行。當時這句話說完就停住了,只留下一句「背後由誰決定要在哪條執行緒恢復,是 Day 7 談 Dispatchers 時才會處理的問題」,沒有再往下展開。

今天正是補上這個答案的時候。

決定協程要被排程到哪一條執行緒上執行、暫停之後又要被排回哪一條執行緒繼續的機制,叫做 Dispatchers>
當初留在 Day 3 的那句「可能換一條執行緒恢復」,答案就是 Dispatcher 在背後做出的決定。

打破一個常見假設,一個協程不等於一條執行緒

在往下談 Dispatcher 實際怎麼運作之前,有一個很容易不自覺抱持的假設,需要先打破。

launchasync 這類啟動協程的語法,看起來像是在啟動一個全新的執行單位,很容易讓人聯想到「每啟動一個協程,系統就多了一條執行緒在跑」,彷彿協程只是包裝過的 Thread。這個聯想是錯的,不過錯得很有根據。

實際情況是,Dispatcher 背後維護著一組數量有限的執行緒資源,這組執行緒的數量通常遠少於同時存在的協程數量。

大量協程共用這少數幾條執行緒,只有在真正需要運算,或者暫停之後需要恢復執行的那一刻,才會被排程到其中一條可用的執行緒上短暫執行。一旦這段工作執行完畢,或者協程再度進入暫停狀態,它就會讓出手上的執行緒,讓 Dispatcher 把這條執行緒交給下一個排隊中的協程使用。整個過程協程本身並不擁有任何一條執行緒,它只是輪流借用。

把這件事放回訂單查詢與扣庫存服務裡看會更具體。假設某個促銷時刻同時有數千個查詢請求湧入,對應著數千個協程同時存在,這並不代表系統裡真的會憑空多出數千條作業系統執行緒。這正是協程模型能夠撐住高併發、卻不會像 Day 02:Thread-per-Request 模型的極限,搞懂你原本在用什麼 描述的那樣迅速把執行緒池打滿的關鍵原因之一:協程的數量與執行緒的數量,從一開始就是兩件互不綁定的事。

認識三個內建 Dispatcher

Dispatcher 決定協程實際使用哪一組執行緒資源,Kotlin 協程內建了幾個常用選項,各自服務不同性質的工作。

Dispatchers.DefaultDispatchers.IO 背後其實共用同一個排程器,差別在於各自對同時可並行執行的協程數量設定了不同的上限,先建立這個共用的前提,再看兩者各自適合的場景會更清楚。

Dispatchers.Default 適合 CPU 密集型的運算工作,例如複雜的資料處理、排序或大量計算。它對同時可並行執行的協程數量設定的上限通常與 CPU 核心數相關,數量不多,因為這類工作本來就該讓核心專心算東西,並行執行的協程數量開得再多也不會讓運算變快,反而會增加排程負擔。

Dispatchers.IO 適合會發生阻塞式等待的操作,例如呼叫傳統阻塞式的資料庫驅動,或是讀寫檔案這類會讓呼叫它的執行緒實際卡住的動作。它對同時可並行執行的協程數量設定了更高的上限,預設是 64 或 CPU 核心數兩者取大,用來容納這類等待。這裡有一個容易被誤解的地方,值得特別講清楚:Dispatchers.IO 這個名字雖然叫 IO,但它精確的定位是用來容納「阻塞式」呼叫,不是所有跟輸入輸出沾上邊的操作都自動該丟給它。如果一段操作本身就是用非阻塞的方式撰寫,它並不會讓執行緒卡住等待,也就沒有搬去 Dispatchers.IO 的必要,硬是切過去反而只是多了一次不必要的排程開銷。

Dispatchers.Main 用於需要在特定主執行緒上執行的情境,常見於 UI 框架,需要確保畫面更新的程式碼跑在同一條指定的執行緒上。在 Spring Boot 這類後端應用程式的情境裡,這個選項較少被直接用到,這裡只需要知道有這個選項存在,不必深入展開。

回到訂單查詢與扣庫存服務的情境,可以把選型原則具體化。如果查詢資料庫的操作本身是用非阻塞的方式撰寫,理論上不需要特地切到 Dispatchers.IO,讓它照著原本的 Dispatcher 繼續跑就好。但如果流程中不得不呼叫某個傳統阻塞式的函式庫,例如一個沒有提供非阻塞版本的舊有工具,這時候就適合把那一段呼叫包裝起來,丟到 Dispatchers.IO 執行,避免這段阻塞拖累原本 Dispatcher 上其他協程的排程。實際語法可以簡單到這樣:

suspend fun readLegacyConfig(): String = withContext(Dispatchers.IO) {
    legacyBlockingClient.read() // 傳統阻塞式呼叫,包裝後丟給 IO 執行
}

withContext(Dispatchers.IO) { } 做的事情很單純,把區塊內的程式碼切到 Dispatchers.IO 對應的執行緒資源上執行,執行完畢後再把結果交還給呼叫端,呼叫端的協程本身不需要知道這段切換背後實際換了幾次執行緒。這裡只需要看懂這個語法在做什麼方向性的切換,不需要展開更完整的操作方式,那部分等 [[Day 08:Coroutine Context 與例外處理,協程出錯了誰負責]] 談完整的協程上下文之後會更清楚脈絡。

選錯 Dispatcher 會發生什麼事

Dispatcher 選型不是隨便選一個能動就好的小事,選錯了是有實際代價的。

設想一個情境:如果把一段會阻塞式等待資料庫回應的操作,錯誤地放在原本用來處理大量輕量協程的 Dispatchers.Default 上執行,會發生什麼事?Dispatchers.Default 背後的執行緒數量本來就不多,設計上是給短時間就能算完、不會卡住的工作使用。一旦有阻塞式呼叫混進來,這幾條原本就不多的執行緒很快會被少數幾個阻塞操作占滿,其他原本應該能很快被排程執行的輕量協程,只能眼睜睜排在後面等,即使它們自己完全沒有問題。

這個現象與 Day 02:Thread-per-Request 模型的極限,搞懂你原本在用什麼 描述的「執行緒池被打滿」本質上是同一件事,只是換了一個場景重新出現。差別在於,Thread-per-Request 模型裡打滿的是整個容器唯一的那組執行緒池,協程世界裡打滿的則是某一個特定 Dispatcher 底下的那組執行緒資源,範圍縮小了,但資源耗盡的邏輯完全一樣。這也提醒一件事,協程模型並不是對資源耗盡問題完全免疫的萬靈丹,它只是把問題搬到了不同的層次,選型選得不對,一樣會付出代價。

Dispatcher 只是協程隨身攜帶的其中一項設定

今天確立了協程實際執行所在的執行緒資源由 Dispatcher 決定,也建立了「協程數量與執行緒數量是兩回事」這個心智模型,大量協程可以共用少數幾條執行緒,只有真正需要運算的那一刻才會被排上去。DefaultIOMain 三個內建選項各自服務不同性質的工作,選型出錯的代價和 Thread-per-Request 模型裡執行緒池被打滿的症狀本質相同。

但今天談的 Dispatcher,其實只是協程隨身攜帶的其中一項設定而已。協程執行時還帶著哪些其他資訊?例如 Day 06:Structured Concurrency,為什麼協程不能亂長亂放 提到的取消訊號如何沿著父子結構傳播,背後又是靠什麼東西在運作、在記錄?這裡先把疑問留著,不給答案。

《Day 08:Coroutine Context 與例外處理,協程出錯了誰負責》 會正式定案這個問題的答案,把 Dispatcher 放進一個更完整的框架裡看待,並補齊協程例外處理的機制。


上一篇
Day 06:Structured Concurrency,為什麼協程不能亂長亂放
系列文
Spring Boot + Kotlin 協程高併發,後端開發新選擇8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言