Kotlin 协程是什么?新手入门详解
欢迎来到 Kotlin 协程的世界!如果你是一名 Kotlin 开发者,或者即将开始学习 Kotlin,那么协程绝对是你绕不开也值得深入学习的一个重要概念。它极大地简化了异步编程和并发编程的复杂性,让你的代码更易读、更易写、更易维护。
但对于很多新手来说,“协程”这个词听起来可能有点陌生,甚至有些高深莫测。它和线程有什么区别?为什么我们需要它?如何开始使用协程?这篇文章将为你一一解答这些问题,带你从零开始,逐步理解 Kotlin 协程的核心概念和用法。
本文将详细覆盖以下内容:
- 为什么我们需要协程? – 传统异步编程的痛点
- 协程是什么? – 定义、核心特性和与线程的区别
- Kotlin 协程的核心构建块 –
suspend
函数 - 协程的启动与管理 – Coroutine Builders (
launch
,async
,runBlocking
) - 协程作用域(CoroutineScope) – 理解结构化并发
- 协程上下文(CoroutineContext) –
Job
,Dispatcher
,CoroutineName
Job
:协程的生命周期与取消Dispatcher
:协程在哪运行?线程调度CoroutineName
:给协程起名字
- 处理结果:
async
和await
- 协程的取消 – 协作式取消
- 切换线程/调度器 –
withContext
的妙用 - 协程与线程的对比总结
- 新手入门实践建议
让我们开始这段协程之旅吧!
1. 为什么我们需要协程? – 传统异步编程的痛点
在软件开发中,尤其是在需要处理耗时操作(如网络请求、文件读写、大量计算)时,异步编程和并发编程是必不可少的。如果这些操作直接在主线程(例如 Android UI 线程)中执行,会导致界面卡死,用户体验极差,甚至会触发“应用无响应”(ANR) 错误。
传统的处理方式主要有以下几种:
- 多线程 (Threads): 直接创建和管理多个线程。
- 优点: 可以并行执行任务。
- 缺点:
- 创建和管理开销大: 创建一个线程需要操作系统分配资源,数量过多容易导致系统资源耗尽。
- 线程切换开销大: 操作系统需要在线程间切换,保存和恢复上下文,这是耗费 CPU 时间的。
- 同步复杂: 共享数据需要加锁,容易引发死锁、活锁等问题。
- 调试困难: 多线程的执行顺序不确定,难以复现和调试问题。
- 回调 (Callbacks): 将异步操作的结果通过回调函数传递回来。
- 优点: 可以避免阻塞主线程。
- 缺点:
- 回调地狱 (Callback Hell): 当有多个依赖关系的异步操作时,代码会层层嵌套,难以阅读和维护。
- 错误处理分散: 错误需要在每个回调中单独处理。
- 逻辑不连贯: 业务逻辑被分割在不同的回调函数中。
- Futures / Promises: 一种更结构化的处理异步结果的方式。
- 优点: 链式调用,一定程度上缓解回调地狱。
- 缺点:
- 虽然比回调好,但链式结构有时仍然不够直观。
- 对于复杂的控制流(如循环、条件判断)处理起来不如同步代码直观。
Kotlin 协程的出现,正是为了解决这些传统异步/并发编程方式的痛点。它提供了一种更简洁、更安全、更易于理解的方式来编写异步非阻塞代码。
2. 协程是什么? – 定义、核心特性和与线程的区别
什么是协程?
简单来说,协程 (Coroutines) 是轻量级的线程 (lightweight threads)。
更准确地说,协程是一种用户级的、可协作的、结构化的并发机制。
- 用户级: 协程的调度和管理主要由协程库在用户空间完成,而不是由操作系统内核完成。这使得协程的创建和切换开销远小于线程。你可以轻松地创建成千上万个协程。
- 可协作 (Cooperative): 协程的挂起和恢复是协作式的。一个协程不会被强制中断(除非发生错误或取消),它会在遇到一个“挂起点”(suspension point)时主动让出执行权,让其他协程有机会运行。
- 结构化 (Structured): Kotlin 协程强制使用“结构化并发”原则。这意味着新的协程总是在一个父协程作用域内启动。子协程的生命周期与其父协程绑定,当父协程取消时,所有子协程也会被取消。这极大地简化了并发代码的管理和错误处理。
核心特性:挂起 (Suspension)
协程最核心的特性是它的挂起 (Suspension) 能力。当一个协程遇到一个耗时操作时,它不会像线程那样“阻塞”等待,而是会将自己的执行状态(包括局部变量、程序计数器等)保存起来,然后挂起。此时,协程所在的线程可以去做其他事情(比如运行其他协程)。当耗时操作完成后,协程可以从之前挂起的地方恢复执行,就像什么都没发生过一样。
这种“挂起而不阻塞”的能力是协程高效和轻量级的关键。
协程 vs. 线程
这是一个新手最常问的问题。理解它们之间的区别至关重要:
特性 | 线程 (Threads) | 协程 (Coroutines) |
---|---|---|
管理方 | 操作系统 (OS Kernel) | 协程库 (User Space) |
创建/切换 | 开销大 (MB 级别栈空间,上下文切换耗时) | 开销小 (KB 级别栈空间,协程库切换) |
数量 | 数量有限 (通常几千个已是上限) | 数量巨大 (轻松创建几十万甚至上百万个) |
阻塞性 | 阻塞 (遇到等待时线程会停下来直到等待结束) | 挂起 (遇到挂起点时让出线程,不阻塞) |
调度 | 操作系统抢占式调度 | 协程库协作式调度 (在挂起点让出) + 调度器在线程上调度 |
结构 | 非结构化 (需要手动管理生命周期、父子关系) | 结构化 (强制父子关系,自动传播取消和错误) |
栈空间 | 固定大小 (通常较大) | 可以动态调整 (取决于实现,如 Stackless Coroutines) |
总结: 协程不是替代线程,而是运行在线程之上。一个线程可以运行多个协程。协程提供了一种更高级别的抽象,让异步代码看起来像同步代码一样简洁。
3. Kotlin 协程的核心构建块 – suspend
函数
suspend
是 Kotlin 协程中最核心的关键字之一。它只能用于函数声明。
kotlin
suspend fun doSomethingUseful(param: String): Int {
// 这个函数可以包含挂起点
delay(1000) // 这是一个挂起点,它会挂起协程而不阻塞线程
println("完成了耗时操作,参数是: $param")
return param.length
}
suspend
关键字的含义:
- 标记: 它只是一个标记,表示这个函数可能是一个挂起点,或者它可以调用其他
suspend
函数。 - 非阻塞: 当
suspend
函数内部遇到一个真正的挂起点(例如delay
、网络请求库的异步函数、数据库操作等)时,当前的协程会挂起,释放它占用的线程。 - 链式调用:
suspend
函数只能从另一个suspend
函数或一个协程构建器(Coroutine Builder)中调用。这是一个编译器的限制,确保协程的执行总是在一个支持挂起/恢复的环境中。
你可以把 suspend
函数想象成一个特殊的函数,它在执行过程中可以“暂停”自己,等待某个异步操作完成,然后再“恢复”执行,而不会霸占着线程不放。
4. 协程的启动与管理 – Coroutine Builders
要在 Kotlin 中启动一个协程,你需要使用协程构建器 (Coroutine Builder)。最常用的构建器有 launch
、async
和 runBlocking
。它们都是扩展函数,通常需要在 CoroutineScope
上调用。
runBlocking
runBlocking
用于启动一个新的协程,并且阻塞当前线程,直到协程执行完成。它主要用于:
- 在非协程代码(如
main
函数、单元测试)中调用挂起函数。 - 作为协程世界的入口。
注意: runBlocking
会阻塞调用它的线程,因此在生产环境的并发代码中应谨慎使用,尤其是在 UI 线程或共享线程池中。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking { // this: CoroutineScope
println(“主程序开始执行: ${Thread.currentThread().name}”)
// 启动一个协程
launch { // this: CoroutineScope
delay(1000L) // 挂起 1 秒
println("协程任务执行完成: ${Thread.currentThread().name}")
}
println("主程序继续执行 (可能在等待协程): ${Thread.currentThread().name}")
// runBlocking 会等待它内部的所有子协程执行完成
}
“`
输出:
主程序开始执行: main @coroutine#1
主程序继续执行 (可能在等待协程): main @coroutine#1
协程任务执行完成: main @coroutine#2
解释: runBlocking
在 main
线程中启动,并在内部创建了一个协程作用域。launch
在这个作用域中启动了一个新的协程。delay(1000L)
挂起协程,但 runBlocking
会等待这个协程完成,所以“主程序继续执行”会立即打印,然后等待 1 秒后,“协程任务执行完成”打印。runBlocking
所在的线程 (main) 被阻塞,直到 launch
协程完成。
launch
launch
用于启动一个新的协程,执行一个不关心返回值的任务。它返回一个 Job
对象,可以用来管理协程(如取消)。launch
是非阻塞的,它会立即返回,而不会等待协程执行完成。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking { // this: CoroutineScope
println(“主程序开始执行: ${Thread.currentThread().name}”)
// 启动多个协程
launch {
delay(1000L)
println("Task 1 完成: ${Thread.currentThread().name}")
}
launch {
delay(500L)
println("Task 2 完成: ${Thread.currentThread().name}")
}
println("所有协程都已启动,主程序不等它们")
// runBlocking 会等待所有子协程完成,这里只是为了演示 launch 是非阻塞的
// 在真实的异步场景中,runBlocking 外面的代码会继续执行
}
“`
输出:
主程序开始执行: main @coroutine#1
所有协程都已启动,主程序不等它们
Task 2 完成: main @coroutine#3
Task 1 完成: main @coroutine#2
解释: launch
协程构建器是非阻塞的,它启动协程后立即返回。因此,“所有协程都已启动,主程序不等它们”立即打印。两个 launch
启动的协程并发运行,Task 2 因为 delay
时间短而先完成。因为使用了 runBlocking
,主程序会等待这两个子协程都完成后才结束。在真实的异步应用中,如果你在 main
函数(没有 runBlocking
)中只用 launch
,主程序可能会在协程完成之前就退出了。
async
async
用于启动一个新的协程,执行一个需要返回结果的任务。它返回一个 Deferred<T>
对象,它是 Job
的一个子类,表示一个延迟计算的值。你可以调用 await()
函数来获取结果。await()
是一个挂起函数,它会挂起当前的协程,直到 async
协程计算出结果。
“`kotlin
import kotlinx.coroutines.
import kotlin.system.
suspend fun performTask1(): Int {
delay(1000)
println(“Task 1 完成”)
return 10
}
suspend fun performTask2(): Int {
delay(500)
println(“Task 2 完成”)
return 20
}
fun main() = runBlocking {
println(“开始并发任务”)
val time = measureTimeMillis {
val result1: Deferred<Int> = async { performTask1() } // 启动任务1
val result2: Deferred<Int> = async { performTask2() } // 启动任务2
println("两个 async 任务已启动,等待结果...")
// await() 会挂起当前协程,直到对应的 async 任务完成
val totalResult = result1.await() + result2.await()
println("所有结果已获取")
println("总结果: $totalResult")
}
println("总耗时: $time ms")
}
“`
输出:
开始并发任务
两个 async 任务已启动,等待结果...
Task 2 完成
Task 1 完成
所有结果已获取
总结果: 30
总耗时: ~10xx ms (接近 1000ms)
解释: async { performTask1() }
和 async { performTask2() }
并发执行。await()
会等待结果,但由于两个任务是并发的,总耗时取决于其中最长的那个(performTask1
耗时 1000ms)。如果使用 launch
,你无法方便地获取计算结果。如果直接调用 performTask1()
和 performTask2()
而不用 async
,它们会顺序执行,总耗时约为 1500ms。async
使得并发执行并获取结果变得简单。
5. 协程作用域(CoroutineScope) – 理解结构化并发
协程作用域 CoroutineScope
是 Kotlin 协程中实现结构化并发的核心概念。
什么是结构化并发?
它是一种并发编程范式,旨在通过将并发操作绑定到特定的父操作或生命周期来提高代码的可读性、可维护性和安全性。在结构化并发中,并发任务(子协程)是在一个明确的作用域 (Scope) 内启动的,并且它们的生命周期与该作用域绑定。
CoroutineScope 的作用:
- 定义协程的生命周期: 启动在同一个
CoroutineScope
内的协程会形成一个父子关系。当父协程或其作用域被取消时,其所有子协程也会被自动取消。这避免了协程的泄露。 - 传播上下文: 协程作用域会携带一个
CoroutineContext
,这个上下文会作为默认值传递给在该作用域内启动的子协程。 - 方便管理: 通过持有
CoroutineScope
的引用,你可以轻松地取消作用域内的所有协程。
如何获得 CoroutineScope?
- 协程构建器自带:
runBlocking
,launch
,async
自身就是CoroutineScope
的扩展函数,它们在内部创建并提供了作用域。runBlocking
的接收者 (this
) 就是一个CoroutineScope
。 - 由库提供: 许多库(如 Android 的 LifecycleScope, ViewModelScope)提供了预定义的作用域,它们绑定到特定的组件生命周期。
- 手动创建: 你可以手动创建一个
CoroutineScope
实例,例如CoroutineScope(Dispatchers.Default)
。
“`kotlin
import kotlinx.coroutines.*
// 手动创建一个作用域
val customScope = CoroutineScope(Dispatchers.Default)
fun main() = runBlocking { // runBlocking 提供一个作用域
println(“runBlocking scope: ${this.coroutineContext[Job]}”)
val job1 = launch { // 在 runBlocking 的作用域内启动协程
println("Job 1 started: ${this.coroutineContext[Job]}")
delay(1000)
println("Job 1 completed")
}
val job2 = customScope.launch { // 在手动创建的作用域内启动协程
println("Job 2 started: ${this.coroutineContext[Job]}")
delay(1500)
println("Job 2 completed")
}
// runBlocking 会等待 job1 完成,但不会等待 job2 完成,
// 因为 job2 不在 runBlocking 的作用域内
job1.join() // 等待 job1 完成
println("Job 1 joined")
// 如果想等待 job2,需要单独 join() 或者管理 customScope 的生命周期
// job2.join()
// customScope.cancel() // 在适当时候取消作用域,取消其下的所有协程
}
“`
输出:
runBlocking scope: ...Job...
Job 1 started: ...Job...
Job 2 started: ...Job...
Job 1 completed
Job 1 joined
Job 2 completed // 可能会在主程序结束后才看到,取决于 customScope 的线程存活时间
结构化并发的重要性:
考虑一个场景:你在 Android 应用中发起多个网络请求,当用户退出当前界面时,你应该取消这些请求以避免资源浪费和更新已销毁的 UI。通过将这些协程启动在一个与界面生命周期绑定的 CoroutineScope
中,你只需要在界面销毁时调用 scope.cancel()
,所有相关的协程都会被自动取消。这比手动追踪每一个 Job
并取消要方便和安全得多。
6. 协程上下文(CoroutineContext)
每个协程都有一个与之关联的协程上下文 (CoroutineContext)。它是一组元素的集合,这些元素定义了协程的各个方面:
Job
: 控制协程的生命周期(运行中、取消、完成)。Dispatcher
: 决定协程在哪个线程或线程池上运行。CoroutineName
: 用于调试的协程名称。CoroutineExceptionHandler
: 处理协程中未捕获的异常(高级)。
协程上下文可以通过 +
运算符组合。当启动一个子协程时,它会继承父协程的上下文,并且可以在此基础上覆盖或添加新的元素。
你可以通过 coroutineContext
属性在协程内部访问当前协程的上下文。
Job
:协程的生命周期与取消
Job
是协程生命周期的句柄。每个通过 launch
或 async
启动的协程都会返回一个 Job
对象。
Job
有一个状态机,包括:New, Active, Completing, Cancelling, Cancelled, Completed。
- 取消 (
cancel()
): 调用job.cancel()
会请求取消协程。取消是协作式的,这意味着协程需要主动检查取消状态并响应(稍后详细说明)。 - 等待完成 (
join()
): 调用job.join()
是一个挂起函数,它会挂起当前协程,直到被join()
的目标协程完成。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
repeat(1000) { i ->
print(“我正在运行 $i … “)
delay(100) // 这是一个挂起点,协程会检查取消状态
}
}
delay(500) // 等待一点时间,让协程运行一下
println("时间到!我要取消这个协程了!")
job.cancel() // 取消协程
job.join() // 等待协程被完全取消/完成
println("协程已取消/完成.")
}
“`
输出:
我正在运行 0 ... 我正在运行 1 ... 我正在运行 2 ... 我正在运行 3 ... 我正在运行 4 ... 时间到!我要取消这个协程了!
协程已取消/完成.
解释: 当 job.cancel()
被调用后,repeat
循环中的 delay(100)
是一个挂起点。协程在挂起/恢复时会检查自身的 isActive
状态。发现已经被取消后,delay
函数会立即抛出 CancellationException
,导致协程停止执行。job.join()
确保在打印“协程已取消/完成”之前,协程的清理工作已经完成。
Dispatcher
:协程在哪运行?线程调度
Dispatcher
决定了协程在哪个线程或线程池上执行。你可以通过在协程构建器或 withContext
中指定 Dispatcher
来切换协程运行的上下文。
Kotlinx.coroutines 库提供了几种标准的调度器:
Dispatchers.Default
: 默认调度器,适合执行 CPU 密集型任务。它的线程池大小默认等于 CPU 核数。例如:大量计算、复杂的数据转换。Dispatchers.IO
: 适合执行阻塞的 I/O 操作,如网络请求、文件读写、数据库访问。它使用一个根据需要增长的线程池。这个线程池比 Default 的大,以应对大量潜在的阻塞任务。Dispatchers.Main
: (特定平台,如 Android, Swing, JavaFX)适合执行需要在主线程/UI 线程上运行的任务,如更新 UI。在非 UI 平台,通常是 Unconfined。Dispatchers.Unconfined
: 不限定在任何特定线程上运行。它会立即在当前调用的线程上启动协程,直到第一个挂起点。挂起后,它会由恢复协程的那个线程来继续执行。对于新手,建议少用或理解透彻再用,它可能导致协程在不期望的线程上恢复。
如何指定 Dispatcher?
可以在协程构建器中作为参数传递:
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
launch(Dispatchers.Default) {
println(“Default 调度器: ${Thread.currentThread().name}”)
// 执行 CPU 密集任务
}
launch(Dispatchers.IO) {
println("IO 调度器: ${Thread.currentThread().name}")
// 执行阻塞 I/O 任务
delay(100) // 模拟 I/O
}
launch(Dispatchers.Unconfined) {
// 立即在主线程开始
println("Unconfined (开始): ${Thread.currentThread().name}")
delay(100) // 挂起点
// 可能会在其他线程恢复
println("Unconfined (恢复): ${Thread.currentThread().name}")
}
launch { // 没有指定,继承父协程的调度器 (这里是 runBlocking 默认的调度器,通常是 Main 或 Default,取决于平台)
println("继承父调度器: ${Thread.currentThread().name}")
}
delay(1000) // 等待所有协程完成
}
“`
输出 (示例,具体线程名和顺序可能不同):
继承父调度器: main @coroutine#2
Unconfined (开始): main @coroutine#4
IO 调度器: DefaultDispatcher-worker-1 @coroutine#3
Default 调度器: DefaultDispatcher-worker-2 @coroutine#5
Unconfined (恢复): DefaultDispatcher-worker-3 @coroutine#4
解释: 不同的调度器将协程的工作分配到不同的线程池。Unconfined
的示例展示了它可能在不同线程上开始和恢复。没有指定调度器的协程会继承父协程(这里是 runBlocking
)的调度器。
CoroutineName
:给协程起名字
CoroutineName
仅仅是为了方便调试,给协程一个可读的名称。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
launch(CoroutineName(“MyAwesomeCoroutine”)) {
println(“当前协程名称: ${coroutineContext[CoroutineName]?.name}”)
}
}
“`
输出:
当前协程名称: MyAwesomeCoroutine
7. 处理结果:async
和 await
正如之前在构建器中提到的,async
用于启动一个需要返回结果的并发任务。它返回 Deferred<T>
,代表一个未来的结果。
“`kotlin
import kotlinx.coroutines.
import kotlin.system.
suspend fun fetchUser(userId: Int): String {
delay(1000) // 模拟网络请求
return “User_$userId”
}
suspend fun fetchOrders(userId: Int): List
delay(1500) // 模拟网络请求
return listOf(“Order_A for $userId”, “Order_B for $userId”)
}
fun main() = runBlocking {
println(“开始并发获取用户和订单”)
val time = measureTimeMillis {
val userDeferred: Deferred<String> = async { fetchUser(123) }
val ordersDeferred: Deferred<List<String>> = async { fetchOrders(123) }
println("异步任务已启动,等待结果...")
val user = userDeferred.await() // 挂起直到 fetchUser 完成
val orders = ordersDeferred.await() // 挂起直到 fetchOrders 完成 (如果 userDeferred 已经完成,这里会立即返回结果)
println("获取到用户: $user")
println("获取到订单: $orders")
}
println("总耗时: $time ms") // 理论上接近 1500ms (最长任务的耗时)
}
“`
输出:
开始并发获取用户和订单
异步任务已启动,等待结果...
获取到用户: User_123
获取到订单: [Order_A for 123, Order_B for 123]
总耗时: ~15xx ms
解释: async { fetchUser(...) }
和 async { fetchOrders(...) }
这两个任务是并发启动的。await()
调用会获取对应的结果,如果结果还没准备好,await()
会挂起当前的协程。由于两个任务并行执行,总耗时由耗时最长的任务决定。这比顺序调用 fetchUser().await() + fetchOrders().await()
(这样总耗时约 1000 + 1500 = 2500ms) 要高效得多。
重要提示: 如果你启动了 async
但从未调用 await()
,并且协程内部抛出了异常,这个异常默认是不会被抛出的,它会被存储在 Deferred
对象中,直到你调用 await()
时才会被抛出。这是一个与 launch
不同的行为(launch
如果不处理异常,会根据上下文的 CoroutineExceptionHandler
来处理或直接导致应用崩溃)。这使得 async
适合用于可能失败并需要传播错误给调用者的场景。
8. 协程的取消 – 协作式取消
Kotlin 协程的取消是协作式的。这意味着,当你调用 job.cancel()
或作用域的 cancel()
方法时,协程不会被立即强制终止。协程内部必须在适当的时机检查自己是否已被取消,并响应取消请求。
协程如何检查取消?
- 调用挂起函数: 大多数 kotlinx.coroutines 提供的挂起函数(如
delay
,withContext
,await
等)都是可取消的。当协程被取消时,这些函数会检查取消状态,如果发现协程已取消,它们会立即抛出CancellationException
。这是响应取消最常见的方式。 - 检查
isActive
属性: 在计算密集型循环等不包含挂起点的地方,你可以手动检查协程的isActive
属性。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
try {
repeat(1000) { i ->
// 手动检查是否活跃 (isActive)
if (!isActive) {
println(“任务被取消了,提前退出循环”)
return@launch // 退出协程
// 或者 throw CancellationException()
}
print(“计算中 $i … “)
Thread.sleep(100) // !注意:这是阻塞的 sleep,不响应取消
// 如果这里是 delay(100),则会自动响应取消
}
} catch (e: CancellationException) {
println(“在 catch 块中捕获到取消异常”)
} finally {
println(“在 finally 块中执行清理工作”)
// 清理资源,释放锁等等
}
println(“任务正常完成”) // 如果被取消,这行不会执行
}
delay(500) // 让任务运行一会儿
println("时间到!请求取消任务!")
job.cancelAndJoin() // 结合 cancel() 和 join()
println("任务已完成取消.")
}
“`
输出 (使用 Thread.sleep
时):
计算中 0 ... 计算中 1 ... 计算中 2 ... 计算中 3 ... 计算中 4 ... 时间到!请求取消任务!
计算中 5 ... 计算中 6 ... // 注意:Thread.sleep 不会响应取消,循环会继续!
// ... 循环会一直运行直到完成或因其他原因中断
// 当循环结束(如果isActive检查被移除),或者 runBlocking 等待超时,finally块可能会执行
// 这个例子说明了为什么要在计算密集型任务中手动检查 isActive 或使用 yield()
输出 (将 Thread.sleep(100)
替换为 delay(100)
时):
计算中 0 ... 计算中 1 ... 计算中 2 ... 计算中 3 ... 计算中 4 ... 时间到!请求取消任务!
在 catch 块中捕获到取消异常
在 finally 块中执行清理工作
任务已完成取消.
解释:
- 当使用可取消的挂起函数 (
delay
) 时,协程在挂起后会检查取消状态并抛出异常,进入catch
块。 - 当使用阻塞函数 (
Thread.sleep
) 时,协程所在的线程被阻塞,无法检查取消状态,除非你在循环中手动检查isActive
。 try...catch...finally
结构对于处理取消非常重要。catch (e: CancellationException)
用于捕获取消异常,finally
块用于执行必要的清理工作,无论协程是正常完成还是被取消。job.cancelAndJoin()
是一个方便的组合函数,它先调用cancel()
,然后调用join()
等待协程结束。
非阻塞的 CPU 密集型任务与 yield()
如果你的任务是 CPU 密集型的(没有调用挂起函数),但又不想阻塞线程,也不想等待太久才响应取消,可以在循环中频繁地检查 isActive
或调用 yield()
函数。yield()
函数是一个挂起函数,它会主动让出当前协程的执行权,给同一线程上的其他协程执行的机会,并且它也是一个检查取消状态的挂起点。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch(Dispatchers.Default) { // 在 Default 调度器上执行 CPU 任务
try {
repeat(1_000_000) { i ->
if (i % 1000 == 0) { // 每隔一定次数检查一次
ensureActive() // 这是一个更方便的检查,如果协程不活跃则抛出 CancellationException
// 或者使用 yield() // yield() 也能检查 isActive 并让出线程
}
// 执行计算密集型工作
}
} catch (e: CancellationException) {
println(“CPU 任务被取消了!”)
} finally {
println(“CPU 任务清理完成.”)
}
}
delay(100) // 等待一点时间
println("时间到!取消 CPU 任务!")
job.cancelAndJoin()
println("CPU 任务已完成取消.")
}
“`
解释: 在 CPU 密集型任务中,通过周期性地调用 ensureActive()
或 yield()
,可以使任务变得协作式,能够在收到取消请求后及时停止。
9. 切换线程/调度器 – withContext
的妙用
在协程中,你可能需要在不同的调度器之间切换。例如,在 Android 应用中,你可能在 Dispatchers.Main
上启动一个协程来处理 UI 交互,然后在协程内部切换到 Dispatchers.IO
去执行网络请求或数据库操作,最后再切换回 Dispatchers.Main
来更新 UI。
withContext
函数是实现这一目标的最佳方式。它是一个挂起函数。
“`kotlin
import kotlinx.coroutines.*
suspend fun fetchData(): String {
// 当前可能在 Main 线程
println(“正在获取数据 (当前线程: ${Thread.currentThread().name})”)
return withContext(Dispatchers.IO) { // 切换到 IO 调度器
// 现在在 IO 线程池
println(“在 IO 线程执行网络请求 (当前线程: ${Thread.currentThread().name})”)
delay(1000) // 模拟网络请求
“Some data from network”
}
// withContext 块执行完毕后,协程会切回之前的调度器 (Main)
// println(“数据获取完毕 (当前线程: ${Thread.currentThread().name})”) // 注意:这行会在 withContext 块之后,恢复到原调度器后执行
}
fun main() = runBlocking { // 在 runBlocking 的调度器上 (通常是 Main 或 Default)
println(“开始主任务 (当前线程: ${Thread.currentThread().name})”)
val result = fetchData() // 调用挂起函数
println(“主任务获取到结果: $result (当前线程: ${Thread.currentThread().name})”)
}
“`
输出 (示例):
开始主任务 (当前线程: main @coroutine#1)
正在获取数据 (当前线程: main @coroutine#1)
在 IO 线程执行网络请求 (当前线程: DefaultDispatcher-worker-1 @coroutine#1)
主任务获取到结果: Some data from network (当前线程: main @coroutine#1)
解释:
- 协程开始在
runBlocking
的默认调度器上运行。 - 调用
fetchData()
进入挂起函数。 withContext(Dispatchers.IO)
暂停当前协程,并将其移交给Dispatchers.IO
所在的线程池执行。withContext
块内部的代码在 IO 线程上执行。withContext
块完成后,协程会自动切回调用withContext
之前的调度器(即runBlocking
的默认调度器,本例中是main
线程)。withContext
函数会返回其块内最后一条语句的值。
withContext
是协程中执行线程切换和并行化(当与 async
结合时)非常强大且常用的工具。它既能改变协程的执行上下文,又能确保在块结束后恢复到原上下文,并且可以返回一个结果,同时也是一个可取消的挂起点。
10. 协程与线程的对比总结
通过上面的介绍,我们可以更清晰地总结协程相对于传统线程的优势:
- 更轻量: 协程的创建和管理开销极低,可以轻松创建大量协程。
- 更高效: 协程通过挂起而非阻塞来处理等待,最大化利用线程资源。一个线程可以高效地同时处理多个挂起的协程。
- 更简洁:
suspend
函数和协程构建器让异步代码写起来像同步代码一样直观,避免了回调地狱。 - 更安全: 结构化并发通过作用域管理协程生命周期,自动传播取消和错误,减少了协程泄露和并发问题的发生。
- 更好的控制流:
async
/await
让并发获取结果变得简单,withContext
让线程切换变得优雅。
11. 新手入门实践建议
- 从
runBlocking
开始: 在小 demo 或测试中,使用runBlocking
作为进入协程世界的入口,方便试验挂起函数和构建器。但在实际应用(尤其是 Android)中,避免在主线程使用runBlocking
。 - 理解
suspend
: 这是协程的基础。记住suspend
函数只能在其他suspend
函数或协程内部调用。它表示函数内部可能有挂起点,不会阻塞线程。 - 区分
launch
和async
:launch
用于“发射并忘记”(fire and forget)的任务,不关心返回值;async
用于需要返回结果的任务,使用await()
获取结果。 - 掌握
delay
和withContext
:delay
是协程中非阻塞的等待,替代Thread.sleep
。withContext
是进行线程切换和执行阻塞/耗时操作的最佳方式。 - 拥抱结构化并发: 始终在
CoroutineScope
内启动协程。理解作用域如何管理协程的生命周期和传播取消。在 Android 开发中,使用LifecycleScope
和ViewModelScope
等是标准做法。 - 理解协作式取消: 知道
cancel()
不是立即终止,以及如何通过挂起函数或手动检查isActive
/ensureActive
来响应取消。在finally
块中进行清理。 - 从小处着手: 从简单的例子开始,逐步引入更复杂的概念,如错误处理、通道 (Channels) 等(这些在本文未深入,是后续学习方向)。
- 查阅官方文档: Kotlin 协程的官方文档非常详细且不断更新,是最好的学习资源。
结论
Kotlin 协程是现代 Kotlin 开发中处理异步和并发问题的强大工具。它们通过引入“挂起”概念,提供了比传统线程更轻量、更灵活、更安全、更易于编写的并发模型。结构化并发原则更是协程的一大亮点,它将并发代码的管理提升到了一个新的高度。
对于新手来说,理解协程的核心概念(suspend
, CoroutineScope
, CoroutineContext
, Job
, Dispatcher
)以及常用的构建器 (launch
, async
, runBlocking
) 和函数 (delay
, withContext
, await
, cancel
, join
) 是入门的关键。
虽然初次接触可能会感到一些挑战,但一旦掌握了这些基础知识,你会发现使用协程编写异步代码是如此的优雅和高效。继续实践,探索更多高级特性,协程将成为你 Kotlin 工具箱中不可或缺的一部分。
希望这篇详细的入门指南能帮助你迈出学习 Kotlin 协程的第一步!祝你学习愉快!