Kotlin 官方协程解读

前言:在网上看过很多类似关于协程的博客文章,很少看到真正说的清楚讲的实在的,于是自己翻看官方的文档,加上自己的理解,进行整理。

一  协程基础

第⼀个协程程序

 代码运⾏的结果:

 本质上,协程是轻量级的线程。 它们在某些 CoroutineScope 上下⽂中与 launch 协 程 构 建 器 ⼀起启动。 这⾥ 我们在 GlobalScope 中启动了⼀个新的协程,这意味着新协程的⽣命周期只受整个应⽤程序的⽣命周期限制。 可以将 GlobalScope.launch { …… } 替换为 thread { …… },并将 delay(……) 替换为 Thread.sleep(……) 达到同样⽬的。 试试看(不要忘记导⼊ kotlin.concurrent.thread)。 

如果你⾸先将 GlobalScope.launch 替换为 thread,编译器会报以下错误: Error: Kotlin: Suspend functions are only allowed to be called from a coroutine or another suspend function 

这是因为 delay 是⼀个特殊的 挂 起 函 数 ,它不会造成线程阻塞,但是会 挂 起 协程,并且只能在协程中使⽤。
 

注:GlobalScope.launch 这种方式启动协程属于顶级协程,它的生命周期是跟启动它的进程生命周期绝对,对于我们android程序员而言,如果在主线程主启动该协程,随之就跟整个app生命周期。所以不建议使用该种方式创建协程。

桥接阻塞与⾮阻塞的世界

第⼀个⽰例在同⼀段代码中混⽤了 ⾮ 阻 塞 的 delay(……) 与 阻 塞 的 Thread.sleep(……)。 这容易让我们 记混哪个是阻塞的、哪个是⾮阻塞的。 让我们显式使⽤runBlocking 协程构建器来阻塞:

 结果是相似的,但是这些代码只使⽤了⾮阻塞的函数 delay。 调⽤了 runBlocking 的主线程会⼀直 阻 塞 直 到 runBlocking 内部的协程执⾏完毕。

注:runBlocking内部使用delay()会去阻塞主线程,保证JVM更多时间的存活,但是GlobalScope.launch内部使用delay()并不能阻塞。

这个⽰例可以使⽤更合乎惯⽤法的⽅式重写,使⽤ runBlocking 来包装 main 函数的执⾏:

这⾥的 runBlocking<Unit> { …… } 作为⽤来启动顶层主协程的适配器。 我们显式指定了其返回类型 Unit,因为在 Kotlin 中 main 函数必须返回 Unit 类型。 这也是为挂起函数编写单元测试的⼀种⽅式:

 等待⼀个作业

延迟⼀段时间来等待另⼀个协程运⾏并不是⼀个好的选择。 让我们显式(以⾮阻塞⽅式)等待所启动的后台 Job 执⾏结束:

 现在,结果仍然相同,但是主协程与后台作业的持续时间没有任何关系了。比上面延迟时间的方式好多了。

结构化的并发

协程的实际使⽤还有⼀些需要改进的地⽅。 当我们使⽤ GlobalScope.launch 时,我们会创建⼀个顶层协 程。 虽然它很轻量,但它运⾏时仍会消耗⼀些内存资源。 如果我们忘记保持对新启动的协程的引⽤,它还会继续 运⾏。 如果协程中的代码挂起了会怎么样(例如,我们错误地延迟了太⻓时间) ,如果我们启动了太多的协程并导 致内存不⾜会怎么样? 必须⼿动保持对所有已启动协程的引⽤并 join 之很容易出错。 有⼀个更好的解决办法。 我们可以在代码中使⽤结构化并发。 我们可以在执⾏操作所在的指定作⽤域内启动协 程, ⽽不是像通常使⽤线程(线程总是全局的)那样在 GlobalScope 中启动。 在我们的⽰例中,我们使⽤ runBlocking 协程构建器将 main 函数转换为协程。 包括 runBlocking 在内的 每个协程构建器都将 CoroutineScope 的实例添加到其代码块所在的作⽤域中。 我们可以在这个作⽤域中启动 协程⽽⽆需显式 join 之,因为外部协程(⽰例中的 runBlocking)直到在其作⽤域中启动的所有协程都执 ⾏完毕后才会结束。


 

作⽤域构建器

除了由不同的构建器提供协程作⽤域之外,还可以使⽤ coroutineScope 构建器声明⾃⼰的作⽤域。 它会创建⼀ 个协程作⽤域并且在所有已启动⼦协程执⾏完毕之前不会结束。runBlocking 与 coroutineScope 可能看起来很类似,因为它们都会等待其协程体以及所有⼦协程结束。 主要 区别在于, runBlocking ⽅法会 阻 塞 当前线程来等待, ⽽ coroutineScope 只是挂起,会释放底层线程⽤于其他 ⽤途。 由于存在这点差异, runBlocking 是常规函数,⽽ coroutineScope 是挂起函数。 可以通过以下⽰例来演⽰:


请注意, (当等待内嵌 launch 时)紧挨“Task from coroutine scope”消息之后, 就会执⾏并输出“Task from runBlocking”——尽管 coroutineScope 尚未结束。

注:coroutineScope.launch{} 内部虽然也执行的delay(),但是并不会影响其他线程的运行,所以{}外其他代码都是可以依次执行。

提取函数重构

我们来将 launch { …… } 内部的代码块提取到独⽴的函数中。 当你对这段代码执⾏“提取函数”重构时,你会 得到⼀个带有 suspend 修饰符的新函数。 这是你的第⼀个 挂 起 函 数 。 在协程内部可以像普通函数⼀样使⽤挂 起函数, 不过其额外特性是,同样可以使⽤其他挂起函数(如本例中的 delay)来 挂 起 协程的执⾏。 

 但是如果提取出的函数包含⼀个在当前作⽤域中调⽤的协程构建器的话,该怎么办? 在这种情况下,所提取函数 上只有 suspend 修饰符是不够的。 为 CoroutineScope 写⼀个 doWorld 扩展⽅法是其中⼀种解决⽅案, 但这可能并⾮总是适⽤,因为它并没有使 API 更加清晰。 惯⽤的解决⽅案是要么显式将 CoroutineScope 作 为包含该函数的类的⼀个字段, 要么当外部类实现了 CoroutineScope 时隐式取得。 作为最后的⼿段,可以 使⽤ CoroutineScope(coroutineContext),不过这种⽅法结构上不安全, 因为你不能再控制该⽅法执⾏的作⽤ 域。 只有私有 API 才能使⽤这个构建器。

二  取消与超时 

取消协程的执⾏

在⼀个⻓时间运⾏的应⽤程序中,你也许需要对你的后台协程进⾏细粒度的控制。 ⽐如说,⼀个⽤⼾也许关闭了 ⼀个启动了协程的界⾯,那么现在协程的执⾏结果已经不再被需要了,这时,它应该是可以被取消的。 该 launch 函数返回了⼀个可以被⽤来取消运⾏中的协程的 Job: 


程序执⾏后的输出如下:

 ⼀旦 main 函数调⽤了 job.cancel,我们在其它的协程中就看不到任何输出,因为它被取消了。 这⾥也有⼀ 个可以使 Job 挂起的函数 cancelAndJoin 它合并了对 cancel 以及 join 的调⽤。

注:这里跟线程的interrupt非常的像,单独使用cancle()并不能立即起到终止的效果。

取消是协作的
协程的取消是 协 作 的。 ⼀段协程代码必须协作才能被取消。 所有 kotlinx.coroutines 中的挂起函数都是 可 被 取 消 的 。 它们检查协程的取消, 并在取消时抛出 CancellationException。 然⽽,如果协程正在执⾏计算任 务,并且没有检查取消的话,那么它是不能被取消的,就如如下⽰例代码所⽰: 

 运⾏⽰例代码,并且我们可以看到它连续打印出了“I'm sleeping”,甚⾄在调⽤取消后, 作业仍然执⾏了五次循 环迭代并运⾏到了它结束为⽌。

使计算代码可取消
我们有两种⽅法来使执⾏计算的代码可以被取消。 第⼀种⽅法是定期调⽤挂起函数来检查取消。 对于这种⽬的 yield 是⼀个好的选择。 另⼀种⽅法是显式的检查取消状态。 让我们试试第⼆种⽅法。 将前⼀个⽰例中的 while (i < 5) 替换为 while (isActive) 并重新运⾏它

 注:isActive是协程内部给我们提高的扩展参数,用来判断是否被cancle,以达到手动取消计算代码的效果。

在 finally 中释放资源

我们通常使⽤如下的⽅法处理在被取消时抛出 CancellationException 的可被取消的挂起函数。 ⽐如说,try {……} finally {……} 表达式以及 Kotlin 的 use 函数⼀般在协程被取消的时候执⾏它们的终结动作: 


join 和 cancelAndJoin 等待了所有的终结动作执⾏完毕, 所以运⾏⽰例得到了下⾯的输出:

 

 运⾏不能取消的代码块

在前⼀个例⼦中任何尝试在 finally 块中调⽤挂起函数的⾏为都会抛出 CancellationException,因为这⾥ 持续运⾏的代码是可以被取消的。 通常,这并不是⼀个问题,所有良好的关闭操作(关闭⼀个⽂件、取消⼀个作 业、或是关闭任何⼀种通信通道)通常都是⾮阻塞的,并且不会调⽤任何挂起函数。 然⽽,在真实的案例中,当你 需要挂起⼀个被取消的协程,你可以将相应的代码包装在 withContext(NonCancellable) {……} 中,并 使⽤ withContext 函数以及 NonCancellable 上下⽂,⻅如下⽰例所⽰: 

 超时

在实践中绝⼤多数取消⼀个协程的理由是它有可能超时。 当你⼿动追踪⼀个相关 Job 的引⽤并启动了⼀个单独 的协程在延迟后取消追踪,这⾥已经准备好使⽤ withTimeout 函数来做这件事。 

 运⾏后得到如下输出:

 withTimeout 抛出了 TimeoutCancellationException,它是 CancellationException 的⼦类。 我们之前 没有在控制台上看到堆栈跟踪信息的打印。 这是因为在被取消的协程中 CancellationException 被认为 是协程执⾏结束的正常原因。 然⽽,在这个⽰例中我们在 main 函数中正确地使⽤了 withTimeout。 由于取消只是⼀个例外,所有的资源都使⽤常⽤的⽅法来关闭。 如果你需要做⼀些各类使⽤超时的特别的额外 操作,可以使⽤类似 withTimeout 的 withTimeoutOrNull 函数,并把这些会超时的代码包装在 try {...} catch (e: TimeoutCancellationException) {...} 代码块中,⽽ withTimeoutOrNull 通过返回 null 来进⾏超时操作,从⽽替代抛出⼀个异常:

 运⾏这段代码时不再抛出异常:

 

三  组合挂起函数 

介绍挂起函数组合的各种⽅法。
默认顺序调⽤
假设我们在不同的地⽅定义了两个进⾏某种调⽤远程服务或者进⾏计算的挂起函数。 我们只假设它们都是有⽤ 的,但是实际上它们在这个⽰例中只是为了该⽬的⽽延迟了⼀秒钟。

 如果需要按 顺 序 调⽤它们,我们接下来会做什么——⾸先调⽤ doSomethingUsefulOne 接 下 来 调⽤ doSomethingUsefulTwo,并且计算它们结果的和吗? 实际上,如果我们要根据第⼀个函数的结果来决定是 否我们需要调⽤第⼆个函数或者决定如何调⽤它时,我们就会这样做。 我们使⽤普通的顺序来进⾏调⽤,因为这些代码是运⾏在协程中的,只要像常规的代码⼀样 顺 序 都是默认的。 下⾯的⽰例展⽰了测量执⾏两个挂起函数所需要的总时间: 

 它的打印输出如下:

 使⽤ async 并发

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

 它的打印输出如下:

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

惰性启动的 async

 它的打印输出如下:

 因此,在先前的例⼦中这⾥定义的两个协程没有执⾏,但是控制权在于程序员准确的在开始执⾏时调⽤ start。 我们⾸先 调⽤ one,然后调⽤ two,接下来等待这个协程执⾏完毕。

注意,如果我们只是在 println 中调⽤ await,⽽没有在单独的协程中调⽤ start,这将会导致顺序⾏为,直到 await 启动该协程 执⾏并等待⾄它结束,这并不是惰性的预期⽤例。 在计算⼀个值涉及挂起函数时,这个 async(start = CoroutineStart.LAZY) 的⽤例⽤于替代标准库中的 lazy 函数。

使⽤ async 的结构化并发

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

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

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

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

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

 

四  协程上下⽂与调度器

协程总是运⾏在⼀些以 CoroutineContext 类型为代表的上下⽂中,它们被定义在了 Kotlin 的标准库⾥。 协程上下⽂是各种不同元素的集合。 其中主元素是协程中的 Job, 我们在前⾯的⽂档中⻅过它以及它的调度器, ⽽本⽂将对它进⾏介绍。

调度器与线程

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

 它执⾏后得到了如下输出(也许顺序会有所不同):

 当调⽤ launch { …… } 时不传参数,它从启动了它的 CoroutineScope 中承袭了上下⽂(以及调度器)。 在这 个案例中,它从 main 线程中的 runBlocking 主协程承袭了上下⽂。 Dispatchers.Unconned 是⼀个特殊的调度器且似乎也运⾏在 main 线程中,但实际上, 它是⼀种不同的机 制,这会在后⽂中讲到。 当协程在 GlobalScope 中启动时,使⽤的是由 Dispatchers.Default 代表的默认调度器。 默认调度器使⽤共享 的后台线程池。 所以 launch(Dispatchers.Default) { …… } 与 GlobalScope.launch { …… } 使⽤相同的调度器。 newSingleThreadContext 为协程的运⾏启动了⼀个线程。 ⼀个专⽤的线程是⼀种⾮常昂贵的资源。 在真实的 应⽤程序中两者都必须被释放,当不再需要的时候,使⽤ close 函数,或存储在⼀个顶层变量中使它在整个应用程序中被重用。

⾮受限调度器 vs 受限调度器

Dispatchers.Unconned 协程调度器在调⽤它的线程启动了⼀个协程,但它仅仅只是运⾏到第⼀个挂起点。 挂 起后,它恢复线程中的协程,⽽这完全由被调⽤的挂起函数来决定。⾮受限的调度器⾮常适⽤于执⾏不消耗 CPU 时间的任务,以及不更新局限于特定线程的任何共享数据(如UI)的协程。 另⼀⽅⾯,该调度器默认继承了外部的 CoroutineScope。 runBlocking 协程的默认调度器,特别是, 当它被限 制在了调⽤者线程时,继承⾃它将会有效地限制协程在该线程运⾏并且具有可预测的 FIFO 调度。

 执⾏后的输出:

注:这里unconfined线程在执行万delay()后所在线程被改了

所以,该协程的上下⽂继承⾃ runBlocking {...} 协程并在 main 线程中运⾏,当 delay 函数调⽤的时 候,⾮受限的那个协程在默认的执⾏者线程中恢复执⾏。 

上下⽂中的作业

协程的 Job 是上下⽂的⼀部分,并且可以使⽤ coroutineContext [Job] 表达式在上下⽂中检索它

println("My job is ${coroutineContext[Job]}"

在调试模式下,它将输出如下这些信息: My job is "coroutine#1":BlockingCoroutine{Active}@6d311334 请注意, CoroutineScope 中的 isActive 只是 coroutineContext[Job]?.isActive == true 的⼀种⽅ 便的快捷⽅式。

⼦协程

当⼀个协程被其它协程在 CoroutineScope 中启动的时候, 它将通过 CoroutineScope.coroutineContext 来 承袭上下⽂,并且这个新协程的 Job 将会成为⽗协程作业的 ⼦ 作业。 当⼀个⽗协程被取消的时候,所有它的⼦ 协程也会被递归的取消。 然⽽,当使⽤ GlobalScope 来启动⼀个协程时,则新协程的作业没有⽗作业。 因此它与这个启动的作⽤域⽆关 且独⽴运作。 

 这段代码的输出如下:

 ⽗协程的职责

⼀个⽗协程总是等待所有的⼦协程执⾏结束。 ⽗协程并不显式的跟踪所有⼦协程的启动,并且不必使⽤ Job.join 在最后的时候等待它们:


结果如下所⽰:

 命名协程以⽤于调试

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

下⾯的例⼦演⽰了这⼀概念:

 程序执⾏使⽤了 -Dkotlinx.coroutines.debug JVM 参数,输出如下所⽰:

 组合上下⽂中的元素

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

 

 这段代码使⽤了 -Dkotlinx.coroutines.debug JVM 参数,输出如下所⽰:

 协程作⽤域

让我们将关于上下⽂,⼦协程以及作业的知识综合在⼀起。 假设我们的应⽤程序拥有⼀个具有⽣命周期的对象, 但这个对象并不是⼀个协程。 举例来说,我们编写了⼀个 Android 应⽤程序并在 Android 的 activity 上下⽂中 启动了⼀组协程来使⽤异步操作拉取并更新数据以及执⾏动画等等。 所有这些协程必须在这个 activity 销毁的 时候取消以避免内存泄漏。 当然,我们也可以⼿动操作上下⽂与作业,以结合 activity 的⽣命周期与它的协程,但 是 kotlinx.coroutines 提供了⼀个封装: CoroutineScope 的抽象。 你应该已经熟悉了协程作⽤域,因为 所有的协程构建器都声明为在它之上的扩展。 我们通过创建⼀个 CoroutineScope 实例来管理协程的⽣命周期,并使它与 activity 的⽣命周期相关 联。CoroutineScope 可以通过 CoroutineScope() 创建或者通过MainScope() ⼯⼚函数。 前者创建了⼀个通 ⽤作⽤域,⽽后者为使⽤ Dispatchers.Main 作为默认调度器的 UI 应⽤程序 创建作⽤域

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

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

 这个⽰例的输出如下所⽰:

 

 你可以看到,只有前两个协程打印了消息,⽽另⼀个协程在 Activity.destroy() 中单次调⽤了 job.cancel()。


版权声明:本文为so1993原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。