Kotlin 协程系列——挂起篇

341 阅读10分钟

前言

Kotlin 协程对于初学者来讲是一个上手成本较高的特性,主要原因有两点:

  • 涉及元素较多,封装层级较深,梳理成本较高,如CoroutineScope, Job, CoroutineContext, CoroutineDispatcher 等。

  • 使用了较多的 Kotlin 语法糖,对使用者的要求相对较高,如拓展函数、高阶函数等。

另外,笔者在项目中发现有的同学对协程使用不当导致了一些线上问题。所以笔者想通过 Kotlin 协程系列文章,降低大家对 Kotlin 协程上手成本,同时在项目中更好的使用它。

本系列文章主要有 8 篇,文章列表如下:

  1. Kotlin 协程系列——挂起篇,主要介绍 Kotlin 协程的基本概念及其挂起和恢复。

  2. Kotlin 协程系列——启动篇,主要梳理 Kotlin 协程的启动过程,并分析其线程切换能力的实现原理。

  3. Kotlin 协程系列——结构化并发篇,主要介绍 Job 的结构化并发。

  4. Kotlin 协程系列——取消篇,主要梳理 Job 取消的流程。

  5. Kotlin 协程系列——异常处理篇,主要分析 Kotlin 协程中的异常处理。

  6. Kotlin 协程系列—— Channel 篇。

  7. Kotlin 协程系列—— Flow 篇。

  8. Kotlin 协程系列—— 最佳实践篇。

本文简介

本篇是 Kotlin 协程系列的第一篇,首先介绍协程的概念,然后介绍 Kotlin 协程的优势,接着重点介绍协程概念中的挂起和恢复在 Kotlin 中是怎样实现的,最后对本文内容进行总结。本篇文章我们主要解决以下四个问题:

  • 协程是什么?

  • 我们为什么要使用 Kotlin 协程?

  • suspend 关键字有什么作用?

  • Kotlin 协程挂起和恢复是怎么实现的?

协程是什么

协程是一种支持程序主动挂起和恢复的技术,而且挂起和恢复在用户态下就可以完成,而不需要陷入内核态(在现代操作系统的环境下而言)。

协程和线程的区别

协程跟线程和进程不是一个层面的概念,协程是编程语言层面的概念,是对子程序的一种功能赋予,使得子程序支持挂起和恢复,而线程和进程是操作系统层面的概念。

协程是基于协作式多任务的,而线程是抢占式多任务。简单来说就是,协程是自己主动挂起的,而线程是被操作系统调度而被动挂起的,从而需要必须要陷入到内核态。

协程的优势

先来看两段代码,两段代码分别通过回调和协程的方式实现相同的功能。

getUserInfo { user ->
    getFriendList(user) { listStr ->
        userListText?.text = listStr
    }
}

fun getUserInfo(callback: (String) -> Unit) {
    thread {
        Thread.sleep(2000)
        Handler(Looper.getMainLooper()).post {
            callback("liwenquan")
        }
    }
}

fun getFriendList(user: String, callback: (String) -> Unit) {
    thread {
        Thread.sleep(2000)
        Handler(Looper.getMainLooper()).post {
            callback("friendList")
        }
    }
}
MainScope().launch(Dispatchers.Main) { 
     val user = getUserInfo()
     val friendList = getFriendList(user)
     userListText?.text = friendList
     Log.d(TAG, "userInfo is: $user")
}

suspend fun getUserInfo(): String {
    withContext(Dispatchers.IO) {
        delay(2000)
    }
    Log.d("liwenquanhhh", "is suspend right now")
    return "liwenquan"
}

suspend fun getFriendList(user: String): String {
    withContext(Dispatchers.IO) {
        delay(2000)
    }
    Log.d("liwenquanhhh", "is suspend right now")
    return "friendList"
}

可以看到,代码片段 2 中,2、3 代码是分别运行于不同的线程的,消除了回调之后多线程协作的操作难度直接就被抹平了,一些原本不可能实现的并发任务变得可能,甚至变得很简单。因此让复杂的并发代码,写起来变得简单且清晰,是协程的优势。

挂起和恢复

示例代码

先看一段示例代码,后续内容我们将基于这段代码来进行分析。

MainScope().launch {
    val user = getUserInfo()
    Log.d(TAG, "userInfo is: $user")
}

suspend fun getUserInfo(): String {
    withContext(Dispatchers.IO) {
        Thread.sleep(2000)
    }
    delay(2000)
    Log.d("liwenquanhhh", "suspend end")
    return "liwenquan"
}

suspend fun getWenquanInfo(): String {
    Log.d("liwenquanhhh", "getWenquanInfo")
    return "liwenquan"
}

反编译 suspend 函数

image

通过反编译字节码可以看到,带有suspend关键字的函数会被转化为一个带有Continuation类型参数的函数。我们在调用挂起函数的时候,会把上一层的Continuation传递给这个挂起函数。

suspend 的本质

public interface Continuation<in T> {
    public val context: CoroutineContext
//      相当于 onSuccess     结果   
//                 ↓         ↓
    public fun resumeWith(result: Result<T>)
}

interface CallBack {
    void onSuccess(String response);
}
// 1.3 之前代码或许更易理解
public interface Callback<T> {
  void onResponse(Call<T> call, Response<T> response);
  void onFailure(Call<T> call, Throwable t);
}

public interface Continuation<in T> {
    public val context: CoroutineContext
    public fun resume(value: T)
    public fun resumeWithException(exception: Throwable)
}

从上面的定义我们能看到:Continuation其实就是一个带有泛型参数的CallBack,其中

  1. context是Continuation将会使用的CoroutineContext;

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

从18~19行我们可以看到,有结果正常返回的时候,Continuation 调用 resume 返回结果,否则调用 resumeWithException 来抛出异常,简直与 Callback 一模一样。

image

以上这个从挂起函数转换成CallBack函数的过程,被称为:CPS 转换(Continuation-Passing-Style Transformation)。

协程的状态转移

本文接下来所展示的,并不是与编译器生成的字节码完全相同的代码,而是足够精确的,能够确保您理解其内部发生了什么的 Kotlin 代码。

挂起点转化为不同状态

Kotlin 编译器会确定函数何时可以在内部挂起,每个挂起点都会被声明为有限状态机的一个状态,每个状态又会被编译器用标签表示:

fun getUserInfo(completion: Continuation<Any?>) {
  // Label 0 -> 第一次执行
  withContext(Dispatchers.IO)
  // Label 1 -> 从 withContext 恢复
  delay(2000)
  // Label 2 -> 从 delay 恢复
  completion.resume("liwenquan")
}

为了更好地声明状态机,我们使用when语句来实现不同的状态:

fun getUserInfo(completion: Continuation<Any?>) {
  when(label) {
      // Label 0 -> 第一次执行
      withContext(Dispatchers.IO)
      // Label 1 -> 从 withContext 恢复
      delay(2000)
      // Label 2 -> 从 delay 恢复
      completion.resume("liwenquan")
  }
}

这时候的代码还不完整,因为各个状态之间无法共享信息,编译器会使用同一个Continuation对象在方法中共享信息。

状态间信息共享

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

  1. 保存必要的数据;

  2. 递归调用getUserInfo函数来恢复执行。

fun getUserInfo(completion: Continuation<Any?>) { 
class GetUserInfoStateMachine(
    // completion 参数是调用了 getUserInfo 的函数的回调
    completion: Continuation<Any?>
  ): ContinuationImpl(completion) {
    // suspend 的本地变量
    // 所有 CoroutineImpls 都包含的通用对象
    var result: Any? = null
    var label: Int = 0
    // 这个方法再一次调用了 getUserInfo 来切换状态机 (标签会已经处于下一个状态),参数是这个私有类实例
    // result 将会是前一个状态的计算结果
    override fun invokeSuspend(result: Any?) {
      this.result = result
      getUserInfo(this)
    }
  }
  ...
}

由于invokeSuspend函数将会再次调用getUserInfo函数,并且传入Continuation对象。此时,编译器还需要添加如何在状态之间切换的信息。

区分是否首次调用

首先需要知道的是:

  1. 函数是第一次被调用;

  2. 函数已经从前一个状态中恢复

做到这些需要检查Contunuation对象传递的是否是getUserInfoStateMachine类型:

fun getUserInfo(completion: Continuation<Any?>) {
  ...
  val continuation = completion as? GetUserInfoStateMachine ?: GetUserInfoStateMachine(completion)
  ...
}

如果是第一次调用,它将创建一个新的GetUserInfoStateMachine实例,并将completion实例作为参数接收,以便它记得如何恢复调用当前函数的函数。如果不是第一次调用,它将继续执行状态机 (挂起函数)。

整体代码分析

现在,我们来看看编译器生成的用于在状态间切换并分享信息的代码:

fun getUserInfo(completion: Continuation<Any?>) {
    class GetUserInfoStateMachine(
        // completion 参数是调用了 loginUser 的函数的回调
        completion: Continuation<Any?>
    ): ContinuationImpl(completion) {
        // 要在整个挂起函数中存储的对象
        // 所有 CoroutineImpls 都包含的通用对象
        var result: Any? = null
        var label: Int = 0
        // 这个函数再一次调用了 getUserInfo 来切换状态机 (标签会已经处于下一个状态) 
        // result 将会是前一个状态的计算结果
        override fun invokeSuspend(result: Any?) {
            this.result = result
            getUserInfo(this)
        }
    }
    val continuation = completion as? GetUserInfoStateMachine ?: GetUserInfoStateMachine(completion)
    when(continuation.label) {
        0 -> {
            // 错误检查
            throwOnFailure(continuation.result)
            // 下次 continuation 被调用时, 它应当直接去到状态 1
            continuation.label = 1
            // Continuation 对象被传入 withContext 函数,从而可以在结束时恢复 
            // 当前状态机的执行
            withContext((CoroutineContext)Dispatchers.getIO(), xxx, continuation)
        }
        1 -> {
            // 检查错误
            throwOnFailure(continuation.result)
            // 下次这 continuation 被调用时, 它应当直接去到状态 2
            continuation.label = 2
            // Continuation 对象被传入 withContext 方法,从而可以在结束时恢复 
            // 当前状态机的执行
            delay(2000L, continuation)
        }
        2 -> {
            // 错误检查
            throwOnFailure(continuation.result)
            // 恢复调用了当前函数的执行
            continuation.cont.resume("liwenquan")
        }
        else -> throw IllegalStateException(...)
    }
}

下面我们来看看编译器生成了什么:

  1. when语句的参数是GetUserInfoStateMachine实例内的label;

  2. 每一次处理新的状态时,为了防止函数被挂起时运行失败,都会进行一次检查;

  3. 在调用下一个挂起函数 (即 withContext) 前,GetUserInfoStateMachine的label都会更新到下一个状态;

  4. 在当前的状态机中调用另一个挂起函数时,continuation的实例 (GetUserInfoStateMachine类型) 会被作为参数传递过去。而即将被调用的挂起函数也同样被编译器转换成一个相似的状态机,并且接收一个continuation对象作为参数。当被调用的挂起函数的状态机运行结束时,它将恢复当前状态机的执行。

最后一个状态与其他几个不同,因为它必须恢复调用它的方法的执行。如代码中所见,它将调用 GetUserInfoStateMachine中存储的cont变量的resume函数。

我们在 Android Studio 中反编译(Tools -> Kotlin -> Show Kotlin Bytecode)看一下真实的代码,相信大家很容易就理解了。

小结

了解了编译器在底层所做的工作后:

  1. 我们可以更好地理解为什么挂起函数会在完成所有它启动的工作后才返回结果。

  2. 我们也能知道挂起的实现原理是:挂起函数的返回值为COROUTINE_SUSPENDED时返回,同时挂起点被保存在了状态机中(GetUserInfoStateMachine的label变量);而恢复时,从Continuation对象之中取出需要被执行的信息就可以了 。

思考

以上分析过后,我们可以思考一下如下两个问题:

  1. 伪挂起函数会是什么效果?
suspend fun getWenquanInfo(): String {
    Log.d("liwenquanhhh", "getWenquanInfo")
    return "liwenquan"
}
  1. 为什么挂起函数只能在协程或者另一个挂起函数中被调用?

总结

下面我们对本篇文章做一下总结:

协程是什么?

协程的概念最核心的点其实就是函数或者一段程序能够被挂起,待会儿再恢复,挂起和恢复是开发者的程序逻辑自己控制的,协程是通过主动挂起出让运行权来实现协作的。

在 Android 平台,我们可以把 Kotlin 协程作为一种基于线程来实现的更上层的工具 API 来使用,类似于 Java 自带的 Executor 系列 API 。线程池对开发者封装了线程,只需要往里面submit Runnable就可以了。而协程同时对开发者封装了线程和Callback,让开发者无需过多的关心线程也可以方便的写出并发操作。

挂起和恢复是怎么实现的?

当挂起函数的返回值为COROUTINE_SUSPENDED时返回,同时挂起点被保存在了状态机中(GetUserInfoStateMachine的label变量);而恢复时,从Continuation对象之中取出需要被执行的信息就可以了 。

参考文章

Bennyhuo 破解 kotlin 协程系列文章

扔物线协程系列文章

字节小站公众号协程主题文章

揭秘协程中的 suspend 修饰符

揭秘Kotlin协程中的CoroutineContext

一看就会!协程原来是这样啊~

Kotlin 协程,怎么开始的又是怎么结束的?原理讲解