协程基础
为了创建能同时执行多项任务(一种称为并发的概念)的应用程序,Kotlin 使用 协程。协程是一种可挂起计算,它允许你以清晰、顺序的风格编写并发代码。协程可以与其他协程并发运行,并可能并行运行。
在 JVM 和 Kotlin/Native 上,所有并发代码(例如协程)都运行在由操作系统管理的 线程 上。协程可以挂起它们的执行,而不是阻塞线程。这允许一个协程在等待某些数据到达时挂起,而另一个协程则在同一线程上运行,从而确保了有效的资源利用。
关于协程与线程之间差异的更多信息,请参见比较协程与 JVM 线程。
挂起函数
协程最基本的构建块是 挂起函数。它允许正在进行的操作暂停并在之后恢复,而不影响代码结构。
要声明一个挂起函数,请使用 suspend 关键字:
suspend fun greet() {
println("Hello world from a suspending function")
}你只能从另一个挂起函数中调用挂起函数。要在 Kotlin 应用程序的入口点调用挂起函数,请使用 suspend 关键字标记 main() 函数:
suspend fun main() {
showUserInfo()
}
suspend fun showUserInfo() {
println("Loading user...")
greet()
println("User: John Smith")
}
suspend fun greet() {
println("Hello world from a suspending function")
}这个示例尚未利用并发,但通过使用 suspend 关键字标记这些函数,你允许它们调用其他挂起函数并在内部运行并发代码。
虽然 suspend 关键字是核心 Kotlin 语言的一部分,但大多数协程特性都通过 kotlinx.coroutines 库提供。
将 kotlinx.coroutines 库添加到你的项目
要将 kotlinx.coroutines 库包含到你的项目中,请根据你的构建工具添加相应的依赖项配置:
// build.gradle.kts
repositories {
mavenCentral()
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
}// build.gradle
repositories {
mavenCentral()
}
dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2'
}<!-- pom.xml -->
<project>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-core</artifactId>
<version>1.10.2</version>
</dependency>
</dependencies>
...
</project>创建你的第一个协程
本页示例使用了显式
this表达式,配合协程构建器函数CoroutineScope.launch()和CoroutineScope.async()。 这些协程构建器是CoroutineScope上的扩展函数,this表达式指的是当前CoroutineScope作为接收者。有关实际示例,请参见从协程作用域中提取协程构建器。
要在 Kotlin 中创建协程,你需要以下内容:
让我们看一个在多线程环境中使用多个协程的示例:
导入
kotlinx.coroutines库:kotlinimport kotlinx.coroutines.*使用
suspend关键字标记可以暂停和恢复的函数:kotlinsuspend fun greet() { println("The greet() on the thread: ${Thread.currentThread().name}") } suspend fun main() {}虽然你可以在某些项目中将
main()函数标记为suspend,但在与现有代码集成或使用框架时可能无法实现。 在这种情况下,请查阅框架文档,看它是否支持调用挂起函数。 如果不支持,请使用runBlocking()通过阻塞当前线程来调用它们。添加
delay()函数来模拟一个挂起任务,例如抓取数据或写入数据库:kotlinsuspend fun greet() { println("The greet() on the thread: ${Thread.currentThread().name}") delay(1000L) }使用
withContext(Dispatchers.Default)来定义多线程并发代码的入口点,该代码运行在共享线程池上:kotlinsuspend fun main() { withContext(Dispatchers.Default) { // Add the coroutine builders here } }挂起函数
withContext()通常用于上下文切换,但在本示例中,它也为并发代码定义了一个非阻塞的入口点。 它使用Dispatchers.Default调度器在共享线程池上运行代码,以实现多线程执行。 默认情况下,此池使用的线程数最多等于运行时可用的 CPU 核心数,最少为两个线程。withContext()代码块内部启动的协程共享相同的协程作用域,这确保了结构化并发。使用一个协程构建器函数,例如
CoroutineScope.launch(),来启动协程:kotlinsuspend fun main() { withContext(Dispatchers.Default) { // this: CoroutineScope // 使用 CoroutineScope.launch() 在作用域内启动协程 this.launch { greet() } println("The withContext() on the thread: ${Thread.currentThread().name}") } }将这些部分组合起来,在共享线程池上同时运行多个协程:
kotlin// 导入协程库 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}") } }
尝试多次运行此示例。你可能会注意到每次运行程序时,输出顺序和线程名称可能会改变,因为操作系统决定线程何时运行。
你可以在代码输出中将协程名称显示在线程名称旁边,以获取额外信息。 为此,请在构建工具或 IDE 运行配置中传入
-Dkotlinx.coroutines.debugVM 选项。关于更多信息,请参阅调试协程。
协程作用域与结构化并发
当你在应用程序中运行多个协程时,你需要一种方法来将它们作为组进行管理。Kotlin 协程依赖于一个称为 结构化并发 的原则来提供这种结构。
根据这一原则,协程形成一个具有关联生命周期的父任务和子任务的树状层级结构。协程的生命周期是从创建到完成、失败或取消的状态序列。
父协程会等待其子协程完成后再结束。如果父协程失败或被取消,所有子协程也会被递归取消。这样保持协程的连接使取消和错误处理可预测且安全。
为了维护结构化并发,新协程只能在定义并管理它们生命周期的 CoroutineScope 中启动。CoroutineScope 包含 协程上下文,它定义了调度器和其他执行属性。当你在另一个协程内部启动一个协程时,它自动成为其父作用域的子项。
在 CoroutineScope 上调用协程构建器函数,例如 CoroutineScope.launch(),会启动与该作用域关联的协程的子协程。在构建器的代码块内部,接收者是一个嵌套的 CoroutineScope,因此你在这里启动的任何协程都将成为其子项。
使用 coroutineScope() 函数创建协程作用域
要使用当前协程上下文创建新的协程作用域,请使用 coroutineScope() 函数。此函数创建协程子树的根协程。它是代码块内部启动的协程的直接父项,也是它们启动的任何协程的间接父项。coroutineScope() 执行挂起代码块,并等待该代码块及其内部启动的任何协程完成。
这是一个示例:
// 导入 kotlin.time.Duration 以秒为单位表示持续时间
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.*
// 如果协程上下文未指定调度器,CoroutineScope.launch() 将使用 Dispatchers.Default
suspend fun main() {
// 协程子树的根
coroutineScope { // this: CoroutineScope
this.launch {
this.launch {
delay(2.seconds)
println("封闭协程的子协程已完成")
}
println("子协程 1 已完成")
}
this.launch {
delay(1.seconds)
println("子协程 2 已完成")
}
}
// 仅在 coroutineScope 中的所有子协程完成后运行
println("协程作用域已完成")
}由于此示例中未指定调度器,coroutineScope() 代码块中的 CoroutineScope.launch() 构建器函数继承当前上下文。如果该上下文没有指定的调度器,CoroutineScope.launch() 将使用 Dispatchers.Default,它运行在共享线程池上。
从协程作用域中提取协程构建器
在某些情况下,你可能希望将协程构建器调用,例如 CoroutineScope.launch(),提取到单独的函数中。
考虑以下示例:
suspend fun main() {
coroutineScope { // this: CoroutineScope
// 调用 CoroutineScope.launch(),其中 CoroutineScope 是接收者
this.launch { println("1") }
this.launch { println("2") }
}
}你也可以不使用显式
this表达式,将this.launch写为launch。 这些示例使用显式this表达式来强调它是一个CoroutineScope上的扩展函数。有关带接收者的 lambda 表达式在 Kotlin 中如何工作的更多信息,请参见带接收者的函数字面量。
coroutineScope() 函数接受一个带有 CoroutineScope 接收者的 lambda 表达式。在此 lambda 内部,隐式接收者是一个 CoroutineScope,因此像 CoroutineScope.launch() 和 CoroutineScope.async() 这样的构建器函数会解析为该接收者上的扩展函数。
要将协程构建器提取到另一个函数中,该函数必须声明一个 CoroutineScope 接收者,否则会发生编译错误:
import kotlinx.coroutines.*
suspend fun main() {
coroutineScope {
launchAll()
}
}
fun CoroutineScope.launchAll() { // this: CoroutineScope
// 在 CoroutineScope 上调用 .launch()
this.launch { println("1") }
this.launch { println("2") }
}
/* -- Calling launch without declaring CoroutineScope as the receiver results in a compilation error --
fun launchAll() {
// 编译错误:this 未定义
this.launch { println("1") }
this.launch { println("2") }
}
*/协程构建器函数
协程构建器函数是一个接受 suspend lambda 表达式的函数,该 lambda 表达式定义了一个要运行的协程。以下是一些示例:
协程构建器函数需要一个 CoroutineScope 来运行。这可以是一个现有作用域,也可以是你使用 coroutineScope()、runBlocking() 或 withContext() 等辅助函数创建的作用域。每个构建器都定义了协程如何启动以及你如何与其结果交互。
CoroutineScope.launch()
CoroutineScope.launch() 协程构建器函数是 CoroutineScope 上的一个扩展函数。它在现有协程作用域内部启动一个新协程,而不阻塞作用域的其余部分。
当不需要结果或你不想等待结果时,使用 CoroutineScope.launch() 在其他工作的同时运行任务:
// 导入 kotlin.time.Duration 以毫秒为单位表示持续时间
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.*
suspend fun main() {
withContext(Dispatchers.Default) {
performBackgroundWork()
}
}
suspend fun performBackgroundWork() = coroutineScope { // this: CoroutineScope
// 启动一个不阻塞作用域的协程
this.launch {
// 挂起以模拟后台工作
delay(100.milliseconds)
println("Sending notification in background")
}
// 主协程继续执行,而前一个协程处于挂起状态
println("Scope continues")
}运行此示例后,你可以看到 main() 函数不会被 CoroutineScope.launch() 阻塞,并继续运行其他代码,而协程在后台工作。
CoroutineScope.launch()函数返回一个Job句柄。 使用此句柄等待启动的协程完成。 关于更多信息,请参见取消与超时。
CoroutineScope.async()
CoroutineScope.async() 协程构建器函数是 CoroutineScope 上的一个扩展函数。它在现有协程作用域内部启动一个并发计算,并返回一个 Deferred 句柄,该句柄代表最终结果。使用 .await() 函数挂起代码直到结果准备就绪:
// 导入 kotlin.time.Duration 以毫秒为单位表示持续时间
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.*
suspend fun main() = withContext(Dispatchers.Default) { // this: CoroutineScope
// 开始下载第一页
val firstPage = this.async {
delay(50.milliseconds)
"First page"
}
// 并行开始下载第二页
val secondPage = this.async {
delay(100.milliseconds)
"Second page"
}
// 等待两个结果并进行比较
val pagesAreEqual = firstPage.await() == secondPage.await()
println("Pages are equal: $pagesAreEqual")
}runBlocking()
runBlocking() 协程构建器函数创建一个协程作用域,并阻塞当前线程,直到在该作用域中启动的协程完成。
仅在没有其他选项可以从非挂起代码中调用挂起代码时,才使用 runBlocking():
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.*
// 一个你无法修改的第三方接口
interface Repository {
fun readItem(): Int
}
object MyRepository : Repository {
override fun readItem(): Int {
// 桥接到一个挂起函数
return runBlocking {
myReadItem()
}
}
}
suspend fun myReadItem(): Int {
delay(100.milliseconds)
return 4
}协程调度器
一个 协程调度器 控制协程使用哪个线程或线程池来执行。协程并非总是绑定到单个线程。它们可以在一个线程上暂停并在另一个线程上恢复,这取决于调度器。这允许你同时运行多个协程,而无需为每个协程分配单独的线程。
尽管协程可以在不同的线程上挂起和恢复,但协程挂起之前写入的值仍保证在恢复时在同一协程中可用。
调度器与协程作用域协同工作,以定义协程何时何地运行。协程作用域控制协程的生命周期,而调度器控制哪些线程用于执行。
你无需为每个协程指定调度器。 默认情况下,协程从其父作用域继承调度器。 你可以指定一个调度器,让协程在不同的上下文中运行。
如果协程上下文不包含调度器,协程构建器将使用
Dispatchers.Default。
kotlinx.coroutines 库包含用于不同用例的不同调度器。例如,Dispatchers.Default 在共享线程池上运行协程,在后台执行工作,与主线程分离。这使其成为 CPU 密集型操作(如数据处理)的理想选择。
要为像 CoroutineScope.launch() 这样的协程构建器指定调度器,请将其作为实参传入:
suspend fun runWithDispatcher() = coroutineScope { // this: CoroutineScope
this.launch(Dispatchers.Default) {
println("Running on ${Thread.currentThread().name}")
}
}另外,你可以使用 withContext() 代码块,在指定的调度器上运行其中的所有代码:
// 导入 kotlin.time.Duration 以毫秒为单位表示持续时间
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.*
suspend fun main() = withContext(Dispatchers.Default) { // this: CoroutineScope
println("Running withContext block on ${Thread.currentThread().name}")
val one = this.async {
println("First calculation starting on ${Thread.currentThread().name}")
val sum = (1L..500_000L).sum()
delay(200L)
println("First calculation done on ${Thread.currentThread().name}")
sum
}
val two = this.async {
println("Second calculation starting on ${Thread.currentThread().name}")
val sum = (500_001L..1_000_000L).sum()
println("Second calculation done on ${Thread.currentThread().name}")
sum
}
// 等待两次计算并打印结果
println("Combined total: ${one.await() + two.await()}")
}要了解更多关于协程调度器及其用途的信息,包括 Dispatchers.IO 和 Dispatchers.Main 等其他调度器,请参见协程上下文与调度器。
比较协程与 JVM 线程
尽管协程是可挂起计算,像 JVM 上的线程一样并发运行代码,但它们在底层的工作方式不同。
线程 由操作系统管理。线程可以在多个 CPU 核心上并行运行任务,并代表 JVM 上并发的标准方法。当你创建线程时,操作系统会为其栈分配内存,并使用内核在线程之间切换。这使得线程功能强大但也资源密集。每个线程通常需要几兆字节内存,通常 JVM 一次只能处理几千个线程。
另一方面,协程不绑定到特定线程。它可以在一个线程上挂起并在另一个线程上恢复,因此许多协程可以共享同一个线程池。当协程挂起时,线程不会被阻塞,并保持空闲以运行其他任务。这使得协程比线程轻量得多,并允许在一个进程中运行数百万个协程,而不耗尽系统资源。
让我们看一个示例,其中 50,000 个协程每个等待五秒,然后打印一个点(.):
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.*
suspend fun main() {
withContext(Dispatchers.Default) {
// 启动 50,000 个协程,每个协程等待五秒,然后打印一个点
printPeriods()
}
}
suspend fun printPeriods() = coroutineScope { // this: CoroutineScope
// 启动 50,000 个协程,每个协程等待五秒,然后打印一个点
repeat(50_000) {
this.launch {
delay(5.seconds)
print(".")
}
}
}现在我们来看同样的示例,使用 JVM 线程:
import kotlin.concurrent.thread
fun main() {
repeat(50_000) {
thread {
Thread.sleep(5000L)
print(".")
}
}
}运行此版本会占用更多内存,因为每个线程都需要自己的内存栈。对于 50,000 个线程,这可能高达 100 GB,相比之下,相同数量的协程大约需要 500 MB。
根据你的操作系统、JDK 版本和设置,JVM 线程版本可能会抛出内存不足错误,或减慢线程创建速度,以避免同时运行过多的线程。
