Kotlin Vocabulary | 揭秘协程中的 suspend 修饰符

Kotlin 协程把suspend 修饰符引入到了我们 Android 开发者的日常开发中。您是否好奇它的底层工作原理呢?编译器是如何转换我们的代码,使其能够挂起和恢复协程操作的呢?

了解这些将会帮您更好地理解挂起函数 (suspend function) 为什么只会在所有工作完成后才会返回,以及如何在不阻塞线程的情况下挂起代码。

本文概要: Kotlin 编译器将会为每个挂起函数创建一个状态机,这个状态机将为我们管理协程的操作!

如果您是 Android 平台上协程的初学者,请查阅下面这些协程 codelab:

  • 在 Android 应用中使用协程
https://codelabs.developers.google.com/codelabs/kotlin-coroutines/#0codelabs.developers.google.com
  • 协程的进阶使用: Kotlin Flow 和 Live
https://codelabs.developers.google.com/codelabs/advanced-kotlin-coroutines/#0codelabs.developers.google.com

协程 101

协程简化了 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 参数但是不会用它做任何事,函数体的字节码则会看起来和一般的函数一样。您也会在其他地方看到 Continuation 接口:

  • 当使用 suspendCoroutinesuspendCancellableCoroutine (首选使用) 来将基于回调的 API 转化为协程时,会直接与一个 Continuation 对象进行交互。它会用于恢复那些执行了参数代码块后挂起的协程;
  • 您可以在一个挂起函数上使用startCoroutine 扩展函数,它会接收一个 Continuation 对象作为参数,并会在新的协程结束时调用它,无论其运行结果是成功还是异常。

使用不同的 Dispatcher

您可以在不同的 Dispatcher 间切换,从而做到在不同的线程中执行计算。那么 Kotlin 是如何知道从哪里开始恢复挂起的计算的呢?

Continuation 有一个子类叫DispatchedContinuation,它的 resume 函数会执行一次调度调用,并会调度至 CoroutineContext 包含的 Dispatcher 中。除了那些将 isDispatchNeeded 方法 (会在调度前调用) 重写为始终返回 false 的 Dispatcher.Unconfined,其他所有的 Dispatcher 都会调用 dispatch 方法。

生成状态机

特殊说明: 本文接下来所展示的,并不是与编译器生成的字节码完全相同的代码,而是足够精确的,能够确保您理解其内部发生了什么的 Kotlin 代码。这些声明由版本为 1.3.3 的协程库生成,可能会在其未来的版本中作出修改。

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)。

接下来,编译器会创建一个私有类,它会:

  1. 保存必要的数据;
  2. 递归调用 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 函数签名中的其他参数变成了可空类型。此时,编译器只需要添加如何在状态之间切换的信息。

首先需要知道的是:

  1. 函数是第一次被调用;
  2. 函数已经从前一个状态中恢复。

做到这些需要检查 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)
        }

        ... // 故意遗漏了最后一个状态
    }
}

花一些时间浏览上面的代码,看看您是否能注意到与之前代码之间的差异。下面我们来看看编译器生成了什么:

  1. when 语句的参数是 LoginUserStateMachine 实例内的 label;
  2. 每一次处理新的状态时,为了防止函数被挂起时运行失败,都会进行一次检查;
  3. 在调用下一个挂起函数 (即 logUserIn) 前,LoginUserStateMachine 的 label 都会更新到下一个状态;
  4. 在当前的状态机中调用另一个挂起函数时,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 对象之中!

发布于 06-01