深入探索suspend关键字

深入学习Kotlin基础知识
2021-05-17 14:29 · 阅读时长11分钟
小课

在学习协程时,我们看到了suspend关键字,那么为什么它能实现这么神奇的功能?我们知道在JVM平台,kotlin代码最终也会编译成字节码,那么编译器到底对suspend关键字进行了哪些处理?明白了这其中的奥秘能够会让我们更好理解为什么suspend不会立即返回结果而且还不会阻塞线程。

协程简化了在Android系统上的异步操作,我们知道在Android系统上主线程是不能阻塞,否则会造成卡顿甚至ANR,所以如果有耗时任务,需要使用其它线程来运行耗时任务,然后通过回调的形式将任务结果返回到主线程处理,但是如果是多个耗时任务需要顺序执行才能拿到最终结果,这样就会使得代码非常不利于理解,比如:

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方法?

实际上,Kotlin编译器编译suspend方法之后会通过有限状态机将转换为优化版的回调,也就是说它最终也是使用回调的形式实现的,但是这些回调不是我们自己写的,而是编译器帮我们完成的,那么具体是怎么转换的呢?

首先,在转换之后,suspend方法的返回值会被改为Unit,另外参数中会多出一个Continuation<Any?>类型的参数,比如上面的loginUser方法会转化为:

fun loginUser(userId: String, password: String, completion: Continuation<Any?>) {
    val user = userRemoteDataSource.logUserIn(userId, password)
    val userDb = userLocalDataSource.logUserIn(user)
    completion.resume(userDb)
}

Continuationsuspend方法之间的桥梁,它保存的一些当前方法调用的信息和状态,它的定义如下:

interface Continuation<in T> {
    public val context: CoroutineContext
    public fun resumeWith(value: Result<T>)
}
  • context变量是与这个continuation关联的协程上下文。
  • resumeWith方法是用来恢复协程的,参数表示要返回的数据或者异常。

当一个suspend方法被编译时,它会根据当前方法去生成一个实现了Continuation的类,这个类中就保存了当前方法运行的Dispatcher是什么,运行目前运行到方法内部的哪个suspend方法了以及他们的返回值是什么,是否有异常等等信息。

以上面的loginUser方法为例,当编译器编译该方法时,首先会识别到当前方法是suspend方法,然后会找到在该方法中调用的其它suspend方法,然后会把每个方法调用点作为有限状态机中的一个状态,后面会根据状态机的状态来分别调用,大概是这样的:

1fun loginUser(userId: String, password: String, completion: Continuation<Any?>) {
2    when(completion.label) {
3        0 -> { // Label 0 -> first execution
4            userRemoteDataSource.logUserIn(userId, password)
5        }
6        1 -> { // Label 1 -> resumes from userRemoteDataSource
7            userLocalDataSource.logUserIn(user)
8        }
9        2 -> { // Label 2 -> resumes from userLocalDataSource
10            completion.resume(userDb)
11        }
12        else -> throw IllegalStateException(...)
13    }
14}

那么又是如何知道当前该执行哪个代码块呢?或者确定当前的label值是什么?

生成的状态机是什么样的?

前面提到了,编译器会为每个suspend方法生成一个实现了Continuation接口的类,实际上是继承于ContinuationImpl的一个子类,这个类中保存了当前的label值和每个suspend方法的返回值

1fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {
2    class LoginUserStateMachine(
3        completion: Continuation<Any?>
4    ): ContinuationImpl(completion) {
5        // 当前suspend方法中一些局部变量
6        var user: User? = null
7        var userDb: UserDb? = null
8        // 通用的变量
9        var result: Any? = null
10        var label: Int = 0
11        // 当内部的suspend方法完成之后回调resume方法时,会触发这里
12        override fun invokeSuspend(result: Any?) {
13            this.result = result
14            loginUser(null, null, this)
15        }
16    }
17    ...
18}

当前loginUser方法根据状态分为了几部分代码,每一部分触发的时机不一样,当内部的suspend方法执行完成返回结果时,会调用completion.resume(...)方法,这个时候就会触发invokeSuspend方法,这时状态已经变了,当调用loginUser(null, null, this)时就会执行另外一部分代码,完整的参考代码如下:

1fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {
2
3    class LoginUserStateMachine(
4        completion: Continuation<Any?>
5    ): ContinuationImpl(completion) {
6        // 当前suspend方法中一些局部变量
7        var user: User? = null
8        var userDb: UserDb? = null
9        // 通用的变量
10        var result: Any? = null
11        var label: Int = 0
12        // 当内部的suspend方法完成之后回调resume方法时,会触发这里
13        override fun invokeSuspend(result: Any?) {
14            this.result = result
15            loginUser(null, null, this)
16        }
17    }
18
19    val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion)
20
21    when(continuation.label) {
22        0 -> {
23            //检查是否有异常,有异常则抛出
24            throwOnFailure(continuation.result)
25            // 更新状态,下次当前loginUser方法时,就会走state=1的代码
26            continuation.label = 1
27            // 把当前的continuation传入到下一个suspend方法,当它完成时调用resume方法就会回调当前的loginUser方法
28            userRemoteDataSource.logUserIn(userId!!, password!!, continuation)
29        }
30        1 -> {
31            //检查是否有异常,有异常则抛出
32            throwOnFailure(continuation.result)
33            // 拿到结果上一个suspend方法返回的结果
34            continuation.user = continuation.result as User
35            // 更新状态,下次当前loginUser方法时,就会走state=2的代码
36            continuation.label = 2
37            // 把当前的continuation传入到下一个suspend方法,当它完成时调用resume方法就会回调当前的loginUser方法
38            userLocalDataSource.logUserIn(continuation.user, continuation)
39        }
40        2 -> {
41            //检查是否有异常,有异常则抛出
42            throwOnFailure(continuation.result)
43            // 拿到结果上一个suspend方法返回的结果
44            continuation.userDb = continuation.result as UserDb
45            // 把当前结果返回到上一级调用
46            continuation.completion.resume(continuation.userDb)
47        }
48        else -> throw IllegalStateException(...)
49    }
50}

参考:ContinuationImpl

注释
如何做到线程切换?

上面只提到了suspend方法被编译后转换之后大概是什么样的,但是并没有提到线程切换,其实线程切换就很简单了,因为Continuation是保存了当前协程的context,当我们执行完当前方法时,我们会调用从上一个方法传过来的Continuation,也就是completion成员变量的resume方法,而resume方法就可以根据context来切换协程执行环境了。

suspendkotlincoroutine协程android协程原理