1. 上一章:第 19 章
Kotlin 图解指南 • 第 20 章

协程基础

章节封面图片

有时,同时做多件事是有帮助的。例如,当你在电话中等待时,你可能也会查看电子邮件。煮咖啡的时候,你可能也会做早餐。开车的时候,你可能会听播客。

同样,有时我们编写的软件同时做多件事也是有帮助的。例如,它可以同时进行两三个网络请求——同时更新屏幕以显示每个请求的进度。

在 Kotlin 中,我们可以使用协程(coroutines)来同时做多件事。使用协程的方法有很多。事实上,专门写一整本书来探索它们也不为过。在本章中,我们将专注于你日常编码中使用协程所需的最基本的协程概念。这些知识将给你信心开始在应用程序中使用它们,并为你未来理解更高级的协程概念打下坚实的基础。

让我们通过拜访 Rusty 和他可靠的机器人 Bot-1 来开始协程的冒险之旅。

一次只做一件事……

Rusty 和 Bot-1

Rusty McAnnick 大部分时间都在设计和管理他的重型机器人 Bot-1,它在建筑项目上工作。有一天,Rusty 和 Bot-1 收到了一个新建筑的建设工单。他们的项目清单包括三个任务:

  • 铺设地基的砖块。
  • 安装窗户。
  • 安装门。

Rusty 和 Bot-1 手头有一大堆砖块,但窗户和门是专门为每个项目订购的,所以必须从仓库发货。他们到达施工现场,准备开始工作。以下是当天的情况。

  1. Bot-1 给仓库打电话订购项目所需的窗户。送货花了一些时间才到达。在他等待的时候,他坐在路沿上,无所事事地消磨时间。一旦卡车终于到达,他就从卡车上卸下窗户。
  2. 然后,他给另一个仓库打电话订购门。同样,他坐在路沿上,无聊地等待门被送来。当门终于送到时,他卸下了门。
  3. 然后,他铺设了砖块。这部分需要一些时间和精力,但他熟练地完成了工作。
  4. 然后,他安装了窗户。
  5. 最后,他安装了门。
Bot-1 很无聊。

如果我们把 Bot-1 随时间完成的工作整理出来,时间线是这样的。

时间线 等待:窗户 等待:门 铺设砖块 安装:窗户 安装:门 Start 1 2 3 4 5

项目完成后,Rusty 清点了所有任务,摇了摇头。”这个项目花了很长时间。那些送货太慢了。客户对我们花了这么长时间交付项目感到不高兴。我想知道……我们能做些什么来加快进度?”

单线程、阻塞式代码

就像 Rusty 的建筑项目一样,当我们的 Kotlin 代码一次只做一件事时,它可能是低效的,特别是当它涉及到等待缓慢的操作(如网络请求)时。首先,让我们创建一个枚举类来表示可以从仓库订购的产品,以及一个用于下订单的函数。

enum class Product(val description: String, val deliveryTime: Long) {
    DOORS("doors", 750),
    WINDOWS("windows", 1_250)
}

fun order(item: Product): Product {
    println("ORDER EN ROUTE  >>> The ${item.description} are on the way!")
    Thread.sleep(item.deliveryTime)
    println("ORDER DELIVERED >>> Your ${item.description} have arrived.")
    return item
}

在上面的代码中,我们使用 Thread.sleep() 来模拟卡车运送产品所需的时间。由于每个产品在不同的仓库,它们的送货时间也不同。Thread.sleep() 接受一个 Long 参数,表示要暂停多长时间,以毫秒为单位——因此要等待一秒,我们可以传递 1_000

接下来,让我们为 Bot-1 需要执行的任务做同样的事情。

fun perform(taskName: String) {
    println("STARTING TASK   >>> $taskName")
    Thread.sleep(1_000)
    println("FINISHED TASK   >>> $taskName")
}

有了这些函数,我们就可以像这样模拟 Rusty 最近的项目。

fun main() {
    val windows = order(Product.WINDOWS)
    val doors = order(Product.DOORS)
    perform("laying bricks")
    perform("installing ${windows.description}")
    perform("installing ${doors.description}")
}

当我们运行这段代码时,会看到如下输出:

ORDER EN ROUTE  >>> The windows are on the way!
ORDER DELIVERED >>> Your windows have arrived.
ORDER EN ROUTE  >>> The doors are on the way!
ORDER DELIVERED >>> Your doors have arrived.
STARTING TASK   >>> laying bricks
FINISHED TASK   >>> laying bricks
STARTING TASK   >>> installing windows
FINISHED TASK   >>> installing windows
STARTING TASK   >>> installing doors
FINISHED TASK   >>> installing doors

就像 Rusty 的建筑工作一样,这段代码是一种非常缓慢的完成工作的方式。让我们看看 Rusty 想出了什么主意来提高他的工作效率!

协程与并发

那天晚上晚些时候,Rusty 坐在他舒适的沙发上,在电视上翻阅频道。最后,他停在一个职业摔跤团队赛频道上。这场团队摔跤比赛有两支摔跤队参加,每队有两名队友,每队只能有一名摔跤手在擂台上。通过标记在擂台外等待的队友,两名摔跤手可以互换位置。

团队摔跤手。

Rusty 兴奋地看着 Sledge 和 Hammer 组成的队伍挑战现任冠军 Villain 和 Vandal。以下是他观看时发生的事情:

  • Sledge 在擂台上轮到他出场,用过肩摔放倒了 Vandal。然后,他标记了 Hammer,两名队友互换位置。
  • 接下来,Hammer 进入擂台,用衣领下摔的动作将 Vandal 打到地上。然后他标记了 Sledge,他们再次互换位置。
  • Sledge 返回擂台,将 Vandal 锁进四字锁腿。他再次标记了 Hammer,他们换了位置。
  • Hammer 再次踏入擂台,对 Vandal 使出了抱头摔。他再次标记了 Sledge。
  • 最后,Sledge 再次进入擂台,将 Vandal 按在垫子上三秒,赢得了比赛!

当 Rusty 继续观看摔跤比赛时,让我们回到一些 Kotlin 代码!

协程简介

使用协程编写的代码的工作方式很像团队摔跤——一个协程可以做一些工作,然后标记退出,让另一个协程运行一会儿。执行路径可以在协程之间交替,像这样:

在协程中运行的代码可以相互让出执行权。 println("Sledge: Pinning 1-2-3!") println("Hammer: Piledriver!") yield() println("Sledge: Figure-four Leglock!") yield() println("Hammer: Clothesline!") yield() println("Sledge: Suplex!") yield() Coroutine 1 Coroutine 2

上面的代码展示了协程的本质,即执行路径可以在不同函数的各部分之间来回跳转。当代码以这种方式编写时,我们说这些任务是并发运行的。

古老的交错图 Clothesline! Pinning 1-2-3! Figure-four Leglock! Suplex! Coroutine 2 Coroutine 1 Piledriver! yield() yield() yield() yield()

准备好创建我们的第一个协程了吗?我们可以通过调用一种称为协程构建器的特殊函数来构建一个新的协程。我们的第一个协程构建器名为 runBlocking()。这个函数接受一个 lambda,其中包含这个协程将运行的代码。(请务必在你编写代码的文件中包含 import kotlinx.coroutines.runBlocking!)

import kotlinx.coroutines.runBlocking

fun main() {
    runBlocking {
        println("Sledge: Suplex!")
        println("Hammer: Clothesline!")
        println("Sledge: Figure-four Leglock!")
        println("Hammer: Piledriver!")
        println("Sledge: Pinning 1-2-3!")
    }
}

让我们仔细看看这段代码的每个部分。

  • runBlocking() 是一个创建(并通常启动)协程的函数。如上所述,这种类型的函数称为协程构建器
  • 传递给 runBlocking() 的 lambda 是一种特殊类型的函数,称为挂起函数。像团队摔跤手一样,挂起函数可以”标记退出”协程,允许另一个协程在此期间运行。我们不说它”标记退出”,而说它挂起执行。由于这个挂起函数是作为一个 lambda 编写的,它也可以被称为挂起 lambda
  • 这个挂起函数在一个协程内部运行。协程可以被挂起函数挂起。

协程在哪里?

当我们看代码时,我们可以指向协程构建器,也可以指向挂起函数。但奇怪的是,我们无法指向实际的协程本身。这是因为协程是某些代码的实例,以及关于其状态的配置和信息——比如它当前是在运行、暂停、已完成,还是其他状态。

协程是代码和相应状态的实例。 { println("") yield() println("") } Running Coroutine #2 { println("") yield() println("") } Suspended Coroutine #1

你是否曾经同时在多个 IDE 中运行同一个 Kotlin 项目?或者你是否曾经同时在多个命令行窗口中从命令行运行它?当你这样做时,程序的每次执行都是它自己的实例。它有自己的状态。例如,如果你创建一个计数到一百万的程序,你可以同时运行这个程序两次,在任何给定时刻,每个实例都会处于不同的数字。

两个终端,代码在不同执行点。 > java -jar count.jar 1 2 3 4 5 6 7 8 > java -jar count.jar 1 2 3 Console #1 Console #2

类似地,协程是一个执行实例,有自己的状态。但它执行的不是一个整个程序,而是传递给其协程构建器的代码块。而且,它的执行不是由操作系统内的机制管理的,而是由我们的 Kotlin 程序内的机制管理的。

让我们开始挂起吧!

穿着背带裤的老人

现在,清单 20.4 中的代码包含一个挂起函数,它可以挂起,但目前它还没有真正挂起。它只是在向控制台打印行。我们没有 runBlocking() 也可以做到这一点!我们也只有一个单一的协程,这就像一个单独的摔跤手而不是一个团队。让我们创建一个第二个协程,它可以与第一个协程一起工作。为此,我们可以从 runBlocking() lambda 内部调用一个协程构建器。

由于 runBlocking() 是我们目前使用过的唯一协程构建器,让我们在这里再次使用它。我们将把第二个和第四个摔跤动作放在嵌套的协程构建器中,将第一个、第三个和第五个放在外层 lambda 中,目的是让摔跤动作像 Sledge 和 Hammer 那样在协程之间交替。

import kotlinx.coroutines.runBlocking

fun main() {
    runBlocking {
        runBlocking {
            println("Hammer: Clothesline!")
            println("Hammer: Piledriver!")
        }
        println("Sledge: Suplex!")
        println("Sledge: Figure-four Leglock!")
        println("Sledge: Pinning 1-2-3!")
    }
}

由于我们在另一个的 lambda 内部有一个 runBlocking() 函数调用,我们正在从一个协程内部创建另一个协程。这在两个协程之间创建了一种父子关系,产生了一个看起来像这样的简单层次结构。

协程层次结构 Hammer: Clothesline! Hammer: Piledriver! runBlocking Sledge: Suplex! Sledge: Figure-four Leglock! Sledge: Pinning 1-2-3! runBlocking

在本章后面,我们将看到为什么这个结构很重要。同时,当我们运行上面的代码时,会得到如下输出。

Hammer: Clothesline!
Hammer: Piledriver!
Sledge: Suplex!
Sledge: Figure-four Leglock!
Sledge: Pinning 1-2-3!

嗯……这打印了所有的摔跤动作,但它们的顺序不是我们想要的——动作是按照它们在代码中出现的顺序打印的。Hammer 做完了的所有动作,然后 Sledge 做完了的所有动作。这里的一个问题是 runBlocking() 协程构建器等待其代码完成后才继续。因此,嵌套的 runBlocking() lambda 中的代码运行直到完成(打印”Hammer: Clothesline!”和”Hammer: Piledriver!”),然后才运行外层 lambda 中的其余 println() 语句。

我们得到的与我们想要的对比。 What we want... What we got... Sledge: Suplex! Sledge: Pinning 1-2-3! Sledge: Figure-four Leglock! Hammer: Clothesline! Hammer: Piledriver! Sledge: Pinning 1-2-3! Sledge: Figure-four Leglock! Sledge: Suplex! Hammer: Piledriver! Hammer: Clothesline!

由于 runBlocking() 会等待其协程完成后才继续执行,因此它通常仅用于构建根级协程。换句话说,它通常仅直接在 main() 函数中使用。从那里开始,其他协程通常使用其他协程构建器来构建。

是时候介绍我们的第二个协程构建器了,它名叫 launch()。与 runBlocking() 一样,launch() 函数也接受一个挂起 lambda 作为参数。让我们把嵌套的 runBlocking() 调用替换为对 launch() 的调用。请务必包含 kotlinx.coroutines.launch 的导入声明。

import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() {
    runBlocking {
        launch {
            println("Hammer: Clothesline!")
            println("Hammer: Piledriver!")
        }
        println("Sledge: Suplex!")
        println("Sledge: Figure-four Leglock!")
        println("Sledge: Pinning 1-2-3!")
    }
}
Sledge: Suplex!
Sledge: Figure-four Leglock!
Sledge: Pinning 1-2-3!
Hammer: Clothesline!
Hammer: Piledriver!

嗯……这仍然不对!结果与清单 20.5几乎相同,只是摔跤手交换了位置。上一次,内部 lambda 的所有摔跤动作首先被打印出来。这一次,外部 lambda 的所有摔跤动作首先被打印出来。事实上,有了这样的输出,他们的对手在比赛中途就被压制了!

输出仍然不是我们想要的。 What we want... What we got... Hammer: Piledriver! Hammer: Clothesline! Sledge: Pinning 1-2-3! Sledge: Figure-four Leglock! Sledge: Suplex! Sledge: Pinning 1-2-3! Sledge: Figure-four Leglock! Sledge: Suplex! Hammer: Piledriver! Hammer: Clothesline!

问题是我们的摔跤手在每次动作后都没有换人,所以他们从未把擂台让给另一位摔跤手。在 Kotlin 中,如果我们想让一个协程换人,它必须遇到一个挂起点。一般来说,当它调用挂起函数时就会发生这种情况。1

为了演示这一点,让我们更新代码,使每次动作后都包含一个对名为 yield() 的函数的调用。

import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.yield

fun main() {
    runBlocking {
        launch {
            println("Hammer: Clothesline!")
            yield()
            println("Hammer: Piledriver!")
            yield()
        }
        println("Sledge: Suplex!")
        yield()
        println("Sledge: Figure-four Leglock!")
        yield()
        println("Sledge: Pinning 1-2-3!")
    }
}

yield() 是一个挂起函数,每次我们调用它时,协程都会到达一个挂起点。这会把摔跤擂台让回给摔跤手的队友——换句话说,它给另一个协程一个运行其部分代码的机会。因此,执行路径在 runBlocking() lambda 和 launch() lambda 之间来回跳动,产生以下输出,显示了正确顺序的摔跤动作。

Sledge: Suplex!
Hammer: Clothesline!
Sledge: Figure-four Leglock!
Hammer: Piledriver!
Sledge: Pinning 1-2-3!
我们终于得到了我们想要的输出! What we want... What we got... Sledge: Suplex! Hammer: Clothesline! Sledge: Pinning 1-2-3! Sledge: Figure-four Leglock! Hammer: Piledriver! Sledge: Pinning 1-2-3! Sledge: Figure-four Leglock! Sledge: Suplex! Hammer: Clothesline! Hammer: Piledriver!

声明挂起函数

让我们更新代码,使输出每次表明摔跤手正在换人。所以,与其直接调用 yield(),我们可以创建另一个函数,先打印”换人!”然后调用 yield()。如果我们尝试为此声明一个常规函数,会收到编译器错误:

fun tagOut() {
    println("    Tagging out!    ")
    yield()
}
Error

此错误的原因是挂起函数不能从任意地方调用。它只能从另一个挂起函数调用!换句话说,常规函数可以调用常规函数,而挂起函数可以调用常规函数另一个挂起函数。

函数类型 能调用常规函数吗? 能调用挂起函数吗?
常规函数
挂起函数

要修复此错误,我们可以简单地在该函数前加上 suspend 修饰符,像这样:

suspend fun tagOut() {
    println("    Tagging out!    ")
    yield()
}

通过这样做,我们将 tagOut() 函数从常规函数更改为挂起函数,所以它现在可以调用 yield() 了。

现在我们已经创建了 tagOut() 函数,我们可以回到 main() 函数,用对 tagOut() 的调用替换对 yield() 的调用:

fun main() {
    runBlocking {
        launch {
            println("Hammer: Clothesline!")
            tagOut()
            println("Hammer: Piledriver!")
            tagOut()
        }
        println("Sledge: Suplex!")
        tagOut()
        println("Sledge: Figure-four Leglock!")
        tagOut()
        println("Sledge: Pinning 1-2-3!")
    }
}

当我们运行这段代码时,输出如下:

Sledge: Suplex!
 Tagging out!
Hammer: Clothesline!
 Tagging out!
Sledge: Figure-four Leglock!
 Tagging out!
Hammer: Piledriver!
 Tagging out!
Sledge: Pinning 1-2-3!

所以,我们可以编写自己的挂起函数,调用其他挂起函数,如 yield()。通常,我们在应用程序代码中编写的挂起函数只是调用其他挂起函数,通常来自库。例如,我们可以使用 Ktor HTTP 客户端库来发起 HTTP 调用,这会挂起协程直到收到响应。

suspend fun getExample(): String {
    return client.get("https://www.example.com/").bodyAsText()
}

在我们继续之前,让我们回顾一下本节学到的主要概念:

  • 协程可以并发运行。换句话说,它们的执行可以被挂起,以便给其他协程运行的机会。
  • 挂起函数能够挂起运行它们的协程。它们只能从其他挂起函数中调用。
  • runBlocking() 创建一个协程。runBlocking() 之后的任何代码都不会运行,直到其协程完成运行。
  • launch() 构建器也创建一个协程,并且在其之后的任何代码都会在协程启动后立即运行。

嗯,思考协程和团队摔跤手之间的相似之处很有趣,但 Rusty 有一些大型项目即将到来,他需要找到更高效的方法来完成他的项目!所以让我们看看他在忙什么……

一次做两件事……

看完摔跤比赛的其余部分后,Rusty 上床睡觉了。当他试图入睡时,他思考着如何使他的建筑项目更高效。他想:”如果我们的任务可以更像团队摔跤手会怎么样?如果 Bot-1 可以开始一项任务,然后那个任务可以’换人’,让 Bot-1 在一段时间内做另一项任务……然后那个任务也可以暂停,Bot-1 可以回到第一个任务……?”

第二天,他决定测试一下。他收到了建造另一栋建筑的工作单。”这一次,”他说,”每当我们订购用品时,不要只是等待送货,让我们开始做下一个可用的任务。”于是,Bot-1 开始工作了。这一天是这样的。

  1. 首先他给仓库打电话订购窗户。
  2. 他没有等待,而是立即给另一个仓库打电话订购门。
  3. 然后,他立即开始铺砖工作。当他还在铺砖的时候,送货车送来了门。铺完最后一块砖后,第二辆卡车到达并送来了窗户。
  4. Bot-1 安装了窗户。
  5. 最后,他安装了门。
时间线。(我们将在下面的 Kotlin 代码中再次使用它) 等待:门 等待:窗户 铺设砖块 安装:窗户 安装:门 Start 1 2 3 3.25

这个项目完成后,Rusty 开心多了。通过做一些工作,放下它,做另一件事,最后再回到第一件事,他们能够节省大量时间。Bot-1 坐在路沿上的时间少多了!

模拟建筑工地

既然我们已经介绍了协程、协程构建器、挂起函数和挂起点的概念,让我们把知识带回到 Rusty 和 Bot-1。回到清单 20.2,我们介绍了一个用于订购窗户和门等物资的函数。让我们把它转换为一个挂起函数,并用协程库中的一个名为 delay() 的挂起函数替换 Thread.sleep()

import kotlinx.coroutines.delay

suspend fun order(item: Product): Product {
    println("ORDER EN ROUTE  >>> The ${item.description} are on the way!")
    delay(item.deliveryTime)
    println("ORDER DELIVERED >>> Your ${item.description} have arrived.")
    return item
}

就像 Thread.sleep() 一样,delay() 函数接受一个 Long 参数来告诉它延迟多长时间。那么 Thread.sleep()delay() 之间的区别是什么?

  • Thread.sleep() 不会挂起协程。相反,它只是阻塞执行指定的时间。由于它不会挂起,它不会给另一个协程在此期间运行的机会。这个函数可以从常规函数或挂起函数中调用。
  • delay() 挂起协程。这意味着协程可以放下其工作指定的时间,允许其他协程在此期间运行。这个函数只能从挂起函数内部调用。

就像常规函数一样,挂起函数可以返回一个值,就像上面两个函数所做的那样。有很多 Kotlin 库使用 suspend 函数返回重要的东西。例如前面提到的 Ktor 客户端库包含从 Web 服务返回响应的 suspend 函数。

当然,我们也可以将结果赋值给变量。为了尝试这一点,让我们获取清单 20.3中的代码,并将其放在 runBlocking() lambda 内部。

fun main() {
    runBlocking {
        val windows = order(Product.WINDOWS)
        val doors = order(Product.DOORS)
        perform("laying bricks")
        perform("installing ${windows.description}")
        perform("installing ${doors.description}")
    }
}

尽管这段代码运行在一个协程中,尽管 order() 函数现在是一个挂起函数,我们仍然得到与清单 20.3完全相同的输出:

ORDER EN ROUTE  >>> The windows are on the way!
ORDER DELIVERED >>> Your windows have arrived.
ORDER EN ROUTE  >>> The doors are on the way!
ORDER DELIVERED >>> Your doors have arrived.
STARTING TASK   >>> laying bricks
FINISHED TASK   >>> laying bricks
STARTING TASK   >>> installing windows
FINISHED TASK   >>> installing windows
STARTING TASK   >>> installing doors
FINISHED TASK   >>> installing doors

这表明协程中的代码仍然是自上而下运行的,就像常规 Kotlin 代码一样——即使调用挂起函数而不是常规函数也是如此。

稍后,我们将更新这段代码,使任务可以并发运行。但首先,让我们考虑一下这段代码的时序。上面的输出大约在五秒内出现——门送货 750 毫秒,窗户 1250 毫秒,每次调用 perform() 一秒。(送货时间在清单 20.1的枚举类中指定。)

前一个代码清单的时间线 等待:窗户 等待:门 铺设砖块 安装:窗户 安装:门 Start 1 2 3 4 5

现在,在 Rusty 最新的项目中,Bot-1 给一个仓库打电话订购窗户,然后立即给另一个仓库打电话订购门。之后,他开始铺砖,所有这些都不等待送货。正如我们发现的,如果我们想在 Kotlin 中并发地做事,我们不能只是把代码扔进一个协程——我们需要两个或更多协程。所以让我们更新清单 20.13中的代码,使 Bot-1 可以在等待送货的同时铺砖。

一个想法是用 launch() 协程构建器包装我们的每个 order() 调用,然后将所有 perform() 调用放在另一个 launch() 中。这样,两个 order() 调用中的每一个都会在自己的协程中发生,与铺砖并发进行。然而,当我们这样做时,会收到编译器错误。

fun main() {
    runBlocking {
        val windows = launch { order(Product.WINDOWS) }
        val doors = launch { order(Product.DOORS) }
        launch {
            perform("laying bricks")
            perform("install ${windows.description}")
            perform("install ${doors.description}")
        }
    }
}
Error

此错误的原因是 launch() 不返回 order() 的结果。相反,它返回一个类型为 Job 的对象。这个 Job 对象很有用,我们将在本章后面看到,但它没有给我们任何获取 order() 调用结果的方法。

Job 和 Deferred 的 UML 图 Job Deferred<T>

相反,我们需要使用第三种协程构建器,名为 async()。这个构建器的工作方式与 launch() 很相似,但它返回的不是 Job 对象,而是返回一个名为 DeferredJob 子类型对象。这个对象给我们提供了一个名为 await() 的函数,它允许我们获取 order() 的结果。以下是我们如何使用它。

fun main() {
    runBlocking {
        val windows = async { order(Product.WINDOWS) }
        val doors = async { order(Product.DOORS) }
        launch {
            perform("laying bricks")
            perform("installing ${windows.await().description}")
            perform("installing ${doors.await().description}")
        }
    }
}

在这段代码中,铺完砖后,我们对两个 Deferred 对象——延迟的窗户和延迟的门——调用 await()await() 是一个挂起函数,它会挂起协程,直到其 async() 协程完成。

在这段代码中,await() 挂起由 launch() 创建的协程,直到由 async() 创建的第一个协程完成。 fun main () { runBlocking { val windows = async { order (Product. WINDOWS ) } val doors = async { order (Product. DOORS ) } launch { perform ( "laying bricks" ) perform ( "installing ${ windows. await (). description } " ) perform ( "installing ${ doors. await (). description } " ) } } } await() suspends this code... ... until this code finishes

如果窗户在砖块仍在铺设时到达,那么窗户将在砖块完成后立即安装。如果在砖块完成时窗户还没有到达,那么由 launch() 创建的协程将被挂起,直到 order(Product.WINDOWS) 完成。

这是我们运行上面清单 20.15得到的输出:

ORDER EN ROUTE  >>> The windows are on the way!
ORDER EN ROUTE  >>> The doors are on the way!
STARTING TASK   >>> laying bricks
FINISHED TASK   >>> laying bricks
ORDER DELIVERED >>> Your doors have arrived.
ORDER DELIVERED >>> Your windows have arrived.
STARTING TASK   >>> installing windows
FINISHED TASK   >>> installing windows
STARTING TASK   >>> installing doors
FINISHED TASK   >>> installing doors

就像 Rusty 一样,通过做这些更改,我们最终节省了大量时间!这段代码现在只需要 3.25 秒完成,而不是 5 秒。

前一个代码清单的时间线。 等待:门 等待:窗户 铺设砖块 安装:窗户 安装:门 Start 1 2 3 3.25

对于清单 20.15中的代码,我们的协程结构如下:

前述代码清单的协程结构。 perform(...) launch order doors async order windows async runBlocking

差不多是时候回到 Rusty 了,但在我们这样做之前,让我们回顾一下本节的一些概念。

  • launch() 构建器返回一个 Job 对象,所以当你不需要从该协程获取结果时,它是正确的选择。
  • async() 构建器创建一个协程,并返回一个 Deferred 对象。你可以在此对象上调用 await() 函数来等待其结果。

Rusty 和 Bot-1 通过并发工作看到了很大的收获,但他们即将找到进一步加快项目进度的方法!让我们看看他们在忙什么!

两个机器人,一次做两件事……

那天晚上晚些时候,Rusty 在家里放松,看着电视上的赛车比赛。当一辆赛车冲进维修站时,他对维修团队的效率感到惊叹!几秒钟内,他们把车顶起来,精确地更换了轮胎,并加满了汽油。这一切发生得如此之快,是因为维修团队有多个团队成员,所有人同时做不同的事情。

”维修团队的插图。还有

Rusty 自言自语道:”如果我的建筑队里有多个机器人,我打赌我们可以进一步加快项目进度!”

第二天,Rusty 开始创建更多机器人。在构建它们时,他考虑了项目中涉及的任务类型。有些任务涉及艰苦的工作,如搬运和铺设砖块,但其他任务只涉及打电话订购物资和等待接收。因此,他将机器人分成不同的团队。

  • 第一个团队负责做艰苦的工作和重型搬运。他称他们为”默认”团队,因为他们做主要的建筑工作。
  • 第二个团队被称为”IO”团队,因为他们处理与场外设施的输入 (I) 和输出 (O)——或者”入站”和”出站”——通信。

每个团队都有一个工头,负责在不同时间为不同的机器人分配任务,视当时的需求而定。

两组机器人——默认团队和 IO 团队。

当下一个工作单到来时,Rusty 兴奋地想要试验他的新机器人团队!

  • IO 团队的机器人给仓库打电话订购窗户和门,并等待它们的到达。
  • 与此同时,默认团队开始铺砖。
  • 门首先被送达,但默认团队还没有完成铺砖。由于砖块必须在安装门之前完成,门在旁边放了一会儿。
  • 砖块完成后,一个机器人开始安装门。
  • 很快,第二辆送货车带着窗户出现了。默认团队中的第二个机器人准备好在窗户到达时立即安装窗户,这样窗户和门就可以同时安装了。

这比上一个项目节省了更多时间!

最新项目的时间线。 等待:窗户 等待:门 铺设砖块 安装:窗户 安装:门 Start 1 2 2.25

Rusty 非常兴奋!当然,不是所有事情都可以同时发生。砖块仍然必须在安装窗户和门之前铺设。然而,通过同时安装窗户和门,他的项目以创纪录的时间完成了!如果我们想在 Kotlin 代码中完成同样的事情,我们需要从学习线程、并发和并行开始。

多线程并发

你在电脑上运行的任何东西——无论是你自己的 Kotlin 程序、你安装的应用程序,还是在后台运行的服务——都在操作系统的线程上运行。2 由于当今大多数计算机有多个处理器核心,它们可以同时处理多个线程。

就像 Rusty 的机器人一样,我们可以使用单个线程在一段时间内做多件事情,但如果我们的计算机有多核处理器,我们也可以使用多个线程在同一个时刻做多件事情。3

这引出了一个重要的区别。

  • 当我们的代码的单个执行路径在两个或多个任务之间来回跳动时,这些任务是并发运行的。
  • 当有多个执行路径,每个在同一时刻运行不同的任务时,这些任务是并行运行的。
并发与并行 Coroutine 1 Coroutine 2 Clothesline! Pinning 1-2-3! Figure-four Leglock! Suplex! Piledriver! Parallelism Coroutine 2 Clothesline! Pinning 1-2-3! Figure-four Leglock! Suplex! Coroutine 1 Piledriver! two tasks interleaved over a period of time Concurrency two tasks at the same moment

到目前为止,我们的协程并发运行代码,但始终在单个线程上。让我们让这些协程在多个线程上运行,这样它们就可以并行运行了!

当我们最后看到清单 20.15中的代码时,它是这样的。

fun main() {
    runBlocking {
        val windows = async { order(Product.WINDOWS) }
        val doors = async { order(Product.DOORS) }
        launch {
            perform("laying bricks")
            perform("install ${windows.await().description}")
            perform("install ${doors.await().description}")
        }
    }
}
IO 团队的工头。

就像 Rusty 的每个团队都有一个向该团队上的机器人分配任务的工头一样,在 Kotlin 中,我们有不同的调度器,它们可以分配协程在它们管理的线程上运行。如果我们想让特定的调度器管理协程,我们只需将调度器作为参数传递给协程构建器。

例如,我们可以使用 Dispatchers.IO 将产品订购任务分配给 IO 团队。其余任务可以使用 Dispatchers.Default 分配给默认团队。

fun main() {
    runBlocking {
        val windows = async(Dispatchers.IO) { order(Product.WINDOWS) }
        val doors = async(Dispatchers.IO) { order(Product.DOORS) }
        launch(Dispatchers.Default) {
            perform("laying bricks")
            perform("install ${windows.await().description}")
            perform("install ${doors.await().description}")
        }
    }
}

之前,一切都在单个线程上运行——换句话说,一个机器人做每个任务。但现在,通过将协程分配给 Dispatchers.IODispatchers.Default,工作正在三个不同的线程上发生:

  • IO 调度器管理的两个线程订购产品并关注它们的送货。
  • Default 调度器管理的一个线程正在铺设砖块,然后安装窗户,最后安装门。

尽管我们已经将工作分配给了不同的团队,但这项工作仍然需要三秒才能完成。为了进一步加快速度,我们需要并行安装窗户和门。

像往常一样,我们需要在我们想要与其他代码并发或并行运行的代码周围放置一个协程构建器。让我们用另一个 launch() 包装最后两个 perform() 调用。请注意,因为砖块必须在安装窗户和门之前铺设,所以我们要等到那项工作完成后才启动这些协程。

fun main() {
    runBlocking {
        val windows = async(Dispatchers.IO) { order(Product.WINDOWS) }
        val doors = async(Dispatchers.IO) { order(Product.DOORS) }
        launch(Dispatchers.Default) {
            perform("laying bricks")
            launch { perform("install ${windows.await().description}") }
            launch { perform("install ${doors.await().description}") }
        }
    }
}

通过这一更改,窗户和门同时安装,只需约 2.25 秒就完成所有工作!

这个项目的时间线。 等待:窗户 等待:门 铺设砖块 安装:窗户 安装:门 Start 1 2 2.25

这段代码创建了 6 个不同的协程——一个由 runBlocking() 创建,两个由 async() 创建,三个由 launch() 创建。结果是一个如下所示的协程层次结构:

最后一个代码清单的协程层次结构。 launch lay bricks install windows launch install doors launch order doors async order windows async runBlocking

这按照我们想要的方式工作,但我们也可以用更少的四个协程更高效地做到这一点。为了实现这一点,我们可以使用一个名为 withContext() 的函数。

withContext():将工作交给另一个调度器

到目前为止,我们已经将订购产品的代码与安装产品的代码分开了。

产品订购与产品安装是分开的。 fun main () { runBlocking { val windows = async (Dispatchers. IO ) { order (Product. WINDOWS ) } val doors = async (Dispatchers. IO ) { order (Product. DOORS ) } launch (Dispatchers. Default ) { perform ( "laying bricks" ) launch { perform ( "installing ${ windows. await (). description } " ) } launch { perform ( "installing ${ doors. await (). description } " ) } } } } ordering windows and doors up here laying bricks installing windows and doors down here

然而,订购产品和安装产品是密切相关的概念,因此将这些任务保持在代码中彼此靠近可能会有帮助。例如,为了将窗户订购代码和窗户安装代码保持彼此靠近——但仍然确保正确的团队负责每个步骤——我们不需要创建一个新的协程。我们可以直接使用一个名为 withContext() 的函数。

withContext() 函数允许我们在不启动全新协程的情况下切换调度器。换句话说,这就像 IO 团队的一个机器人下订单并等待其到达;然后,一旦到达,它就把窗户交给默认团队的机器人进行实际安装工作。以下是代码的样子。

fun main() {
    runBlocking {
        launch(Dispatchers.IO) {
            val windows = order(Product.WINDOWS)
            withContext(Dispatchers.Default) { 
                perform("install ${windows.description}") 
            }
        }
        launch(Dispatchers.IO) {
            val doors = order(Product.DOORS)
            withContext(Dispatchers.Default) {
                perform("install ${doors.description}") 
            }
        }
        launch(Dispatchers.Default) {
            perform("laying bricks")
        }
    }
}

在这段代码中,我们调用 launch() 创建三个协程——一个处理窗户,一个处理门,一个铺砖。在前两个中,产品在由 Dispatchers.IO 管理的线程上订购。但是,一旦产品到达,我们使用 withContext() 更改调度器,以便 perform() 在由 Dispatchers.Default 管理的线程上发生。

窗户在 IO 线程上订购,并在 Default 线程上安装。 launch (Dispatchers. IO ) { val windows = order (Product. WINDOWS ) withContext (Dispatchers. Default ) { perform ( "install ${ windows. description } " ) } } runs on an IO thread runs on a Default thread

通过进行此更改,我们创建了更少的协程,并且相关的工作(例如,订购窗户和安装窗户)在代码中保持更近。不过,我们引入了一个问题!窗户和门应该只在砖块铺设完成后才能安装。当我们查看清单 20.19的输出时,我们会注意到我们在砖块工作完成之前就开始安装门了!

STARTING TASK   >>> laying bricks
ORDER EN ROUTE  >>> The windows are on the way!
ORDER EN ROUTE  >>> The doors are on the way!
ORDER DELIVERED >>> Your doors have arrived.
STARTING TASK   >>> install doors
FINISHED TASK   >>> laying bricks
ORDER DELIVERED >>> Your windows have arrived.
STARTING TASK   >>> install windows
FINISHED TASK   >>> install doors
FINISHED TASK   >>> install windows

我们如何在开始安装门和窗户之前等待砖块工作完成?

你可能记得,当我们调用 async() 构建器时,它返回一个 Deferred 对象,我们可以对该对象调用 await() 来挂起协程,直到结果准备好。类似地,launch() 构建器返回一个 Job 对象,其中包括一个名为 join() 的函数。与 await() 一样,join() 函数会挂起协程,直到 launch() 块中的代码完成。让我们再次重新排列代码。这一次,我们将确保在安装窗户和门之前砖块工作已经完成。

fun main() {
    runBlocking {
        val bricksJob = launch(Dispatchers.Default) {
            perform("laying bricks")
        }
        launch(Dispatchers.IO) {
            val windows = order(Product.WINDOWS)
            bricksJob.join()
            withContext(Dispatchers.Default) { 
                perform("install ${windows.description}") 
            }
        }
        launch(Dispatchers.IO) {
            val doors = order(Product.DOORS)
            bricksJob.join()
            withContext(Dispatchers.Default) { 
                perform("install ${doors.description}") 
            }
        }
    }
}

在这里,我们重新排列了 launch() 调用,使砖块工作首先进行。我们将那个 launch() 调用的结果赋值给一个名为 bricksJob 的变量。然后,在剩余的两个 launch() 块内部,我们调用 bricksJob.join() 来挂起,直到砖块工作完成。当我们运行这段代码时,我们可以看到门和窗直到砖块铺设完成后才会被安装。

STARTING TASK   >>> laying bricks
ORDER EN ROUTE  >>> The windows are on the way!
ORDER EN ROUTE  >>> The doors are on the way!
ORDER DELIVERED >>> Your doors have arrived.
FINISHED TASK   >>> laying bricks
STARTING TASK   >>> install doors
ORDER DELIVERED >>> Your windows have arrived.
STARTING TASK   >>> install windows
FINISHED TASK   >>> install doors
FINISHED TASK   >>> install windows

Rusty 和他的团队完成了最近的项目,但在建筑行业,你永远不知道会有什么阻碍会被扔进工程!让我们看看他们即将面对什么挑战。

取消

取消整个工作

有一天,Rusty 和他的建筑团队正在忙于一个项目,这时他收到了客户的电话。

”嘿,Rusty,是这样的……”客户开始说道,”我们要将整个业务搬到城市的另一部分。所以你知道的那个你正在建造的建筑?我们不再需要它了。直接取消整个项目吧。”

嗯,团队已经在工作进行中了,但如果客户不再需要那个建筑,继续工作就没有意义了。两个机器人正在等待窗户和门的送货,一个正在铺砖。Rusty 依次跑到每个机器人那里传递消息。正在等待送货的机器人立即收到了消息,所以它们收拾东西准备回家。

然而,正在铺砖的 Bot-3 正戴着耳机埋头工作,所以他一开始没有注意到 Rusty。最后一块砖铺完后,他终于抬起头来,看到 Rusty 示意大家准备回家,所以他最终收拾好东西并结束了工作。

BrickBot 没有注意到 Rusty。

取消顶层协程

就像在建筑工地一样,有时协程作业需要被取消。我们可以在传递给协程构建器的 lambda 内部调用名为 cancel() 的函数。让我们更新代码,以便在所有工作开始后取消作业。

fun main() {
    runBlocking {
        val bricksJob = launch(Dispatchers.Default) {
            perform("laying bricks")
        }
        launch(Dispatchers.IO) {
            val windows = order(Product.WINDOWS)
            bricksJob.join()
            withContext(Dispatchers.Default) { perform("install ${windows.description}") }
        }
        launch(Dispatchers.IO) {
            val doors = order(Product.DOORS)
            bricksJob.join()
            withContext(Dispatchers.Default) { perform("install ${doors.description}") }
        }
        cancel()
    }
}

运行此代码,我们将看到如下输出:

STARTING TASK   >>> laying bricks
ORDER EN ROUTE  >>> The windows are on the way!
ORDER EN ROUTE  >>> The doors are on the way!
FINISHED TASK   >>> laying bricks
Exception in thread "main" kotlinx.coroutines.JobCancellationException: 
BlockingCoroutine was canceled; job=BlockingCoroutine{Canceled}@7006c658

通过调用 cancel(),我们得到了一个 JobCancellationException。就像在 Rusty 的工地一样,砌砖任务没有被中断。稍后我们会了解为什么会出现这种情况。现在,让我们更仔细地了解取消是如何工作的。

正如我们在本章中所见,当一个协程启动另一个协程时(该协程可能又启动另一个!),我们最终会得到一个协程层次结构。在清单 20.21中,由 runBlocking() 创建的协程最终创建了另外三个协程,形成的结构如下所示。

前述代码清单的协程层次结构。 order and install doors launch order and install windows launch lay bricks launch runBlocking

属于同一层次结构的所有协程都存在于同一个协程作用域中。实际上,CoroutineScope是一个真正的接口,而 launch()async() 协程构建器是该接口上的扩展函数,这使得它们能够将新协程绑定到该 CoroutineScope 上。

通过将协程组织到作用域中,Kotlin 可以跟踪该作用域的协程及其之间的父子关系。这样,如果工作被取消或出现问题,Kotlin 将确保每个协程都被正确处理,而不需要开发者手动处理这些情况。这个特性称为结构化并发。让我们看看当一个作业被取消时,结构化并发是如何应用的。

清单 20.21中,我们在 runBlocking() lambda 内部调用了 cancel(),即层次结构顶部的协程。

cancel() 函数在根协程上被调用。 order and install doors launch order and install windows launch lay bricks launch runBlocking cancel() was called on this coroutine

得益于结构化并发,当根协程被取消时,我们不需要手动取消其每个子协程。相反,每个子协程自动收到取消信号。如果该子协程恰好有自己的子协程,它也会将取消信号传递给他们。

取消父协程也会取消其子协程。 order and install doors launch order and install windows launch launch lay bricks runBlocking canceled canceled canceled canceled

然而,就像 Bot-3 戴着耳机埋头工作,没有注意到 Rusty 一样,协程不会注意到取消信号除非它记得去检查它

已经有一段时间了,让我们再看一遍清单 20.2perform() 函数的代码。

fun perform(taskName: String) {
    println("STARTING TASK   >>> $taskName")
    Thread.sleep(1_000)
    println("FINISHED TASK   >>> $taskName")
}

为了模拟执行任务所需的时间,此函数使用 Thread.sleep()。由于运行此代码的线程一直处于忙碌状态,它从不抬头查看作业是否已被取消。

不要连续工作 1000 毫秒,让我们把工作分成五个单元,这样机器人每 200 毫秒休息一次。我们将使用一个名为 repeat() 的函数循环五次,每次迭代只睡眠五分之一的时间。

我们将调用 yield() 来休息一下。这将使我们的机器人有机会注意到作业是否已被取消。记住,我们不能从常规函数调用挂起函数。由于 yield() 是一个挂起函数,我们也必须将 perform() 变成一个挂起函数。

suspend fun perform(taskName: String) {
    println("STARTING TASK   >>> $taskName")
    repeat(5) {
        Thread.sleep(200)
        yield()
    }
    println("FINISHED TASK   >>> $taskName")
}
时间线图示显示差异 Start 0.2 0.4 0.6 0.8 1.0 laying bricks laying bricks laying bricks laying bricks laying bricks laying bricks check check check check check check Listing 20.22 Listing 20.23

通过时不时调用此函数,协程有机会抬头查看工作是否已被取消。再次运行代码,现在产生的输出如下:

STARTING TASK   >>> laying bricks
ORDER EN ROUTE  >>> The windows are on the way!
ORDER EN ROUTE  >>> The doors are on the way!
Exception in thread "main" kotlinx.coroutines.JobCancellationException:
BlockingCoroutine was canceled; job=BlockingCoroutine{Canceled}@7006c658

这一次,正在砌砖的线程有机会注意到作业已被取消,所以它没有完成工作就退出了。

这表明协程中的取消是合作式的。一个正在努力工作的协程(砌砖或运行计算)不会注意到取消,除非它偶尔从工作中休息一下。如果它的代码不通过选择检查取消来合作,它就不会注意到信号。你可以通过调用 yield() 来检查取消,就像我们在上面的清单 20.23中所做的那样。或者,我们可以检查 CoroutineScopeisActive 属性,或者调用其 ensureActive() 函数。

好消息是,实际项目中许多挂起函数(如协程库中的、Ktor 中的等)都是这样编写的,它们会注意到取消。但是,如果你有一个正在执行重体力活的协程,请确保它有机会偶尔暂停一下,以便能够快速响应取消而不做不必要的工作。

然而,并非所有取消都必须影响整个作业,Rusty 和他的团队即将发现这一点!

取消部分作业

有一天,团队正在努力建造另一栋建筑时,客户打来电话,说:”我知道你们已经开始安装门了,但我们决定想要更开放的空间感觉。所以,不用麻烦安装门了。我仍然想要这栋建筑——只是不要门。”

这一次,Rusty 没有告诉所有机器人停止工作,而是直接去找正在等待门配送的机器人,并向他发送取消信号。窗户仍然被安装了,建筑成功完工,只是没有门。

IO-Bot 等待门交货时注意到 Rusty 的信号。

取消子协程

正如我们之前看到的,由于结构化并发,当你取消一个协程时,该协程连同其所有子协程一起被取消。但是,取消不会影响父协程或兄弟协程。为了演示这一点,让我们取消作业,就像 Rusty 的客户所做的那样。

fun main() {
    runBlocking {
        val bricksJob = launch(Dispatchers.Default) {
            perform("laying bricks")
        }
        launch(Dispatchers.IO) {
            val windows = order(Product.WINDOWS)
            bricksJob.join()
            withContext(Dispatchers.Default) { perform("install ${windows.description}") }
        }
        launch(Dispatchers.IO) {
            val doors = order(Product.DOORS)
            bricksJob.join()
            cancel()
            withContext(Dispatchers.Default) { perform("install ${doors.description}") }
        }
    }
}

当我们运行此代码时,得到如下输出:

STARTING TASK   >>> laying bricks
ORDER EN ROUTE  >>> The windows are on the way!
ORDER EN ROUTE  >>> The doors are on the way!
ORDER DELIVERED >>> Your doors have arrived.
FINISHED TASK   >>> laying bricks
ORDER DELIVERED >>> Your windows have arrived.
STARTING TASK   >>> install windows
FINISHED TASK   >>> install windows

这正如我们所希望的那样工作!注意,门到达了,但它们从未被安装。其他一切都按计划继续——砖块被铺好,窗户被安装了。所以,当我们取消一个协程时,该协程本身被取消,如果它有任何子协程,它们也会被取消。但是父协程和兄弟协程不受影响。

父协程和兄弟协程不受取消的影响。 order and install doors launch order and install windows launch lay bricks launch runBlocking canceled

然而,取消并不是唯一可能影响作业的意外情况,Rusty 和他的团队即将发现这一点!

当我们无法从问题中恢复时

有一天,建筑团队回来做另一个建筑项目。当砖块正在铺设时,IO 团队的一个机器人打电话要求交付门。然而,仓库给了他一些令人惊讶的消息。

”对不起,但我们不能给你送门。你的客户已经超出了预算,他买不起更多的门了。”电话那头的声音说。

没有门,而且确实没有更多的钱,项目根本无法继续。机器人取消了他的工作,然后跑到 Rusty 那里告诉他情况。”好吧,看起来我们不得不放弃这个项目了,”Rusty 说,然后他走向其他队友,向他们发出停止工作的信号。

IO-Bot 向 Rusty 解释问题。

协程中的异常

有时存在无法恢复的问题。在第 17 章中,我们学习了所有关于异常的知识,以及未被捕获的异常如何最终到达调用堆栈的顶部,并导致整个应用程序崩溃。协程中也有类似的情况,但它还涉及取消正在进行的工作。为了演示这一点,让我们在订购门的协程内部抛出一个异常。

fun main() {
    runBlocking {
        val bricksJob = launch(Dispatchers.Default) {
            perform("laying bricks")
        }
        launch(Dispatchers.IO) {
            val windows = order(Product.WINDOWS)
            bricksJob.join()
            withContext(Dispatchers.Default) { perform("install ${windows.description}") }
        }
        launch(Dispatchers.IO) {
            val doors = order(Product.DOORS)
            throw Exception("Out of money!")
            bricksJob.join()
            withContext(Dispatchers.Default) { perform("install ${doors.description}") }
        }
    }
}

运行此代码时,我们将看到如下输出:

STARTING TASK   >>> laying bricks
ORDER EN ROUTE  >>> The doors are on the way!
ORDER EN ROUTE  >>> The windows are on the way!
ORDER DELIVERED >>> Your doors have arrived.
Exception in thread "main" java.lang.Exception: Out of money!

虽然三个作业的工作都已启动,但由于从门作业内部抛出的异常,它们都没有完成。

默认情况下,协程内部的未捕获异常会影响其作用域内的所有协程:

  • 带有未捕获异常的协程将取消其所有子协程。
  • 然后,它将异常传递给其父协程,父协程又取消其所有子协程,而这些子协程又取消它们各自的子协程,以此类推。
  • 此过程持续进行,直到异常到达协程层次结构的顶部。
协程中的异常导致其父协程被取消,以及该父协程的所有子协程也被取消。 order and install doors launch order and install windows launch lay bricks launch runBlocking canceled canceled canceled

这种行为——取消子协程并在整个协程作用域中向上传播异常——是结构化并发的另一个特性。与取消一样,它减少了我们本来必须自己做的大量手动工作,以确保所有协程被正确关闭。

告别,Rusty 和公司!

当太阳落山在新建造的天际线上时,Rusty 站着欣赏他和他的机器人建筑团队完成的工作。最新的大楼看起来很棒,并且在最短的时间内完成了。他回想起几天前 Bot-1 还在路边低效地等待交货。他们取得了多大的进步!通过并发和并行地工作,他们的建筑建造时间更短,客户满意度达到了历史最高水平。

当机器人们关闭电源时,Rusty 打盹睡着了,梦想着有一天能够扩大他的机器人团队,建造一座摩天大楼!

总结

如今大多数软件项目都需要一定程度的并发,在本章中,我们学习了:

恭喜你完成了这一章的学习!协程对许多开发者来说可能是一个具有挑战性的主题,但掌握了这些基础知识,你将能够自信地使用它们!


  1. 许多 Kotlin 开发者发现将挂起点概括为对挂起函数的任何调用是一个有用的归纳,但这并不完全精确。编写一个实际上不会挂起的挂起函数是可能的。在协程机制的最底层,有一个叫做 suspendCoroutineUninterceptedOrReturn() 的函数。从技术上讲,当这个函数被调用并传入一个返回名为 COROUTINE_SUSPENDED 的特殊值的 lambda 时,就是一个挂起点。当 delay()yield() 等函数被调用时,它们可能会调用另外的函数一两个,但在调用堆栈的某个地方,它会导致调用 suspendCoroutineUninterceptedOrReturn(),这就是实际的挂起点。 ↩︎

  2. 在大多数操作系统中,每个线程也由一个进程拥有,进程包含资源和其他上下文。因此,一个进程可以有一个或多个线程。关于这个主题的更多信息,请考虑阅读 Kirill Bobrov(2024)著的《Grokking Concurrency》,由 Manning Publications 出版。 ↩︎

  3. 即使你的电脑只有一个核心,你通常仍然可以使用多个线程。这些线程的工作会被分割,这样每个线程都能在处理器上获得一点时间。事实上,它的工作方式与我们目前创建的协程非常相似,只不过线程不是选择退出(协作式多任务),而是在某个时刻被出去——就像裁判吹哨把摔跤手踢出擂台,然后稍后再叫他回来(抢占式多任务)。 ↩︎