《Day 03:第一個 suspend function,協程到底暫停了什麼》結尾留下一個疑問:一個函式暫停了,那一整串呼叫呢。今天先不回答這個疑問,而是往回看一次,把前三天分散建立的知識放進同一個畫面裡對照清楚,再帶著這個疑問往下一階段前進。
前三天各自定案了一塊知識,今天把它們並排列出來,當作對照前的共同基準。
這三塊知識目前還各自站在自己的位置上,讀者可能記得阻塞怎麼定義、也記得暫停怎麼定義,但還沒真正把兩者放進同一個情境裡,親眼看過它們面對同樣的流量時,行為到底差在哪裡。今天的任務就是做這件事,把三天累積的內容收進同一場促銷活動裡,看一次兩種模型的具體反應。
回到 Day 1 開場那個畫面:促銷活動開賣的瞬間,大量使用者同時點下確認結帳,請求瞬間湧入訂單查詢與扣庫存服務。這次假設這批流量分別打在兩個版本的服務上,一個維持原本的 Thread-per-Request 寫法,另一個把查詢與扣庫存的關鍵步驟改寫成 suspend function,其他條件完全相同,包含機器規格、執行緒池上限、資料庫回應速度。
Thread-per-Request 版本會發生的事,其實就是 Day 2 拆解過的機制原封不動重演一次。每一個湧入的請求都要佔用一條執行緒,執行緒池的容量固定,一旦同時處理中的請求數量逼近這個上限,多出來的請求只能在容器層排隊。而正在處理中的那些執行緒,多數時間都卡在等資料庫確認訂單狀態、等內部服務確認庫存、等寫入扣庫存完成,符合 Day 2 定義的阻塞狀態,執行緒被占著卻沒有真正在運算。排隊的隊伍隨著湧入的請求愈積愈長,使用者感受到的等待也跟著拉長,逾時的比例開始上升。
suspend function 版本面對同一批流量,行為出現分歧的地方就在「等待」這一步。查詢訂單狀態時,執行緒同樣要等資料庫回應,但這次符合 Day 3 定義的暫停,執行緒在等待期間被釋放出去,先接手另一個排隊中的請求,等原本那個查詢的結果真正回來,協程才被接回某條可用的執行緒繼續往下跑。同樣一批執行緒,因為不再被大量等待中的請求白白占住,能夠同時撐住的在途請求數量會比 Thread-per-Request 版本更多,逾時的比例也會相對較低。
這裡要誠實補一句,避免留下錯誤期待。上面這段對照只呈現行為差異的方向與原因,不代表協程版本快了幾倍或撐住了多少請求,這篇不會、也不打算給出任何具體數字。量化的比較是 《Day 19:幫協程模型量身打造一場效能比較》 與 《Day 20:實測結果解讀,協程贏在哪裡、代價又是什麼》 用 k6 正式量測後才會回答的問題,今天要建立的只是質化的直覺,先知道差異發生在哪個環節、為什麼會發生,數字留到那時候再看。
把前一節的敘事收斂成一張對照表,方便日後回頭查閱。
| 對照維度 | Thread-per-Request 版本 | 協程版本 |
|---|---|---|
| 執行緒與請求的對應關係 | 一條執行緒從頭到尾綁定一個請求,中途不換手 | 一條執行緒可以在等待空檔被釋放去接手其他請求,同一個請求的前後段落不一定跑在同一條執行緒上 |
| 等待資料庫回應時執行緒的狀態 | 阻塞,執行緒被占用著,即使自己什麼都沒在做,也無法去處理其他請求 | 暫停,執行緒被釋放出去,協程等結果回來後再被接回某條可用的執行緒 |
| 執行緒池上限被打滿的速度 | 湧入的請求數量一旦逼近執行緒池上限,很快就會開始排隊 | 執行緒不再被大量等待中的請求占住,池子被打滿的速度相對較慢 |
| 程式碼撰寫方式的直觀差異 | 一般函式,邏輯照著寫下來的順序直接執行 | 函式標上 suspend,只能在協程或另一個 suspend function 內被呼叫,其餘寫法與一般函式相近 |
這張表想呈現的是兩者在高併發、大量等待外部回應情境下的行為差異,不是要證明協程「全面優於」Thread-per-Request。Day 02:Thread-per-Request 模型的極限,搞懂你原本在用什麼 已經提醒過,Thread-per-Request 並非設計錯誤,對於低併發、或者運算本身就吃重而非大量等待外部回應的場景,這套模型依然運作良好,簡單直觀,維護成本也低。表格呈現的差異只在特定情境下才會被放大,讀到這裡先別急著下「協程比較好」這種簡化的結論,技術選型從來不是單選題。
前三天看到的,無論是 Day 1 的症狀描述,還是 Day 3 的 findOrderById 範例,都聚焦在「一個請求、一個協程」這個最小情境。今天的對照表也是站在這個最小情境上做的,一個請求對應一次查詢、一次暫停、一次恢復,畫面乾淨,容易看懂。
但訂單查詢與扣庫存服務的真實流程,往往不會只有一個協程在跑。查完訂單狀態,接著要確認庫存數量,確認沒問題後還要送一次寫入把庫存扣減下去,這幾個步驟裡,有些可能適合同時發起,不必排隊一個接一個做。一旦一個請求裡面需要同時或接續啟動不只一個協程,新的問題就冒出來了:這些協程各自活多久,誰該負責確認它們都執行完畢,或者在必要時把它們一起收掉。這件事目前還沒有答案,Day 3 結尾提過這個疑問,今天把它重新擺回檯面上,因為接下來要正式面對它了。
《Day 05:Coroutine Scope,協程住在哪裡、活多久》會正式進入協程核心觀念期,第一個要搞懂的問題,就是協程住在哪裡、活多久。