协程基础
本节涵盖了基础协程概念。
你的第一个协程
协程 是可挂起计算的一个实例。概念上,它类似于一个线程,在于它接受一个代码块来运行,并与代码的其余部分并发工作。 然而,协程不绑定到任何特定线程。它可以在一个线程中挂起执行,并在另一个线程中恢复。
协程可以被认为是轻量级线程,但它们之间存在许多重要差异,使得其在实际使用中与线程大相径庭。
运行以下代码来创建你的第一个可运行协程:
import kotlinx.coroutines.*
fun main() = runBlocking { // this: CoroutineScope
launch { // 启动一个新的协程并继续
delay(1000L) // 非阻塞式延迟 1 秒(默认时间单位为毫秒)
println("World!") // 延迟后打印
}
println("Hello") // 主协程继续执行,而前一个协程处于延迟状态
}
你可以在这里获取完整代码。
你将看到以下结果:
Hello
World!
让我们剖析这段代码的作用。
launch
是一个_协程构建器_。它启动一个新的协程,与代码的其余部分并发执行,代码的其余部分会继续独立工作。这就是为什么 Hello
先被打印出来。
delay
是一个特殊的_挂起函数_。它使协程_挂起_特定时间。挂起协程并不会_阻塞_底层线程,而是允许其他协程运行并使用底层线程来执行它们的代码。
runBlocking
也是一个协程构建器,它连接了常规 fun main()
的非协程世界与 runBlocking { ... }
花括号内部包含协程的代码。IDE 中 runBlocking
左花括号后的 this: CoroutineScope
提示就突显了这一点。
如果你移除或忘记这段代码中的 runBlocking
,你会在 launch
调用处得到一个错误,因为 launch
只在 CoroutineScope
上声明:
Unresolved reference: launch
runBlocking
的名称意味着运行它的线程(在本例中 — 主线程)在调用期间会被_阻塞_,直到 runBlocking { ... }
内的所有协程完成执行。你经常会在应用程序的最顶层看到 runBlocking
的这种用法,但在实际代码中却很少见,因为线程是昂贵的资源,阻塞它们效率低下,并且通常不被期望。
结构化并发
协程遵循结构化并发的原则,这意味着新的协程只能在限定协程生命周期的特定 CoroutineScope
中启动。上面的例子表明 runBlocking
建立了相应的协程作用域,这就是为什么前面的例子会等待 World!
在一秒延迟后打印出来,然后才退出。
在实际应用程序中,你会启动大量的协程。结构化并发确保它们不会丢失且不会泄露。外部作用域只有在其所有子协程完成之后才能完成。结构化并发还确保代码中的任何错误都能正确报告,永不丢失。
提取函数重构
让我们将 launch { ... }
内部的代码块提取到一个单独的函数中。当你对这段代码执行“提取函数”重构时,你会得到一个带有 suspend
修饰符的新函数。这是你的第一个_挂起函数_。挂起函数可以在协程内部像常规函数一样使用,但它们的额外特性是,它们反过来可以使用其他挂起函数(例如本例中的 delay
)来_挂起_协程的执行。
import kotlinx.coroutines.*
fun main() = runBlocking { // this: CoroutineScope
launch { doWorld() }
println("Hello")
}
// 这是你的第一个挂起函数
suspend fun doWorld() {
delay(1000L)
println("World!")
}
你可以在这里获取完整代码。
作用域构建器
除了由不同构建器提供的协程作用域之外,还可以使用 coroutineScope
构建器声明自己的作用域。它会创建一个协程作用域,并且只有在所有已启动的子协程完成之后才会完成。
runBlocking
和 coroutineScope
构建器可能看起来相似,因为它们都等待其主体及所有子协程完成。 主要区别在于,runBlocking
方法会_阻塞_当前线程进行等待,而 coroutineScope
只是挂起,释放底层线程供其他用途使用。由于这个区别,runBlocking
是一个常规函数,而 coroutineScope
是一个挂起函数。
你可以在任何挂起函数中使用 coroutineScope
。 例如,你可以将 Hello
和 World
的并发打印移至 suspend fun doWorld()
函数中:
import kotlinx.coroutines.*
fun main() = runBlocking {
doWorld()
}
suspend fun doWorld() = coroutineScope { // this: CoroutineScope
launch {
delay(1000L)
println("World!")
}
println("Hello")
}
你可以在这里获取完整代码。
这段代码也打印:
Hello
World!
作用域构建器与并发
coroutineScope
构建器可以在任何挂起函数内部使用,以执行多个并发操作。 让我们在 doWorld
挂起函数中启动两个并发协程:
import kotlinx.coroutines.*
// 顺序执行 doWorld,然后是 "Done"
fun main() = runBlocking {
doWorld()
println("Done")
}
// 并发执行这两个部分
suspend fun doWorld() = coroutineScope { // this: CoroutineScope
launch {
delay(2000L)
println("World 2")
}
launch {
delay(1000L)
println("World 1")
}
println("Hello")
}
你可以在这里获取完整代码。
launch { ... }
代码块内部的两个代码片段都是_并发_执行的,World 1
在启动一秒后先打印,World 2
在启动两秒后接着打印。 doWorld
中的 coroutineScope
只有在两者都完成之后才会完成,所以 doWorld
会在此之后才返回并允许 Done
字符串被打印:
Hello
World 1
World 2
Done
显式的 Job
launch
协程构建器会返回一个 Job
对象,它是一个已启动协程的句柄,可以用来显式地等待其完成。 例如,你可以等待子协程完成,然后打印 "Done" 字符串:
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch { // 启动一个新的协程并保留对其 Job 的引用
delay(1000L)
println("World!")
}
println("Hello")
job.join() // 等待子协程完成
println("Done")
}
你可以在这里获取完整代码。
这段代码输出:
Hello
World!
Done
协程是轻量级的
协程比 JVM 线程的资源密集度更低。使用线程时会耗尽 JVM 可用内存的代码,可以使用协程来表达,而不会触及资源限制。例如,以下代码启动 50,000 个不同的协程,每个协程等待 5 秒,然后打印一个点('.'),同时消耗很少的内存:
import kotlinx.coroutines.*
fun main() = runBlocking {
repeat(50_000) { // 启动大量协程
launch {
delay(5000L)
print(".")
}
}
}
你可以在这里获取完整代码。
如果你使用线程编写相同的程序(移除 runBlocking
,将 launch
替换为 thread
,并将 delay
替换为 Thread.sleep
),它将消耗大量内存。根据你的操作系统、JDK 版本及其设置,它要么抛出内存不足错误,要么缓慢启动线程,以至于永远不会有太多并发运行的线程。