Coroutine 的概念與 Kotlin Compiler 的運作原理
大家好,我是 ONDA 飯店營運管理解決方案的後端工程師 Gunner(거너, 鄭在勳)。
今天想跟各位聊聊 Kotlin 環境下 Coroutine(協程)的運作方式。
首先,協程讓我們能把原本用 callback 寫的程式碼,改寫成循序執行的寫法。看一段範例:
// callback 版本
fun postItem(item: Item) {
requestTokenAsync { token ->
createPostAsync(token, item) { post ->
processPost(post)
}
}
}
// coroutine 版本
suspend fun postItem(item: Item) {
val token = requestToken()
val post = createPost(token, item)
processPost(post)
}
有了協程,就能避開 callback 地獄,程式碼也好讀多了。協程是怎麼做到的?
1. Coroutines 與 Suspension Point
維基百科這樣定義「Coroutines」:
「Coroutines are computer program components that allow execution to be suspended and resumed, …」 —— 維基百科
照這個定義,協程是程式的一種組成部分,能讓運算暫停與恢復。
Kotlin 的協程也能暫停與恢復運算 —— Kotlin 把暫停運算的點叫做「suspension point」。用 IntelliJ IDE 時,會像下面這樣標出 suspension point:

suspension point 會出現在呼叫有 suspend 修飾詞的函式時。上圖中 requestToken()、createPost()、processPost() 這三個函式都宣告成 suspend。
執行到 suspension point 時,原本的運算流程會暫停,先執行該函式,完成後再繼續原本的運算 —— 內部怎麼暫停的,我們看個範例:
suspend fun main() {
println("Before")
suspendCoroutine { continuation ->
thread {
println("Suspended")
Thread.sleep(1000)
continuation.resumeWith(Result.success(Unit))
println("Resumed")
}
}
println("After")
}
呼叫 suspendCoroutine 函式時,main() 的運算就暫停了。傳入 suspendCoroutine 的 lambda 裡呼叫 continuation.resumeWith(),就能恢復原本函式(這裡是 main)的執行。就這樣,suspension point 暫停執行、跑完 suspend 函式後,恢復原本暫停的那一點。
2. Continuation == Callback
講協程運作時一定會提到「Continuation」這個概念。常見的說法是「Kotlin 協程用 CPS(Continuation Passing Style)來暫停與恢復協程」。
前面範例裡 suspendCoroutine 函式接收的 lambda,參數名就叫「continuation」。continuation 是個老概念,scheme 語言有個 call/cc 函式專門用它 —— call/cc 就是 call-with-current-continuation 的縮寫。
用 continuation 能暫停原本函式的運算,等 suspend 函式完成後再恢復原本函式的運算。continuation 裡裝了什麼,能做到這些事?
假設我們做事做到一半暫停,去做別的事,做完之後要回來繼續原本的事 —— 這時需要記住(或寫下)幾件事:
- 做到哪裡了(該從哪繼續)
- 原本工作的環境(如果在做 Excel,就是那份 Excel 檔案和參考的其他資料)
有了這兩樣,才能恢復暫停的工作。
函式的暫停與恢復也一樣,需要這兩樣:
- 運算跑到哪裡了
- 暫停時的 context 變數值(比如 varA = 10、varB = "This is string")
把這些資訊放進 continuation 再暫停,做完別的事後,用 continuation 裡的資訊就能恢復原本函式的工作。平常用不到 continuation,可能不太有感覺,但有沒有覺得這概念很熟悉?
沒錯,就是「callback」。callback 通常是把想在特定時間點執行的 lambda 傳進去執行。continuation 也一樣,把想在特定時間點執行的 lambda 傳進去執行。
差別在執行時機:continuation 的 lambda 馬上執行,原本運算在 lambda 結束後才恢復;callback 的 lambda 在函式內某處才呼叫執行,原本運算馬上繼續。執行時機不同,但 callback 跟 continuation 本質上是類似的概念。
3. Kotlin 編譯器幫你搞定轉換
前面提了 suspension point 跟 continuation。這些內部處理是誰做的?
就是 Kotlin 編譯器。Kotlin 編譯器看到有 suspend 修飾詞的函式,會在內部做這樣的轉換:
// Kotlin
suspend fun createPost(token: Token, item: Item): Post { ... }
// 上面的 Kotlin 程式碼會轉成下面這樣的 Java 程式碼
// Java/JVM
Object createPost(Token token, Item item, Continuation<Post> cont) { ... }
Continuation 是個介面,長這樣:
// Kotlin ~v1.2
interface Continuation<in T> {
val context: CoroutineContext
fun resume(value: T)
fun resumeWithException(exception: Throwable)
}
// Kotlin v1.3~
interface Continuation<in T> {
val context: CoroutineContext
fun resumeWith(result: Result<T>)
}
用 resumeWith 呼叫傳結果值來恢復原本函式,這點看得出來 —— 但每個 suspension point 都會暫停和恢復,怎麼知道跑到哪裡了?
Kotlin 在轉換程式碼時用「label」標記。我們用程式碼示範:
suspend fun requestToken() { ... }
suspend fun createPost(token, item) { ... }
suspend fun processPost(post) { ... }
suspend fun postItem(item: Item) {
// LABEL 0
val token = requestToken()
// LABEL 1
val post = createPost(token, item)
// LABEL 2
processPost(post)
}
每個 suspension point 都標了 label 註解。這樣標好 label、記住是哪個 label,下次恢復流程時就能從想要的 label 點繼續。
suspend fun postItem(item: Item) {
switch (label) {
case 0:
val token = requestToken()
case 1:
val post = createPost(token, item)
case 2:
processPost(post)
}
}
跑到哪個階段的 label 資訊在哪裡?前面說過在 continuation 裡 —— 用 continuation 裡的 label 資訊分支,就能從暫停的程式碼恢復執行。
fun postItem(item: Item, cont: Continuation) {
val sm = object : CoroutineImpl { ... }
when (sm.label) {
0 -> val token = requestToken(sm)
1 -> val post = createPost(token, item, sm)
2 -> processPost(post)
}
}
架構大概成形了。看程式碼,第一次執行時如果沒有 continuation 就建一個,之後每次 suspension point 呼叫時重複用它。
但好像少了點什麼。沒看到儲存 label 的程式碼,而且 label 是 1 時呼叫 createPost 需要的 item 值從哪來?這行執行時已經是用 resumeWith 恢復運算的時候,原本函式進入時收到的 item 值應該不在了吧?
沒錯。所以如前面提到的,state 也要存到 continuation,之後再拿出來用 —— 加上這段程式碼:
fun postItem(item: Item, cont: Continuation) {
val sm = object : CoroutineImpl { ... }
when (sm.label) {
0 -> {
sm.item = item
sm.label = 1
val token = requestToken(sm)
}
1 -> {
val item = sm.item
val token = sm.result as Token
sm.label = 2
val post = createPost(token, item, sm)
}
2 -> processPost(post)
}
}
記住 suspension point、管理 state 的工作由 suspendCoroutine() 跟 continuation 負責 —— Kotlin 編譯器處理有 suspend 修飾詞的程式碼時,會自動這樣轉換。
不過實際轉換程式碼不會寫得這麼簡單。反編譯實際編譯出的 bytecode 會看到複雜的 Java 程式碼,這裡為了方便理解用 Kotlin 程式碼舉例 —— 大方向是一樣的,這樣理解沒問題。
4. Coroutine 對 future-like 提供的擴充功能
前面的例子應該讓各位了解,callback 風格的程式碼能寫成 direct 風格。但這樣寫好像不會帶來特別的效能提升,而且用 callback 除了看起來不太好看,也沒什麼問題 —— 除了風格差異,還有別的好處嗎?
Coroutine 的優點是能有效利用閒置執行緒。suspension point 暫停後開始其他工作時,如果在特定執行緒池執行,原本工作暫停期間,原本那條執行緒就是閒置狀態,可以給其他需要的地方用。
這樣一來,要同時發多個 async 呼叫一起處理時,如果 async 呼叫用 suspend 呼叫,就能有效利用執行緒。還有另一個好處 —— 通常 JVM 的各種 async 函式庫都提供自己的「Future-like」:
- Guava: ListenableFuture
- RxJava: Observable
- JDK8: CompletableFuture
- …
類別不同,但基本上都是跟 Future 運作原理一樣的類別。Kotlin 的 coroutine 提供與這些 future-like 類別整合的擴充程式碼。
有了這些擴充,只要函式庫有提供對應的 future-like 擴充,就能用統一的方式取值 —— 比如協程裡用 await(),不管對象是 ListenableFuture、CompletableFuture、Observable、Promise 哪一個,都用同樣方式運作。這是透過 Kotlin 擴充(extension)做到的,結果就是用協程能不管函式庫,用同樣方式寫整合程式碼。
// 用 Guava 的 Java 函式
public ListenableFuture<Image> guavaLoadImageAsync(String name) { … }
// 用 RxJava 的 Java 函式
public Observable<Image> rjLoadImageAsync(String name) { … }
// 合併兩張圖片的 Java 函式
public Image combineImage(Image image1, Image image2) { … }
// Kotlin 使用的程式碼
// 接收兩張圖片,以 CompletableFuture 形式回傳
fun combineImagesAsync(name1: String, name2: String): CompletableFuture<Image> = future {
val future1 = guavaLoadImageAsync(name1)
val future2 = rjLoadImageAsync(name2)
combineImages(future1.await(), future2.await())
}
💡 Kotlin Compiler
-
Kotlin 編譯器會轉換有 suspend 修飾詞的函式。
-
轉換後的函式用管理 suspension point 與 state 的 continuation,像 callback 一樣暫停/恢復運算。
-
暫停的原本執行緒在恢復前可以給其他工作用,效率更高。
-
透過協程對 future-like 提供的擴充,能用統一方式處理不同的 future-like 函式庫。
以上介紹了 Coroutine 的概念與 Kotlin Compiler 的運作原理。另外,多虧協程對各種 future-like 提供的擴充,能用相同形式的程式碼處理 future-like。謝謝。