Kotlin 协程 - 挂起函数 Suspend Function

本文详细解析了Kotlin协程中的挂起函数(suspend()),包括挂起恢复过程、非阻塞执行优势、suspend关键字的作用,以及Continuation的实现原理。讨论了如何自定义挂起函数,如官方挂起函数封装和回调函数改造,并涉及面试中可能的问题。

参考文章1

参考文章2

一、概念

  • 函数类型:suspend () → Unit
  • 挂起:挂起函数挂起的是父协程,让协程和线程分离让出线程,等挂起函数执行完返回到父协程中,恢复在之前的线程上执行后续代码。
  • 本质:暂停当前任务,中途去做其它事情,做完后回来继续。同样是切线程做其他事情,只是做完了可以回来继续做之前的事。
  • 作用:和普通函数封装功能一样,挂起函数通常被放到其他线程中执行,能更方便的指定线程而不用担心调用时出现切换问题。
  • 限制:挂起函数“挂起恢复”的特性只能在协程环境下实现,因此只能在其它挂起函数或协程中被调用,创建的只能是子协程。
  • 原理:协程会被编译成 switch 语句(状态机模式),每个挂起函数都是其中的一个 case 分支,每个 case 依赖前面的 case,这就是协程切换与挂起停止的原理。

1.1 挂起恢复的过程

①挂起函数挂起的是父协程。

  • 此时被挂起的父协程:代码不会继续往下执行(即写在挂起函数下面的那些,而挂起函数上面的代码没执行完的话,挂起函数指定在相同线程执行会等挂起函数执行完再执行,指定在其它线程则继续并发执行)。
  • 此时被挂起的父协程所在的线程:不会阻塞而是脱离当前任务去执行其他任务或无事可干(就是它原本受系统调度的样子,回收或再利用)。

②父协程被挂起后,挂起函数在它指定的线程中执行自己的代码。

③挂起函数执行完后,携带结果恢复到父协程中,父协程从之前的进度继续往下执行。

1.2 非阻塞式

        单线程是阻塞式的,当前任务没做完就不会执行后面的代码,多线程是切到别的线程执行就不会阻塞之前的线程。

        对比其它基于 Java 的多线程解决方案,协程借助 Kotlin 语言简洁的优势以及“挂起恢复”的特性消除了回调嵌套,即原先串行写的代码现在并行来写(让串行嵌套的异步代码像同步那样并行编写),逻辑直观并消除模板代码(Java切线程会回调里嵌套回调),降低了多线程异步之间协作任务的操作难度(调用起来不用考虑任务是执行在哪个线程)。

1.3 suspend 关键字

编码阶段用来限制该函数只能在协程环境中被调用,因为“挂起恢复”只有在协程环境中才能实现。由于挂起函数通常被拿来包装子线程耗时任务,因此也有该提醒的意思。在函数体中调用了由协程库提供的挂起函数,才会使用到 continuation 参数,suspend 关键字才不会多余,不然 AndroidStudio 会提示可去掉。
编译阶段由 suspend 修饰的函数编译后会增加一个形参 Continuation(续体)。当调用其它挂起函数的时候,会将当前协程作为 Continuation 参数进行传递(因为所有协程都是 AbstractContinuation 的子类,而它继承了 Continuation、Job、CoroutineScope,因此不管调用方是协程作用域对象、协程构建器、挂起函数,都是协程续体可传递给被调用的挂起函数),因此挂起函数只能在协程环境中被调用。编译后的函数体中就可以通过该续体对象调用 resume 系列函数来恢复到父协程或取消当前协程。这就是CPS(Continuation Passing Style)续体传递方式。

函数类型从 suspend(Int) -> Boolean 变成 (Int, Continuation<Boolean>) -> Any?

suspend fun show(num: Int): Boolean
//编译后
//增加参数 continuation,原返回值类型 Boolean 成为 Continution<Boolean> 持有的泛型
//返回值 Any? 是由于父协程是否挂起返回的东西不同:
    //挂起返回的是枚举值 COROUTINE_SUSPEND
    //未挂起返回的是结果值的类型或异常,详见 getResult() 源码
fun show(num: Int, continuation: Continuation<Boolean>): Any? {
    //状态机实现,包含挂起恢复逻辑
    //如果 show() 内部没有挂起点,不会生成状态机,通常直接计算后返回结果。
}

1.4 挂起点

从语法上说是调用一个挂起函数的位置。真正的挂起点是该挂起函数中调用了由协程库提供的原始挂起函数,这些库函数调用了 trySuspend() return了一个枚举值 COROUTINE_SUSPENDED 作为标记,使得当前函数退出执行从而被挂起。

二、协程续体 Continuation

        是对父协程中排在该挂起函数之后的代码的封装(因此这些后续执行的父协程代码就拿到了该挂起函数中的结果),可以看做该挂起函数执行完恢复到父协程后要做的事,也就是回调。

        Continuation 是一个接口,子接口 CancellableContinuation 表示可以取消的续体。他们的实现类 ContinuationImpl 和 CancellableContinuationImpl 都是 intel 修饰的,意味着 Continuation 对象不是通过手动创建,而是由编译器生成。

Continuation

1.持有一个属性保存协程上下文。

2.定义抽象方法 resumeWith() 携带结果恢复到父协程。

BaseContinuationImpl

1.实现了 resumeWith() 并设为 final。

2.定义了 invokeSuspend() 抽象函数由Kotlin编译器自动实现,该方法是状态机的入口。

CancellableContinuation

1.定义抽象方法 cancel() 用来取消协程。

2. invokeOnCancellation() 用来释放资源

3.各种方法判断该协程状态。

ContinuationImpl

增加了intercepted()拦截器功能,实现线程调度等。

CancellableContinuationImpl

trySuspend() 挂起父协程

BaseContinuationImpl实现了 resumeWith() 携带结果返回到父协程,定义了 invokeSuspend() 抽象函数由Kotlin编译器自动实现,该方法是状态机的入口。
ContinuationImpl继承自BaseContinuationImpl,增加了intercepted()拦截器功能,实现线程调度等。
SuspendLambda继承自ContinuationImpl,是对 suspend{} 挂起代码块的封装(挂起函数作为函数参数就是suspend lambda 形式)。
编译生成的匿名对象

2.1 源码 Continuation

是对父协程中排在该挂起函数之后的代码的封装(因此这些后续执行的父协程代码就拿到了该挂起函数中的结果),可以看做该挂起函数执行完恢复到父协程后要做的事,也就是回调。

public interface Continuation<in T> {
    //保存协程的上下文。
    public val context: CoroutineContext
    //携带结果恢复到被挂起的父协程中,结果被封装在Result对象中。
    //可以是:Result .success(传值)、Result .failure(传异常)。
    //为了方便调用可以使用下面两个扩展函数,不然需要手动将值或异常包装为Result对象使用。
    public fun resumeWith(result: Result<T>)
}
//携带值恢复。
public inline fun <T> Continuation<T>.resume(value: T): Unit = resumeWith(Result.success(value))
//携带异常恢复。
public inline fun <T> Continuation<T>.resumeWithException(exception: Throwable): Unit = resumeWith(Result.failure(exception))

2.2 源码 CancellableContinuation

    public interface CancellableContinuation<in T> : Continuation<T> {
        //检查续体状态
        public val isActive: Boolean
        public val isCancelled: Boolean
        public val isCompleted: Boolean
        //使用可选的异常来结束掉这个续体,成功返回true。
        public fun cancel(cause: Throwable? = null): Boolean
        //当续体被取消或抛出异常时调用,一般用来释放资源。
        public fun invokeOnCancellation(handler: CompletionHandler)
    }

    2.3 源码 CancellableContinuationImpl

    internal open class CancellableContinuationImpl<in T>(
        final override val delegate: Continuation<T>,
        resumeMode: Int
    ) : DispatchedTask<T>(resumeMode), CancellableContinuation<T>, CoroutineStackFrame {
        internal fun getResult(): Any? {
            //父协程成功挂起,就返回COROUTINE_SUSPENDED
            if (trySuspend()) {... return COROUTINE_SUSPENDED }
            //否则就返回异常或值
            if (isReusable) { ... }    //如果父协程已恢复(或自己是伪挂起函数)
            if (state is CompletedExceptionally) { ... }    //如果父协程异常
            if (resumeMode.isCancellableMode) { ... }    //如果父协程已取消
            return getSuccessfulResult(state)    //如果父协程成功计算出值
        }
    }

    三、状态机

    3.1 状态划分

    有限状态机 Finite State Machine(FSM)是一种计算模型,描述系统如何响应输入或事件在不同状态之间移动。

    有限状态机组成部分协程对应实现
    有限状态集合“有限”这个词至关重要,我们必须在设计时能够穷举所有的状态。具有三个挂起点的协程将恰好有四个状态(初始状态加上每个挂起后的一个状态),没有不确定性(需要复杂的结构)或运行时才发现(需要昂贵的内存分配),将已知有限的情况转为简单高效的 swith 语句来跳转到不同分支执行。
    起始状态执行开始的地方,通常称为初始状态或STATE_0。从函数体开始的地方到遇到的第一个挂起函数之间。
    触发事件触发状态转换。可以是:子协程任务完成后的恢复、delay到期。
    状态转换规则挂起点充当转换。当调用挂起函数时,从当前状态转换到“等待”配置,当操作完成时,再次转换到下一个状态。
    动作在转换期间或在状态驻留期间执行的操作,即两个挂起点之间的业务代码。

    通过标签记住当前状态,以便 switch(lebel) 跳转到不同状态分支执行代码。第一个挂起函数 delay(1000) 之前的代码就是状态0,父协程执行到 delay(1000) 后被挂起并将标签设为1(switch开始执行分支1,即状态1中的代码)。此时来到状态1中,当子协程 delay(1000) 执行完后恢复到父协程中继续执行 println(1),然后遇到第二个挂起函数 delay(2000),父协程又被挂起并将标签设为2。此时来到状态2中,子协程 delay(2000) 执行完后恢复到父协程中继续执行 println(2)......以此类推。

    suspend fun demo() {
        println("0")    //状态0里的动作
        delay(1000)     //第一个挂起点(转换到状态1)
        println("1")    //状态1里的动作
        delay(2000)     //第二个挂起点(转换到状态2)
        println("2")    //状态2里的动作
    }

    在有限循环中调用挂起函数,最后一行执行完,循环没结束就是回到循环第一行代码所处的状态中,循环结束了就是进入到下一个状态。delay(1000)执行完进入状态1,delay(2000)执行完进入状态2,println(1.2)执行完,循环没结束就回到状态0执行println(0),循环结束了就继续执行状态1中的 println(1.2)。

    //状态0
    for(i in 0..100) {
        println("0")
        delay(1000)
        //状态1
        println("1.1")
    }
    println(1.2)
    delay(2000)
    //状态2

    在无限循环中调用挂起函数,最后一行执行完回到循环第一行代码所处的状态中。delay(1000)执行完进入状态1,delay(2000)执行完进入状态2,println(2)执行完回到状态0执行 println(0)。

    //状态0
    while(true) {
        println("0")
        delay(1000)
        //状态1
        println("1")
        delay(2000)
        //状态2
        println("2")
    }

    3.2 实现流程

    将字节码反编译成 Java 代码,具体实现的代码变过,仅作参考,弄懂也没用。

    1. 生成一个 requestWithSuspend() 方法,参数为传递进来的 Continuation 对象。
    2. 第一个 lebal{} 代码块里:
      1. 首先会判断是不是初次运行,是就创建一个对象(挂起函数会创建 ContinuationImpl 对象,挂起函数用作函数参数是Lambda形式会创建 SuspendLambda 对象),将传递进来的的 Continuation 对象用作构造的参数,相当于新的包装了旧的。不是初次运行则不会创建,保证在整个运行期间只会产生一个实例,极大的节省了内存。
      2. ContinuationImpl 对象中的字段,result 存储运行的结果,lebal 存储当前的状态用于 switch 语句切换分支执行不同状态的代码(代表下一步从哪继续执行),invokeSuspend() 会重新调用最外层的 requestWithSuspend()。
      3. 重新调用后由于不是初次运行,会跳出该代码块,进入到下一个 lebal{} 代码块,执行 switch() 语句。
    3. 第二个 lebal{} 代码块里,会嵌套众多 lebal{} 代码块,每一个代码块都是一个状态里的业务代码(注意lebal变量只是switch语句的状态),顺序是从里到外。执行完业务代码后就是遇到了下一个挂起点,会先进行存档再判断是否挂起成功,具体是先将 ContinuationImpl 对象的 lebal +1 并保存局部变量,将 ContinuationImpl 对象传递给下一个挂起函数(子协程通过该续体调用 resumeWith() 恢复回来,该方法会调用 invokeSuspend() 进而执行下一个状态的代码,也就是读档),然后判断是否挂起成功,成功就 return 常量 COROUTINE_SUSPENDED 结束父协程函数(挂起父协程,等待子协程调用 resumeWith() 再恢复执行),否则进入外层 lebal{} 代码块执行下一个状态的业务代码。嵌套最里层的那个 lebal{} 代码块里:
      1. 获取 ContinuationImpl 对象中的字段 reslut 存储每次子协程返回的临时结果(就可以给下一个状态用,也就是父协程后续代码可以拿到子协程返回的结果继续执行)。
      2. 有一个变量存着用来返回的挂起常量 COROUTINE_SUSPENDED。
      3. 有一个 switch() 语句根据 ContinuationImpl 对象中的字段 lebal 执行对应分支。只有第一个分支中包含了状态0的业务代码,其它分支里只进行异常判断,因为子协程执行完 resumeWith() 回来会进入到下一个 lebal 对应的分支中。是异常就抛出,不是就跳出当前 lebal{} 代码块执行外层 lebal{} 代码块,即下一个状态中的业务代码。

    Kotlin编译后会为每个协程生成一个 SuspendLambda 的匿名实现类对象(协程嵌套协程会生成多个):

    • 非阻塞式挂起:对于我们在协程函数体中写的代码,会以挂起函数为分割点,将代码分为多个部分(状态)填充到 invokeSuspend() 函数中。当协程开始执行时会首先调用一次 invokeSuspend() 触发初始化,当执行到挂起函数的时候,判断挂起成功会返回COROUTINE_SUSPEND标志,导致 invokeSuspend() 函数 return 停止执行。
    • 状态机:使用 lebal 标记嵌套的代码块方便内部 switch() 跳出,每一层嵌套都是一个挂起函数中的内容(内层是上一个,外层是下一个),每执行完一层就会将 lebal +1,最里层是一个 switch() 语句,当每次恢复调用进来会执行对应状态值的代码(判断Result携带的结果是异常就抛出,正常会跳出对应代码块也就是挂起函数执行完就跳出这层,然后执行内层的上一个挂起函数内容),由此保证了各个状态是按顺序执行(用同步的方式写出异步代码)。
    • Continuation传递:由于实现了Continuation,每次判断挂起成功 return 的时候,SuspendLambda 都会将自己作为续体传递过去。避免了每个挂起函数都需要创建续体对象。
    • 协程的恢复:当挂起函数执行完会调用续体的 resumeWith() 携带结果(值或异常)恢复,而该函数中又会调用 invokeSuspend(),根据状态机的状态值执行下一个状态的代码。
    • 初始调用:挂起函数被调用,编译器生成的状态机函数接收一个 Continuation 参数,label = 0。
    • 执行到挂起点:遇到 delay 等挂起函数,调用其底层实现,挂起协程并返回 COROUTINE_SUSPENDED。
    • 保存状态:label 更新为下一个状态,Continuation 保存上下文。
    • 恢复执行:调度器(如 Dispatchers.Default)在适当时间调用 Continuation.resumeWith,状态机根据 label 跳转到对应分支。
    • 完成或继续挂起:如果没有更多挂起点,返回最终结果;否则继续挂起。

    四、自定义挂起函数

    4.1 用官方挂起函数封装代码

    直接使用系统提供的挂起函数来封装代码是非常方便的,所有官方框架中的挂起函数都是可以取消的。

    • 什么时候定义:需要做耗时操作的时候(I/O、计算、等待)才会挂起当前的协程。
    • 解决了什么:创建者告知这是一个耗时操作,并指定了该协程在后台线程执行,保障了调用者的线程安全。
    • suspend 关键字:用来限制该函数只能在协程里调用或者在其他挂起函数里调用,因为“挂起->执行完->切回去”只有在协程中使用才能实现。真正挂起操作靠的是最终调用的那个协程自带的挂起函数。也有提醒“这是一个耗时操作,是挂起函数要在协程中使用”的意思。
    suspend fun getData(): String = withContext(Dispatchhers.IO) {
        apiService.getData()
    }

    4.2 对回调函数进行改造 suspendCancellableCoroutine

            对于已有的调用了回调的函数可以改造成挂起函数,onSuccess() 回调中通过 resume() 返回数据,onFailure() 回调中通过 resumeWithException() 返回异常,通过 invokeOnCancellation() 在协程被取消时释放资源,必须声明返回值类型(推荐)或对 suspendCancellableCoroutine() 使用泛型。

            不推荐使用不可取消的 suspendCoroutine() 进行包装,需要手动检查协程的取消并抛出 CancellationException 停止后续代码的执行,除非是该函数之后的业务代码关联性非常重要必须执行不能受协程取消的影响。详见:协程的取消

    public suspend inline fun <T> suspendCancellableCoroutine(
        crossinline block: (CancellableContinuation<T>) -> Unit
    ): T =
        //捕获当前协程的续体
        suspendCoroutineUninterceptedOrReturn { uCont ->
            //拦截续体,使之成为可取消的续体
            val cancellable = CancellableContinuationImpl(uCont.intercepted(), resumeMode = MODE_CANCELLABLE)
            //初始化续体
            cancellable.initCancellability()
            //调用我们的业务代码,并提供续体作为参数供使用
            block(cancellable)
            //通过续体获取结果并返回
            cancellable.getResult()
        }
    //改造前
    fun request() {
        getData(object: Callback {
            override fun onSuccess(str: String) { ... }
            override fun onFailure(exception: Throwable) { ... }
        })
    }
    //改造后
    //这里直接声明了返回值类型(推荐),也可以使用泛型 suspendCancelableCoroutine<String>{}
    suspend fun requestWithSuspend(): String = suspendCancelableCoroutine { cancelableContinuation ->
        getData(object: Callback) {
            //携带结果恢复
            override fun onSuccess(str: String) { cancelableContinuation.resume(str) }
            //携带异常恢复
            override fun onFailure(exception: Throwable) { cancelableContinuation.resumeWithException(exception) }
        }
        //取消
        cancellableContinuation.cancel()
        //被取消时的回调,用来释放资源
        cancellableContinuation.invokeOnCancellation { }
        //检查续体状态
        cancellableContinuation.isActive
        cancellableContinuation.isCancelled
        cancellableContinuation.isCompleted
    }

    五、其它系统挂起函数

    resume( )返回到上一个挂起的协程,并从之前的状态中恢复执行。

    delay( )

    会延迟协程执行的时间,不会当前阻塞线程,结束后继续执行协程。自带 isActive 检查机制。
    measureTimeMillis( )

    六、面试相关

    挂起函数不一定挂起协程:

    • 挂起函数不一定真的会挂起,如果只是提供了挂起的条件,但是协程没有产生异步调用,那么协程还是不会被挂起。
    • 当 async() 的返回值 Deferred 已经可以用时,await() 不会挂起协程而是直接返回结果。
    评论
    成就一亿技术人!
    拼手气红包6.0元
    还能输入1000个字符
     
    红包 添加红包
    表情包 插入表情
     条评论被折叠 查看
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

    当前余额3.43前往充值 >
    需支付:10.00
    成就一亿技术人!
    领取后你会自动成为博主和红包主的粉丝 规则
    hope_wisdom
    发出的红包
    实付
    使用余额支付
    点击重新获取
    扫码支付
    钱包余额 0

    抵扣说明:

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

    余额充值