开启并发新篇章:Kotlin Coroutines 协程入门详解
在现代软件开发中,处理耗时操作(如网络请求、数据库访问、文件读写、复杂计算)是 unavoidable 的挑战。传统的处理方式,无论是使用多线程还是基于回调的异步编程,都各有其痛点。多线程编程复杂且容易出错(死锁、竞态条件),资源开销大;而基于回调的方式则容易导致臭名昭著的“回调地狱”(Callback Hell),代码可读性和维护性急剧下降。
Kotlin Coroutines(协程)正是为了解决这些问题而生。它提供了一种全新的、更简洁、更结构化的方式来编写异步和并发代码。协程被誉为“轻量级线程”,但其内部机制与传统线程有着本质区别,这使得它在处理大量并发任务时具有显著的优势。
本文将带你深入理解 Kotlin Coroutines 的核心概念,并通过丰富的示例代码,一步步带你踏入协程的世界。
为什么选择 Kotlin Coroutines?
在我们深入技术细节之前,先来理解为什么 Coroutines 如此受欢迎:
- 简化异步代码: 使用协程,你可以用看起来是同步的、顺序执行的代码风格来编写异步逻辑,极大地提高了代码的可读性和可维护性。告别层层嵌套的回调。
- 轻量级: 协程不是操作系统线程。成千上万个协程可以在少量甚至单个线程上运行。它们的创建和切换成本远低于线程,允许你在不耗尽系统资源的情况下处理大规模并发。
- 结构化并发(Structured Concurrency): 协程引入了作用域(Scope)的概念。协程的生命周期与特定的作用域绑定,当作用域结束时,在其内部启动的所有协程也会被取消。这使得协程的生命周期管理更加容易,有效防止了资源泄露和未完成任务的问题。
- 取消(Cancellation)支持: 协程的取消是合作式的,提供了一种优雅且可控的方式来停止正在运行的任务。
- 与 Kotlin 语言深度集成: 协程是 Kotlin 语言的一部分,而非一个完全独立、需要额外大量配置的库。这使得协程的语法自然流畅。
简单来说,协程让异步编程变得更简单、更高效、更安全。
协程的核心概念:suspend
函数
理解协程的第一步是理解 suspend
关键字。
在 Kotlin 中,suspend
是一个函数修饰符。它标记一个函数为“可挂起”的函数。这意味着函数在执行过程中,可以在某个点暂停(挂起)执行,而不会阻塞当前的线程,并在稍后某个时刻从暂停的地方恢复执行。
“`kotlin
// 一个普通的阻塞函数
fun blockingOperation() {
println(“开始阻塞操作…”)
Thread.sleep(2000) // 模拟耗时操作,会阻塞当前线程
println(“阻塞操作结束.”)
}
// 一个可挂起的函数
suspend fun suspendableOperation() {
println(“开始可挂起操作…”)
kotlinx.coroutines.delay(2000) // 模拟耗时操作,但不会阻塞线程
println(“可挂起操作结束.”)
}
“`
关键点:
suspend
函数只能在另一个suspend
函数、协程或者协程构建器(Coroutine Builder)中调用。suspend
关键字本身并不会使函数运行在另一个线程上。它只是标记该函数具备“挂起”的能力。实际的线程调度是由协程的Dispatcher
(分发器)负责的。suspend
函数内部通常会调用其他suspend
函数(例如delay
、进行网络请求的库函数等),正是这些内部调用点允许函数执行挂起操作。
delay(ms)
是 kotlinx.coroutines
库提供的一个 suspend
函数,它会暂停当前协程的执行,但不阻塞线程。等待指定的时间后,协程会在线程池中的某个可用线程上恢复执行。
启动协程:Coroutine Builders
suspend
函数定义了可挂起的操作,但我们不能直接调用一个顶层的 suspend
函数(除了 main
函数中的 runBlocking
)。我们需要一个“入口”来启动一个协程,这个入口就是协程构建器(Coroutine Builder)。
最常用的协程构建器有两个:launch
和 async
。它们都在一个 CoroutineScope
(协程作用域)内启动协程。
1. runBlocking
runBlocking
是一个特殊的构建器,主要用于将普通阻塞代码桥接到协程世界,通常在测试或者应用程序的 main
函数中使用。
它会阻塞调用它的线程,直到其内部的协程执行完毕。 因此,绝大多数情况下,不应该在 UI 线程或任何需要保持响应的线程中使用 runBlocking
。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking { // this: CoroutineScope
println(“主线程开始…”)
// 在 runBlocking 内部启动一个协程
launch { // 在 runBlocking 提供的作用域内启动
delay(1000)
println("我在协程中执行了 1 秒后.")
}
println("主线程等待协程完成...")
// runBlocking 会等待其内部启动的所有协程完成
println("主线程结束.")
}
“`
输出:
主线程开始...
主线程等待协程完成...
我在协程中执行了 1 秒后.
主线程结束.
在这个例子中,runBlocking
阻塞了 main
函数所在的线程,直到 launch
启动的协程执行完毕。
2. launch
launch
是最常用的协程构建器之一。它在一个新的协程中启动一个任务,并返回一个 Job
对象。launch
通常用于“启动一个任务并忘记它”的场景,即我们不关心任务的返回值,只关心它是否完成或者是否需要取消。
launch
是非阻塞的。它会立即返回 Job
对象,而不会等待协程内部的代码执行完成。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking { // 外层使用 runBlocking 只是为了让 main 函数能够运行协程
println(“主线程开始…”)
val job = launch { // 启动一个新的协程
println("协程开始执行...")
delay(1000) // 模拟耗时操作
println("协程执行完毕.")
}
println("主线程继续执行,不等协程.")
job.join() // 阻塞当前协程/线程,直到 job 完成
println("主线程等待协程完成后才到这里.")
}
“`
输出:
主线程开始...
主线程继续执行,不等协程.
协程开始执行...
协程执行完毕.
主线程等待协程完成后才到这里.
在这个例子中,launch
启动后立即返回,主线程(或者说 runBlocking
所在的协程)继续执行打印语句。job.join()
使得 runBlocking
所在的协程暂停,等待 launch
启动的协程完成后再恢复。
3. async
async
是另一个重要的协程构建器。它也在一个新的协程中启动一个任务,但它会返回一个结果。async
返回一个 Deferred<T>
对象,它是 Job
的一个子类,额外的提供了一个 await()
方法。调用 await()
会挂起当前协程,直到 async
启动的协程计算出结果并返回。
async
也是非阻塞的。调用 async
会立即返回 Deferred
对象。
async
常用于需要并行执行多个任务并等待它们全部完成并获取结果的场景。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
println(“主线程开始…”)
// 启动第一个异步任务
val deferred1: Deferred<Int> = async {
println("异步任务 1 开始...")
delay(2000)
println("异步任务 1 完成.")
10 // 返回结果
}
// 启动第二个异步任务
val deferred2: Deferred<Int> = async {
println("异步任务 2 开始...")
delay(1000)
println("异步任务 2 完成.")
20 // 返回结果
}
println("主线程继续执行,不等异步任务.")
// 等待并获取结果
val result1 = deferred1.await() // 挂起当前协程,直到 deferred1 完成
val result2 = deferred2.await() // 挂起当前协程,直到 deferred2 完成
println("两个异步任务都完成了,结果总和: ${result1 + result2}")
println("主线程结束.")
}
“`
输出:
主线程开始...
主线程继续执行,不等异步任务.
异步任务 1 开始...
异步任务 2 开始...
异步任务 2 完成.
异步任务 1 完成.
两个异步任务都完成了,结果总和: 30
主线程结束.
注意输出的顺序:两个 async
任务是并行启动的,”异步任务 2 完成.” 先于 “异步任务 1 完成.” 打印,因为它的延迟时间更短。await()
调用会等待对应的任务完成后再继续。
launch
vs async
总结:
launch
: 用于启动一个不需要返回结果的任务(fire-and-forget)。返回Job
。async
: 用于启动一个需要返回结果的任务。返回Deferred<T>
。
协程上下文:CoroutineContext
每个协程都有一个相关的上下文(CoroutineContext
),它是一组元素的集合。这些元素包括:
Job
: 控制协程的生命周期。Dispatcher
: 决定协程在哪个线程或线程池上执行。CoroutineName
: 协程的名称,用于调试。CoroutineExceptionHandler
: 处理未捕获的异常(主要用于顶层或 SupervisorJob 下的launch
)。
当启动一个协程时,新的协程的上下文会继承父协程的上下文,并通过参数或者构建器中的额外元素进行组合。
“`kotlin
import kotlinx.coroutines.*
import kotlin.coroutines.CoroutineContext
fun main() = runBlocking
val coroutineName = CoroutineName(“MyCoroutine”)
val job = Job() // 创建一个独立的 Job
val dispatcher = Dispatchers.Default // 指定调度器
// 结合父上下文、创建的 Job、调度器和名称
val newContext: CoroutineContext = coroutineContext + job + dispatcher + coroutineName
launch(newContext) {
// 这个协程将使用 newContext 作为其上下文
println("当前协程名称: ${coroutineContext[CoroutineName]?.name}")
println("当前调度器: ${coroutineContext[CoroutineDispatcher]}")
println("当前 Job: ${coroutineContext[Job]}")
}
delay(100) // 确保协程有时间执行
job.cancel() // 取消我们创建的 Job,也会取消这个协程
job.join() // 等待 Job 完成取消
println("主协程结束.")
}
“`
输出(Job 和 Dispatcher 的具体内容可能不同):
当前协程名称: MyCoroutine
当前调度器: Dispatchers.Default@xxxxxx
当前 Job: StandaloneCoroutine{Cancelling}@xxxxxx
主协程结束.
Dispatchers(分发器)
Dispatcher
是 CoroutineContext
中最重要的元素之一,它决定了协程在哪里运行。kotlinx.coroutines
提供了几种内置的分发器:
Dispatchers.Main
: 专用于 UI 更新。在 Android 开发中,它会将协程调度到主线程/UI 线程。在其他平台(如桌面应用)也可能有对应的 Main 分发器。注意:在没有 UI 环境(如纯 Kotlin/JVM 应用)中使用Dispatchers.Main
会抛异常,需要引入对应的平台库(如kotlinx-coroutines-android
)。Dispatchers.IO
: 适用于执行阻塞的 I/O 操作,如网络请求、文件读写、数据库访问等。它是一个共享的线程池,线程数量会根据需要增长,但有上限。Dispatchers.Default
: 适用于执行 CPU 密集型任务,如排序、计算等。它是一个共享的线程池,线程数量默认等于 CPU 的核心数。Dispatchers.Unconfined
: 特殊的分发器。它在调用者线程中启动协程,直到第一个挂起点。挂起后,协程会在恢复它的线程中恢复执行。不建议在大多数场景下使用,因为它可能会导致协程在意外的线程上恢复执行,难以推理。 主要用于某些特殊的高级场景或测试。
如何指定 Dispatcher?
可以通过协程构建器(launch
, async
等)的第一个参数来指定 CoroutineContext
,通常我们只需要指定其中的 Dispatcher
。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
launch { // 没有指定分发器,继承父协程的 Dispatcher (runBlocking 默认是当前线程)
println(“Default context: I’m working in thread ${Thread.currentThread().name}”)
}
launch(Dispatchers.Default) { // 指定 Default 分发器
println(“Default Dispatcher: I’m working in thread ${Thread.currentThread().name}”)
}
launch(Dispatchers.IO) { // 指定 IO 分发器
println(“IO Dispatcher: I’m working in thread ${Thread.currentThread().name}”)
}
launch(Dispatchers.Unconfined) { // 指定 Unconfined 分发器
println(“Unconfined : I’m working in thread ${Thread.currentThread().name}”)
delay(100)
// 在挂起后,可能会在不同的线程上恢复
println(“Unconfined after delay: I’m working in thread ${Thread.currentThread().name}”)
}
println("主协程等待...")
delay(2000) // 等待所有协程执行完成
println("主协程结束.")
}
“`
输出(线程名可能不同):
Default context: I'm working in thread main
Unconfined : I'm working in thread main
Default Dispatcher: I'm working in thread DefaultDispatcher-worker-1
IO Dispatcher: I'm working in thread DefaultDispatcher-worker-2
主协程等待...
Unconfined after delay: I'm working in thread DefaultDispatcher-worker-3
主协程结束.
从输出可以看出,没有指定分发器的协程在 runBlocking
所在的 main
线程执行。Default
和 IO
分发器使用了它们各自的线程池。Unconfined
启动时在 main
线程,但挂起后在 DefaultDispatcher
的线程中恢复。
协程作用域:CoroutineScope
CoroutineScope
是协程结构化并发的核心。它定义了协程的生命周期边界。所有通过 scope.launch { ... }
或 scope.async { ... }
启动的协程都与该 scope
关联。
- 当
scope
被取消时,其内部启动的所有协程也会被取消。 - 当一个父协程因为异常而失败时(在使用默认的 Job 行为时),它会取消其作用域内所有子协程。
这种父子结构和作用域的绑定关系,极大地简化了协程的生命周期管理和错误处理,避免了泄露未完成的协程。
我们已经在 runBlocking
的例子中看到了 CoroutineScope
的隐式使用(runBlocking { ... }
中的 this
就是一个 CoroutineScope
)。
在实际应用中,我们通常会创建自己的 CoroutineScope
,并将其生命周期绑定到组件(如 Android Activity/Fragment, ViewModel, 业务逻辑层等)的生命周期上。
“`kotlin
import kotlinx.coroutines.*
class MyManager {
// 创建一个与 MyManager 生命周期绑定的 Scope
// 使用 SupervisorJob() 可以让子协程的失败不影响兄弟协程和父协程 (高级概念,入门阶段了解即可)
// 使用 Dispatchers.Default 作为默认分发器
private val managerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun performTask() {
// 在 managerScope 中启动一个协程
managerScope.launch {
println("任务开始在线程 ${Thread.currentThread().name}")
delay(1500)
println("任务完成.")
}
}
fun cleanup() {
// 当 MyManager 不再需要时,取消其关联的 Scope
// 这将取消所有在该 Scope 中启动的协程
managerScope.cancel()
println("Manager Scope 已取消.")
}
}
fun main() = runBlocking {
val manager = MyManager()
manager.performTask() // 启动任务
delay(500) // 等待一段时间
manager.cleanup() // 清理,取消协程
// 等待足够长的时间,看任务是否被取消(它应该会被取消)
delay(2000)
println("主协程结束.")
}
“`
输出:
任务开始在线程 DefaultDispatcher-worker-1
Manager Scope 已取消.
主协程结束.
注意到“任务完成.”没有被打印,说明协程在 cleanup()
调用 managerScope.cancel()
后被成功取消了。
在 Android 开发中,Libraries like kotlinx-coroutines-android
provide extensions for lifecycle-aware scopes (e.g., lifecycleScope
in androidx.lifecycle:lifecycle-runtime-ktx
), making scope management even easier.
GlobalScope (不推荐)
GlobalScope
是一个特殊的 CoroutineScope
。它没有父 Job,也不与任何 Job 关联。在 GlobalScope
中启动的协程不受结构化并发的约束,它们会一直运行,直到完成或者被手动取消。
强烈不建议在日常应用代码中使用 GlobalScope
,因为它使得协程的生命周期难以管理,容易导致资源泄露和意外行为。应该总是倾向于使用有明确生命周期的 CoroutineScope
。GlobalScope
主要用于一些顶层的、与应用生命周期无关的、不需要被取消的任务(非常少见)。
协程的取消:Cooperative Cancellation
协程的取消是合作式的。这意味着协程在执行时需要“配合”取消操作。大多数 kotlinx.coroutines
提供的挂起函数(如 delay
, yield
, withContext
, 文件 I/O 操作,网络请求等)都是可取消的。当协程被取消时,如果它在执行这些可取消的挂起函数,这些函数会抛出 CancellationException
,从而中断协程的执行。
如果你的协程在执行计算密集型任务,并且没有调用任何挂起函数,它将不会检查是否被取消,也不会响应取消请求。为了让这样的协程可取消,你需要在适当的地方定期检查 isActive
属性或调用 yield()
函数。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
repeat(1000) { i ->
// 检查协程是否活跃 (没有被取消)
if (!isActive) {
println(“任务被取消了,跳出循环.”)
return@launch // 或者 break
}
print(“任务 $i … “)
delay(100) // 这是一个可取消的挂起点
}
}
delay(500) // 等待一小段时间
println("\n发出取消信号...")
job.cancel() // 取消任务
job.join() // 等待任务完成取消
println("任务已结束/取消.")
}
“`
输出:
任务 0 ... 任务 1 ... 任务 2 ... 任务 3 ... 任务 4 ... 任务 5 ...
发出取消信号...
任务被取消了,跳出循环.
任务已结束/取消.
在上面的例子中,当我们调用 job.cancel()
时,delay(100)
察觉到协程已被取消,并抛出 CancellationException
,导致协程提前结束。如果在循环内部没有 delay
,我们就需要手动添加 isActive
检查或 yield()
来使其可取消。
yield()
函数会暂停当前协程的执行,让其他协程有机会运行,并检查协程的取消状态。
取消后的清理工作
有时在协程被取消时需要执行清理工作(例如关闭文件句柄,释放资源)。可以使用 finally
块来确保清理代码总是执行。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
try {
repeat(1000) { i ->
print(“工作 $i… “)
delay(100) // 挂起点,可能在此取消
}
} catch (e: CancellationException) {
println(“\n任务被取消了,捕获 CancellationException.”)
} finally {
// 在取消后执行清理工作
println(“清理资源…”)
}
}
delay(500)
println("\n发出取消信号...")
job.cancelAndJoin() // 取消并等待完成
println("Done.")
}
“`
输出:
工作 0... 工作 1... 工作 2... 工作 3... 工作 4... 工作 5...
发出取消信号...
清理资源...
任务被取消了,捕获 CancellationException.
Done.
注意,在 finally
块中如果需要调用挂起函数,需要使用 withContext(NonCancellable) { ... }
。NonCancellable
是一个特殊的上下文元素,确保其内部的挂起函数不受外部取消的影响。
kotlin
// 在 finally 中执行需要挂起的清理操作
finally {
println("清理资源...")
withContext(NonCancellable) {
delay(500) // 即使在取消后,这个延时也会执行
println("清理完成.")
}
}
协程的异常处理
协程的异常处理是一个需要仔细理解的话题,特别是对于 launch
和 async
构建器以及结构化并发而言。
1. launch
中的异常
默认情况下,使用 launch
启动的协程中的未捕获异常会向上冒泡,传播到其父协程。如果父协程没有设置 CoroutineExceptionHandler
,异常会继续向上直到达到根协程(通过 runBlocking
或 GlobalScope
启动的协程)。在 JVM 上,这通常会导致应用程序崩溃(或者至少是协程所在的 Dispatcher 的线程崩溃)。
为了处理 launch
协程中的异常,可以使用 CoroutineExceptionHandler
并将其添加到协程的 CoroutineContext
中。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val handler = CoroutineExceptionHandler { _, exception ->
println(“捕获到协程异常: $exception”)
}
val job = launch(handler) { // 将 handler 添加到协程上下文
println("协程正在执行...")
throw RuntimeException("这是一个错误!") // 抛出异常
println("这行代码不会被执行.")
}
job.join() // 等待协程完成或失败
println("主协程结束.")
}
“`
输出:
协程正在执行...
捕获到协程异常: java.lang.RuntimeException: 这是一个错误!
主协程结束.
重要提示: CoroutineExceptionHandler
只对使用 launch
启动的顶层协程有效,或者在 SupervisorJob
下的子协程有效。它对使用 async
启动的协程无效。
2. async
中的异常
使用 async
启动的协程中的异常会被“推迟”到调用其 await()
方法时抛出。这意味着,如果你启动了一个 async
任务但从不调用 await()
,它的异常可能会被静默忽略(如果父 Job 没有失败),或者如果父 Job 失败,异常会导致父 Job 被取消,但 async
本身的异常并不会导致应用程序崩溃(除非你在 await()
时捕获它)。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val deferred = async {
println(“异步任务开始…”)
delay(500)
throw IllegalStateException(“异步任务中的错误!”) // 抛出异常
25 // 这行不会被执行
}
println("主协程等待结果...")
try {
val result = deferred.await() // 尝试获取结果,异常在此抛出
println("结果: $result") // 这行不会被执行
} catch (e: IllegalStateException) {
println("在 await() 时捕获到异常: $e")
}
println("主协程结束.")
}
“`
输出:
主协程等待结果...
异步任务开始...
在 await() 时捕获到异常: java.lang.IllegalStateException: 异步任务中的错误!
主协程结束.
这里的异常是在调用 await()
时被捕获的。如果没有调用 await()
并且父协程成功完成,那么 async
内部的异常可能不会被处理。
3. SupervisorJob
在默认的 Job
行为下,如果一个子协程失败,会导致其父 Job 失败,进而取消所有兄弟协程。在某些场景下(例如 Android UI 中),我们希望一个任务的失败不影响同一个作用域内的其他独立任务。这时可以使用 SupervisorJob
。
SupervisorJob
的规则是:子协程的失败不会传播给父 Job,也不会导致其他兄弟协程被取消。子协程仍然可以通过 CoroutineExceptionHandler
处理自己的异常。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val supervisor = SupervisorJob() // 创建一个 SupervisorJob
val scope = CoroutineScope(coroutineContext + supervisor) // 基于当前上下文和 SupervisorJob 创建作用域
val job1 = scope.launch(CoroutineExceptionHandler { _, e -> println("处理 Job1 异常: $e") }) {
println("Job1 开始...")
delay(100)
throw AssertionError("Job1 失败!") // Job1 失败
}
val job2 = scope.launch {
println("Job2 开始...")
delay(1000) // Job2 延迟更久
println("Job2 完成.") // 如果 Job1 失败导致 Job2 取消,这行不会打印
}
delay(200) // 等待 Job1 启动并失败
supervisor.join() // 等待 supervisor Job (即其所有子协程) 完成
println("主协程结束.")
}
“`
输出:
Job1 开始...
Job2 开始...
处理 Job1 异常: java.lang.AssertionError: Job1 失败!
Job2 完成.
主协程结束.
可以看到,尽管 Job1 失败了,但 Job2 并没有被取消,它正常执行直到完成。这就是 SupervisorJob
的作用。
总结
Kotlin Coroutines 为异步和并发编程带来了革命性的改变。通过 suspend
函数、协程构建器(launch
, async
, runBlocking
)、CoroutineContext
、Dispatcher
和 CoroutineScope
,我们可以用更直观、更易读的方式编写复杂的并发逻辑。结构化并发、合作式取消和更精细的异常处理机制,使得协程成为处理现代应用中并发挑战的强大工具。
入门核心要点回顾:
suspend
函数:标记可挂起的操作,不阻塞线程。launch
: 启动一个不关心返回值的协程任务,返回Job
。async
: 启动一个需要返回结果的协程任务,返回Deferred
。runBlocking
: 阻塞当前线程以运行协程,主要用于测试或main
函数。Dispatcher
: 决定协程在哪个线程池运行 (Main
,IO
,Default
,Unconfined
)。CoroutineScope
: 定义协程的生命周期和结构,实现结构化并发。Job
: 表示协程的生命周期,用于取消 (cancel
) 和等待 (join
)。- 取消是合作式的,需要协程主动检查或调用可取消的挂起函数。
- 异常处理取决于构建器和 Job 类型 (
launch
+ExceptionHandler
,async
+await
+try/catch
,SupervisorJob
)。
协程的世界远不止这些,还有 Flow
用于处理异步数据流,Channel
用于协程间通信,以及更复杂的调度器和作用域管理。但掌握了本文介绍的基础概念,你已经为深入学习和在项目中使用 Kotlin Coroutines 打下了坚实的基础。
现在,是时候在你的 Kotlin 项目中尝试使用 Coroutines,亲身体验它带来的便利与强大了!