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>)
}
context
是 Continuation 将会使用的CoroutineContext
;resumeWith
会恢复协程的执行,同时传入一个 Result 参数,Result 中会包含导致挂起的计算结果或者是一个异常。
从 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
对象之中。