kotlin协程硬核解读(3. suspend挂起函数&挂起和恢复的实现原理)

Posted open-Xu

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了kotlin协程硬核解读(3. suspend挂起函数&挂起和恢复的实现原理)相关的知识,希望对你有一定的参考价值。

版权声明:本文为openXu原创文章【openXu的博客】,未经博主允许不得以任何形式转载

上一篇文章中我们了解了协程的一些术语、相关类的作用和协程的基本使用,如果仅仅掌握这些也是可以上手使用协程了,但是如果在使用过程中遇到了一些莫名其妙的问题却找不到原因,不知道怎么解决甚至怀疑从一开始就使用错了,那是因为没有理解协程的实现原理。从这篇文章开始,我们从源码角度深入解读协程,当然源码跟踪只能从一个角度着手,不可能做到全面解读,但是打通了一条路线你会发现其他的分支都是相似的。

1. 自定义挂起函数

函数是对为实现某个功能或者计算某个结果的多行代码的封装,挂起函数也是一样,与普通函数不同的是挂起函数"通常"被放到其他线程(异步),并且能在不阻塞当前线程的情况下同步的得到函数的结果。不阻塞当前线程就是挂起,它指的是当协程中调用挂起函数时会记录当前的状态并挂起(暂停)协程的执行(释放当前线程),以达到非阻塞等待异步计算结果的目的。说白了就是不阻塞协程代码块所在的线程但暂停挂起点之后的代码执行,当挂起函数执行完毕再恢复挂起点后的代码执行。比如下面示例中,在主线程开启一个协程,调用挂起函数delay()延迟1s后在更新UI,与Thread.sleep不同的是delay不会阻塞主线程,这个延迟动作是在子线程中完成的。

CoroutineScope(Dispatchers.Main).launch{
    //UI线程,代码块中的代码按顺序一行行执行
    delay(1000)    //挂起点
    textView.text = "延迟1s" //续体
}

1.1 为什么需要自定义挂起函数

函数的作用就是对功能的封装,比如从服务器获取用户信息、将数据存在在本地数据库等都可以被封装成一个函数,如果把这个函数定义为普通的函数,在调用这些函数时就会阻塞当前线程(当前线程去执行这个函数就不能干别的事情了)。所以在android这种UI线程环境中我们通常需要开启子线程来调用这些函数,并在函数执行完毕后手动切回UI线程。如果将这些函数定义为挂起函数,这些步骤就可以让协程自动帮我们完成了,而我们关注的侧重点是函数功能代码的封装。Retrofit http请求客户端和Room数据库等添加了对协程的支持,可以将功能接口定义为挂起函数,而这些挂起函数通俗的说都属于自定义挂起函数(非协程库提供的挂起函数)。

挂起函数的目的是用来挂起协程的执行等待异步计算的结果,所以一个挂起函数通常有两个要点:挂起异步,接下来我们一步步来实现自定义挂起函数

1.2 suspend到底有什么用?

所有的挂起函数都由suspend关键字修饰,是不是有suspend修饰的函数都是挂起函数?答案是NO,比如:

//定义一个User实体类
data class User(val name:String)

//定义一个函数模拟耗时获取User对象
suspend fun getUser():User{
    println("假的挂起函数${Thread.currentThread()}")
    Thread.sleep(1000)
    return User("openXu")
}

getUser()函数有suspend修饰,但是IDE提示Remove redundant suspend modifier移除冗余的suspend修饰符,为什么呢?我们先搞清楚suspend到底是什么?它有什么作用?

suspend是kotlin中的修饰符,kotlin源码最终都将被编译为java的class执行,而java中并没有这个修饰符,所以suspend仅仅在编码和编译阶段起作用:

  • 在编码阶段:suspend仅仅作为一个标志,表示这是一个挂起函数,它只能在协程或者其他挂起函数中被调用,如果在普通函数中调用IDE会提示错误;并且它可以调用其他挂起函数
  • 在编译阶段:由suspend修饰的函数被编译为class后,函数会被增加一个Continuation(续体)类型的参数

借助Android Studio–>Tools菜单–>Kotlin–>Show Kotlin Bytecode–>Decompile查看kotlin对应的java源码

上面的getUser()方法被编译后对应的java代码如下:

   public static final Object getUser(@NotNull Continuation $completion) {
      String var1 = "假的挂起函数" + Thread.currentThread();
      boolean var2 = false;
      System.out.println(var1);
      Thread.sleep(1000L);
      //假挂起函数根本原因是函数返回值不是COROUTINE_SUSPENDED
      return new User("openXu");
   }

对于jvm来说,这就是一个参数为Continuation类型的普通函数,这个参数在函数体中并没有被使用,所以是一个多余的参数,而suspend的作用就是在编译时增加这个参数,所以suspend修饰符就是多余的。

怎样让suspend修饰符不多余?就是在函数体类要使用Continuation类型的参数,而这个参数是编译器自动添加的,在编码阶段肯定是没办法使用,只能在运行阶段去使用,怎样在运行阶段使用它呢?答案就是调用协程库提供的挂起函数。要真正实现挂起,必须调用一些协程库中定义的顶层挂起函数,只有这些库自带的挂起函数才能真正实现协程的挂起,而调用他们的地方才是真正的挂起点(真正的挂起操作是这些顶层挂起函数内部调用了trySuspend()并返回了COROUTINE_SUSPENDED标志使得当前线程退出执行从而挂起协程)。

1.3 不完全挂起函数(组合挂起函数)

为了真正挂起协程就要调用协程库中的挂起函数,协程库的挂起函数很多,是不是随便调用一个就ok呢?比如:

suspend fun getUser():User{
	//调用自带的挂起函数实现挂起
	delay(1000)          //真正的挂起点
	//以下为函数真正的耗时逻辑
    Thread.sleep(1000)        //模拟耗时
    return User("openXu")
}

在getUser()中调用了delay(),IDE不再提示suspend多余(通过查看反编译后的java代码发现Continuation参数确实在函数体中被使用),但是这个挂起对getUser()并没有意义,我们分析getUser()的执行,首先在挂起作用域中调用这个函数,函数体第一句调用了delay()挂起了协程,协程所在的线程(当前线程)将会停止继续执行(非阻塞),直到1s延迟完成,协程将恢复当前线程继续执行下面的函数代码,也就是说函数体一部分耗时计算不是在协程被挂起的状态下执行的,而是直接运行在协程所在的线程(执行式阻塞当前线程),这种函数称为不完全挂起函数

协程中并没有关于不完全挂起函数的定义,为了方便大家更好的理解挂起函数,笔者结合实际在定义挂起函数时的问题自创了这个名词,其实它就是一个组合挂起函数(在函数中调用其他挂起函数)

之前项目开发过程中就遇到过不完全挂起函数造成卡顿的问题,项目使用了Retrofit+协程,将接口方法定义为挂起函数:

@GET("tree/json")
suspend fun getTree(): ApiResult<MutableList<Category>>

通过viewModelScope.launch{}或者MainScope().launch {}在UI线程中启动协程,然后直接调用挂起接口函数从服务器请求数据:

viewModelScope.launch {
    try {
        if(showDialog) dialog.value = true  //UI线程,修改DialogLiveData值为true,显示请求对话框
        //×××错误的方式:调用不完全挂起函数
        val category = RetrofitClient.apiService.getTree()

        //√√√正确的方式:将不完全挂起函数的未挂起部分挂起
        /*val category = withContext(Dispatchers.IO){
        	RetrofitClient.apiService.getTree()
        }*/

        if(showDialog) dialog.value = false //UI线程
    } catch (e: Exception) {
        if(showDialog)
            dialog.value = false
        onError(e, showErrorToast)
    }
}

每次应用程序启动后第一次调用这个接口请求数据时,请求对话框都会延迟一会儿才能显示或者卡顿一会儿,再次请求这个接口就不会卡了。刚开始以为是项目太大接口太多,或者因为模块化开发导致Retrofit需要做的事情太多了造成卡顿,但是不知道怎么解决,后来研究挂起函数后才明白,**自定义挂起接口方法getTree()不就是个不完全挂起函数吗?**调用getTree()方法后,Retrofit通过反射创建接口代理对象、解析接口方法注解和参数,创建Call对象等操作都是协程当前线程(UI线程)执行的,只有真正调用call.enqueue()的地方才挂起协程,这就造成了主线程的阻塞;为什么只有第一次卡顿呢?Retrofit将解析后的接口方法ServiceMethod缓存到了serviceMethodCache的Map中,下次再调用这个接口方法时,就不需要去解析方法注解和参数了,直接从Map中取就可以了。

Retrofit只有在真正执行请求的时候才调用协程库的挂起函数suspendCancellableCoroutine()挂起协程,可在retrofit2.KotlinExtensions.kt文件中查看源码

//ServiceMethod的adapt()中调用call的扩展函数await(),并传入continuation作为参数
//这种调用方式看起来有些奇怪,其实就是java调用kotlin代码
KotlinExtensions.await(call, continuation);

/**Call的扩展方法,被定义在retrofit2.KotlinExtensions.kt文件中*/
suspend fun <T : Any> Call<T>.await(): T {
    return suspendCancellableCoroutine { continuation ->
        ...
        //发起请求:相当于this.enqueue,而扩展方法中的this就是被扩展的类也就是call对象
        enqueue(object : Callback<T> {
            override fun onResponse(call: Call<T>, response: Response<T>) {
                if (response.isSuccessful) {
                    val body = response.body()
                    //恢复协程执行,返回响应结果
                    continuation.resume(body)
                } else {
                	//恢复协程执行,抛出一个异常
                    continuation.resumeWithException(HttpException(response))
                }
            }
            ...
        })
    }
}

怎样避免不完全挂起函数造成的线程阻塞(主线程执行了函数的一部分耗时代码)?就是让自定义挂起函数的整个函数体{}都是在协程挂起之后执行,通常将函数体写为Lambda表达式作为参数传递给顶层挂起函数。

1.4 真正的、完全的挂起函数

协程库定义了以下顶层挂起函数方便我们自定义挂起函数:

//①. 不常用
public suspend inline fun <T> suspendCoroutine(crossinline block: (Continuation<T>) -> Unit): T
//②. 常用
public suspend inline fun <T> suspendCancellableCoroutine(
    crossinline block: (CancellableContinuation<T>) -> Unit
): T

这两个函数的作用是捕获当前的协程的续体对象(下面会讲到的SuspendLambda对象)作为参数,其实是SuspendLambda中调用到挂起函数时将this作为参数传入的。通常被用于定义自己的挂起函数。它们都调用了另一个顶层挂起函数suspendCoroutineUninterceptedOrReturn()用于对参数续体对象进行包装,然后执行作为参数传入的代码块block,在等待恢复信号期间(代码块在未来某一时刻调用续体的resume系列方法)挂起协程的执行。

这两个函数的区别是,suspendCancellableCoroutine()函数会用将续体对象拦截包装为一个CancellableContinuation类型,CancellableContinuation是一个可以cancel()取消的续体,用于控制协程的生命周期。尽管协程库提供了不可取消的suspendCoroutine()函数,但推荐始终选择使用suspendCancellableCoroutine()处理协程作用域的取消,从底层API取消事件传播。

下面我们就通过调用suspendCancellableCoroutine改造一下自己的挂起函数:

//调用suspendCancellableCoroutine(),将函数体作为参数传入
suspend fun getUser(): User = suspendCancellableCoroutine {
	//被拦截后的可取消续体对象
    cancellableContinuation ->
    println("挂起函数执行线程${Thread.currentThread()}") //Thread[main,5,main]
    Thread.sleep(3000)
    cancellableContinuation.resume(User("openXu"))
    cancellableContinuation.cancel()
}

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
	//在主线程中开启一个协程
	CoroutineScope(Dispatchers.Main).launch{
	    showProgressDialog()       //UI:显示进度框
	    val user = getUser()       //挂起点
	    tv.text = user.name        //更新UI
	    dismissProgressDialog()    //UI:隐藏进度框
	}
}

getUser()函数直接被赋值为协程库提供的挂起函数,函数体是作为参数传入的,这样调用getUser()的地方就相当于调用了suspendCancellableCoroutine(),会立马挂起协程,这样getUser()才是真正的、完全的挂起函数

上述示例是在Activity环境中,在UI线程开启一个协程后调用挂起函数getUser(),并且在之前和之后显示和隐藏进度圈,运行项目可以观察到进度圈显示后,卡顿了3s然后隐藏。

不是说挂起不会阻塞当前线程吗?为什么还会卡顿?因为我们并没有指定挂起函数执行的线程,默认就在当前UI线程调度了,就相当于在UI线程进行了耗时操作。目前我们的自定义挂起函数只是实现了挂起,但这个挂起并没有太大意义,因为是单线程的,所以为了实现挂起不阻塞主线程,还缺少异步。

挂起函数不一定是在子线程执行的。如果你在其他文章中看到别人说挂起函数是在子线程中执行的,听话:鼠标移到浏览器右上角,看见红色叉叉了吗?叉掉它。

1.5 异步挂起函数

我们对getUser()函数进行改造,在black代码块中创建一个子线程,使得函数体代码运行在子线程中,运行项目就不会出现卡顿了:

suspend fun getUser(): User = suspendCancellableCoroutine {
    cancellableContinuation ->
    //创建子线程实现异步
    Thread {
        try {
            Thread.sleep(3000)
            when(Random.nextInt(10)%2){ 
                0->{ //10以内随机数如果是偶数返回成功
                    cancellableContinuation.resume(User("openXu"))
                    cancellableContinuation.cancel()
                }
                1-> throw Exception("模拟异常")
            }
        }catch (e:Exception){
        	//通过resumeWithException()用一个异常恢复协程执行
            cancellableContinuation.resumeWithException(e)
        }
    }.start()
}

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
	//在主线程中开启一个协程
	CoroutineScope(Dispatchers.Main).launch{
		showProgressDialog(null)   //UI:显示进度框
        try {
            val user = getUser() //挂起点
      		tv.text = user.name        //更新UI
        }catch (e:Exception){
            FLog.d("挂起异常${e.message}")
        }
        dismissProgressDialog()    //UI:隐藏进度框
	}
}

1.6 withContext()

上面通过3步我们自定义了一个挂起函数:

  • 函数使用suspend修饰
  • 调用协程库提供的suspendCoroutine()或(强制推荐)suspendCancellableCoroutine()挂起函数实现真正、完全的挂起
  • 开启子线程执行函数体,最后通过Continuation续体对象返回函数结果恢复协程执行

步骤是非常清晰,但是代码量和代码清洁度不容乐观,有没有更方便的方式自定义挂起函数呢?协程库提供了withContext()函数,严格说起来它并不是用来自定义挂起函数的,而通常用于线程切换,只是它恰好能实现挂起异步这两个要素,并且还接受一个函数block作为参数:

suspend fun getUser(): User = withContext(Dispatchers.IO) {
    Thread.sleep(3000)
    when(Random.nextInt(10)%2){ //10以内随机数如果是偶数返回成功
        0->User("openXu")
        else-> throw Exception("模拟异常")
    }
}

1.7 withContext()和suspendCancellableCoroutine()怎么选?

//1. withContext()
public suspend fun <T> withContext(
    context: CoroutineContext,
    block: suspend CoroutineScope.() -> T
): T {
	...
    return suspendCoroutineUninterceptedOrReturn sc@ { uCont ->
		...
    }
}

//2. suspendCancellableCoroutine()
public suspend inline fun <T> suspendCancellableCoroutine(
    crossinline block: (CancellableContinuation<T>) -> Unit
): T =
    suspendCoroutineUninterceptedOrReturn { uCont ->
        ...
    }
//都是通过suspendCoroutineUninterceptedOrReturn()实现的
public suspend inline fun <T> suspendCoroutineUninterceptedOrReturn(crossinline block: (Continuation<T>) -> Any?): T {
    contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
    ...
}

withContext()suspendCancellableCoroutine()函数都是协程库提供的顶层挂起函数,发现他们都调用了suspendCoroutineUninterceptedOrReturn()来捕获协程的续体对象,并且都接受一个函数类型作为参数,这就为自定义挂起函数提供了可行性。但是究竟该选用哪个函数呢?withContext()使用更简洁所以都用它就完事了?

withContext()可接受协程上下文作为参数,这样我们可以传入Dispatchers调度器自动切换挂起函数执行的线程;而suspendCancellableCoroutine()并不具备切换线程的能力,通常需要我们手动创建线程。所以当函数体已经具备异步能力的就选suspendCancellableCoroutine(),而不具备异步能力(需要手动切线程)的就选withContext()

需要注意的是,如果使用withContext()则直接通过return返回计算结果或者通过throw抛异常来返回错误从而恢复协程执行;而用suspendCancellableCoroutine()的时候则通过续体continuationresume系列方法来实现协程恢复

比如Retrofit对挂起函数的支持,在执行请求的时候调用的Call扩展函数await()中就是使用suspendCancellableCoroutine(),因为函数体中调用的Call.enqueue()已经具备异步能力,就没必要再切子线程了。Call还有一个同步请求的方法execute(),如果我们调用这个方法请求数据的话,就可以通过withContext()来实现自定义挂起函数了:


//Retrofit源码
suspend fun <T : Any> Call<T>.await(): T {
  return suspendCancellableCoroutine { continuation ->
    //调用Call的enqueue()进行异步请求
    enqueue(object : Callback<T> {
      override fun onResponse(call: Call<T>, response: Response<T>) {
        if (response.isSuccessful) {
		val body = response.body()
		continuation.resume(body)//恢复协程执行,返回请求结果
        } else {
          continuation.resumeWithException(HttpException(response))//恢复协程执行,抛异常
        }
      }
      ...
    })
  }
}

//使用withContext()改造
suspend fun <T : Any> Call<T>.await(): T {
    return withContext(Dispatchers.IO){
        val response = execute()   //调用同步请求
        if (response.isSuccessful) {
            val body = response.body()
            return@withContext body!! //恢复协程执行,返回请求结果
        } else {
            throw HttpException(response)//恢复协程执行,抛异常
        }
    }
}

2. 挂起、恢复的实现细节

上面通过示例了解了在开发中应该怎样将一个耗时函数定义为挂起函数,接下来我们继续探索挂起函数底层是怎么实现协程的挂起和恢复的

2.1 Continuation续体

Continuation是一个接口,它有一个子接口CancellableContinuation,从名字上可以看出它表示可以cancel的续体,它们的实现类ContinuationImpl
CancellableContinuationImpl都是internal修饰的(内部使用),**协程库根本没打算让我们直接创建续体对象,挂起函数的续体对象都是通过suspendCoroutineUninterceptedOrReturn()函数自动获取的。**所以目前我们先不去扣它们的实现细节,只通过接口的定义来了解一下续体是什么,以及续体的作用:

Continuation接口:

  • 成员context: 当前协程的CoroutineContext上下文对象
  • resumeWith(result: Result<T>):用一个结果来恢复协程的执行,这个结果(成功or异常)被封装为Result对象内
  • 扩展函数resume()resumeWithException():这两个扩展函数是为了更方便我们调用的,毕竟直接调用resumeWith()需要我们手动将结果数据或者异常包装为Result对象

CancellableContinuation接口:

  • 成员isActiveisCompletedisCancelled:Boolean值,表示当前续体是否是活动状态、是否已完成、是否已取消
  • cancel(cause: Throwable? = null):可选的通过一个异常取消当前续体执行,因为可以取消,所以多了上面的3个状态属性

关于续体,我们现在需要了解到的是:续体是对挂起点之后代码块的封装,表示挂起函数执行完后需要恢复继续执行的代码。同时可以将它当作一个CallBack回调,因为可以调用其resume方法返回挂起点函数的计算结果(成功or失败)

suspend fun getUser(): User = suspendCancellableCoroutine{
    continuation ->
    ...
    //调用resume()返回函数结果,并恢复协程执行
    continuation.resume(User("openXu"))
}
fun main() {
    runBlocking {
        //调用挂起函数:将被隐式传入一个Continuation参数
        val user = getUser()    //挂起点
        //简单的理解为下面的代码就是挂起点1的续体(每个挂起点之后的代码,当协程恢复后需要执行的代码)
        println("请求结果$user")
        ...
    }

如果觉得上面示例中的注释还不直观,那将上面的代码手动改成如下方式,这样就更加通俗了:

//自定义续体接口
interface Continuation<T>{
	//续体的回调抽象方法,它的实现方法体就是对挂起点之后代码的封装
    fun resume(t:T)   
}
//getUser()函数接受一个续体对象作为参数
fun getUser(continuation: Continuation<User>) {
    ...
    //1. 作为回调,返回函数结果
    continuation.resume(User("openXu"))
}
fun main() {
    //调用getUser()函数,传入一个续体的匿名类对象作为参数
    getUser(object:Continuation<User>{
        override fun resume(user: User) {
            //2. getUser()挂起点之后的代码
            println("请求结果$user")
            ...
        }
    })
}

2.2 续体传递风格CPS

给一个普通函数加上suspend修饰符,函数被编译后会自动增加一个类型为Continuation的参数。每个挂起函数在被编译后都会附加一个Continuation类型的参数,在调用时隐式传入续体对象(在挂起函数中可通过续体对象的resume系列函数返回成功值或者异常来恢复协程的执行)。这就是CPS(Continuation-Passing-Style:续体传递风格)。

比如getUser()挂起函数的声明是这样的:

suspend fun getUser(): User

经过CPS变换后,将变为:

fun getUser(continuation: Continuation<User>): Any?
对应的java源码:
Object getUser(Continuation $completion)

函数的返回类型User被移动到符加的参数续体的泛型位置上,而返回值类型变成了Any?(Any相当于java中的Object)。为什么返回值类型变成了Any?我们通过源码来解决这个问题:

//①. 自定义挂起函数,获取一个User对象
suspend fun getUser(): User = suspendCancellableCoroutine{
    continuation ->
    //函数体block:偷个懒省略了线程切换的步骤,直接通过续体对象返回一个User对象恢复协程执行
    continuation.resume(User("openXu"))
}

//②. 这个函数上面已经讲过,用于自定义挂起函数的
public suspend inline fun <T> suspendCancellableCoroutine(
    crossinline block: (Canc

以上是关于kotlin协程硬核解读(3. suspend挂起函数&挂起和恢复的实现原理)的主要内容,如果未能解决你的问题,请参考以下文章

kotlin协程硬核解读(3. suspend挂起函数&挂起和恢复的实现原理)

kotlin协程硬核解读(3. suspend挂起函数&挂起和恢复的实现原理)

kotlin协程硬核解读(6. 协程调度器实现原理)

kotlin协程硬核解读(6. 协程调度器实现原理)

kotlin协程硬核解读(6. 协程调度器实现原理)

kotlin协程硬核解读(6. 协程调度器实现原理)