协程的基本概念
要创建能够同时执行多个任务的应用程序, 也就是称为并发的概念, Kotlin 使用 协程(Coroutine). 协程是一种可挂起的计算, 让你能够以清晰的顺序风格, 编写并发代码. 协程可以与其他协程并发(Concurrent)运行, 也可以并行(Parallel)运行.
在 JVM 和 Kotlin/Native 中, 所有的并发代码, 例如协程, 都运行在由操作系统管理的 线程(Thread) 上. 协程可以挂起执行, 而不是阻塞线程. 这样, 一个协程可以在等待某些数据到达时挂起, 而另一个协程可以在同一个线程上运行, 从而确保有效利用资源.
有关协程与线程的区别, 详情请参见 比较协程与 JVM 线程.
挂起函数
协程最基本的构建块是 挂起函数(Suspending Function). 它允许一个正在运行的操作暂停, 并在之后恢复, 而不影响代码的结构.
要声明挂起函数, 请使用 suspend 关键字:
挂起函数只能从另一个挂起函数中调用. 要在 Kotlin 应用程序的入口点调用挂起函数, 请用 suspend 关键字标记 main() 函数:
这个示例还没有使用并发, 但通过用 suspend 关键字标记函数, 你允许它们调用其他挂起函数, 并在其内部运行并发代码.
虽然 suspend 关键字是 Kotlin 核心语言的一部分, 但协程的大多数功能, 都通过 kotlinx.coroutines 库提供.
向你的项目添加 kotlinx.coroutines 库
要在你的项目中引入 kotlinx.coroutines 库, 请根据你的构建工具, 添加相应的依赖项配置:
创建你的第一个协程
要在 Kotlin 中创建协程, 你需要以下内容:
一个 挂起函数.
一个 协程作用域, 挂起函数在其中运行, 例如在
withContext()函数内.一个 协程构建器, 用于启动挂起函数, 例如
CoroutineScope.launch().一个 派发器(Dispatcher), 控制挂起函数使用哪些线程.
我们来看看一个示例, 它在多线程环境中使用多个协程:
导入
kotlinx.coroutines库:import kotlinx.coroutines.*用
suspend关键字标记可以暂停和恢复的函数:suspend fun greet() { println("The greet() on the thread: ${Thread.currentThread().name}") } suspend fun main() {}添加
delay()函数来模拟挂起任务, 例如获取数据或写入数据库:suspend fun greet() { println("The greet() on the thread: ${Thread.currentThread().name}") delay(1000L) }使用
withContext(Dispatchers.Default), 为运行在共享线程池上的多线程并发代码定义入口点:suspend fun main() { withContext(Dispatchers.Default) { // 在这里添加协程构建器 } }使用 协程构建器函数, 例如
CoroutineScope.launch(), 启动协程:suspend fun main() { withContext(Dispatchers.Default) { // this: CoroutineScope // 在作用域内, 使用 CoroutineScope.launch() 启动协程 this.launch { greet() } println("The withContext() on the thread: ${Thread.currentThread().name}") } }组合这些部分, 在共享的线程池上同时运行多个协程:
// 导入协程库 import kotlinx.coroutines.* // 导入 kotlin.time.Duration, 表达秒单位的时间段 import kotlin.time.Duration.Companion.seconds // 定义一个挂起函数 suspend fun greet() { println("The greet() on the thread: ${Thread.currentThread().name}") // 挂起 1 秒, 并释放线程 delay(1.seconds) // delay() 函数在这里模拟一个挂起的 API 调用 // 你可以在这里添加挂起的 API 调用, 例如网络请求 } suspend fun main() { // 在共享的线程池上, 运行这个代码块之内的代码 withContext(Dispatchers.Default) { // this: CoroutineScope this.launch() { greet() } // 启动另一个协程 this.launch() { println("The CoroutineScope.launch() on the thread: ${Thread.currentThread().name}") delay(1.seconds) // delay 函数在这里模拟一个挂起的 API 调用 // 你可以在这里添加挂起的 API 调用, 例如网络请求 } println("The withContext() on the thread: ${Thread.currentThread().name}") } }
试试多次运行这个示例. 你可能会注意到, 每次运行程序时的输出顺序和线程名称可能都会改变, 因为操作系统决定线程何时运行.
协程作用域与结构化并发
当你在应用程序中运行很多协程时, 你需要一种方式, 将它们作为群组来管理. Kotlin 协程依赖一种称为 结构化并发(Structured Concurrency) 的原则, 提供这种结构.
根据这个原则, 协程组成一个父/子任务的树形层级结构, 具有相互关联的生命周期. 协程的生命周期是指, 从创建到完成, 失败或取消的一系列状态.
父协程在完成之前会等待其子协程完成. 如果父协程失败或被取消, 它的所有子协程也会被递归的取消. 以这种方式保持协程的连接, 使得取消和错误处理变得能够预测, 而且安全.
为了维持结构化并发, 新协程只能在 CoroutineScope 中启动, CoroutineScope 会定义并管理它们的生命周期. CoroutineScope 包含 协程上下文(Coroutine Context), 它定义了派发器和其他执行属性. 当你在另一个协程内部启动一个协程时, 它会自动成为其父作用域的子协程.
在 CoroutineScope 上调用 协程构建器函数, 例如 CoroutineScope.launch()), 会启动与这个作用域关联的协程的子协程. 在构建器的代码块内, 接收者 是嵌套的 CoroutineScope, 因此在这里启动的任何协程都成为它的子协程.
使用 coroutineScope() 函数创建协程作用域
要使用当前协程上下文创建新的协程作用域, 请使用 coroutineScope() 函数. 这个函数创建一个协程子树的根协程. 它是在代码块内启动的协程的直接父协程, 以及这些协程启动的任何协程的间接父协程. coroutineScope() 执行挂起代码块, 并等待代码块, 以及其中启动的任何协程执行完成.
下面是一个示例:
由于这个示例中没有指定 派发器, coroutineScope() 代码块中的 CoroutineScope.launch() 构建器函数会继承当前上下文. 如果这个上下文没有指定派发器, CoroutineScope.launch() 会使用 Dispatchers.Default, 它在共享线程池上运行.
从协程作用域中提取协程构建器
在某些情况下, 你可能希望将协程构建器的调用, 例如 CoroutineScope.launch(), 提取到单独的函数中.
请看以下示例:
coroutineScope() 函数接受一个带有 CoroutineScope 接收者的 Lambda 表达式. 在这个 Lambda 表达式内部, 隐含的接收者是 CoroutineScope, 因此构建器函数, 例如 CoroutineScope.launch() 和 CoroutineScope.async(), 会解析为这个接收者上的 扩展函数.
要将协程构建器提取到另一个函数中, 这个函数必须声明 CoroutineScope 接收者, 否则会发生编译错误:
协程构建器函数
协程构建器函数是一种接受 suspend Lambda 表达式 的函数, 这个 Lambda 表达式定义了要运行的协程. 以下是一些示例:
协程构建器函数需要在一个 CoroutineScope 之内运行. 这个作用域可以是现有的作用域, 也可以是使用辅助函数, 例如 coroutineScope(), runBlocking() 或 withContext(), 创建的作用域. 每个构建器定义了协程如何启动, 以及你如何与它的结果交互.
CoroutineScope.launch()
CoroutineScope.launch() 协程构建器函数是 CoroutineScope 上的扩展函数. 它在现有的 协程作用域 之内, 启动一个新协程, 而不阻塞作用域内的其它协程.
当不需要结果, 或者不想等待结果时, 可以使用 CoroutineScope.launch(), 与其他工作并行运行任务:
运行这个示例后, 你可以看到 main() 函数不会被 CoroutineScope.launch() 阻塞, 当协程在后台工作时, 会继续运行其他代码.
CoroutineScope.async()
CoroutineScope.async() 协程构建器函数是 CoroutineScope 上的扩展函数. 它在现有 协程作用域 之内, 启动一个并发计算, 并返回一个 Deferred 句柄, 表示最终的结果. 使用 .await() 函数挂起代码, 直到得到结果:
runBlocking()
runBlocking() 协程构建器函数创建一个协程作用域, 并阻塞当前 线程, 直到在这个作用域中启动的协程执行结束.
只有在没有其他方式可以从非挂起代码调用挂起代码时, 才使用 runBlocking():
协程派发器(Dispatcher)
协程派发器(Dispatcher) 控制使用哪个线程或线程池来执行协程. 协程并不一定绑定到单个线程. 根据派发器不同, 协程可以在一个线程上暂停, 在另一个线程上恢复. 这样你就可以同时运行多个协程, 而不需要为每个协程分配单独的线程.
派发器与 协程作用域 一起工作, 定义协程何时运行, 以及在哪里运行. 协程作用域控制协程的生命周期, 派发器控制用于执行的线程.
kotlinx.coroutines 库包含各种不同的派发器, 适用于不同的使用场景. 例如, Dispatchers.Default 在共享线程池上运行协程, 独立于主线程之外, 在后台执行工作. 因此它成为 CPU 密集型操作, 例如数据处理, 的理想选择.
要为协程构建器, 例如 CoroutineScope.launch(), 指定派发器, 请将它作为参数:
或者, 你可以使用 withContext() 代码块, 在指定的派发器上运行其中的所有代码:
关于协程派发器及其使用, 包括其他派发器, 例如 Dispatchers.IO 和 Dispatchers.Main, 详情请参见 协程上下文与派发器.
比较协程与 JVM 线程
尽管协程与 JVM 上的线程类似, 是可挂起计算, 能够并发的运行代码, 但它们的底层工作方式是不同的.
线程 由操作系统管理. 线程可以在多个 CPU 核心上并行运行任务, 是 JVM 上并发的标准方式. 当你创建一个线程时, 操作系统会为它的栈分配内存, 并使用内核在线程之间切换. 这使得线程功能强大, 但也消耗大量资源. 每个线程通常需要几兆字节内存, JVM 通常只能同时处理几千个线程.
另一方面, 协程不绑定到特定的线程. 它可以在一个线程上挂起, 在另一个线程上恢复, 因此多个协程可以共用同一个线程池. 当协程挂起时, 线程不会被阻塞, 可以运行其他任务. 这使得协程比线程轻量很多, 能够在一个进程中运行几百万个协程, 而不会耗尽系统资源.
我们来看一个示例, 其中 50,000 个协程各自等待 5 秒, 然后打印一个点号 (.):
现在让我们看看使用 JVM 线程的相同示例:
运行这个版本会使用更多内存, 因为每个线程都需要自己的内存栈. 对于 50,000 个线程, 内存消耗可能高达 100 GB, 而同等数量的协程只需要大约 500 MB.
根据你的操作系统, JDK 版本和设置, JVM 线程版本可能会抛出内存不足错误, 或者放慢线程创建速度, 以避免同时运行过多线程.