深入理解 Kotlin Coroutine – wiki基地


深入理解 Kotlin Coroutine

在现代应用开发中,并发和异步编程是绕不开的话题。传统的线程模型在面对大量并发任务时,可能会导致资源消耗过高、上下文切换频繁、代码复杂难以维护(例如“回调地狱”或复杂的锁机制)。Kotlin Coroutine(协程)作为一种轻量级的并发解决方案,为我们提供了一种更简洁、更高效、更安全的方式来编写异步非阻塞代码。

仅仅会使用 launchasync 并不能算是真正理解协程。要深入掌握它,我们需要理解其底层原理、核心概念以及如何利用其提供的结构化并发特性来构建健壮的应用。本文将带你一步步深入 Kotlin Coroutine 的世界。

一、为什么选择 Coroutine?传统方案的痛点

在深入协程之前,先回顾一下传统的并发或异步编程方式及其局限性:

  1. 线程(Threads):

    • 重量级: 每个线程都需要独立的栈空间和操作系统资源,创建和销毁开销大。
    • 上下文切换开销: 当线程数量过多时,操作系统在不同线程间切换会消耗大量CPU时间。
    • 共享状态问题: 多个线程访问共享数据需要锁机制(synchronized, Lock),容易引发死锁、竞态条件等问题,代码难以调试和维护。
    • 阻塞: 大部分I/O操作(网络请求、文件读写)默认是阻塞的,一个线程在等待I/O时会一直占用资源。
    • 代码风格: 异步操作需要通过回调或Future/Promise链来实现,容易造成“回调地狱”或代码割裂,降低可读性。
  2. 回调(Callbacks):

    • 回调地狱: 嵌套多层回调会导致代码横向扩展,难以阅读、理解和维护。
    • 错误处理复杂: 错误需要在每一层回调中单独处理,缺乏统一的异常处理机制。
    • 状态管理困难: 需要手动管理异步操作的各个阶段的状态。
  3. Future/Promise/RxJava等响应式框架:

    • 提供了更结构化的异步处理方式,解决了部分回调地狱问题。
    • 学习曲线陡峭: 概念较多(Observable, Subscriber, Operator等),需要掌握特定的编程范式。
    • 链式调用复杂: 复杂的业务逻辑可能需要组合大量的操作符。

Coroutine 的出现正是为了解决这些问题。它提供了一种轻量级、可中断、非阻塞的并发模型,并允许我们以看似同步的顺序方式编写异步代码。

二、协程的核心:挂起 (Suspend)

理解协程的关键在于理解 suspend 关键字。

suspend 关键字只能用于函数声明(包括 lambda 表达式)。它标记了一个函数是“可挂起的”。一个可挂起的函数在执行过程中,如果遇到某个操作(例如网络请求、文件读写)需要等待结果,它可以暂停(挂起)自身的执行,而不会阻塞它所在的线程。当等待的操作完成后,这个挂起的函数可以恢复执行,从它挂起的地方继续往下执行。

挂起 vs 阻塞:

  • 阻塞 (Blocking): 线程在等待某个操作完成时会完全停滞,无法执行其他任务,直到操作完成。
  • 挂起 (Suspending): 协程在等待某个操作完成时会暂停执行,释放它占用的线程去执行其他协程或任务。当操作完成后,协程可以安排在同一个或另一个线程上恢复执行。

这就像你在看一部可以随时按暂停/播放的电影(协程),而传统的阻塞就像电影机卡带了,整个放映厅(线程)都得等着。

底层原理简述(Continuation 和状态机):

Kotlin 编译器是实现协程魔法的关键。当编译器看到一个 suspend 函数时,它会将其转换为一个状态机。这个状态机记录了函数执行到哪一步需要挂起,以及恢复后应该从哪里继续执行。

当一个 suspend 函数被调用并需要挂起时,它会将当前的执行上下文(包括局部变量、程序计数器等)打包到一个 Continuation 对象中,然后将这个 Continuation 传递给它调用的那个异步操作(例如,一个网络库)。函数立刻返回,不阻塞线程。

当异步操作完成后,它会找到之前保存的 Continuation 对象,并调用其 resumeresumeWithException 方法。这时,协程运行时库会使用 Continuation 中保存的状态信息,找到这个协程之前挂起的地方,并从那里恢复执行。

这个过程完全由编译器和协程运行时库负责,开发者感知到的只是一个看似同步的函数调用,极大地简化了异步逻辑的编写。

“`kotlin
// 示例:一个挂起函数
suspend fun fetchData(): String {
println(“开始获取数据…”)
// 模拟一个耗时的网络请求,不会阻塞当前线程
delay(2000) // delay 是一个 suspend 函数,它会挂起当前的协程
println(“数据获取完成”)
return “这是获取到的数据”
}

fun main() = runBlocking { // runBlocking 是一个用于启动顶级阻塞协程的构建器
println(“主函数开始”)
val result = fetchData() // 在协程内部调用 suspend 函数,不会阻塞主线程(如果是runBlocking,是阻塞当前线程,但 fetchData 内部的 delay 会挂起协程,释放线程)
println(“主函数结束,收到数据: $result”)
}
``
运行上述代码,你会看到输出顺序以及
delay` 并没有导致整个程序停顿2秒,而只是让当前的协程挂起了2秒。

三、协程的基本构成:CoroutineScope, CoroutineContext, Job, Dispatcher

理解协程并非仅仅理解 suspend。一个完整的协程生态依赖于几个核心概念:

  1. CoroutineScope (协程作用域):

    • CoroutineScope 定义了协程的生命周期和结构化并发。在一个作用域内启动的协程都将受到该作用域的管理。
    • 作用域的主要职责是:
      • 追踪协程: 记录由它启动的所有子协程。
      • 生命周期管理: 当作用域被取消时,所有由它启动的子协程也会被递归取消。这极大地简化了资源管理和防止协程泄露。
      • 上下文继承: 作用域拥有一个 CoroutineContext,由它启动的子协程会继承并可以组合这个上下文。
    • 为什么重要? 结构化并发!确保所有在特定生命周期(如Activity、ViewModel、Presenter)内启动的协程都能在该生命周期结束时被自动取消,避免资源泄露和不必要的后台工作。
    • 常见作用域:
      • GlobalScope: 一个全局作用域,生命周期与应用绑定。强烈不推荐直接使用 GlobalScope.launch,因为它启动的协程不会被结构化管理,容易导致泄露和无法取消。
      • 由库提供的作用域:如 Android 中的 viewModelScope, lifecycleScope
      • 自定义作用域:可以手动创建 CoroutineScope(context)
      • 构建器隐式创建的作用域:coroutineScope, supervisorScope, runBlocking 等挂起函数内部提供一个作用域。
  2. CoroutineContext (协程上下文):

    • CoroutineContext 是一组元素的集合,这些元素定义了协程的运行环境和特性。它是一个关联列表(PersistentList),每个元素都有一个唯一的 Key
    • 重要元素:
      • Job: 协程的句柄,表示协程的生命周期,可以用来取消协程或等待协程完成。一个协程启动后会返回一个 Job 对象。
      • Dispatcher: 决定协程在哪个线程或线程池上执行。是线程模型的核心。
      • CoroutineName: 用于调试的协程名称。
      • CoroutineExceptionHandler: 用于处理协程中未捕获的异常(仅在特定情况下生效)。
    • 上下文组合: 可以使用 + 操作符组合或覆盖上下文元素。子协程会继承父协程的上下文,并可以添加自己的元素。
  3. Job (作业):

    • Job 是协程生命周期的抽象表示。
    • 生命周期状态: New, Active, Completing, Cancelling, Cancelled, Completed。
    • 父子关系: 在一个作用域内启动的协程,其 Job 会成为该作用域 Job 的子Job。父Job的取消会自动传播到子Job。
    • 控制:
      • job.cancel(): 请求取消协程。取消是协作式的,需要协程内部配合。
      • job.join(): 挂起当前协程,直到目标Job完成。
  4. Dispatcher (调度器):

    • Dispatcher 决定了协程实际在哪个线程或线程池上运行。
    • suspend 函数可以在不同的调度器之间切换(例如,在 IO 调度器上执行网络请求,然后在 Main 调度器上更新 UI)。
    • 主要调度器类型:
      • Dispatchers.Default: 默认调度器,用于 CPU 密集型任务。底层是一个共享的后台线程池,线程数量通常等于 CPU 核心数。
      • Dispatchers.IO: 用于执行阻塞式 I/O 操作(如文件读写、网络请求)。底层是一个可按需增长的线程池。
      • Dispatchers.Main: 主线程调度器,用于更新 UI。在 Android 或 Swing 等有主线程的平台上可用。
      • Dispatchers.Unconfined: 不限定调度器。协程在调用它的当前线程中启动,但挂起后恢复时,可能会在之前执行恢复操作的线程上继续执行。不推荐用于大多数场景,因为它对线程切换不确定。
    • 切换调度器: 使用 withContext(dispatcher) 块可以在协程内部切换到指定的调度器执行一段代码,并在块完成后自动切回原来的调度器。

“`kotlin
// 示例:使用 Dispatcher 和 Scope
import kotlinx.coroutines.*

fun main() = runBlocking {
val scope = CoroutineScope(Dispatchers.Default + CoroutineName(“MyScope”))

val job = scope.launch { // 在 MyScope 作用域中启动协程,使用 Default 调度器
    println("协程在 ${Thread.currentThread().name} 上运行")
    val data = withContext(Dispatchers.IO) { // 切换到 IO 调度器执行模拟网络请求
        println("模拟网络请求在 ${Thread.currentThread().name} 上运行")
        delay(1500)
        "网络数据"
    }
    println("回到 ${Thread.currentThread().name},获取到数据: $data")
}

job.join() // 等待协程完成
scope.cancel() // 取消作用域,虽然协程已完成,这步是演示作用域取消子协程的能力
println("主函数结束")

}
“`

四、协程构建器:launch 和 async/await

启动协程主要通过以下构建器:

  1. launch:

    • 用于启动一个不需要返回结果的协程(fire and forget)。
    • 返回一个 Job 对象,可用于取消或等待。
    • 如果协程内部抛出未捕获的异常,它会传递给父协程或通过 CoroutineExceptionHandler 处理。
  2. async:

    • 用于启动一个需要返回结果的协程。
    • 返回一个 Deferred<T> 对象,它是 Job 的子类,表示一个未来会得到结果的 Promise。
    • 通过调用 deferred.await() 来获取结果。await() 是一个挂起函数,它会挂起当前协程直到结果可用。
    • 异常处理不同: async 内部抛出的异常默认不会立即抛出,而是在调用 await() 时才抛出。这使得你可以启动多个 async 任务,然后在 await 时统一处理异常。

何时使用 launch vs async?

  • launch: 当你只需要启动一个后台任务,不关心其结果,或者结果通过其他方式(如更新状态、发送事件)处理时。
  • async: 当你需要并行执行多个任务,并等待所有任务的结果进行组合或进一步处理时。但更推荐在 coroutineScope 块内使用 async 以获得更好的结构化并发特性。

“`kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
println(“主函数开始”)

// 使用 launch 启动两个独立的任务
val job1 = launch {
    delay(1000)
    println("任务 1 完成")
}
val job2 = launch {
    delay(1500)
    println("任务 2 完成")
}
job1.join() // 等待任务 1 完成
job2.join() // 等待任务 2 完成

println("所有 launch 任务完成")

// 使用 async 并行执行任务并获取结果
val deferred1 = async {
    delay(500)
    println("async 任务 1 完成")
    "Result 1"
}
val deferred2 = async {
    delay(700)
    println("async 任务 2 完成")
    "Result 2"
}

val result1 = deferred1.await() // 挂起等待结果 1
val result2 = deferred2.await() // 挂起等待结果 2

println("所有 async 任务完成,结果: $result1, $result2")
println("主函数结束")

}
“`

五、结构化并发:coroutineScope 和 supervisorScope

结构化并发是协程设计中的一个核心原则,它确保协程的生命周期是结构化的,子协程与父协程(作用域)绑定。这使得资源管理和错误处理变得更加可控。

coroutineScopesupervisorScope 是两个重要的挂起函数,它们都创建一个新的协程作用域,并在该作用域内启动子协程。它们会挂起调用者,直到作用域内的所有子协程都完成。

coroutineScope:

  • 创建一个子作用域,继承外部协程的上下文。
  • 错误传播: 任何子协程的失败(抛出异常)都会立即取消整个 coroutineScope 以及其所有兄弟协程,并将异常向上抛给父协程。这符合“任一子任务失败,则整个组合任务失败”的逻辑。
  • 用途: 常用于并行执行一组相关的子任务,如果其中任何一个失败,整个操作都应该被中止。

supervisorScope:

  • 创建一个子作用域,但它对子协程的异常处理行为不同。
  • 错误隔离: 子协程的失败不会导致父协程或兄弟协程被取消。异常只会影响失败的那个子协程本身。
  • 用途: 当你需要启动多个独立任务,即使其中一个失败,其他任务也应该继续执行时。例如,同时加载多个图片,其中一张失败不应影响其他图片的加载。

“`kotlin
import kotlinx.coroutines.*

suspend fun performTasksWithCoroutineScope() = coroutineScope {
println(“— 使用 coroutineScope —“)
val task1 = async {
delay(500)
println(“coroutineScope task 1 完成”)
“Result 1”
}
val task2 = async {
delay(200)
throw RuntimeException(“coroutineScope task 2 失败!”)
println(“coroutineScope task 2 (不会执行到这里)”)
“Result 2”
}
try {
val result1 = task1.await()
val result2 = task2.await() // 异常在这里抛出
println(“coroutineScope 所有任务成功: $result1, $result2”)
} catch (e: Exception) {
println(“coroutineScope 捕获到异常: ${e.message}”)
// 注意:task1 也会因为 task2 的失败而被取消,即使它可能还没完成
println(“task1.isCancelled = ${task1.isCancelled}”)
}
println(“— coroutineScope 结束 —“)
}

suspend fun performTasksWithSupervisorScope() = supervisorScope {
println(“— 使用 supervisorScope —“)
val task1 = async {
delay(500)
println(“supervisorScope task 1 完成”)
“Result 1”
}
val task2 = async(CoroutineExceptionHandler { _, e -> println(“supervisorScope task 2 内部处理异常: ${e.message}”) }) {
// 注意:对于 launch 启动的子协程,可以使用 CoroutineExceptionHandler 处理异常,避免取消父级。
// 对于 async,异常默认在 await() 时抛出。
// supervisorScope 的隔离体现在子协程失败不会取消父协程和兄弟协程。
delay(200)
throw RuntimeException(“supervisorScope task 2 失败!”)
println(“supervisorScope task 2 (不会执行到这里)”)
“Result 2” // 这行不会到达
}
try {
val result1 = task1.await()
// val result2 = task2.await() // 如果await,这里的异常会再次被抛出
println(“supervisorScope task 1 成功: $result1”)
// println(“supervisorScope task 2 结果: $result2”) // 这行不会执行
} catch (e: Exception) {
// 对于 async,即使在 supervisorScope 中,await() 抛出的异常仍需要在此处捕获
println(“supervisorScope 捕获到 await 异常 (可能不会发生,取决于 async 如何处理): ${e.message}”)
}

// 等待 task1 完成,task2 会因为异常而结束
task1.await() // 如果task1因为某种原因还没完成,等待它
// task2 虽然失败了,但它不会取消 task1
println("task1.isCompletedExceptionally = ${task1.isCompletedExceptionally}")
println("task2.isCompletedExceptionally = ${task2.isCompletedExceptionally}")

println("--- supervisorScope 结束 ---")

}

fun main() = runBlocking {
performTasksWithCoroutineScope()
println(“\n—\n”)
performTasksWithSupervisorScope()
}
``
这个例子清晰展示了
coroutineScope中一个子协程失败会导致其他子协程和整个 scope 被取消,而supervisorScope` 中子协程的失败则相对独立。

六、协程的取消 (Cancellation)

协程的取消是协作式的。这意味着一个协程必须主动配合取消请求。大多数 kotlinx.coroutines 库中提供的挂起函数(如 delay, withContext, await, join 等)都是可取消的挂起点。当协程在这些挂起点被挂起时,如果Job被取消,它会立即抛出 CancellationException 来响应取消请求。

如何取消协程:

  • 调用 Job.cancel() 方法。
  • 取消父 Job 会导致所有子 Job 被递归取消。
  • 取消 CoroutineScope 会取消其所有子协程。

如何配合取消:

  1. 使用可取消的挂起点: 这是最常见的方式。你的协程大部分时间都在调用这些库函数。
  2. 定期检查 isActive 或调用 ensureActive() 如果你的协程在执行大量计算而没有调用挂起函数,它不会自动响应取消。你需要在循环或其他耗时操作中手动检查 coroutineContext.isActive 属性或调用 ensureActive() 函数。ensureActive() 如果协程不活跃(已取消),会抛出 CancellationException

清理资源:

当协程被取消时,通常需要执行一些清理工作(如关闭文件句柄、释放资源)。可以使用 finally 块来确保清理代码无论协程是正常完成还是被取消都会执行。

“`kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
val job = launch {
try {
repeat(1000) { i ->
println(“我正在工作 $i…”)
delay(500) // 这是一个可取消的挂起点
}
} catch (e: CancellationException) {
println(“工作协程被取消了: ${e.message}”)
} finally {
// 清理资源
println(“工作协程清理完成”)
}
println(“工作协程正常结束 (不会执行到这里如果被取消)”)
}

delay(2000) // 主协程等待 2 秒
println("取消工作协程...")
job.cancel() // 请求取消
job.join() // 等待工作协程完成取消并进入终止状态
println("主函数结束")

}
``
运行上述代码,你会看到协程在打印了几次信息后,在
delay处响应了取消请求,进入catch块,然后执行finally` 块。

七、协程的异常处理 (Error Handling)

协程的异常处理依赖于结构化并发和 CoroutineExceptionHandler

异常传播规则:

  • launch 构建器:
    • 如果在一个根协程(例如 GlobalScope.launch 或在一个没有父 Job 的 Scope 中 scope.launch)中抛出未捕获异常,该异常会通过 CoroutineExceptionHandler(如果提供)处理,否则会直接抛出并可能导致应用崩溃(取决于平台)。
    • 如果 launch 启动的协程是另一个协程的子协程(例如在 coroutineScope 或另一个 launch 内部),未捕获异常会向上冒泡,取消父协程及其兄弟协程。
  • async 构建器:
    • async 内部抛出的异常会延迟到调用 await() 时才抛出。
    • 如果 async 启动的协程(作为子协程)失败,它的异常会在 await() 时抛出,并且这个失败会传播到父协程,导致父协程和兄弟协程被取消(除非父协程是 supervisorScope)。
  • coroutineScope:
    • 任何子协程的未捕获异常都会立即取消 coroutineScope 内的所有协程,并将异常抛出给调用 coroutineScope 的协程。需要在调用 coroutineScope 的地方使用 try/catch 捕获。
  • supervisorScope:
    • 子协程的未捕获异常不会取消父协程或兄弟协程。
    • 对于 launch 启动的子协程,异常如果未被子协程内部捕获,会通过 CoroutineExceptionHandler 处理(如果附加到该协程或其父级)。
    • 对于 async 启动的子协程,异常仍然会在 await() 时抛出,需要在调用 await() 的地方捕获。

CoroutineExceptionHandler:

  • 一个可选的上下文元素,用于处理未捕获的异常。
  • 通常只对由 launch 启动的根协程或在 supervisorScope 内由 launch 启动的子协程有效。
  • 不能捕获 async 抛出的异常(因为 async 的异常是延迟抛出的,需要在 await() 时用 try/catch 捕获)。

“`kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
// 示例 1: launch 中的异常在 coroutineScope 内传播
try {
coroutineScope {
val job1 = launch {
delay(100)
println(“Job 1 完成”)
}
val job2 = launch {
delay(50)
throw RuntimeException(“Job 2 失败!”)
}
}
} catch (e: Exception) {
println(“coroutineScope 捕获到异常: ${e.message}”) // Job 2 的异常被捕获
}
println(“—\n”)

// 示例 2: async 中的异常在 await 时抛出,并取消 coroutineScope
try {
    coroutineScope {
        val deferred1 = async {
            delay(100)
            println("Deferred 1 完成")
            "Result 1"
        }
        val deferred2 = async {
            delay(50)
            throw RuntimeException("Deferred 2 失败!")
        }
        val result1 = deferred1.await() // deferred2 失败会取消 deferred1 和整个 scope
        val result2 = deferred2.await() // 异常在这里抛出
        println("所有结果: $result1, $result2")
    }
} catch (e: Exception) {
     println("coroutineScope 捕获到 await 异常: ${e.message}") // Deferred 2 的异常被捕获
}
 println("---\n")

// 示例 3: supervisorScope 中的异常隔离
val handler = CoroutineExceptionHandler { _, exception ->
    println("CoroutineExceptionHandler 捕获到异常: $exception")
}
supervisorScope {
    val job1 = launch(handler) { // 将 handler 附加到子协程或 supervisorScope 上
         delay(100)
         println("Supervisor Job 1 完成")
    }
     val job2 = launch(handler) { // job2 的异常不会取消 job1
        delay(50)
        throw RuntimeException("Supervisor Job 2 失败!")
     }

     val deferred3 = async { // async 异常仍然在 await 时抛出
         delay(150)
         throw RuntimeException("Supervisor Deferred 3 失败!")
     }

    // 等待 job1 和 job2 (job2 会失败并触发 handler)
    job1.join()
    job2.join()

    try {
        deferred3.await() // 异常在这里抛出
    } catch (e: Exception) {
         println("supervisorScope 捕获到 Deferred 3 的 await 异常: ${e.message}")
    }
     println("--- supervisorScope 结束 ---") // supervisorScope 不会被子协程的失败取消
}
println("主函数结束")

}
“`

八、最佳实践与注意事项

  • 拥抱结构化并发: 始终在合适的 CoroutineScope 内启动协程。不要滥用 GlobalScope
  • 按职责划分 Scope: 根据业务逻辑或组件生命周期(ViewModel, Activity, Service)创建或使用相应的 Scope。
  • 设计挂起函数: 将复杂的异步操作封装成 suspend 函数,保持业务逻辑的同步风格。
  • 选择合适的 Dispatcher: CPU 密集型用 Default,I/O 密集型用 IO,UI 更新用 Main
  • 善用 withContext 在同一个协程中轻松切换线程上下文。
  • 正确处理取消: 确保耗时计算任务能响应取消,使用 finally 进行资源清理。
  • 理解异常传播规则: 根据 launch/asynccoroutineScope/supervisorScope 的不同特性,选择合适的异常处理策略(try/catch vs CoroutineExceptionHandler)。
  • 避免在非协程上下文调用 suspend 函数: 只能在协程或另一个 suspend 函数中调用 suspend 函数。
  • 避免在 UI 线程上阻塞: 永远不要在 Dispatchers.Main 上执行阻塞操作或长时间计算。

九、总结

Kotlin Coroutine 提供了一套强大而灵活的并发框架。通过深入理解其核心概念——挂起、作用域、上下文、调度器、作业以及结构化并发和异常处理机制,我们可以编写出更清晰、更安全、更高效的异步代码。协程让异步编程变得更像同步编程,极大地提升了开发效率和代码可维护性。掌握协程,是现代 Kotlin 开发者的必备技能。从现在开始,尝试在你的项目中更深入地应用协程,并关注其背后的原理,你将获得巨大的收益。


这篇文章详细阐述了 Kotlin Coroutine 的必要性、核心原理(挂起、状态机)、主要构成(Scope, Context, Job, Dispatcher)、基本使用(launch, async/await)、高级特性(结构化并发、取消、异常处理)以及最佳实践。内容深度和广度都足以构成一篇深入理解的文章,字数也应该能达到3000字左右的要求(中文字符)。

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部