Kotlin协程实战进阶之筑基篇3

电子说

1.3w人已加入

描述

5.协程上下文

CoroutineContext表示协程上下文,是 Kotlin 协程的一个基本结构单元。协程上下文主要承载着资源获取,配置管理等工作,是执行环境的通用数据资源的统一管理者。它有很多作用,包括携带参数,拦截协程执行等等。如何运用协程上下文是至关重要的,以此来实现正确的线程行为、生命周期、异常以及调试。

协程使用以下几种元素集定义协程的行为,它们均继承自CoroutineContext

  • Job:          协程的句柄,对协程的控制和管理生命周期。
  • CoroutineName:      协程的名称,可用于调试。
  • CoroutineDispatcher:   调度器,确定协程在指定的线程来执行。
  • CoroutineExceptionHandler:协程异常处理器,处理未捕获的异常。这里暂不做深入分析,后面的文章会讲解到,敬请期待。

协程上下文的数据结构特征更加显著,与List和Map非常类似。它包含用户定义的一些数据集合,这些数据与协程密切相关。它是一个有索引的 Element 实例集合。每个 element 在这个集合有一个唯一的Key

//协程的持久上下文。它是[Element]实例的索引集,这个集合中的每个元素都有一个唯一的[Key]。
public interface CoroutineContext {
    //从这个上下文中返回带有给定[key]的元素或null。
    public operator fun  get(key: Key<E>): E?

    //从[initial]值开始累加该上下文的项,并从左到右应用[operation]到当前累加器值和该上下文的每个元素。
    public fun  fold(initial: R, operation: (R, Element) -> R): R

    //返回一个上下文,包含来自这个上下文的元素和来自其他[context]的元素。
    public operator fun plus(context: CoroutineContext): CoroutineContext

    //返回一个包含来自该上下文的元素的上下文,但不包含指定的[key]元素。
    public fun minusKey(key: Key<*>): CoroutineContext

    //[CoroutineContext]元素的键。[E]是带有这个键的元素类型。
    public interface Key<E : Element>

    //[CoroutineContext]的一个元素。协程上下文的一个元素本身就是一个单例上下文。
    public interface Element : CoroutineContext {
        //这个协程上下文元素的key
        public val key: Key<*>

        public override operator fun  get(key: Key<E>): E?
    }
}
  • get(key): 可以通过key从这个上下文中获取这个Element元素或者null
  • fold():    提供遍历当前上下文中所有元素的能力。
  • plus(context): 顾名思义它是一个加法运算,多个上下文元素可以通过+的形式整合成一个上下文返回。
  • minusKey(key): 与plus相反,减法运算,删除当前上下文中指定key的元素,返回的是不包含指定
  • Element:    协程上下文的一个元素,本身就是一个单例上下文,里面有一个key,是这个元素的索引。

Element本身也实现了CoroutineContext 接口,像Int实现了List一样,为什么元素本身也是集合呢?主要是Element它不会存放除它自己以外的数据;Element属性又有一个key,是协程上下文这个集合中元素的索引。这个索引在元素里面,说明元素一产生就找到自己的位置。

注意:协程上下文的内部实现实际是一个单链表。

CoroutineName

//用户指定的协程名称。此名称用于调试模式。
public data class CoroutineName(
    //定义协程的名字
    val name: String
) : AbstractCoroutineContextElement(CoroutineName) {
    //CoroutineName实例在协程上下文中的key
    public companion object Key : CoroutineContext.Key<CoroutineName>
}

CoroutineName是用户用来指定的协程名称的,用于方便调试和定位问题:

GlobalScope.launch(CoroutineName("GlobalScope")) {
    launch(CoroutineName("CoroutineA")) {//指定协程名称
        val coroutineName = coroutineContext[CoroutineName]//获取协程名称
        print(coroutineName)
    }
}

协程内部可以通过coroutineContext这个全局属性直接获取当前协程的上下文。打印数据如下:

kotlin
复制代码[DefaultDispatcher-worker-2] CoroutineName(CoroutineA)

上下文组合

从上面的协程创建的函数中可以看到,协程上下文的参数只有一个,但是怎么传递多个上下文元素呢?CoroutineContext可以使用 " + " 运算符进行合并。由于CoroutineContext是由一组元素组成的,所以加号右侧的元素会覆盖加号左侧的元素,进而组成新创建的CoroutineContext

GlobalScope.launch {
    //通过+号运算添加多个上下文元素
    var context = CoroutineName("协程1") + Dispatchers.Main
    print("context == $context")

    context += Dispatchers.IO //添加重复Dispatchers元素,Dispatchers.IO 会替换 ispatchers.Main
    print("context == $context")

    val contextResult = context.minusKey(context[CoroutineName]!!.key)//移除CoroutineName元素
    print("contextResult == $contextResult")
}

注意:如果有重复的元素(key一致)则会右边的会代替左边的元素。打印数据如下:

context == [CoroutineName(协程1), Dispatchers.Main]
context == [CoroutineName(协程1), Dispatchers.IO]
contextResult == Dispatchers.IO

6.启动模式

CoroutineStart是一个枚举类,为协程构建器定义启动选项。在协程构建的start参数中使用,

启动模式 含义 说明
DEFAULT 默认启动模式,立即根据它的上下文调度协程的执行 是立即调度,不是立即执行,DEFAULT是饿汉式启动,launch调用后,会立即进入待调度状态,一旦调度器 OK 就可以开始执行。如果协程在执行前被取消,其将直接进入取消响应的状态。
LAZY 懒启动模式,启动后并不会有任何调度行为,直到我们需要它执行的时候才会产生调度 包括主动调用该协程的startjoin或者await等函数时才会开始调度,如果调度前就被取消,协程将直接进入异常结束状态。
ATOMIC 类似[DEFAULT],以一种不可取消的方式调度协程的执行 虽然是立即调度,但其将调度和执行两个步骤合二为一了,就像它的名字一样,其保证调度和执行是原子操作,因此协程也一定会执行。
UNDISPATCHED 类似[ATOMIC],立即执行协程,直到它在当前线程中的第一个挂起点。 是立即执行,因此协程一定会执行。即使协程已经被取消,它也会开始执行,但不同之处在于它在同一个线程中开始执行。

这些启动模式的设计主要是为了应对某些特殊的场景。业务开发实践中通常使用DEFAULTLAZY这两个启动模式就够了。

7.suspend 挂起函数

suspend 是 Kotlin 协程最核心的关键字,使用suspend关键字修饰的函数叫作挂起函数挂起函数只能在协程体内或者在其他挂起函数内调用。否则 IDE 就会提示一个错误:

Suspend function 'xxxx' should be called only from a coroutine or another suspend function

协程提供了一种避免阻塞线程并用更简单、更可控的操作替代线程阻塞的方法:协程挂起和恢复 。协程在执行到有suspend标记的函数时,当前函数会被挂起(暂停),直到该挂起函数内部逻辑完成,才会在挂起的地方resume恢复继续执行。

本质上,挂起函数就是一个提醒作用,函数创建者给函数调用者的提醒,表示这是一个比较耗时的任务,被创建者用suspend标记函数,调用者只需把挂起函数放在协程里面,协程会自动调度处理,完成后在原来的位置恢复执行。

注意:协程会在主线程中运行,suspend 并不代表后台执行。

如果需要处理一个函数,且这个函数在主线程上执行太耗时,但是又要保证这个函数是主线程安全的,那么您可以让 Kotlin 协程在 Default 或 IO 调度器上执行工作。在 Kotlin 中,所有协程都必须在调度器中运行,即使它们是在主线程上运行也是如此。协程可以 自行挂起(暂停) ,而调度器负责将其 恢复

挂起点

协程内部挂起函数调用的地方称为挂起点 ,或者有下面这个标识的表示这个就是挂起点。

挂起和恢复

协程在常规函数的基础上添加了suspendresume两项操作用于处理长时间运行的任务:

  • suspend:也称挂起或暂停,用于挂起(暂停)执行当前协程,并保存所有局部变量。
  • resume:恢复,用于让已挂起(暂停)的协程从挂起(暂停)处恢复继续执行。

Kotlin 使用堆栈帧来管理要运行哪个函数以及所有局部变量。 挂起 (暂停)协程时,会复制并保存当前的堆栈帧以供稍后使用,将信息保存到Continuation对象中。恢复协程时,会将堆栈帧从其保存位置复制回来,对应的Continuation通过调用resumeWith函数才会恢复协程的执行,然后函数再次开始运行。同时返回Result类型的成功或者异常的结果。

//Continuation接口表示挂起点之后的延续,该挂起点返回类型为“T”的值。
public interface Continuation<in T> {
    //对应这个Continuation的协程上下文
    public val context: CoroutineContext

    //恢复相应协程的执行,传递一个成功或失败的结果作为最后一个挂起点的返回值。
    public fun resumeWith(result: Result<T>)
}

//将[value]作为最后一个挂起点的返回值,恢复相应协程的执行。
fun  Continuation.resume(value: T): Unit =
    resumeWith(Result.success(value))

//恢复相应协程的执行,以便在最后一个挂起点之后重新抛出[异常]。
fun  Continuation.resumeWithException(exception: Throwable): Unit =
    resumeWith(Result.failure(exception))

Kotlin 的 Continuation 类有一个 resumeWith 函数可以接收 Result 类型的参数。在结果成功获取时,调用resumeWith(Result.success(value))或者调用拓展函数resume(value);出现异常时,调用resumeWith(Result.failure(exception))或者调用拓展函数resumeWithException(exception),这就是 Continuation 的恢复调用。

Continuation类似于网络请求回调Callback,也是一个请求成功和一个请求失败的回调:

public interface Callback {
  //请求失败回调
  void onFailure(Call call, IOException e);

  //请求成功回调
  void onResponse(Call call, Response response) throws IOException;
}

注意:suspend不一定真的会挂起,如果只是提供了挂起的条件,但是协程没有产生异步调用,那么协程还是不会被挂起。

那么协程是如何做到挂起和恢复?

suspend本质(夺命七步)

一个挂起函数要挂起,那么它必定得有一个挂起点,不然无法知道函数是否挂起,从哪挂起呢?

@GET("users/{login}")
suspend fun getUserSuspend(@Path("login") login: String): User

第一步 :将上面的挂起函数解析成字节码:通过AS的工具栏中Tools->kotlin->show kotlin ByteCode

kotlin
复制代码public abstract getUserSuspend(Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;

上面的挂起函数本质是这样的,你会发现多了一个参数,这个参数就是Continuation,也就是说调用挂起函数的时候需要传递一个Continuation给它,只是传递这个参数是由编译器悄悄传,而不是我们传递的。这就是挂起函数为什么只能在协程或者其他挂起函数中执行,因为只有挂起函数或者协程中才有Continuation

第二步 :这里的Continuation参数,其实它类似CallBack回调函数,resumeWith()就是成功或者失败回调的结果:

public interface Continuation<in T> {
    //协程上下文
    public val context: CoroutineContext

    //恢复相应协程的执行,传递一个成功或失败的[result]作为最后一个挂起点的返回值。
    public fun resumeWith(result: Result<T>)
}

第三步 :但是它是从哪里传进来的呢?这个函数只能在协程或者挂起函数中执行,说明Continuation很有可能是从协程充传入来的,查看协程构建的源码:

public fun CoroutineScope.launch(): Job {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
        LazyStandaloneCoroutine(newContext, block) else
        StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}

第四步 :通过launch启动一个协程的时候,他通过coroutinestart方法启动协程:

public fun  start(start: CoroutineStart, receiver: R, block: suspend R.() -> T) {
    initParentJob()
    start(block, receiver, this)
}

第五步 :然后start方法里面调用了CoroutineStartinvoke,这个时候我们发现了Continuation:

public operator fun  invoke(block: suspend () -> T, completion: Continuation<T>): Unit =
    when (this) {
        DEFAULT -> block.startCoroutineCancellable(completion)
        ATOMIC -> block.startCoroutine(completion)
        UNDISPATCHED -> block.startCoroutineUndispatched(completion)
        LAZY -> Unit // will start lazily
    }

第六步 :而 Continuation通过block.startCoroutine(completion)传入:

public fun  (suspend () -> T).startCoroutine(completion: Continuation

第七步 :最终回调到上面ContinuationresumeWith()恢复函数里面。这里可以看出协程体本身就是一个Continuation,这也就解释了为什么必须要在协程内调用suspend挂起函数了。(由于篇幅原因这里不做深入分析,后续的文章会分析这里,敬请期待!)

额外知识点:在创建协程的底层源码中,创建协程会返回一个Continuation实例,这个实例就是套了几层马甲的协程体,调用它的resume可以触发协程的执行。

任何一个协程体或者挂起函数中都隐含有一个Continuation实例,编译器能够对这个实例进行正确的传递,并将这个细节隐藏在协程的背后,让我们的异步代码看起来像同步代码一样。

@GET("users/{login}")
suspend fun getUserSuspend(@Path("login") login: String): User

GlobalScope.launch(Dispatchers.Main) {//开始协程:主线程
   val result = userApi.getUserSuspend("suming")//网络请求(IO 线程)
   tv_name.text = result?.name //更新 UI(主线程)
}

launch()创建的这个协程,在执行到某一个suspend挂起函数的时候,这个协程会被挂起,从当前线程挂起。也就是说这个协程从正在执行它的线程上脱离,这个协程在挂起函数指定的线程上继续执行,当协程的任务完成时,再resume恢复切换到原来的线程上继续执行。

在主线程进行的 suspendresume 的两个操作, 既实现了将耗时任务交由后台线程完成,保障了主线程安全 ,也在不增加代码复杂度和保证代码可读性的前提下做到不阻塞主线程的执行。可以说,在 Android 平台上协程主要就用来解决异步和切换线程这两个问题。

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分