Kotlin 協程中引入了
suspend
修飾符和挂起函數的概念,Kotlin 編譯器将會為每個挂起函數建立一個狀态機,這個狀态機将為我們管理協程的操作。
協程
協程簡化了 Android 平台的異步操作。正如官網《利用 Kotlin 協程提升應用性能》所介紹的,我們可以使用協程管理那些可能阻塞主線程的異步任務,更奇妙的是可以使用指令式代碼替換那些基于回調的 API:
// 簡化的隻考慮了基礎功能的代碼
fun loginUser(userId: String, password: String, userResult: Callback<User>) {
// 異步回調
userRemoteDataSource.logUserIn { user ->
// 成功的網絡請求
userLocalDataSource.logUserIn(user) { userDb ->
// 儲存結果到資料庫
userResult.success(userDb)
}
}
}
上面的回調可以通過使用協程轉換為順序調用:
suspend fun loginUser(userId: String, password: String): User {
val user = userRemoteDataSource.logUserIn(userId, password)
val userDb = userLocalDataSource.logUserIn(user)
return userDb
}
如上,我們為函數添加了
suspend
修飾符,它可以告訴編譯器,該函數需要在協程中執行。協程提供了一種簡單的方式來實作線程間的切換以及對異常的處理。當我們把一個函數寫成挂起函數時,編譯器在内部究竟做了什麼事呢?
Suspend工作原理
回到 loginUser 挂起函數,注意它調用的另一個函數也是挂起函數:
suspend fun loginUser(userId: String, password: String): User {
val user = userRemoteDataSource.logUserIn(userId, password)
val userDb = userLocalDataSource.logUserIn(user)
return userDb
}
// UserRemoteDataSource.kt
suspend fun logUserIn(userId: String, password: String): User
// UserLocalDataSource.kt
suspend fun logUserIn(userId: String): UserDb
簡而言之,Kotlin 編譯器會把挂起函數使用有限狀态機轉換為一種優化版回調。也就是說,編譯器會幫你實作這些回調。
Continuation 接口
挂起函數通過 Continuation 對象在方法間互相通信。
Continuation
其實隻是一個具有泛型參數和一些額外資訊的回調接口,它會執行個體化挂起函數所生成的狀态機。
interface Continuation<in T> {
public val context: CoroutineContext
public fun resumeWith(value: Result<T>)
}
-
是 Continuation 将會使用的context
;CoroutineContext
-
會恢複協程的執行,同時傳入一個 Result 參數,Result 中會包含導緻挂起的計算結果或者是一個異常。resumeWith
從 Kotlin 1.3 開始,您也可以使用 resumeWith 對應的擴充函數: resume (value: T) 和 resumeWithException (exception: Throwable)
編譯器将會在函數簽名中使用額外的 completion 參數 (Continuation 類型) 來代替 suspend 修飾符。而該參數将會被用于向調用該挂起函數的協程傳回結果:
fun loginUser(userId: String, password: String, completion: Continuation<Any?>) {
val user = userRemoteDataSource.logUserIn(userId, password)
val userDb = userLocalDataSource.logUserIn(user)
completion.resume(userDb)
}
為了簡化起見,我們的例子将會傳回一個
Unit
而不是
User
。User 對象将會在被加入的 Continuation 參數中 “傳回”。
其實,挂起函數在位元組碼中傳回的是
Any
。因為它是由
T | COROUTINE_SUSPENDED
構成的組合類型。這種實作可以使函數在可能的情況下同步傳回。
注意: 如果您使用 suspend 修飾符标記了一個函數,而該函數又沒有調用其它挂起函數,那麼編譯器會添加一個額外的 Continuation 參數但是不會用它做任何事,函數體的位元組碼則會看起來和一般的函數一樣。
使用不同的 Dispatcher
協程 可以在不同的
Dispatcher
間切換,進而做到在不同的線程中執行計算。這是通過 Continuation 的子類 DispatchedContinuation 實作的。它的 resume 函數會執行一次排程調用,并會排程至
CoroutineContext
包含的 Dispatcher 中。除了那些将
isDispatchNeeded
方法 (會在排程前調用) 重寫為始終傳回 false 的
Dispatcher.Unconfined
,其他所有的 Dispatcher 都會調用
dispatch
方法。
生成狀态機
Kotlin 編譯器會确定函數何時可以在内部挂起,每個挂起點都會被聲明為有限狀态機的一個狀态,每個狀态又會被編譯器用标簽表示:
fun loginUser(userId: String, password: String, completion: Continuation<Any?>) {
// Label 0 -> 第一次執行
val user = userRemoteDataSource.logUserIn(userId, password)
// Label 1 -> 從 userRemoteDataSource 恢複
val userDb = userLocalDataSource.logUserIn(user)
// Label 2 -> 從 userLocalDataSource 恢複
completion.resume(userDb)
}
為了更好地聲明狀态機,編譯器會使用 when 語句來實作不同的狀态:
fun loginUser(userId: String, password: String, completion: Continuation<Any?>) {
when(label) {
// Label 0 -> 第一次執行
userRemoteDataSource.logUserIn(userId, password)
}
// Label 1 -> 從 userRemoteDataSource 恢複
userLocalDataSource.logUserIn(user)
}
// Label 2 -> 從 userLocalDataSource 恢複
completion.resume(userDb)
}
else -> throw IllegalStateException(...)
}
}
這時候的代碼還不完整,因為各個狀态之間無法共享資訊。編譯器會使用同一個 Continuation 對象在方法中共享資訊,這也是為什麼 Continuation 的泛型參數是 Any,而不是原函數的傳回類型 (即 User)。
接下來,編譯器會建立一個私有類,它會:
- 儲存必要的資料;
- 遞歸調用 loginUser 函數來恢複執行。
fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {
class LoginUserStateMachine(
// completion 參數是調用了 loginUser 的函數的回調
completion: Continuation<Any?>
): CoroutineImpl(completion) {
// suspend 的本地變量
var user: User? = null
var userDb: UserDb? = null
// 所有 CoroutineImpls 都包含的通用對象
var result: Any? = null
var label: Int = 0
// 這個方法再一次調用了 loginUser 來切換
// 狀态機 (标簽會已經處于下一個狀态)
// result 将會是前一個狀态的計算結果
override fun invokeSuspend(result: Any?) {
this.result = result
loginUser(null, null, this)
}
}
...
}
由于
invokeSuspend
函數将會再次調用
loginUser
函數,并且隻會傳入 Continuation 對象,是以 loginUser 函數簽名中的其他參數變成了可空類型。此時,編譯器隻需要添加如何在狀态之間切換的資訊。
首先需要知道的是:
- 函數是第一次被調用;
- 函數已經從前一個狀态中恢複。
做到這些需要檢查
Contunuation
對象傳遞的是否是
LoginUserStateMachine
類型:
fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {
...
val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion)
...
}
如果是第一次調用,它将建立一個新的
LoginUserStateMachine
執行個體,并将
completion
執行個體作為參數接收,以便它記得如何恢複調用目前函數的函數。如果不是第一次調用,它将繼續執行狀态機 (挂起函數)。
現在,我們來看看編譯器生成的用于在狀态間切換并分享資訊的代碼:
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {
...
val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion)
when(continuation.label) {
0 -> {
// 錯誤檢查
throwOnFailure(continuation.result)
// 下次 continuation 被調用時, 它應當直接去到狀态 1
continuation.label = 1
// Continuation 對象被傳入 logUserIn 函數,進而可以在結束時恢複
// 目前狀态機的執行
userRemoteDataSource.logUserIn(userId!!, password!!, continuation)
}
1 -> {
// 檢查錯誤
throwOnFailure(continuation.result)
// 獲得前一個狀态的結果
continuation.user = continuation.result as User
// 下次這 continuation 被調用時, 它應當直接去到狀态 2
continuation.label = 2
// Continuation 對象被傳入 logUserIn 函數,進而可以在結束時恢複
// 目前狀态機的執行
userLocalDataSource.logUserIn(continuation.user, continuation)
}
... // 故意遺漏了最後一個狀态
}
}
-
語句的參數是when
執行個體内的LoginUserStateMachine
;label
- 每一次處理新的狀态時,為了防止函數被挂起時運作失敗,都會進行一次檢查;
- 在調用下一個挂起函數 (即
) 前,logUserIn
的LoginUserStateMachine
都會更新到下一個狀态;label
- 在目前的狀态機中調用另一個挂起函數時,continuation 的執行個體 (
類型) 會被作為參數傳遞過去。而即将被調用的挂起函數也同樣被編譯器轉換成一個相似的狀态機,并且接收一個LoginUserStateMachine
對象作為參數。當被調用的挂起函數的狀态機運作結束時,它将恢複目前狀态機的執行。continuation
最後一個狀态與其他幾個不同,因為它必須恢複調用它的方法的執行。如您将在下面代碼中所見,它将調用
LoginUserStateMachine
中存儲的
cont
變量的
resume
函數:
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {
...
val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion)
when(continuation.label) {
...
2 -> {
// 錯誤檢查
throwOnFailure(continuation.result)
// 擷取前一個狀态的結果
continuation.userDb = continuation.result as UserDb
// 恢複調用了目前函數的函數的執行
continuation.cont.resume(continuation.userDb)
}
else -> throw IllegalStateException(...)
}
}
Kotlin 編譯器幫我們做了很多工作!例如示例中的挂起函數:
suspend fun loginUser(userId: String, password: String): User {
val user = userRemoteDataSource.logUserIn(userId, password)
val userDb = userLocalDataSource.logUserIn(user)
return userDb
}
編譯器為我們生成了下面這些代碼:
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {
class LoginUserStateMachine(
// completion 參數是調用了 loginUser 的函數的回調
completion: Continuation<Any?>
): CoroutineImpl(completion) {
// 要在整個挂起函數中存儲的對象
var user: User? = null
var userDb: UserDb? = null
// 所有 CoroutineImpls 都包含的通用對象
var result: Any? = null
var label: Int = 0
// 這個函數再一次調用了 loginUser 來切換
// 狀态機 (标簽會已經處于下一個狀态)
// result 将會是前一個狀态的計算結果
override fun invokeSuspend(result: Any?) {
this.result = result
loginUser(null, null, this)
}
}
val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion)
when(continuation.label) {
0 -> {
// 錯誤檢查
throwOnFailure(continuation.result)
// 下次 continuation 被調用時, 它應當直接去到狀态 1
continuation.label = 1
// Continuation 對象被傳入 logUserIn 函數,進而可以在結束時恢複
// 目前狀态機的執行
userRemoteDataSource.logUserIn(userId!!, password!!, continuation)
}
1 -> {
// 檢查錯誤
throwOnFailure(continuation.result)
// 獲得前一個狀态的結果
continuation.user = continuation.result as User
// 下次這 continuation 被調用時, 它應當直接去到狀态 2
continuation.label = 2
// Continuation 對象被傳入 logUserIn 方法,進而可以在結束時恢複
// 目前狀态機的執行
userLocalDataSource.logUserIn(continuation.user, continuation)
}
2 -> {
// 錯誤檢查
throwOnFailure(continuation.result)
// 擷取前一個狀态的結果
continuation.userDb = continuation.result as UserDb
// 恢複調用了目前函數的執行
continuation.cont.resume(continuation.userDb)
}
else -> throw IllegalStateException(...)
}
}
Kotlin 編譯器将每個挂起函數轉換為一個狀态機,在每次函數需要挂起時使用回調并進行優化。
最後
了解了編譯器在底層所做的工作後,我們能更好地了解為什麼挂起函數會在完成所有它啟動的工作後才傳回結果。同時,也知道 suspend 是如何做到不阻塞線程的: 當方法被恢複時,需要被執行的資訊全部被存在了
Continuation
對象之中。