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

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

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

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博主「谷歌开发者_」的原创文章

相关推荐

MySQL进阶五之自动读写分离mysql-proxy

自动读写分离目前,大量现网用户的业务场景中存在读多写少、业务负载无法预测等情况,在有大量读请求的应用场景下,单个实例可能无法承受读取压力,甚至会对业务产生影响。为了实现读取能力的弹性扩展,分担数据库压...

Postgres vs MySQL_vs2022连接mysql数据库

...

3分钟短文 | Laravel SQL筛选两个日期之间的记录,怎么写?

引言今天说一个细分的需求,在模型中,或者使用laravel提供的EloquentORM功能,构造查询语句时,返回位于两个指定的日期之间的条目。应该怎么写?本文通过几个例子,为大家梳理一下。学习时...

一文由浅入深带你完全掌握MySQL的锁机制原理与应用

本文将跟大家聊聊InnoDB的锁。本文比较长,包括一条SQL是如何加锁的,一些加锁规则、如何分析和解决死锁问题等内容,建议耐心读完,肯定对大家有帮助的。为什么需要加锁呢?...

验证Mysql中联合索引的最左匹配原则

后端面试中一定是必问mysql的,在以往的面试中好几个面试官都反馈我Mysql基础不行,今天来着重复习一下自己的弱点知识。在Mysql调优中索引优化又是非常重要的方法,不管公司的大小只要后端项目中用到...

MySQL索引解析(联合索引/最左前缀/覆盖索引/索引下推)

目录1.索引基础...

你会看 MySQL 的执行计划(EXPLAIN)吗?

SQL执行太慢怎么办?我们通常会使用EXPLAIN命令来查看SQL的执行计划,然后根据执行计划找出问题所在并进行优化。用法简介...

MySQL 从入门到精通(四)之索引结构

索引概述索引(index),是帮助MySQL高效获取数据的数据结构(有序),在数据之外,数据库系统还维护者满足特定查询算法的数据结构,这些数据结构以某种方式引用(指向)数据,这样就可以在这些数据结构...

mysql总结——面试中最常问到的知识点

mysql作为开源数据库中的榜一大哥,一直是面试官们考察的重中之重。今天,我们来总结一下mysql的知识点,供大家复习参照,看完这些知识点,再加上一些边角细节,基本上能够应付大多mysql相关面试了(...

mysql总结——面试中最常问到的知识点(2)

首先我们回顾一下上篇内容,主要复习了索引,事务,锁,以及SQL优化的工具。本篇文章接着写后面的内容。性能优化索引优化,SQL中索引的相关优化主要有以下几个方面:最好是全匹配。如果是联合索引的话,遵循最...

MySQL基础全知全解!超详细无废话!轻松上手~

本期内容提醒:全篇2300+字,篇幅较长,可搭配饭菜一同“食”用,全篇无废话(除了这句),干货满满,可收藏供后期反复观看。注:MySQL中语法不区分大小写,本篇中...

深入剖析 MySQL 中的锁机制原理_mysql 锁详解

在互联网软件开发领域,MySQL作为一款广泛应用的关系型数据库管理系统,其锁机制在保障数据一致性和实现并发控制方面扮演着举足轻重的角色。对于互联网软件开发人员而言,深入理解MySQL的锁机制原理...

Java 与 MySQL 性能优化:MySQL分区表设计与性能优化全解析

引言在数据库管理领域,随着数据量的不断增长,如何高效地管理和操作数据成为了一个关键问题。MySQL分区表作为一种有效的数据管理技术,能够将大型表划分为多个更小、更易管理的分区,从而提升数据库的性能和可...

MySQL基础篇:DQL数据查询操作_mysql 查

一、基础查询DQL基础查询语法SELECT字段列表FROM表名列表WHERE条件列表GROUPBY分组字段列表HAVING分组后条件列表ORDERBY排序字段列表LIMIT...

MySql:索引的基本使用_mysql索引的使用和原理

一、索引基础概念1.什么是索引?索引是数据库表的特殊数据结构(通常是B+树),用于...