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

本文详细介绍了如何自定义挂起函数,并通过源码解析了挂起与恢复的实现原理。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

版权声明:本文为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}")</
评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

open-Xu

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值