[译]破解suspend方法

1,442 阅读9分钟

原文作者 :Manuel Vivo

原文地址: The suspend modifier — under the hood

译者 : 京平城

本文将帮助你理解suspend方法的实现原理。

Coroutines 101

在Android上使用Coroutines(后用协程指代)将帮助我们简化异步任务的开发。利用协程管理异步任务可以避免对主线程造成阻塞。

使用协程的话,我们不再需要写回调风格的代码:

// Simplified code that only considers the happy path
fun loginUser(userId: String, password: String, userResult: Callback<User>) {
  // Async callbacks
  userRemoteDataSource.logUserIn { user ->
    // Successful network request
    userLocalDataSource.logUserIn(user) { userDb ->
      // Result saved in DB
      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修饰符会提示我们该方法必须在一个协程内部或者另一个suspend方法内部调用。
suspend方法和普通方法的区别是它具有挂起和恢复的能力。

和回调不一样,协程为我们提供了一种切线程和异常处理的简单方法。

我们来看一下编译器到底为我们做了什么?

Suspend under the hood

回到loginUser方法,该方法中其他的耗时操作也被定义成了suspend方法:

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编译器会把suspend方法转换成一个基于有限状态机实现的回调方法。

是的,本质上来说协程还是基于回调的,只不过编译器帮你写了回调代码。

Continuation interface

suspend方法之间使用Continuation接口来协作。Continuation接口是一个通用的回调接口,包含一些额外的信息。

我们来看一下Continuation接口的定义:

interface Continuation<in T> {
  public val context: CoroutineContext
  public fun resumeWith(value: Result<T>)
}
  • 参数context,即协程的上下文CoroutineContext
  • resumeWith协程内部的逻辑执行完毕之后调用的回调方法。Result是一个协程执行成功或失败返回的复合对象,即 Success T | Failure Throwable。

注意:从Kotlin1.3版本开始,你还可以使用新增的Continuation的扩展方法resume(value: T)resumeWithException(exception: Throwable)

public inline fun <T> Continuation<T>.resume(value: T): Unit =
    resumeWith(Result.success(value))
    
public inline fun <T> Continuation<T>.resumeWithException(exception: Throwable): Unit =
    resumeWith(Result.failure(exception))

Kotlin编译器会将suspend关键字替换成方法签名中一个Continuation类型的参数completion,用来给调用它的协程传递执行结果。

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

为了简化示例,这里loginUser方法的返回值用Unit来代替UserUser将由completion调用resume方法来返回。

事实上suspend方法返回的结果类型是Any?,它是一个复合类型,即T | COROUTINE_SUSPENDED

注意: 如果一个suspend方法内部没有调用其他的suspend方法,那么编译器仍然会在方法签名中添加Continuation参数,但不会使用它,它和一个普通方法没什么两样。

Continuation接口还可以用于以下场景:

  • 当使用suspendCoroutine或者suspendCancellableCoroutine转换基于回调的APIs的时候,你可以直接使用Continuation对象来恢复一个因为执行阻塞代码而被挂起的协程。
    译者注:补充一个例子
suspend fun requestDataSuspend() = suspendCoroutine { continuation ->
    requestDataFromServer { data -> // 普通方法还是通过callback接受数据
        if (data != null) {
            continuation.resume(data)
        } else {
            continuation.resumeWithException(MyException())
        }
    }
}
  • 你可以使用扩展方法startCoroutine来启动一个协程。该方法接收一个Continuation对象,当协程执行完毕返回结果或者异常的时候Continuation对象的回调方法才会被调用。 译者注:补充一个例子
val suspendLambda = suspend {
    "Hello world!"
}
val completion = object : Continuation<String> {
    override val context get() = EmptyCoroutineContext
    override fun resumeWith(result: Result<String>) {
        println(result.getOrThrow())//接收到了suspendLambda的返回值
        println("completion is called")
    }
}
suspendLambda.startCoroutine(completion)

// print
Hello world!
completion is called

Using different Dispatchers

你可以使用Dispatchers来切线程。Kotlin是怎么知道在哪个线程恢复挂起操作的呢?
Continuation有一个子类DispatchedContinuation,在它的resumeWith方法里会调用CoroutineContext中可用的Dispatcherdispatch方法。所有的Dispatchers都会调用dispatch方法,除了Dispatchers.Unconfined,因为它的isDispatchNeeded方法总是返回false。

The generated State machine

声明:以下文章中的示例代码不完全等同于编译器生成的字节码,但是它已经足够精准了,能帮助我们理解suspend方法的实现原理。示例代码基于Coroutines1.3.3版本。 Kotlin编译器在suspend方法内部会将每一个挂起点标记为一个状态,基于有限状态机。这些状态可以用labels来表示:

fun loginUser(userId: String, password: String, completion: Continuation<Any?>) {
  
  // Label 0 -> first execution
  val user = userRemoteDataSource.logUserIn(userId, password)
  
  // Label 1 -> resumes from userRemoteDataSource
  val userDb = userLocalDataSource.logUserIn(user)
  
  // Label 2 -> resumes from userLocalDataSource
  completion.resume(userDb)
}

为了更好的表示状态机,编译器会使用when来区分不同的状态:

fun loginUser(userId: String, password: String, completion: Continuation<Any?>) {
  when(label) {
    0 -> { // Label 0 -> first execution
        userRemoteDataSource.logUserIn(userId, password)
    }
    1 -> { // Label 1 -> resumes from userRemoteDataSource
        userLocalDataSource.logUserIn(user)
    }
    2 -> { // Label 2 -> resumes from userLocalDataSource
        completion.resume(userDb)
    }
    else -> throw IllegalStateException(/* ... */)
  }
}

这代码还不完整,因为不同的状态之间没有办法共享信息。编译器会使用方法签名中的Continuation对象来实现信息的共享。这就是为什么Continuation的类型是Any?的原因。

此外,编译器会创建一个私有类

  1. 存储共享的数据
  2. 递归调用loginUser方法来恢复执行。
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
  
    // 保存状态机的执行数据
    var result: Any? = null
    var label: Int = 0
  
    // 这个方法会再次调用loginUser方法,触发状态机的下一步执行
    // result是前一个状态的计算结果,label会被置为下一个状态值
    override fun invokeSuspend(result: Any?) {
      this.result = result
      loginUser(null, null, this)
    }
  }
  /* ... */
}

invokeSuspend方法会再次调用loginUser方法,userIdpassword参数都为空,参数Continuation对象将携带状态机恢复执行所需的信息。

状态机必须知道:

  1. 该方法是否是第一次执行
  2. 该方法是否是从上一次挂起后恢复执行
fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {
  /* ... */
  val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion)
  /* ... */
}

如果该方法是第一次执行,会创建一个新的LoginUserStateMachine实例并且存储completion实例来保存挂起时候Continuation的信息。
如果不是第一次执行,那么将继续执行状态机里下一步的代码。

fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {
    /* ... */

    val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion)

    when(continuation.label) {
        0 -> {
            // 检查失败
            throwOnFailure(continuation.result)
            // 状态标志位置为1,为进入下一个状态运算做准备
            continuation.label = 1
            // 把continuation传递给userRemoteDataSource.logUserIn方法
            // logUserIn方法执行完毕后会进入执行状态机的下一步运算
            userRemoteDataSource.logUserIn(userId!!, password!!, continuation)
        }
        1 -> {
            // 检查失败
            throwOnFailure(continuation.result)
            // 从continuation中获取上一步运算的执行结果
            continuation.user = continuation.result as User
            // 状态标志位置为2,为进入下一个状态运算做准备
            continuation.label = 2
            // 把continuation传递给userLocalDataSource.logUserIn方法
            // logUserIn方法执行完毕后会进入执行状态机的下一步运算
            userLocalDataSource.logUserIn(continuation.user, continuation)
        }
          /* ... leaving out the last state on purpose */
    }
}

我们来看一下这段代码:

  • when条件检查的label参数来自于LoginUserStateMachine实例。
  • 每次新的状态执行的时候,先检查一下上一个挂起方法调用是否执行失败了。
  • 执行下一个挂起方法之前,先把标志位label更新成执行下一步操作的状态值。
  • 在状态机执行过程中,如果调用了另外一个挂起方法,会把当前的continuation实例当做方法的一个参数传递给它。
    被调用的挂起方法同样拥有自己的状态机来接收该continuation实例并且在执行完毕的时候会回调continuation实例的resume方法。

    译者注continuation实例调用resume方法后,该方法内部会调用 LoginUserStateMachine实例的invokeSuspend方法来再次调用loginUser方法继续下一次的状态运算。具体可以参考ContinuationImpl.ktresumeWith方法。

状态机的最后一步执行是不同的,它调用了resume方法并且把执行结果作为参数返回给方法的调用者。

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
}

编译器会为我们生成如下代码:

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

        // 保存状态机的执行数据
        var result: Any? = null
        var label: Int = 0

        // 这个方法会再次调用loginUser方法,触发状态机的下一步执行
        // result是前一个状态的计算结果,label会被置为下一个状态值
        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)
            // 状态标志位置为1,为进入下一个状态运算做准备
            continuation.label = 1
            // 把continuation传递给userRemoteDataSource.logUserIn方法
            // logUserIn方法执行完毕后会进入执行状态机的下一步运算
            userRemoteDataSource.logUserIn(userId!!, password!!, continuation)
        }
        1 -> {
            // 检查失败
            throwOnFailure(continuation.result)
            // 从上一个状态运算中获取结果
            continuation.user = continuation.result as User
            // 状态标志位置为2,为进入下一个状态运算做准备
            continuation.label = 2
            // 把continuation传递给userLocalDataSource.logUserIn方法
            // logUserIn方法执行完毕后会进入执行状态机的下一步运算
            userLocalDataSource.logUserIn(continuation.user, continuation)
        }
        2 -> {
            // 检查失败
            throwOnFailure(continuation.result)
            // 从上一个状态运算中获取结果
            continuation.userDb = continuation.result as UserDb
            // 从方法调用处恢复执行
            continuation.cont.resume(continuation.userDb)
        }
        else -> throw IllegalStateException(/* ... */)
    }
}

译者注:注意这里continuation.cont.resume(continuation.userDb)是不会再次调用loginUser方法了,而是把整个loginUser方法运算的最终结果返回给该方法的调用者。

总结:
Kotlin编译器会把每个挂起方法都转换成一个状态机,每个挂起方法最终也是通过回调来实现恢复执行的。

通过本文,我们可以更好的理解为什么一个挂起方法不会立即返回执行结果而是一直挂起到执行完毕,还有为什么挂起方法不会阻塞线程。这一切的魔力都在Continuation对象中,他携带了方法恢复执行时所需的上下文信息。