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

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

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

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

  • 在 Android 利用中应用协程
  • 协程的进阶应用: Kotlin Flow 和 Live Data

协程 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.ktsuspend fun logUserIn(userId: String, password: String): User// UserLocalDataSource.ktsuspend 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 接口:

  • 当应用 suspendCoroutine 或 suspendCancellableCoroutine (首选应用) 来将基于回调的 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 对象之中!