Coroutines: built-in support vs library
在讨论协程时,通常将其视为一个单一的概念。实际上,它们由两个组件组成:Kotlin语言提供的内置支持(编译器支持和Kotlin标准库中的元素),以及Kotlin协程库(名为kotlinx.coroutines)。有时它们被视为相同的实体,但它们彼此非常不同。
内置语言支持旨在尽可能提供最小的支持并给予最大的自由。它可以用于再现从其他编程语言中已知的几乎任何并发样式,但直接使用它并不方便。它的大多数元素,如suspendCoroutine或Continuation,应该由库创建者而不是应用程序开发者使用。
另一方面,我们有kotlinx.coroutines库。这是一个需要添加到项目中的单独依赖项。它是建立在内置语言支持之上的。它更容易使用,可以为开发人员提供具体的并发样式。
目前,内置的支持和kotlinx.coroutines库几乎总是一起使用的,但这不是一个要求。许多计算机科学论文展示了suspension概念的普遍性。Kotlin协程库团队也展示了这一点。在寻找最佳并发样式时,他们使用内置的Kotlin支持来重现许多其他语言(如Go的Goroutines)的并发样式。kotlinx.coroutines目前提供的并发样式优雅、方便,并与编程生态系统中的其他模式相一致。然而,模式和编程风格随着时间的推移而改变。也许有一天,我们的社区会提出更好的并发样式。如果是这样,有人很可能能够使用内置的Kotlin支持来实现它,并将其作为一个单独的库发布。这个有前途的新库甚至可能取代kotlinx.coroutines库。谁知道未来会带来什么呢?
到目前为止,在本书中我们主要关注的是 Kotlin 语言自带的支持。从现在开始,我们将集中讨论 kotlinx.coroutines 库。
现在我们已经了解了内置支持的工作原理,是时候集中精力研究 kotlinx.coroutines 库了。在这一部分中,我们将学习使用该库所需的所有内容。我们将探索协程构建器、不同的协程上下文以及取消操作的工作原理。最后,我们将学习如何设置协程、如何测试它们以及如何安全地访问共享状态的实用知识。
Coroutine builders
挂起函数需要彼此传递continuation。它们没有任何问题调用普通函数,但普通函数无法调用挂起函数。
每个挂起函数都需要被另一个挂起函数调用,而这个挂起函数又需要被另一个挂起函数调用,以此类推。这些调用需要从某个地方开始。这就是协程构建器,是从普通世界到挂起世界的桥梁。
我们将探讨kotlinx.coroutines库提供的三个关键协程构建器:
- launch
- runBlocking
- async
launch builder
launch的工作方式在概念上类似于启动一个新线程(线程函数)。我们只需启动一个协程,它将独立运行,就像发射到空中的烟花一样。这就是我们使用launch的方式 - 启动一个进程。
launch函数是CoroutineScope接口的扩展函数。这是一个重要机制称为结构化并发,其目的是在父协程和子协程之间建立关系。稍后在本章中,我们将学习结构化并发,但现在我们将通过在GlobalScope对象上调用launch(以及稍后的async)来避免这个主题。但是,这不是标准做法,因为我们在实际项目中很少使用GlobalScope。
另一件你可能注意到的事情是,在主函数结束时,我们需要调用Thread.sleep。如果不这样做,该函数将在启动协程后立即结束,因此它们没有机会完成任务。这是因为delay不会阻塞线程:它会挂起协程。你可能还记得在"How does suspension work?"一章中提到,delay只是设置一个计时器,在设置的时间后恢复,并挂起协程直到此时。如果线程没有被阻塞,那么没有任何事情忙碌,所以程序就会结束(稍后我们将看到,如果使用结构化并发,则不需要Thread.sleep)。
在某种程度上,launch的工作方式类似于守护线程,但成本要低得多。这个比喻一开始很有用,但后来会出现问题。维护被阻塞的线程总是昂贵的,而维护挂起的协程几乎是免费的(如"Coroutines under the hood"章节中所述)。它们都启动一些独立的进程,并需要一些东西来防止程序在它们完成任务之前结束(在下面的示例中,这是Thread.sleep(2000L))。
runBlocking builder
通常情况下,协程不应该阻塞线程,只应该挂起它们。然而,在某些情况下,阻塞是必要的。比如在 main 函数中,我们需要阻塞线程,否则程序将会过早结束。对于这种情况,我们可以使用 runBlocking。
runBlocking 是一个非常不寻常的构建器。它在协程挂起时阻塞启动它的线程(类似于挂起 main 函数)。这意味着 runBlocking 内部的 delay(1000L) 将会表现得像 Thread.sleep(1000L)。
实际上,有几种特定情况下使用runBlocking。第一种是在main函数中需要阻塞线程,否则程序会立即结束。另一个常见的用例是单元测试,因为我们需要出于相同的原因阻塞线程。
在我们的示例中,我们可以使用 runBlocking 来替换 Thread.sleep(2000) 为 delay(2000)。稍后,我们会看到在引入结构化并发后,它甚至更加有用。
runBlocking曾经是一个重要的构建器,但在现代编程中它相对较少使用。在单元测试中,我们经常使用其后继者runTest,它使协程在虚拟时间中运行(这是一个非常有用的测试功能,我们将在《测试协程》一章中介绍)。对于main函数,我们通常将其设置为挂起函数。
async builder
async协程构建器类似于launch,但它旨在生成一个值。该值需要通过lambda表达式返回。async函数返回一个Deferred对象,其中T是生成值的类型。Deferred具有一个挂起的await方法,一旦值准备好就返回该值。在下面的示例中,生成的值为42,其类型为Int,因此返回Deferred,await返回类型为Int的42。
与 launch 构建器类似,async 构建器也会在调用时立即启动一个协程。因此,它是一种同时启动多个进程并等待它们所有结果的方法。返回的 Deferred 对象一旦生成了值,就会在其内部存储该值,因此一旦该值准备就绪,它将立即从 await 中返回。但是,如果我们在值生成之前调用 await,我们会被暂停,直到该值准备就绪。
async构建器的工作方式与launch非常相似,但它具有额外的支持以返回值。如果所有的launch函数都被替换成async,代码仍然能正常工作。但不要这么做!async是关于生成值的,所以如果我们不需要值,应该使用launch。
async构建器经常用于并行执行两个进程,例如从两个不同的地方获取数据,将它们组合在一起。
Structured Concurrency
如果一个协程在GlobalScope上启动,程序将不会等待它。正如先前提到的,协程不会阻塞任何线程,也没有任何东西防止程序结束。这就是为什么在下面的示例中,如果我们想要看到"World!"被打印出来,就需要在runBlocking结束时添加额外的延迟。
为什么我们需要这个 GlobalScope 呢?这是因为 launch 和 async 是 CoroutineScope 接口的扩展函数。但是,如果你看一下这些函数以及 runBlocking 的定义,你会发现 block 参数是一个函数类型,其接收者类型也是 CoroutineScope。
这意味着我们可以摆脱GlobalScope;相反,launch可以在runBlocking提供的接收器上调用,即使用this.launch或仅使用launch。结果,launch成为runBlocking的子级。正如父母可能会认识到的那样,父母的责任是等待所有孩子,因此runBlocking将暂停,直到所有孩子完成。
一个父协程提供了一个作用域,它的子协程都在这个作用域内执行。这种关系被称为结构化并发。以下是父子关系的最重要的影响:
- 子协程从父协程继承上下文(但也可以覆盖它,这将在"协程上下文"章节中解释);
- 父协程会一直挂起直到所有子协程完成(这将在"Job和等待子协程"章节中讲解)
- 当父协程被取消时,其子协程也会被取消(这将在取消章节中解释)。
- 当子协程抛出异常时,它也会破坏父协程(这将在异常处理章节中解释)。
请注意,与其他协程构建器不同,runBlocking不是CoroutineScope的扩展函数。这意味着它不能作为子协程:它只能用作根协程(层次结构中所有子协程的父协程)。这意味着runBlocking将在不同情况下与其他协程一起使用。正如我们之前提到的,这与其他构建器非常不同。
The bigger picture
挂起函数需要从其他挂起函数中调用。这一切都需要从协程构建器开始。除了 runBlocking 之外,构建器需要在 CoroutineScope 上启动。在我们的简单示例中,作用域是由 runBlocking 提供的,但在更大的应用程序中,它要么由我们构建(我们将在《构建协程作用域》一章中解释如何实现),要么由我们使用的框架提供(例如后端的 Ktor 或 Android 上的 Android KTX)。一旦第一个构建器在作用域上启动,其他构建器就可以在第一个构建器的作用域上启动,以此类推。这本质上是我们的应用程序结构的方式。
以下是几个实际项目中使用协程的示例。前两个示例适用于后端和 Android。MainPresenter 代表了 Android 典型案例。UserController 代表了后端应用程序典型案例。
然而,有一个问题:那么挂起函数怎么办呢?我们可以在那里挂起,但我们没有任何作用域。将作用域作为参数传递不是一个好的解决方案(如我们将在"作用域函数"章节中看到)。相反,我们应该使用coroutineScope函数,它是一个挂起函数,用于为构建器创建一个作用域。
Using coroutineScope
假设在某个存储库函数中,您需要异步加载两个资源,例如用户数据和文章列表。在这种情况下,您只想返回用户应该看到的文章。要调用async,我们需要一个作用域,但我们不想将其传递给函数。为了从暂停函数创建作用域,我们使用coroutineScope函数。
coroutineScope是一个挂起函数,用于创建其 lambda 表达式的作用域。该函数返回 lambda 表达式返回的任何内容(如 let、run、use 或 runBlocking)。因此,在上面的示例中,它返回 List,因为这是 lambda 表达式返回的内容。
coroutineScope是我们在挂起函数中需要作用域时使用的标准函数。它非常重要。它的设计非常适合这种情况,但是要分析它,我们首先需要学习一些有关上下文、取消和异常处理的知识。这就是为什么该函数将在专门的章节中详细介绍(Coroutine scope functions)。
我们也可以开始使用挂起的 main 函数以及 coroutineScope,这是使用 runBlocking 函数的现代替代方法。
展示了kotlinx.coroutines库中不同类型元素的使用方式的图表。通常我们会从一个scope或runBlocking开始,然后在这些函数中调用其他的builder或suspending function。我们不能在suspending function上使用builder,因此我们需要使用coroutine scope函数(例如coroutineScope)。
这些知识足以满足大多数 Kotlin 协程的使用。在大多数情况下,我们只需要在挂起函数中调用其他挂起函数或普通函数。如果我们需要引入并发处理,我们将一个函数包装在 coroutineScope 中,并在其作用域上使用构建器。一切都需要从某个作用域上调用一些构建器开始。我们将在后面的部分中学习如何构建这样的作用域,但对于大多数项目,它只需要定义一次,并且很少会被改动。即使我们已经学习了基本知识,还有很多可以学习的。在接下来的章节中,我们将深入探讨协程。我们将学习如何使用不同的上下文,如何控制取消,异常处理,如何测试协程等等。还有很多令人兴奋的特性等待着我们去发现。
👀关注公众号:Android老皮!!!欢迎大家来找我探讨交流👀