百度360必应搜狗淘宝本站头条
当前位置:网站首页 > IT技术 > 正文

安卓开发必读 | Kotlin Vocabulary—揭秘协程中的suspend 修饰符

wptr33 2024-12-03 18:45 37 浏览

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

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

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

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

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

协程 101

协程简化了 Android 平台的异步操作。正如官方文档《利用 Kotlin 协程提升应用性能》所介绍的,我们可以使用协程管理那些以往可能阻塞主线程或者让应用卡死的异步任务。

《利用 Kotlin 协程提升应用性能》
https://developer.android.google.cn/kotlin/coroutines

协程也可以帮我们用命令式代码替换那些基于回调的 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 编译器会把挂起函数使用有限状态机 (稍后讲到) 转换为一种优化版回调。也就是说,编译器会帮您实现这些回调!

有限状态机
https://en.wikipedia.org/wiki/Finite-state_machine

Continuation 接口

挂起函数通过 Continuation 对象在方法间互相通信。Continuation 其实只是一个具有泛型参数和一些额外信息的回调接口,稍后我们会看到,它会实例化挂起函数所生成的状态机。

Continuation
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.coroutines/-continuation/index.html
Continuation
https://github.com/JetBrains/kotlin/blob/master/libraries/stdlib/src/kotlin/coroutines/Continuation.kt

我们先来看看它的声明:

interface Continuation<in T> {
  public val context: CoroutineContext
  public fun resumeWith(value: Result<T>)
}

context 是 Continuation 将会使用的 CoroutineContext;

resumeWith 会恢复协程的执行,同时传入一个 Result 参数,Result 中会包含导致挂起的计算结果或者是一个异常。

Result
https://github.com/Kotlin/kotlinx.coroutines/blob/master/stdlib-stubs/src/Result.kt

注意: 从 Kotlin 1.3 开始,您也可以使用 resumeWith 对应的扩展函数: resume (value: T) 和 resumeWithException (exception: Throwable)。

resume
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.coroutines/resume.html
resumeWithException
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.coroutines/resume-with-exception.html

编译器将会在函数签名中使用额外的 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 对象作为参数,并会在新的协程结束时调用它,无论其运行结果是成功还是异常。

suspendCoroutine 
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.coroutines/suspend-coroutine.html
suspendCancellableCoroutine
https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/suspend-cancellable-coroutine.html
startCoroutine
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.coroutines/start-coroutine.html

使用不同的 Dispatcher

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

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

DispatchedContinuation
https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-core/common/src/internal/DispatchedContinuation.kt 

生成状态机

特殊说明: 本文接下来所展示的,并不是与编译器生成的字节码完全相同的代码,而是足够精确的,能够确保您理解其内部发生了什么的 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)。

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

保存必要的数据;

递归调用 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 对象之中!


转载自:CSDN博主「谷歌开发者_」的原创文章

相关推荐

oracle数据导入导出_oracle数据导入导出工具

关于oracle的数据导入导出,这个功能的使用场景,一般是换服务环境,把原先的oracle数据导入到另外一台oracle数据库,或者导出备份使用。只不过oracle的导入导出命令不好记忆,稍稍有点复杂...

继续学习Python中的while true/break语句

上次讲到if语句的用法,大家在微信公众号问了小编很多问题,那么小编在这几种解决一下,1.else和elif是子模块,不能单独使用2.一个if语句中可以包括很多个elif语句,但结尾只能有一个...

python continue和break的区别_python中break语句和continue语句的区别

python中循环语句经常会使用continue和break,那么这2者的区别是?continue是跳出本次循环,进行下一次循环;break是跳出整个循环;例如:...

简单学Python——关键字6——break和continue

Python退出循环,有break语句和continue语句两种实现方式。break语句和continue语句的区别:break语句作用是终止循环。continue语句作用是跳出本轮循环,继续下一次循...

2-1,0基础学Python之 break退出循环、 continue继续循环 多重循

用for循环或者while循环时,如果要在循环体内直接退出循环,可以使用break语句。比如计算1至100的整数和,我们用while来实现:sum=0x=1whileTrue...

Python 中 break 和 continue 傻傻分不清

大家好啊,我是大田。...

python中的流程控制语句:continue、break 和 return使用方法

Python中,continue、break和return是控制流程的关键语句,用于在循环或函数中提前退出或跳过某些操作。它们的用途和区别如下:1.continue(跳过当前循环的剩余部分,进...

L017:continue和break - 教程文案

continue和break在Python中,continue和break是用于控制循环(如for和while)执行流程的关键字,它们的作用如下:1.continue:跳过当前迭代,...

作为前端开发者,你都经历过怎样的面试?

已经裸辞1个月了,最近开始投简历找工作,遇到各种各样的面试,今天分享一下。其实在职的时候也做过面试官,面试官时,感觉自己问的问题很难区分候选人的能力,最好的办法就是看看候选人的github上的代码仓库...

面试被问 const 是否不可变?这样回答才显功底

作为前端开发者,我在学习ES6特性时,总被const的"善变"搞得一头雾水——为什么用const声明的数组还能push元素?为什么基本类型赋值就会报错?直到翻遍MDN文档、对着内存图反...

2023金九银十必看前端面试题!2w字精品!

导文2023金九银十必看前端面试题!金九银十黄金期来了想要跳槽的小伙伴快来看啊CSS1.请解释CSS的盒模型是什么,并描述其组成部分。...

前端面试总结_前端面试题整理

记得当时大二的时候,看到实验室的学长学姐忙于各种春招,有些收获了大厂offer,有些还在苦苦面试,其实那时候的心里还蛮忐忑的,不知道自己大三的时候会是什么样的一个水平,所以从19年的寒假放完,大二下学...

由浅入深,66条JavaScript面试知识点(七)

作者:JakeZhang转发链接:https://juejin.im/post/5ef8377f6fb9a07e693a6061目录...

2024前端面试真题之—VUE篇_前端面试题vue2020及答案

添加图片注释,不超过140字(可选)...

今年最常见的前端面试题,你会做几道?

在面试或招聘前端开发人员时,期望、现实和需求之间总是存在着巨大差距。面试其实是一个交流想法的地方,挑战人们的思考方式,并客观地分析给定的问题。可以通过面试了解人们如何做出决策,了解一个人对技术和解决问...