iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
Software Development

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

Day 03:第一個 suspend function,協程到底暫停了什麼

  • 分享至 

  • xImage
  •  

Day 02:Thread-per-Request 模型的極限,搞懂你原本在用什麼》 結尾停在一個問句上:如果執行緒能在等待的時候先被借走,去處理別的請求,等結果回來再接手繼續,會發生什麼事?今天要正式把這個問句接上答案的第一塊積木。

回顧,理想中的解法長什麼樣子

昨天拆解出來的結論很明確,執行緒之所以不夠用,真正的關鍵在於大量執行緒被阻塞在等待外部回應的那段時間裡,白白占著位置卻沒有真正做事,運算量本身反而不是主要成本。順著這個結論往下想,理想的解法方向是讓執行緒在等待時能被讓出去,先去做別的事,等結果真正回來了再接手繼續。

Kotlin 協程世界裡,實現這個理想的第一塊積木,叫做 suspend function。這個詞從今天開始正式定案,後續全系列一律使用這個英文原文,不另外翻譯、不另創中文說法。

suspend 關鍵字在說什麼

suspend 是一個修飾函式的關鍵字。一個函式被標上 suspend,代表這個函式內部有可能執行「暫停」這個動作,暫停期間函式不會繼續往下跑,但也不會讓呼叫它的執行緒陪著它一起卡住。這正是今天要花整篇文章拆解清楚的行為。

這裡有一個語法上的限制值得先知道:一個 suspend function 只能在協程內部被呼叫,或者在另一個 suspend function 內部被呼叫,不能從一般的函式裡直接呼叫。這個限制的存在是為了確保「暫停」這件事發生時,呼叫它的環境本身知道該怎麼因應,具體編譯器是怎麼實作這個檢查的,不是今天要處理的問題,先知道這個限制存在就夠了。

把這個語法意義套進訂單查詢與扣庫存服務裡的查詢訂單這個動作。原本這個查詢會讓負責處理請求的執行緒阻塞等待資料庫回應,這件事 Day 2 已經拆解得很清楚。如果把這個查詢改寫成一個 suspend function,代表它有能力在等待資料庫回應的那段時間,把控制權交還出去,讓執行緒去做別的事,不必乾等在原地。這一節先停在語法意義本身,具體範例留到下一節統一呈現。

一個最基礎的範例,暫停與恢復發生在哪一刻

先看一段最小化的範例,只圍繞查詢單筆訂單這個動作,不加其他東西。這裡查詢訂單用的 findByIdOrNull 不是 CrudRepository 內建的方法,而是 Spring Data Kotlin 額外提供的一個擴充函式,實際使用時需要另外 import org.springframework.data.repository.findByIdOrNull,它做的事情很單純,把標準的 findById 回傳的 Optional<Order> 轉換成 Kotlin 慣用的可空型別 Order?,省去手動拆解 Optional 的步驟。

suspend fun findOrderById(orderId: Long): Order? {
    println("開始查詢訂單 $orderId")
    val order = orderRepository.findByIdOrNull(orderId) // 這一行實際發生等待
    println("查詢完成,訂單 $orderId 的查詢結果已經拿到")
    return order
}

suspend fun handleOrderQuery(orderId: Long) {
    val order = findOrderById(orderId)
    if (order == null) {
        println("查無訂單 $orderId")
        return
    }
    println("訂單狀態:${order.status}")
}

findOrderById 就是一個最小的 suspend function,它只做一件事,查詢單筆訂單,函式簽章上多了 suspend 這個關鍵字,回傳型別則標成 Order?,因為查無此訂單本來就是查詢動作合理的結果之一,不是例外狀況。呼叫它的 handleOrderQuery 同樣標上了 suspend,這正好呼應前一節提到的限制,suspend function 只能在協程或另一個 suspend function 內部被呼叫,拿到結果後先用一個簡單的 if 判斷是否為 null,查無訂單就直接印出訊息並結束,查得到才繼續往下讀取狀態欄位。

對照三個關鍵時間點。第一個時間點是 handleOrderQuery 呼叫 findOrderById 的那一刻,這是函式呼叫進入的瞬間,跟一般函式呼叫沒有兩樣。第二個時間點是 orderRepository.findByIdOrNull(orderId) 這一行,這是實際發生等待的地方,資料庫還沒把結果送回來之前,這個函式必須停在這裡,這正是「暫停」發生的那一刻。第三個時間點是資料庫把結果送回來之後,程式繼續往下執行到 println("查詢完成...") 這一行,這是「恢復」發生的那一刻。

暫停,指的就是第二個時間點停下來的動作。恢復,指的就是第三個時間點接續下去的動作。今天要看懂的東西,其實就只有這兩個時間點而已,沒有更多。

findOrderById 執行過程的三個時間點

這個範例刻意保持單一動作、單一函式,除了查無訂單這個最基本的空值判斷之外,沒有處理其他例外情況,沒有同時查兩筆訂單,也沒有指定這段程式要跑在哪種執行緒資源上。這些都留給後面的文章處理,今天只想讓你看懂,一個 suspend function 內部,暫停與恢復實際上發生在哪裡。

暫停不是阻塞,這是今天最重要的一句話

現在把 Day 2 定義的「阻塞」搬過來,跟今天的「暫停」放在一起對照。Day 2 的定義是這樣寫的:一條執行緒在等待某個操作完成之前,無法去處理任何其他請求,即使它自己當下什麼都沒在做。

暫停是另一種完全不同的運作方式。協程在等待期間,執行緒被釋放去處理其他請求,等結果回來後,協程可能在原本的執行緒上恢復執行,也可能在另一條可用的執行緒上恢復執行。這裡先點出「可能換一條執行緒恢復」這個現象,背後由誰決定要在哪條執行緒恢復,是 Day 7 談 Dispatchers 時才會處理的問題,今天只需要知道有這回事。

把這句話拆成兩個角度看,會更清楚差異在哪。阻塞的執行緒,等待期間完全被占用,一步都動不了,這件事 Day 2 已經反覆講過。暫停的協程,等待期間執行緒是空出來的,可以被拿去接手別的工作,等結果真正回來,協程才會被接回某條可用的執行緒上繼續往下跑。同樣是「等待」兩個字,執行緒實際上被怎麼對待,完全是兩回事。

回到訂單查詢與扣庫存服務的情境。假設促銷活動開賣的那一刻,大量查詢訂單的請求同時湧入,如果查詢函式是一般函式,Day 2 描述的那套機制會如實上演,執行緒池很快被等待中的請求塞滿,後面的請求只能排隊。如果查詢函式改寫成 suspend function,執行緒池不會被這樣迅速塞滿,因為等待中的協程沒有霸占著執行緒不放,執行緒被讓出去接手別的請求,同一批執行緒能夠承接的請求數量因此不再受限於「有多少請求正在等待」,而是受限於「真正需要運算的工作有多少」。

讀到這裡,如果你剛好對 JavaScript 的 async / await 有印象,可能會覺得這個語法看起來似曾相識。表面上確實類似,都是在函式前面加個關鍵字、呼叫時看起來還是一行一行往下寫。今天要建立的重點是底層那條執行緒的資源被怎麼對待,語法糖本身長什麼樣子只是外觀,不是今天真正要談的東西。suspend function 解決的問題,要放回 Day 2 建立的執行緒池與阻塞脈絡裡去理解,才抓得到重點,如果只把它當成另一種寫非同步程式碼的語法,會錯過今天真正想講的事。

一個函式暫停了,那一整串呼叫呢

今天看到的,只是一個 suspend function 暫停又恢復的最小行為,範例裡只有一次查詢、一次暫停、一次恢復,乾淨俐落。

但真實世界的訂單查詢與扣庫存流程,往往不會只有一步。查完訂單狀態,可能還要接著確認庫存數量,確認庫存足夠後,還要再送一次寫入把庫存扣減下去,有些步驟或許還適合同時進行,讓多個協程並行推進,不必排隊一個接一個做。這時候暫停與恢復的行為會不會變得複雜,這些一個接一個、甚至同時運作的協程之間,有沒有誰該負責誰、誰該等誰的規則?今天先把這個疑問留在這裡,不給答案。

下一篇 《Day 04:小結:把 Thread 模型與協程模型放在一起比一次》 會先做一次地基期的整合回顧,把 Thread-per-Request 模型與今天看到的 suspend function 放在同一個情境下對照一次,再正式進入協程世界更核心的觀念。


上一篇
Day 02:Thread-per-Request 模型的極限,搞懂你原本在用什麼
系列文
Spring Boot + Kotlin 協程高併發,後端開發新選擇4
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言