Kotlin进阶-组合挂起函数、协程上下文与调度器

一.组合挂起函数

1.默认顺序调用

假设我们在不同的地方定义了两个进行某种调用远程服务或者进行计算的挂起函数。我们只假设它们都是有用 的,但是实际上它们在这个示例中只是为了该目的而延迟了一秒钟。

image.png

如果需要按 顺序 调用它们,我们接下来会做什么——首先调用 doSomethingUsefulOne 接下来 调用 doSomethingUsefulTwo ,并且计算它们结果的和,我们使用普通的顺序来进行调用,因为这些代码是运行在协程中的,只要像常规的代码一样 顺序 都是默认的。 下面的示例展示了测量执行两个挂起函数所需要的总时间:

image.png

运行结果:

image.png

2.使用 async 并发

如果 doSomethingUsefulOne 与 doSomethingUsefulTwo 之间没有依赖,并且我们想更快的得到结 果,让它们进行 并发 吗?这就是 async 可以帮助我们的地方。
在概念上,async 就类似于 launch。它启动了一个单独的协程,这是一个轻量级的线程并与其它所有的协程一起 并发的工作。不同之处在于 launch 返回一个 Job 并且不附带任何结果值,而 async 返回一个 Deferred —— 一个轻量级的非阻塞 future,这代表了一个将会在稍后提供结果的 promise。你可以使用 .await() 在 一个延期的值上得到它的最终结果,但是 Deferred 也是一个 Job,所以如果需要的话,你可以取消它。

image.png

运行结果:

image.png

这里快了两倍,因为两个协程并发执行。请注意,使用协程进行并发总是显式的。

3.惰性启动的 async

async 可以通过将 start 参数设置为 CoroutineStart.LAZY 而变为惰性的。在这个模式下,只有结果通过 await 获取的时候协程才会启动,或者在 Job 的 start 函数调用的时候。运行下面的示例:

image.png

运行结果:

image.png

因此,在先前的例子中这里定义的两个协程没有执行,控制权在于程序员准确的在开始执行时调用 start。 我们首先调用 one,然后调用 two,接下来等待这个协程执行完毕。
注意,如果我们只是在 println 中调用 await,而没有在单独的协程中调用 start,这将会导致顺序行为,直到 await 启动该协程 执行并等待至它结束,这并不是惰性的预期用例。在计算一个值涉及挂起函数时,这个
async(start = CoroutineStart.LAZY) 的用例用于替代标准库中的 lazy 函数(什么时候使用什么时候启动)。

4.async ⻛格的函数

我们可以定义异步⻛格的函数来 异步 的调用 doSomethingUsefulOne 和 doSomethingUsefulTwo 并 使用 async 协程建造器并带有一个显式的 GlobalScope 引用。我们给这样的函数的名称中加上“......Async”后 缀来突出表明:事实上,它们只做异步计算并且需要使用延期的值来获得结果。

image.png

注意,这些 xxxAsync 函数不是 挂起 函数。它们可以在任何地方使用。然而,它们总是在调用它们的代码中意 味着异步(这里的意思是 并发 )执行。

image.png

这种带有异步函数的编程⻛格仅供参考,因为这在其它编程语言中是一种受欢迎的⻛格。在 Kotlin 的协程 中使用这种⻛格是强烈不推荐的,原因如下所述。

考虑一下如果 val one = somethingUsefulOneAsync() 这一行和 one.await() 表达式这里在代码 中有逻辑错误,并且程序抛出了异常,以及程序在操作的过程中中止,将会发生什么。通常情况下,一个全局的异 常处理者会捕获这个异常,将异常打印成日记并报告给开发者,但是反之该程序将会继续执行其它操作。但是这里我们的 somethingUsefulOneAsync 仍然在后台执行,尽管如此,启动它的那次操作也会被终止。这个程序将不会进行结构化并发。

5.使用 async 的结构化并发

让我们使用使用 async 的并发这一小节的例子并且提取出一个函数并发的调用 doSomethingUsefulOne 与 doSomethingUsefulTwo 并且返回它们两个的结果之和。由于 async 被定义为了 CoroutineScope 上 的扩展,我们需要将它写在作用域内,并且这是 coroutineScope 函数所提供的:

image.png

这种情况下,如果在 concurrentSum 函数内部发生了错误,并且它抛出了一个异常,所有在作用域中启动的 协程都会被取消。

image.png

从上面的 main 函数的输出可以看出,我们仍然可以同时执行这两个操作:

image.png

取消始终通过协程的层次结构来进行传递:

image.png

请注意,如果其中一个子协程(即 two)失败,第一个 async 以及等待中的父协程都会被取消:

image.png

所以,这种带有异步函数的编程⻛格仅供参考,因为这在其它编程语言中是一种受欢迎的⻛格。在 Kotlin 的协程 中使用这种⻛格是强烈不推荐的

二.协程上下文与调度器

1.调度器与线程

协程总是运行在一些以 CoroutineContext 类型为代表的上下文中,它们被定义在了 Kotlin 的标准库里。 协程上下文是各种不同元素的集合。其中主元素是协程中的 Job,我们在前面的介绍中⻅过它以及它的调度器,
而下面将对它进行介绍。

2.调度器与线程

协程上下文包含一个 协程调度器(CoroutineDispatcher),它确定了相关的协程在哪个线程或哪些线程上 执行。协程调度器可以将协程限制在一个特定的线程执行,或将它分派到一个线程池,亦或是让它不受限地运行。
所有的协程构建器诸如 launch 和 async 接收一个可选的 CoroutineContext 参数,它可以被用来显式的为一 个新协程或其它上下文元素指定一个调度器。

image.png

运行结果:

image.png

当调用 launch { ...... } 时不传参数,它从启动了它的 CoroutineScope 中承袭了上下文(以及调度器)。在这 个案例中,它从 main 线程中的 runBlocking 主协程承袭了上下文。

Dispatchers.Unconfined 是一个特殊的调度器且似乎也运行在 main 线程中,但实际上,它是一种不同的机制,开始运行可能会在main线程中,如果挂起再次运行可能就会在其他线程中了。

当协程在 GlobalScope 中启动时,使用的是由 Dispatchers.Default 代表的默认调度器。默认调度器使用共享 的后台线程池。所以launch(Dispatchers.Default) { ...... } 与 GlobalScope.launch { ...... } 使用相同的调度器。

newSingleThreadContext 为协程的运行启动了一个线程。一个专用的线程是一种非常昂贵的资源。在真实的 应用程序中两者都必须被释放,当不再需要的时候,使用 close 函数,或存储在一个顶层变量中使它在整个应用 程序中被重用。

3.非受限调度器 vs 受限调度器

Dispatchers.Unconfined 协程调度器在调用它的线程启动了一个协程,但它仅仅只是运行到第一个挂起点。协程挂起后,非受限的协程调度器恢复线程中的协程,而这完全由被调用的挂起函数来决定(其实就是非受限调度器在调用他的线程中启动了一个协程,当运行到第一个挂起点并挂起结束时,再次运行协程就不在之前的线程中了)。非受限的调度器非常适用于执行不消耗 CPU 时间的任务,以及不更新局限于特定线程的任何共享数据(如UI)的协程。

另一方面,该调度器默认继承了外部的 CoroutineScope。runBlocking 协程的默认调度器,特别是,当它被限 制在了调用者线程时,继承自它将会有效地限制协程在该线程运行并且具有可预测的 FIFO 调度(先进先出,其实就是按顺序从上到下执行runBlocking中的协程)。

image.png

运行结果:

image.png

所以,该协程的上下文继承自 runBlocking {...} 协程并在 main 线程中运行,当 delay 函数调用的时 候,非受限的那个协程在默认的执行者线程中恢复执行。

非受限的调度器是一种高级机制,可以在某些极端情况下提供帮助而不需要调度协程以便稍后执行或产生 不希望的副作用,因为某些操作必须立即在协程中执行。非受限调度器不应该在通常的代码中使用。

4.调试协程与线程

协程可以在一个线程上挂起并在其它线程上恢复。如果没有特殊工具,甚至对于一个单线程的调度器也是难 以弄清楚协程在何时何地正在做什么事情。

用日志调试
让线程在每一个日志文件的日志声明中打印线程的名 字。这种特性在日志框架中是普遍受支持的。但是在使用协程时,单独的线程名称不会给出很多协程上下文信 息,所以 kotlinx.coroutines 包含了调试工具来让它更简单。

image.png

运行结果:

image.png

使用 -Dkotlinx.coroutines.debug JVM 参数运行上面的代码:

image.png
image.png

然后debug模式运行,结果如下:

image.png

可以看到除了线程的名字,后边还打印出了协程的名字

5.在不同线程间跳转

其中一个使用 runBlocking 来显式指定了一个上下文,并且另一个使用 withContext 函数来改变协程的上下文,而仍然驻留在相同的协程中。

image.png

运行结果:

image.png

其实就是同一个协程在不同的线程中跳转

注意,在这个例子中,当我们不再需要某个在 newSingleThreadContext 中创建的线程的时候,它使用了 Kotlin 标准库中的 use 函数来释放该线程。

6.上下文中的作业

协程的 Job 是上下文的一部分,并且可以使用 coroutineContext [Job] 表达式在上下文中检索它:

image.png

运行结果:

image.png

请注意,CoroutineScope 中的 isActive 只是 coroutineContext[Job]?.isActive == true 的一种方便的快捷方式。
意思就是CoroutineScope 中的 isActive的值就是coroutineContext[Job]?.isActive == true

7.子协程

当一个协程被其它协程在 CoroutineScope 中启动的时候,它将通过 CoroutineScope.coroutineContext 来 承袭上下文,并且这个新协程的 Job 将会成为父协程作业的 子 作业。当一个父协程被取消的时候,所有它的子 协程也会被递归的取消。

然而,当使用 GlobalScope 来启动一个协程时,则新协程的作业没有父作业。因此它与这个启动的作用域无关 且独立运作。

image.png

运行结果:

image.png

启动协程,
1,GlobalScope.launch的协程打印job1: I run in GlobalScope and execute independently!;
2,100ms后,launch的协程打印job2: I am a child of the request coroutine;
3,500ms后,request协程被取消;
4,1000ms后,GlobalScope.launch的协程打印job1: I am not affected by cancellation of the request;
5,1000ms后,主协程打印main: Who has survived request cancellation?
从上边的分析可以知道,当父协程(request)被取消后,子协程(launch)也被取消了(因为没有打印job2: I will not execute this line if my parent request is cancelled),而GlobalScope.launch协程正常打印,说明它与启动的作用域无关,独立运作。

8.父协程的职责

一个父协程总是等待所有的子协程执行结束。父协程并不显式的跟踪所有子协程的启动,并且不必使用 Job.join 在最后的时候等待它们:

image.png

运行结果:

image.png

将上边代码中的request.join()注释掉,再来执行

image.png

运行结果:

image.png

可以看到,request.join()会等待request协程中所有子协程执行完毕,主协程在继续执行。
无论主协程是否request.join(),request协程都会将子协程执行完。

9.命名协程以用于调试

当协程经常打印日志并且你只需要关联来自同一个协程的日志记录时,则自动分配的 id 是非常好的。然而,当 一个协程与特定请求的处理相关联时或做一些特定的后台任务,最好将其明确命名以用于调试目的。 CoroutineName 上下文元素与线程名具有相同的目的。当调试模式开启时,它被包含在正在执行此协程的线程 名中。

image.png

程序执行使用了 -Dkotlinx.coroutines.debug JVM 参数,运行结果:

image.png

10.组合上下文中的元素

有时我们需要在协程上下文中定义多个元素。我们可以使用 + 操作符来实现。比如说,我们可以显式指定一个 调度器来启动协程并且同时显式指定一个命名:

image.png

运行结果:

image.png

11.协程作用域

让我们将关于上下文,子协程以及作业的知识综合在一起。假设我们的应用程序拥有一个具有生命周期的对象, 但这个对象并不是一个协程。举例来说,我们编写了一个 Android 应用程序并在 Android 的 activity 上下文中 启动了一组协程来使用异步操作拉取并更新数据以及执行动画等等。所有这些协程必须在这个 activity 销毁的 时候取消以避免内存泄漏。当然,我们也可以手动操作上下文与作业,以结合 activity 的生命周期与它的协程,但 是 kotlinx.coroutines 提供了一个封装:CoroutineScope 的抽象。你应该已经熟悉了协程作用域,因为 所有的协程构建器都声明为在它之上的扩展。

我们通过创建一个 CoroutineScope 实例来管理协程的生命周期,并使它与 activity 的生命周期相关 联。CoroutineScope 可以通过 CoroutineScope() 创建或者通过MainScope() 工厂函数创建(使用MainScope需要添加依赖:implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.4')。前者创建了一个通 用作用域,而后者为使用 Dispatchers.Main 作为默认调度器的 UI 应用程序 创建作用域:

image.png

现在,我们可以使用定义的 scope 在这个 Activity 的作用域内启动协程。对于该示例,我们启动了十个协 程,它们会延迟不同的时间:

image.png

在 main 函数中我们创建 activity,调用测试函数 doSomething ,并且在 500 毫秒后销毁这个 activity。这取 消了从 doSomething 启动的所有协程。我们可以观察到这些是由于在销毁之后,即使我们再等一会 儿,activity 也不再打印消息。

image.png

12.线程局部数据

有时,能够将一些线程局部数据传递到协程与协程之间是很方便的。然而,由于它们不受任何特定线程的约束, 如果手动完成,可能会导致出现样板代码。

ThreadLocal,asContextElement 扩展函数在这里会充当救兵。它创建了额外的上下文元素,且保留给定 ThreadLocal 的值,并在每次协程切换其上下文时恢复它。

image.png

在这个例子中我们使用 Dispatchers.Default 在后台线程池中启动了一个新的协程,所以它工作在线程池中的 不同线程中,但它仍然具有线程局部变量的值,我们指定使用 threadLocal.asContextElement(value = "launch"),无论协程执行在哪个线程中,通过threadLocal.get()获取到的值都是“launch”。

运行结果:

image.png

你可能感兴趣的:(Kotlin进阶-组合挂起函数、协程上下文与调度器)