iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
Software Development

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

Day 02:Thread-per-Request 模型的極限,搞懂你原本在用什麼

  • 分享至 

  • xImage
  •  

Day 01:為什麼高併發後端需要協程,從一個會卡住的 API 說起》結尾留下一個看似簡單卻很少人真正答得出來的問題:你的 Spring Boot 應用程式收到每一個請求時,背後到底是怎麼分配執行緒去處理的。今天就把這個問題攤開來,一次講清楚。

一個請求進來之後發生了什麼事

昨天用服務生罰站的比喻,點出執行緒在等待時被白白佔住這個現象。今天要把這個白話印象升級成具體的技術名詞與運作流程。

Spring Boot 預設採用的並發模型,有一個正式名稱:Thread-per-Request,逐字翻譯就是「每個請求,一條執行緒」。這個名字本身已經把整個模型的核心說完了,每當有一個請求進來,系統就配一條執行緒給它,這條執行緒從頭到尾負責處理完這個請求,中途不會換手,也不會被拿去做別的事。

具體的運作流程是這樣的。Spring Boot 應用程式底層通常內嵌一個 Servlet 容器,最常見的是 Tomcat,這個容器維護著一個執行緒池,裡面預先準備好一批執行緒待命。當有新的 HTTP 請求送達,容器就從這個執行緒池中取出一條閒置的執行緒,把請求交給它處理,從解析請求內容、執行 Controller 與 Service 的邏輯、查詢資料庫、組裝回應,一路到把回應送出為止,全程都是同一條執行緒在跑。等回應送出,這條執行緒才被歸還到池子裡,等著接下一個請求。

這個模型的優點很直觀:一條執行緒對應一個請求,程式邏輯照著寫下來的順序一步步執行,不需要特別去想「這段邏輯執行完之後控制權要交還給誰」,除錯時看一條執行緒的堆疊,就能看到這個請求從頭到尾做了什麼。這也是為什麼多數工程師寫 Spring Boot 專案時很少去追究背後的執行緒分配,因為這套模型讓你幾乎不需要意識到執行緒的存在。

執行緒池不是無限的

問題在於,執行緒池裡準備好的執行緒數量是有上限的,這個上限並非隨口說說的數字,而是實實在在寫在容器設定裡的一個參數值,代表系統同一時間能夠處理的請求數量上限。

這個上限之所以存在,來自作業系統執行緒本身的成本。每建立一條執行緒,就要配置一份獨立的記憶體堆疊,作業系統的排程器也要花額外的心力去輪流調度這些執行緒。執行緒數量一多,這些開銷會逐漸累積成實際負擔,記憶體被瓜分掉一大塊,排程器忙著切換上下文卻沒空真正做事,系統整體效能反而下滑。因此執行緒池不會,也不應該無限擴張,Spring Boot 內嵌的 Tomcat 就提供類似 server.tomcat.threads.max 這樣的參數,讓你設定執行緒池的上限,這裡只需要知道這個參數存在、它決定了池子的天花板,不需要深入研究該怎麼調這個數字。

拿訂單查詢與扣庫存服務來想像一下。假設這個服務的執行緒池上限是兩百,平常流量下,同時處理中的請求數量遠低於兩百,執行緒池游刃有餘。促銷活動開賣那一刻,同時湧入的請求數量遠超過兩百,這時候會發生什麼事?多出來的請求並不會被直接拒絕或報錯,而是先在容器層排隊等候,等某一條執行緒處理完手上的請求、被釋放回池子裡,才輪到隊伍最前面的請求被取出處理。請求數量愈是遠超過兩百,排隊的隊伍就愈長,使用者感受到的延遲也就愈明顯。

這裡剛好可以回頭呼應《Day 01:為什麼高併發後端需要協程,從一個會卡住的 API 說起》提到的「加機器」解法。多開一台伺服器,本質上就是多開一組執行緒池,讓系統整體能同時容納更多正在等待或處理中的請求。這確實有效,但代價會隨著機器數量疊加,並不是無成本的萬靈丹,這一點前一篇已經談過,這裡不再重複展開。

執行緒在等待的時候,到底在等什麼

上一段提到「執行緒被請求佔住」,但一條執行緒實際上大部分時間其實花在等待,真正埋頭運算的比例反而不高。這一節要把「等待」這個模糊的說法,拆解成精確定義,因為這個定義接下來會反覆被引用。

還是用訂單查詢與扣庫存服務走一遍。請求進來後,執行緒第一件事是查詢資料庫確認訂單狀態,這一步要送出查詢,然後停在原地等資料庫把結果回傳回來。接著可能要呼叫另一個內部服務確認庫存數量是否足夠,這一步同樣要等那個服務給出回應。確認沒問題之後,再送出一次寫入,把庫存扣減下去,同樣要等寫入動作確認完成,才能繼續往下走。這幾個時間點,都是執行緒把運算的主導權交出去、自己停在原地什麼也不做的時刻。它沒有在計算任何東西,純粹在等一個外部系統把結果送回來。

現在可以精確定義本篇要用的「阻塞」一詞:一條執行緒在等待某個操作完成之前,無法去處理任何其他請求,即使它自己當下什麼都沒在做。注意這個定義的兩個重點,一是這條執行緒確實被佔用著,沒有被釋放出去給別的請求使用,二是它自己並沒有真正在運算,只是單純停在原地等結果。這兩件事同時成立,才構成阻塞。這個定義接下來會反覆用到,值得先在腦中畫個底線。

有一件事需要特別澄清,阻塞不等於程式當掉或出錯。看到「執行緒動彈不得」這幾個字,很容易聯想到程式卡死或發生例外,但阻塞其實是這套模型正常運作的一部分,是設計上刻意選擇的行為模式,只是這個選擇在某些場景下會付出效率上的代價。一條執行緒阻塞在等資料庫回應,跟一支程式因為邏輯錯誤而真的卡死,是兩種完全不同性質的狀況,前者是正常流程中必然發生的等待,後者才是需要修復的錯誤。把這兩者分清楚,才不會誤以為只要系統變慢就代表哪裡寫壞了。

把三件事串起來,這就是流量一大就卡住的原因

前面三節分別講了三件事:每個請求會佔用一條執行緒直到處理完畢、執行緒池的容量有明確上限、請求處理過程中執行緒有相當大一部分時間其實處於阻塞等待。把這三件事疊在一起看,流量一高系統就容易卡住甚至逾時的完整原因,就浮現出來了。

促銷活動開賣的那一刻,大量請求同時湧入,每個請求都需要佔用一條執行緒才能被處理。執行緒池的容量是固定的兩百條,超過這個數字的請求只能排隊等候。而正在處理中的那兩百個請求,多數時間都卡在等資料庫回應、等內部服務確認庫存、等寫入確認完成,真正花在運算上的時間占比很低,也就是處於阻塞狀態。執行緒被這些等待占著遲遲無法釋放,排隊的請求自然也就遲遲輪不到,整條隊伍愈積愈長,回應時間也就從幾十毫秒一路推高到數秒甚至逾時。

對照前一篇描述的症狀,其實正好一一呼應。使用者按下結帳沒反應,對應的是請求排在隊伍裡,還沒輪到任何一條執行緒接手。回應時間從幾十毫秒暴增到數秒,對應的是排隊時間隨著隊伍拉長而急遽增加。CPU 使用率沒有真正衝到滿載,系統卻依然又慢又不穩,對應的正是執行緒忙著阻塞等待,而不是忙著真正運算。原本看起來各自獨立的症狀,其實都指向同一套機制。

這裡也想誠實補充一點,避免留下錯誤印象。Thread-per-Request 模型並不是設計上的失誤,也稱不上過時的技術。對於低併發的場景,或是運算本身就很吃重、真正花時間的是計算而不是等待外部回應的場景,這套模型其實運作得相當好,簡單直觀,維護成本也低。問題只出現在高併發、又同時大量倚賴等待外部回應的場景下,這套模型的缺點才會被放大到難以忽視的程度。這也是這個系列後段會誠實討論協程並非萬能、也有不適用情境的原因之一:技術選型從來不是非黑即白的事

如果執行緒能在等待時被借走就好了

把前面拆解的機制重新看一遍,問題的核心其實已經很清楚:執行緒之所以不夠用,關鍵在於大量執行緒被阻塞在等待外部回應的那段時間裡,白白占著位置卻沒有真正在做事,運算量本身反而不是主因。

順著這個問題往下想,理想的解法方向會是什麼?如果一條執行緒在等資料庫回應的那幾十毫秒裡,能夠先把自己讓出來,去處理排隊中的另一個請求,等原本那個查詢的結果真正回來了,再回頭接手繼續往下執行,會發生什麼事?執行緒池裡的兩百條執行緒,就不再是兩百個被動等待的名額,而是能夠在等待的空檔裡靈活調度、承接更多請求的資源。這聽起來是很合理的方向,但傳統的 Thread-per-Request 模型做不到這件事,執行緒一旦接下一個請求,就得從頭陪到尾,沒有中途讓出去再接回來的機制。

這正是 Kotlin 協程要解決的核心問題,先把方向記在這裡就好,不急著展開協程實際上是怎麼做到「先讓出去、之後再接手」這件事的,那需要一塊一塊建立起正確的心智模型才行。

下一篇《Day 03:第一個 suspend function,協程到底暫停了什麼》會正式帶出協程世界的第一塊積木,寫下你的第一個能夠暫停又能被接手繼續的函式,並且直接拿今天定義的「阻塞」去對照協程的「暫停」,看看兩者究竟差在哪裡。


上一篇
Day 01:為什麼高併發後端需要協程,從一個會卡住的 API 說起
下一篇
Day 03:第一個 suspend function,協程到底暫停了什麼
系列文
Spring Boot + Kotlin 協程高併發,後端開發新選擇4
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言