kotlin协程学习——内置的支持

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老皮!!!欢迎大家来找我探讨交流👀

相关推荐
泓博10 分钟前
KMP(Kotlin Multiplatform)改造(Android/iOS)老项目
android·ios·kotlin
移动开发者1号26 分钟前
使用Baseline Profile提升Android应用启动速度的终极指南
android·kotlin
移动开发者1号1 小时前
解析 Android Doze 模式与唤醒对齐
android·kotlin
Devil枫3 小时前
Kotlin扩展函数与属性
开发语言·python·kotlin
菠萝加点糖3 小时前
Kotlin Data包含ByteArray类型
android·开发语言·kotlin
IAM四十二9 天前
Google 端侧 AI 框架 LiteRT 初探
android·深度学习·tensorflow
CYRUS_STUDIO9 天前
手把手教你用 Chrome 断点调试 Frida 脚本,JS 调试不再是黑盒
android·app·逆向
Just丶Single10 天前
安卓NDK初识
android
编程乐学10 天前
网络资源模板--基于Android Studio 实现的咖啡点餐App
android·android studio·大作业·奶茶点餐·安卓移动开发·咖啡点餐
二流小码农10 天前
鸿蒙开发:基于node脚本实现组件化运行
android·ios·harmonyos