Kotlin 协程:结构化并发与挂起函数

FreeGuideOnline 最新 2026-06-17

为什么需要 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 是一个挂起函数,它会创建一个新的作用域,并等待内部所有协程完成后再返回。如果任何一个子协程失败,整个作用域会被取消,未完成的任务也会被取消。

取消协程

协程的取消是协作式的。当一个协程正在执行可取消的挂起函数(如 delayyield)时,取消会立即生效。否则,我们需要定期检查协程的活跃状态。使用 isActive 属性或 ensureActive() 方法:

val job = launch {
    while (isActive) { // 响应取消
        // 执行工作
        doCpuIntensiveWork()
        yield() // 主动让出执行权,让取消检查点生效
    }
}
job.cancel() // 取消协程

异常处理与监督

在结构化并发中,异常会从子协程向父协程传播,并导致父协程取消,进而取消所有其他子协程。可以通过 SupervisorJobsupervisorScope 来改变这种行为,使得一个子协程的失败不会影响其他兄弟协程。

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 清除时会自动取消该作用域内的协程,完美体现了结构化并发的优势。

常见误区与最佳实践

  1. 不要用 GlobalScope 作为常规用途 GlobalScope 是一个顶层作用域,生命周期与应用程序一致,很容易造成协程泄漏。仅用于特定场景(如后台长时间任务且无需结构化并发)。

  2. 避免在挂起函数中使用阻塞代码 挂起函数中应调用其他挂起函数或快速执行的代码。如果需要执行阻塞操作,使用 withContext(Dispatchers.IO) 将其调度到 IO 线程池。

  3. 正确处理取消 对于计算密集型循环,务必通过 isActive 检查或调用 yield() 来确保协程可以被及时取消。

  4. 使用 coroutineScope 进行安全的并发分解 当需要并行执行多个任务并在完成时返回聚合结果时,总是使用 coroutineScopesupervisorScope 包裹 async,这样可以避免异常丢失和作用域泄漏。

总结

Kotlin 协程通过挂起函数提供了非阻塞的异步编程模型,而结构化并发则保证了协程的可靠管理和资源安全。掌握 launchasync、作用域、上下文切换以及取消机制,是高效使用协程的必经之路。从简单的 delay 到复杂的并发网络请求,你都可以用简洁的线性代码表达,彻底告别回调地狱。