Kotlin 协程:结构化并发与挂起函数
为什么需要 Kotlin 协程?
在 Android 或后端开发中,我们经常需要执行耗时操作(如网络请求、数据库读写)而不阻塞主线程。传统的回调或 RxJava 等方式容易导致代码嵌套、难以维护。Kotlin 协程提供了一种轻量级线程的解决方案,让你能够以近乎同步的代码风格编写异步逻辑,同时保持高效的资源利用。
协程的核心优势:
- 挂起非阻塞:协程可以在不阻塞线程的情况下挂起,释放线程去执行其他任务。
- 结构化并发:通过作用域管理协程生命周期,防止泄漏。
- 代码简洁:消除回调地狱,使用顺序代码处理异步逻辑。
挂起函数:协程的最小执行单元
挂起函数(suspend function)是协程世界的基石。它能够被暂停(挂起),并在稍后恢复执行,而不会阻塞当前线程。
声明挂起函数
在普通函数前添加 suspend 关键字即可:
suspend fun fetchUserData(): User {
// 模拟耗时网络请求
delay(1000) // delay 本身也是一个挂起函数
return User("Alice", 28)
}
delay 不会阻塞线程,它只是挂起当前协程,让出线程资源。这和我们熟悉的 Thread.sleep 完全不同,后者会阻塞线程。
挂起函数的调用限制
挂起函数只能在另一个挂起函数或协程作用域中调用,不能在普通函数中直接使用:
fun main() {
// 错误!不能直接调用挂起函数
// fetchUserData()
}
正确的起点是使用协程构建器,例如 runBlocking(常用于测试或简单示例)或实际项目中的 CoroutineScope.launch。
挂起的底层原理:CPS 转换
编译器会对挂起函数进行 Continuation-Passing Style (CPS) 转换,在编译期生成一个带有额外 Continuation 参数的方法。挂起函数内部每一个挂起点都会被拆分成状态机,从而实现暂停和恢复。你无需关心细节,但需要理解:挂起函数并不会创建新线程,它运行在当前线程,只是当到达挂起点时,当前线程可以去执行其他代码。
结构化并发:安全地管理协程
结构化并发(Structured Concurrency)是 Kotlin 协程的关键设计原则。它确保:
- 协程不会被丢失或泄漏。
- 错误能够被正确传播和处理。
- 所有子协程的生命周期都不会超出父协程的范围。
协程作用域与结构化并发
一切协程运行都需要一个 CoroutineScope。当你通过作用域启动一个协程时,该协程就成为了此作用域的子协程。父协程会等待所有子协程执行完成,如果父协程被取消,其所有子协程也会被递归取消。
fun main() = runBlocking { // this: CoroutineScope
launch { // 子协程1
delay(500)
println("任务1完成")
}
launch { // 子协程2
delay(1000)
println("任务2完成")
}
println("主协程继续执行")
// 主协程会等待两个子协程都完成后才会结束
}
输出顺序:
主协程继续执行
任务1完成
任务2完成
协程构建器:launch 与 async
launch:启动一个不返回结果的协程,返回Job。适合“发射后不管”的任务。async:启动一个返回结果的协程,返回Deferred(继承自Job)。通过await()获取结果。
示例:并发执行两个网络请求并合并结果
suspend fun fetchUser(): User = ...
suspend fun fetchOrders(): List<Order> = ...
suspend fun loadUserWithOrders(): UserWithOrders = coroutineScope {
val userDeferred = async { fetchUser() }
val ordersDeferred = async { fetchOrders() }
val user = userDeferred.await()
val orders = ordersDeferred.await()
UserWithOrders(user, orders)
}
coroutineScope 是一个挂起函数,它会创建一个新的作用域,并等待内部所有协程完成后再返回。如果任何一个子协程失败,整个作用域会被取消,未完成的任务也会被取消。
取消协程
协程的取消是协作式的。当一个协程正在执行可取消的挂起函数(如 delay、yield)时,取消会立即生效。否则,我们需要定期检查协程的活跃状态。使用 isActive 属性或 ensureActive() 方法:
val job = launch {
while (isActive) { // 响应取消
// 执行工作
doCpuIntensiveWork()
yield() // 主动让出执行权,让取消检查点生效
}
}
job.cancel() // 取消协程
异常处理与监督
在结构化并发中,异常会从子协程向父协程传播,并导致父协程取消,进而取消所有其他子协程。可以通过 SupervisorJob 或 supervisorScope 来改变这种行为,使得一个子协程的失败不会影响其他兄弟协程。
suspend fun fetchDataConcurrently() = supervisorScope {
launch {
// 这个协程的失败不会影响其他
fetchUser()
}
launch {
fetchOrders()
}
}
对于 launch 启动的协程,可以使用 CoroutineExceptionHandler 来捕获未处理的异常。
协程上下文与调度器
协程上下文是决定协程“在哪个线程/线程池上运行”的关键元素。通过 CoroutineDispatcher 指定:
- Dispatchers.Main:Android 主线程,用于 UI 更新。
- Dispatchers.IO:针对 I/O 密集型任务(如网络、磁盘读写)。
- Dispatchers.Default:针对 CPU 密集型任务(如排序、复杂计算)。
- Dispatchers.Unconfined:不限制线程,在最初调用的线程中启动,但第一个挂起点后线程可能会改变(一般不推荐用于生产)。
launch(Dispatchers.IO) {
val data = fetchDataFromServer() // 在 IO 线程池执行
withContext(Dispatchers.Main) {
updateUI(data) // 切换到主线程更新界面
}
}
withContext 是一个挂起函数,用于切换当前协程的上下文,并执行给定代码块,完成后恢复到原上下文。它是异步逻辑中切换线程的推荐方式。
实战:构建一个安全的网络请求
让我们综合运用以上知识,编写一个模拟从网络获取用户信息的协程函数,并处理错误:
// 模拟网络请求
suspend fun getUserFromApi(userId: String): User {
delay(2000)
if (userId.isEmpty()) throw IllegalArgumentException("userId 不能为空")
return User(userId, "用户名")
}
// 在 ViewModel 或类似环境中调用
fun loadUser(userId: String) {
viewModelScope.launch {
try {
val user = withContext(Dispatchers.IO) {
getUserFromApi(userId)
}
// 自动回到 Dispatchers.Main (viewModelScope 的默认调度器通常是 Main)
displayUser(user)
} catch (e: Exception) {
showError(e.message)
}
}
}
这里 viewModelScope 是 Android 架构组件提供的生命周期感知协程作用域,当 ViewModel 清除时会自动取消该作用域内的协程,完美体现了结构化并发的优势。
常见误区与最佳实践
-
不要用 GlobalScope 作为常规用途
GlobalScope是一个顶层作用域,生命周期与应用程序一致,很容易造成协程泄漏。仅用于特定场景(如后台长时间任务且无需结构化并发)。 -
避免在挂起函数中使用阻塞代码 挂起函数中应调用其他挂起函数或快速执行的代码。如果需要执行阻塞操作,使用
withContext(Dispatchers.IO)将其调度到 IO 线程池。 -
正确处理取消 对于计算密集型循环,务必通过
isActive检查或调用yield()来确保协程可以被及时取消。 -
使用
coroutineScope进行安全的并发分解 当需要并行执行多个任务并在完成时返回聚合结果时,总是使用coroutineScope或supervisorScope包裹async,这样可以避免异常丢失和作用域泄漏。
总结
Kotlin 协程通过挂起函数提供了非阻塞的异步编程模型,而结构化并发则保证了协程的可靠管理和资源安全。掌握 launch、async、作用域、上下文切换以及取消机制,是高效使用协程的必经之路。从简单的 delay 到复杂的并发网络请求,你都可以用简洁的线性代码表达,彻底告别回调地狱。