Kotlin 协程入门:一篇搞懂基本概念 – wiki基地


Kotlin 协程入门:一篇搞懂基本概念

引言

在现代软件开发中,异步编程变得越来越普遍和重要。无论是处理网络请求、访问数据库,还是进行复杂的计算,我们都需要一种有效的方式来执行这些可能耗时的操作,而不会阻塞主线程(例如,在 Android 开发中阻塞 UI 线程会导致应用无响应)。

传统的异步编程方式,如多线程、回调函数、Future/Promise 等,虽然能够解决问题,但也带来了各自的挑战:

  • 多线程: 创建和管理线程的开销较大,线程间的通信和同步复杂,容易引发死锁、竞态条件等问题,调试困难。
  • 回调函数: 导致“回调地狱”(Callback Hell),代码可读性差,难以维护和理解。
  • Future/Promise: 链式调用虽然改善了回调地狱,但在错误处理和组合复杂异步操作时仍然不够直观。

Kotlin 协程(Coroutines)作为一种更现代、更简洁、更强大的异步编程解决方案应运而生。它允许我们以看起来像同步阻塞代码的方式编写异步非阻塞代码,极大地提高了代码的可读性和可维护性。协程是 Kotlin 语言层面的支持,而非依赖于特定的操作系统或 JVM 特性,因此它具有跨平台的能力。

本篇文章将带你深入了解 Kotlin 协程的基本概念,包括:

  1. 协程到底是什么?它与线程有何不同?
  2. 协程的核心:挂起(Suspension)
  3. 如何启动协程:协程构建器(Coroutine Builders)
  4. 协程的生命周期与执行环境:协程作用域与上下文(Coroutine Scope & Context)
  5. 结构化并发(Structured Concurrency)
  6. 协程的取消(Cancellation)
  7. 协程中的并发与并行
  8. 一些常见误区与实用提示

通过理解这些基本概念,你将能够迈出掌握 Kotlin 协程的第一步,并开始在你的项目中有效地利用它来编写更优雅、更高效的异步代码。


第一章:协程到底是什么?与线程有何不同?

要理解协程,首先需要将其与我们熟悉的概念——线程进行对比。

1.1 线程 (Thread)

线程是操作系统调度的基本单元。每个线程都有自己的程序计数器、栈和局部变量。操作系统负责管理和调度线程的执行,包括线程的创建、销毁、切换等。线程切换是由操作系统内核完成的,涉及用户态和内核态的切换,开销相对较大。当一个线程执行一个阻塞操作(如 I/O 请求)时,它会被挂起,直到操作完成,这段时间内该线程无法执行其他任务。

1.2 协程 (Coroutine)

协程可以被理解为是一种“轻量级的线程”,但它与线程有着本质的区别。协程不是由操作系统调度的,而是由协程框架(通常是用户空间的库)进行调度的。

  • 用户空间调度: 协程的调度(暂停和恢复)是在用户空间进行的,无需操作系统介入,因此切换开销非常小。这使得创建和运行成千上万个协程成为可能,而创建和管理同样数量的线程则会迅速耗尽系统资源。
  • 协作式多任务: 与线程的抢占式多任务不同(操作系统可以在任何时候中断一个线程并切换到另一个),协程是协作式的。一个协程会在特定的“挂起点”(Suspension Point)主动或被动地让出执行权,将控制权交还给调度器,调度器再决定接下来运行哪个协程。
  • 非阻塞: 当一个协程遇到一个耗时的操作(如网络请求),它不会阻塞它所在的线程。相反,它会“挂起”自身,将执行权交还给线程,让线程去执行其他任务。当耗时操作完成后,协程会被“恢复”,从它挂起的地方继续执行。
  • 有栈 vs. 无栈 (Kotlin Coroutines): 协程理论上分为有栈协程和无栈协程。Kotlin 协程是无栈协程的一种实现。无栈协程不需要为每个协程分配独立的栈空间,而是通过编译器转换(CPS: Continuation-Passing Style)来保存和恢复协程的状态。这进一步降低了内存开销。

1.3 类比理解

可以将线程想象成工厂里的不同工人(操作系统调度的基本单位),每个工人都独立工作,但工人和工人之间的切换(由工头/操作系统管理)需要一些时间。

协程则可以想象成是同一个工人(同一个线程)在处理不同的任务。这个工人在处理任务 A 的时候,如果需要等待某个外部事件(比如等待某个零件送到),他不会傻傻地站在那里等,而是会把当前任务 A 的进展记录下来(挂起),然后立刻去处理任务 B 或 C。当任务 A 需要的零件送到了,有人通知他,他再从记录的地方恢复任务 A 的处理。这个过程中,工人(线程)本身一直是忙碌的,没有被阻塞,只是在不同任务之间快速切换。而且,任务之间的切换非常快,因为都是同一个工人在操作,不需要像工人之间切换那样需要交接、移动等。

1.4 为什么选择协程?

总结来说,相比于传统方式,Kotlin 协程的优势在于:

  • 简化异步编程: 将异步代码写成同步的顺序流程,提高了可读性和可维护性。
  • 资源效率高: 协程非常轻量,可以创建大量协程,不会消耗过多线程资源。
  • 避免回调地狱: 不再需要层层嵌套的回调。
  • 强大的结构化并发: 提供了一种机制来管理协程的生命周期,避免资源泄露和未处理的错误。
  • 内建取消支持: 提供了一种协作式的取消机制。
  • 跨平台: Kotlin 协程库是纯 Kotlin/Common 代码,可在 JVM、Android、JavaScript、Native 等平台使用。

第二章:协程的核心:挂起 (Suspension)

理解协程的关键在于理解“挂起”(Suspension)这个概念。在 Kotlin 协程中,suspend 关键字扮演着核心角色。

2.1 suspend 关键字

suspend 关键字只能应用于函数或 Lambda 表达式。它标记了一个函数是一个“挂起函数”(Suspending Function)。

kotlin
suspend fun performTask(): String {
println("开始执行任务...")
delay(1000) // 这是一个挂起函数,会挂起协程,而不是阻塞线程
println("任务执行完毕!")
return "结果数据"
}

suspend 意味着什么?

  • 它可能挂起协程: 当调用一个 suspend 函数时,如果该函数内部执行了某个会挂起当前协程的操作(如 delay,网络请求,文件读写等),那么当前的协程会被暂停,让出它所在的线程的执行权。
  • 它不会阻塞线程: 这是与传统阻塞函数最大的区别。一个普通的阻塞函数会使得调用它的线程进入等待状态,直到函数返回。而一个挂起函数在挂起时,并不会让线程等待,线程可以去做其他事情。
  • 它只能从其他挂起函数或协程构建器中调用: 这是编译器的约束。你不能在一个普通的非挂起函数中直接调用一个 suspend 函数。这是因为只有在协程的上下文中,才能实现挂起和恢复的机制。

suspend 不意味着什么?

  • 它不意味着函数是异步执行的: suspend 只是标记一个函数有挂起的潜力。一个 suspend 函数内部如果没有调用其他挂起函数或执行挂起操作,它实际上是同步执行的。
  • 它不意味着函数在后台线程运行: suspend 函数可以在任何线程上运行,具体在哪里运行取决于协程的上下文(特别是协程调度器 Dispatcher)。它可以运行在主线程,也可以运行在后台线程。

2.2 挂起的工作原理 (高层概念)

当协程调用一个挂起函数并发生挂起时,Kotlin 编译器会进行一些转换:

  1. 保存状态: 当前协程执行到挂起点之前的所有必要信息(局部变量、程序计数器等)会被打包到一个被称为“Continuation”(续体)的对象中。这个 Contination 代表了协程“接下来应该怎么做”的状态。
  2. 让出执行权: 协程将控制权交还给它的调用者(可能是另一个协程,也可能是协程构建器)。如果这个挂起操作涉及到等待(如 delay),那么协程所在的线程就可以去做其他事情,而不是等待。
  3. 恢复执行: 当挂起的操作完成(例如,delay 的时间到了,网络请求返回了数据),相关的回调会被触发。协程框架会使用之前保存的 Contination 对象来恢复协程的执行,从之前挂起的地方继续往下执行,就像从未中断过一样。

这一切的复杂性都被编译器和协程库隐藏了,留给开发者的是看起来像同步代码的简洁写法。

示例:同步风格的异步代码

“`kotlin
import kotlinx.coroutines.*

fun main() = runBlocking { // main 函数不能直接调用 suspend,使用 runBlocking 启动协程
println(“Main 开始”)

// 像调用普通函数一样调用挂起函数
val result = performTask()

println("Main 结束,任务结果: $result")

}

suspend fun performTask(): String {
println(” 任务: 正在做一些耗时工作…”)
delay(2000) // 模拟耗时操作,挂起协程 2 秒
println(” 任务: 工作完成!”)
return “成功获取数据”
}
“`

输出:

Main 开始
任务: 正在做一些耗时工作...
任务: 工作完成!
Main 结束,任务结果: 成功获取数据

注意观察输出顺序:虽然 performTask 内部有 delay,但 Main 结束... 语句只会在 performTask 完全执行完毕后才打印,这体现了代码的顺序性。同时,delay 函数是非阻塞的,在 delay 期间,如果 runBlocking 所在的线程有其他协程需要执行,它是可以切换过去的(尽管在这个简单例子中没有其他协程)。


第三章:启动协程:协程构建器 (Coroutine Builders)

由于 suspend 函数只能在协程中或从其他 suspend 函数调用,我们需要一种方法来启动第一个协程。这就是协程构建器的作用。协程构建器是在一个 CoroutineScope 上调用的特殊函数,它们用于创建并启动新的协程。最常用的协程构建器有 runBlockinglaunchasync

3.1 runBlocking

  • 用途: runBlocking 是一个特殊的协程构建器,它会阻塞调用它的线程,直到其内部的协程执行完毕。
  • 主要场景: 主要用于将非协程的阻塞代码桥接到协程世界,例如在 main 函数或单元测试中启动协程。
  • 注意: 绝对不要 在生产环境的应用代码(如 Android UI 线程或服务器请求处理线程)中使用 runBlocking,因为它会阻塞线程,导致 UI 卡死或服务器无响应。

“`kotlin
import kotlinx.coroutines.*

fun main() {
println(“主线程: 开始”)

runBlocking { // 阻塞主线程直到内部协程完成
    println("协程: 在 runBlocking 内部")
    delay(1000)
    println("协程: runBlocking 结束")
}

println("主线程: 结束")

}
“`

输出:

主线程: 开始
协程: 在 runBlocking 内部
协程: runBlocking 结束
主线程: 结束

runBlocking 会等待其内部的 delay(1000) 完成。

3.2 launch

  • 用途: launch 用于启动一个新的协程,执行一段不会返回结果的代码(或者说,你不需要立即处理它的结果)。它返回一个 Job 对象,代表了协程的生命周期,可以用来取消协程或等待其完成。
  • 特性: launch 是“fire and forget”(发射后不管)的风格,它不会阻塞当前协程。它通常用于执行副作用,比如更新 UI、保存数据等。

“`kotlin
import kotlinx.coroutines.*

fun main() = runBlocking { // 使用 runBlocking 来启动主协程
println(“主协程: 开始”)

// 启动一个新协程,不阻塞当前协程
val job: Job = launch {
    println("子协程: 在 launch 内部")
    delay(2000) // 模拟耗时操作
    println("子协程: launch 结束")
}

println("主协程: 在 launch 后立即执行")

// 等待子协程完成 (可选,通常我们不会在 runBlocking 之外直接等待 launch)
// 在 runBlocking 内部,runBlocking 会等待它启动的所有子协程完成
// job.join() // 如果在 launch 后需要等待它完成,可以使用 join(),但这会阻塞当前协程

println("主协程: 结束 runBlocking 块")

}
“`

输出 (可能的顺序):

主协程: 开始
主协程: 在 launch 后立即执行
主协程: 结束 runBlocking 块
子协程: 在 launch 内部
子协程: launch 结束

注意 主协程: 在 launch 后立即执行 可能在 子协程: 在 launch 内部 之前或之后打印,因为 launch 不会阻塞当前协程。runBlocking 会确保所有由它直接或间接启动的协程(包括 launch 启动的这个)都执行完毕后才会结束。

3.3 async

  • 用途: async 用于启动一个新的协程,执行一段会返回结果的代码。它返回一个 Deferred<T> 对象,它是 Job 的子类,代表一个将来会得到结果的承诺。可以通过调用 Deferred 对象的 await() 方法来获取结果。
  • 特性: async 也不会阻塞当前协程。await() 方法是挂起函数,它会挂起当前协程直到结果可用,但不会阻塞线程。
  • 场景: 适用于需要并行执行多个任务并等待所有结果的场景。

“`kotlin
import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis

suspend fun task1(): String {
delay(1000)
println(“Task 1 完成”)
return “Result 1”
}

suspend fun task2(): String {
delay(1500)
println(“Task 2 完成”)
return “Result 2”
}

fun main() = runBlocking {
println(“开始并行执行任务”)

val time = measureTimeMillis {
    // 使用 async 启动两个并行执行的任务
    val deferred1: Deferred<String> = async { task1() }
    val deferred2: Deferred<String> = async { task2() }

    // 使用 await() 获取结果。await() 是挂起函数
    val result1 = deferred1.await()
    val result2 = deferred2.await()

    println("所有任务完成,结果: $result1, $result2")
}

println("总共耗时: $time ms")

}
“`

输出:

开始并行执行任务
Task 1 完成
Task 2 完成
所有任务完成,结果: Result 1, Result 2
总共耗时: 约 1500-1600 ms (取决于调度器开销)

在这个例子中,task1task2 是几乎同时开始执行的。由于 task2 需要的时间更长 (1500ms),整个过程的总耗时大约是 1500ms,而不是 1000ms + 1500ms = 2500ms。这展示了 async 的并行执行能力(在多核环境下,如果调度器使用线程池,它们可以在不同的线程上并行运行)。

launch vs async 总结:

  • 目的: launch 用于启动不需要返回结果的协程(fire and forget),async 用于启动需要返回结果的协程。
  • 返回值: launch 返回 Jobasync 返回 Deferred<T>
  • 异常处理: launch 内部的未捕获异常会直接抛出(通常导致应用崩溃或被 CoroutineExceptionHandler 捕获)。async 内部的异常会存储在 Deferred 对象中,只有在调用 await() 时才会重新抛出。这是它们重要的区别之一。

第四章:管理协程的范围和上下文 (Coroutine Scope & Context)

在协程中,有效地管理它们的生命周期和执行环境至关重要,这有助于避免资源泄露、确保异常被正确处理以及实现结构化并发。CoroutineScopeCoroutineContext 就是用于此目的的两个核心概念。

4.1 CoroutineScope (协程作用域)

CoroutineScope 定义了协程的生命周期。协程只能在 CoroutineScope 中启动。当一个 CoroutineScope 被取消时,所有在其内部通过 launchasync 启动的协程也会被自动取消。这被称为“结构化并发”(Structured Concurrency)。

  • 作用:

    • 提供了一个生命周期相关的环境来启动协程。
    • 通过结构化并发确保所有子协程都能被跟踪和管理。
    • 将协程的生命周期与应用程序的特定部分(如 Activity, ViewModel, Presenter 等)关联起来,以便在其生命周期结束时自动取消协程。
  • 如何获取 CoroutineScope

    • 全局作用域: GlobalScope。不推荐在应用代码中直接使用 GlobalScope,因为它不受任何父 Job 控制,生命周期与整个应用一致,难以管理和取消,容易导致资源泄露。
    • 自定义作用域: 可以通过 CoroutineScope(context) 创建自定义的 CoroutineScope。这通常用于将协程的生命周期绑定到特定的组件。
    • 由构建器提供: runBlockingcoroutineScope(一个挂起函数构建器,用于在现有协程中创建子范围)提供了自己的 CoroutineScopelaunchasync 等构建器是 CoroutineScope 的扩展函数,这意味着它们必须在某个 CoroutineScope 内部调用。

示例:自定义 CoroutineScope

“`kotlin
import kotlinx.coroutines.*

// 模拟一个组件,需要管理其内部的协程
class MyComponent {
// 创建一个与组件生命周期绑定的 CoroutineScope
// CoroutineContext 可以由 Job() 和 Dispatcher 组成
private val componentScope = CoroutineScope(Job() + Dispatchers.Default)

fun startWork() {
    // 在组件的作用域中启动协程
    componentScope.launch {
        println("组件协程: 开始工作")
        delay(2000)
        println("组件协程: 工作完成")
    }
}

fun destroy() {
    println("组件: 销毁,取消所有协程")
    // 取消 scope,所有子协程都会被取消
    componentScope.cancel()
}

}

fun main() = runBlocking {
val component = MyComponent()
component.startWork()

delay(1000) // 等待一段时间,让协程开始执行

component.destroy() // 销毁组件,协程会被取消

delay(2000) // 再次等待,观察协程是否被取消
println("Main 结束")

}
“`

输出 (可能的顺序):

组件协程: 开始工作
组件: 销毁,取消所有协程
Main 结束

main 函数中,我们启动组件的工作,然后延迟 1 秒,此时协程已经开始并正在 delay(2000) 中。接着我们调用 component.destroy(),这会取消 componentScope,从而取消了该 scope 中启动的协程。因此,组件协程: 工作完成 不会被打印。

4.2 CoroutineContext (协程上下文)

CoroutineContext 是一组元素的集合,定义了协程的执行环境。它可以看作是一个键值对的集合,每个元素都是一个实现了 CoroutineContext.Element 接口的对象。

常用的上下文元素包括:

  • Job 管理协程的生命周期,负责取消和父子协程关系。每个协程都有一个 Job。协程的 Job 是其父 Job 的子 Job(结构化并发的基础)。
  • CoroutineDispatcher 决定协程在哪个线程或线程池上运行。协程可以在运行时通过 withContext 切换调度器。
  • CoroutineName 协程的名称,用于调试。
  • CoroutineExceptionHandler 处理未捕获的异常。

组合上下文:

可以使用 + 操作符来组合不同的上下文元素。例如:Job() + Dispatchers.Default + CoroutineName("MyCoroutine")

继承上下文:

新启动的协程会继承父协程的上下文元素,除非你在启动时显式覆盖它们。例如,在一个 Scope 中启动的协程会继承该 Scope 的 Job(成为其子 Job)和 Dispatcher。

示例:切换 Dispatcher

“`kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
println(“主线程/协程: ${Thread.currentThread().name}”) // runBlocking 默认使用主线程

// 在不同的调度器上执行耗时操作
val result = withContext(Dispatchers.IO) { // 切换到 IO 调度器(通常是线程池)
    println("IO 协程: ${Thread.currentThread().name}")
    delay(1500) // 模拟 IO 耗时
    "数据加载完成"
}

println("主线程/协程: 回到 ${Thread.currentThread().name},结果: $result")

}
“`

输出 (可能的顺序):

主线程/协程: main @coroutine#1
IO 协程: DefaultDispatcher-worker-1 @coroutine#1
主线程/协程: main @coroutine#1,结果: 数据加载完成

withContext 是一个非常重要的挂起函数。它可以在切换协程上下文(最常用的是切换 Dispatcher)的同时执行一段代码,并在代码块执行完毕后自动将协程切回原先的上下文。这使得在不同线程间切换变得非常简单和安全。

常用的 CoroutineDispatcher

  • Dispatchers.Default 默认调度器,适用于 CPU 密集型任务。使用一个共享的后台线程池,线程数通常等于 CPU 核心数。
  • Dispatchers.IO 适用于执行阻塞的 I/O 操作,如网络请求、文件读写、数据库访问等。使用一个根据需要创建新线程的线程池(最大线程数较高)。
  • Dispatchers.Main 主线程调度器,通常用于更新 UI(如 Android)。在不同的平台上有不同的实现(例如,Android 上绑定到主 Looper)。注意: 在 JVM 控制台应用中,Dispatchers.Main 默认是未实现的,需要添加额外的依赖。
  • Dispatchers.Unconfined 不限制协程在特定线程。协程会从调用它的线程开始执行,并在第一个挂起点之后,在恢复它的线程上继续执行。不建议在普通代码中使用,适用于一些特殊的高级场景。

4.3 结构化并发 (Structured Concurrency)

结构化并发是协程的一个核心设计原则,它通过父子协程关系来管理协程的生命周期。

  • 父子关系: 当在一个 CoroutineScope 中使用 launchasync 启动一个新的协程时,新协程的 Job 会成为该 Scope 的 Job 的子 Job。
  • 生命周期绑定: 子协程的生命周期与其父协程绑定。
    • 父协程等待所有子协程完成。一个父 Job 在其所有子 Job 完成之前不会进入完成状态。
    • 父协程被取消时,所有子协程都会被递归取消。
    • 子协程的异常处理:当子协程失败时,它会通知父协程,父协程可能会因此被取消(取决于配置的 CoroutineExceptionHandler 或 SupervisorJob)。

这种父子层次结构确保了协程不会“丢失”,它们的生命周期是可预测和可管理的。当一个操作(由一个父协程代表)需要启动多个子任务(由子协程代表)时,结构化并发保证了当父操作结束时,所有相关的子任务都会被正确处理(要么完成,要么被取消)。

例如,在一个 Activity 中启动多个网络请求协程,当 Activity 销毁时,取消 Activity 绑定的 Scope,所有网络请求协程都会被自动取消,避免了内存泄露和不必要的资源消耗。


第五章:协程的取消 (Cancellation)

协程的取消是协作式的。这意味着协程并不会在任何时候都可以被强制终止,它需要在代码中主动或被动地支持取消。大多数 Kotlin 协程提供的挂起函数(如 delay, withContext, 所有的 IO 操作等)都是“可取消的”(Cancellable)。当协程在调用这些可取消的挂起函数时被取消,它们会抛出 CancellationException

5.1 如何触发取消

  • 调用 Job.cancel() 方法。
  • 取消父 Job 会导致所有子 Job 被递归取消。
  • 在一个 coroutineScope 块中,如果一个子协程失败并抛出异常,整个 coroutineScope 会被取消。
  • async 构建器中,如果调用 await() 时协程已经被取消,await() 会抛出 CancellationException

5.2 协作式取消

如果一个协程正在执行一个计算密集型的非挂起代码块,它不会自动检查取消状态,即使其 Job 已经被取消,它也会一直运行直到计算完成。为了使协程能够被取消,它必须:

  • 调用一个可取消的挂起函数:delay()。这是最常见的方式。
  • 定期检查 Job.isActive 属性: 在长时间运行的非挂起代码中使用 if (!isActive) returnif (!isActive) throw CancellationException()
  • 使用 ensureActive() 这是 isActive 检查和在不活跃时抛出 CancellationException 的快捷方式。
  • 使用 yield() 这是一个挂起函数,它会暂停当前协程并让出执行权,同时检查协程的取消状态。

示例:协作式取消

“`kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) { // 在默认调度器上启动
var nextPrintTime = startTime
var i = 0
while (isActive) { // 检查 isActive 状态
// 每隔 500ms 打印一次
if (System.currentTimeMillis() >= nextPrintTime) {
println(“协程: 正在执行计算 $i …”)
i++
nextPrintTime += 500L
}
// yield() 也可以让出执行权并检查取消状态
// yield()
}
println(“协程: 循环结束 (isActive = false)”)
}

delay(1300) // 等待一段时间
println("主协程: 等待够了,取消!")
job.cancelAndJoin() // 取消 Job 并等待其完成 (join 会等待取消后的清理工作)
println("主协程: 结束")

}
“`

输出 (可能的顺序):

协程: 正在执行计算 0 ...
协程: 正在执行计算 1 ...
协程: 正在执行计算 2 ...
主协程: 等待够了,取消!
协程: 循环结束 (isActive = false)
主协程: 结束

在上面的例子中,while(isActive) 检查使得协程能够在 delay(1300) 结束后被取消。如果没有 isActive 检查,协程会一直在 while 循环中运行,直到 runBlocking 的父 Job 完成(这通常意味着直到应用程序结束,如果是在 main 函数中)。

5.3 取消后的清理工作

当协程被取消并抛出 CancellationException 时,它会像处理其他异常一样沿着调用链向上冒泡。通常,你需要在协程被取消时执行一些清理工作(如关闭文件句柄、释放资源)。

可以使用标准的 Kotlin 异常处理机制 (try/catch/finally) 来执行清理:

“`kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
val job = launch {
try {
repeat(1000) { i ->
println(“协程: 正在运行 $i …”)
delay(500) // 可取消的挂起函数
}
} catch (e: CancellationException) {
println(“协程: Caught CancellationException”)
} finally {
// 清理工作,不论正常完成还是取消都会执行
println(“协程: 执行 finally 清理”)
}
}

delay(1300)
println("主协程: 取消 Job!")
job.cancelAndJoin() // 等待取消和清理完成
println("主协程: 结束")

}
“`

输出:

协程: 正在运行 0 ...
协程: 正在运行 1 ...
协程: 正在运行 2 ...
主协程: 取消 Job!
协程: Caught CancellationException
协程: 执行 finally 清理
主协程: 结束

注意,在 finally 块中执行挂起函数可能会有问题,因为协程此时已经处于取消状态。如果你需要在 finally 中执行挂起操作(例如,关闭一个挂起的资源),可以使用 withContext(NonCancellable) 来临时切换到一个不会被取消的上下文:

“`kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
val job = launch {
try {
delay(2000)
} finally {
// 在 NonCancellable 上下文中执行挂起清理
withContext(NonCancellable) {
println(“协程: 在 NonCancellable 中执行清理…”)
delay(1000) // 这个 delay 不会被取消
println(“协程: 清理完成”)
}
}
}

delay(500)
println("主协程: 取消 Job!")
job.cancelAndJoin()
println("主协程: 结束")

}
“`

输出:

主协程: 取消 Job!
协程: 在 NonCancellable 中执行清理...
协程: 清理完成
主协程: 结束


第六章:协程中的并发与并行

理解并发(Concurrency)和并行(Parallelism)在协程语境下的含义很重要。

  • 并发 (Concurrency): 多个任务在同一时间段内交替执行,宏观上看起来是同时进行的,但微观上在单核 CPU 上仍然是顺序执行,通过任务间的快速切换来实现。协程的挂起和恢复机制使得在单个线程内实现并发成为可能。
  • 并行 (Parallelism): 多个任务在同一时间点上同时执行,需要多核 CPU 或多台计算机。协程可以通过使用支持多线程的 CoroutineDispatcher(如 Dispatchers.DefaultDispatchers.IO)来实现并行。

协程如何实现并发和并行?

  • 并发 (单线程): 可以在同一个单线程调度器(如 Dispatchers.Main on Android UI thread)上启动多个协程。当一个协程挂起时,调度器可以切换到该线程上的另一个等待执行的协程。这使得 UI 线程可以处理多个异步 UI 更新或事件,而不会阻塞。
  • 并行 (多线程): 可以使用 Dispatchers.DefaultDispatchers.IO 在线程池上启动多个协程。不同的协程可能在不同的线程上运行,从而实现真正的并行执行。

示例:使用 async 实现并行

我们在第三章中展示的 async 例子就是一个典型的并行示例,因为它使用了默认的调度器 (Dispatchers.Default,它是一个线程池) 来同时执行两个 suspend 函数。

“`kotlin
import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis

suspend fun loadData1(): String {
println(“加载数据1 开始…”)
delay(2000)
println(“加载数据1 结束.”)
return “Data 1”
}

suspend fun loadData2(): String {
println(“加载数据2 开始…”)
delay(1500)
println(“加载数据2 结束.”)
return “Data 2”
}

fun main() = runBlocking {
val time = measureTimeMillis {
val deferred1 = async { loadData1() } // 在 Dispatchers.Default (或继承的) 上启动
val deferred2 = async { loadData2() } // 在 Dispatchers.Default (或继承的) 上启动

    val data1 = deferred1.await()
    val data2 = deferred2.await()

    println("合并数据: $data1 和 $data2")
}
println("总耗时: $time ms")

}
“`

如前所述,由于 async 通常会在线程池上启动协程,这两个数据加载任务可以并行执行。总耗时会接近两个任务中最长耗时那个,而不是两者相加。

通过合理地选择 CoroutineDispatcher 并结合 launchasync,我们可以轻松地在协程中实现复杂的并发和并行控制。


第七章:常见误区与实用提示

在使用 Kotlin 协程时,有一些常见的误区和最佳实践需要注意:

  1. 在主线程/UI 线程上阻塞: 绝对不要 在负责处理用户交互或响应请求的线程上调用阻塞函数或 runBlocking。这会导致应用无响应(ANR – Application Not Responding)。耗时操作应该切换到合适的后台调度器 (Dispatchers.IODispatchers.Default) 上执行,使用 withContext 是最优雅的方式。
  2. 滥用 GlobalScope GlobalScope 启动的协程生命周期与应用绑定,不受控制,难以取消,容易导致资源泄露。应尽量使用结构化并发,在适当的 CoroutineScope 中启动协程。
  3. 忽略 Job 的生命周期: Job 是协程生命周期的句柄。在启动协程后,如果你需要管理它的生命周期(如取消、等待),要保留返回的 JobDeferred 对象。
  4. 不处理异常: 未处理的协程异常可能会导致应用崩溃。对于 launch 启动的顶层协程,可以使用 CoroutineExceptionHandler。对于 async,异常会在调用 await() 时抛出,需要使用 try/catch 处理。在结构化并发中,子协程的异常通常会传播给父协程。
  5. 在取消后执行阻塞或非协作式代码: 在协程的 finally 块中或取消后继续执行长时间运行的非挂起代码时要小心。如果需要执行挂起清理,使用 withContext(NonCancellable)。确保长时间的计算循环有 isActive 检查或 yield() 调用。
  6. 混淆 launchasync 根据是否需要获取异步操作的结果来选择。如果需要结果,使用 asyncawait();如果只是执行一个任务而不需要等待结果,使用 launch
  7. 过度使用 runBlocking runBlocking 主要用于测试或 main 函数,不应用于普通的业务逻辑或组件代码。
  8. 不理解 CoroutineScope 的传播: 在某个 CoroutineScope 中启动的子协程会自动继承父 Scope 的 JobCoroutineContext(除非覆盖)。理解这种传播有助于正确地组织协程。

实用提示:

  • 使用 Job.cancelAndJoin() 取消并等待协程完成清理。
  • 利用 CoroutineName 为协程命名,方便调试。
  • 在 Android 开发中,使用 Jetpack 提供的 lifecycle-runtime-ktxlifecycle-viewmodel-ktx 库,它们提供了绑定到组件生命周期的 CoroutineScope(如 lifecycleScopeviewModelScope)。
  • 对于复杂的异步流处理,考虑使用 Kotlin Flow。
  • 学习如何使用 CoroutineExceptionHandler 来统一处理协程中的异常。

第八章:总结与展望

至此,我们已经详细探讨了 Kotlin 协程的基本概念:

  • 理解了协程作为轻量级用户空间调度单位的本质,以及它与线程的区别。
  • 掌握了 suspend 关键字的含义和作用,它是实现协程非阻塞挂起的基石。
  • 学习了 runBlockinglaunchasync 这三个核心协程构建器,知道如何启动协程以及它们各自的用途和返回值。
  • 深入理解了 CoroutineScopeCoroutineContext 的作用,它们是管理协程生命周期、控制执行环境和实现结构化并发的关键。
  • 了解了协程的协作式取消机制,以及如何编写可取消的协程和进行取消后的清理。
  • 区分了协程中的并发与并行,并知道如何通过调度器来实现它们。
  • 认识了一些常见的协程使用误区,并学习了相应的最佳实践。

Kotlin 协程为异步编程提供了一种强大、简洁且富有表现力的方式。通过将复杂的异步逻辑编写成看起来像同步顺序执行的代码,它显著提高了代码的可读性和可维护性,并有效地解决了传统回调地狱和线程管理难题。结构化并发更是协程的一大亮点,它帮助我们更安全地管理协程的生命周期,避免资源泄露。

掌握了这些基本概念,你就已经为使用 Kotlin 协程处理各种异步任务打下了坚实的基础。然而,协程的世界还有更多精彩的内容等待你去探索,例如:

  • 协程间的通信: 通道 (Channels)
  • 处理数据流: 协程流 (Flow)
  • 更复杂的并发模式: Select 表达式
  • 协程的单元测试
  • 底层的实现细节

这些高级概念可以帮助你解决更复杂的异步编程场景。

Kotlin 协程是现代 Kotlin 开发中不可或缺的一部分,特别是在 Android、后端开发以及任何需要处理大量异步操作的领域。投入时间学习和实践协程,无疑将大大提升你的开发效率和代码质量。

现在,是时候动手实践了!尝试在你的项目中使用协程来重构现有的异步代码,或者用协程来实现新的异步功能,在实践中加深理解。祝你在协程的学习之旅中取得成功!


发表评论

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

滚动至顶部