Kotlin 协程入门:一篇搞懂基本概念
引言
在现代软件开发中,异步编程变得越来越普遍和重要。无论是处理网络请求、访问数据库,还是进行复杂的计算,我们都需要一种有效的方式来执行这些可能耗时的操作,而不会阻塞主线程(例如,在 Android 开发中阻塞 UI 线程会导致应用无响应)。
传统的异步编程方式,如多线程、回调函数、Future/Promise 等,虽然能够解决问题,但也带来了各自的挑战:
- 多线程: 创建和管理线程的开销较大,线程间的通信和同步复杂,容易引发死锁、竞态条件等问题,调试困难。
- 回调函数: 导致“回调地狱”(Callback Hell),代码可读性差,难以维护和理解。
- Future/Promise: 链式调用虽然改善了回调地狱,但在错误处理和组合复杂异步操作时仍然不够直观。
Kotlin 协程(Coroutines)作为一种更现代、更简洁、更强大的异步编程解决方案应运而生。它允许我们以看起来像同步阻塞代码的方式编写异步非阻塞代码,极大地提高了代码的可读性和可维护性。协程是 Kotlin 语言层面的支持,而非依赖于特定的操作系统或 JVM 特性,因此它具有跨平台的能力。
本篇文章将带你深入了解 Kotlin 协程的基本概念,包括:
- 协程到底是什么?它与线程有何不同?
- 协程的核心:挂起(Suspension)
- 如何启动协程:协程构建器(Coroutine Builders)
- 协程的生命周期与执行环境:协程作用域与上下文(Coroutine Scope & Context)
- 结构化并发(Structured Concurrency)
- 协程的取消(Cancellation)
- 协程中的并发与并行
- 一些常见误区与实用提示
通过理解这些基本概念,你将能够迈出掌握 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 编译器会进行一些转换:
- 保存状态: 当前协程执行到挂起点之前的所有必要信息(局部变量、程序计数器等)会被打包到一个被称为“Continuation”(续体)的对象中。这个 Contination 代表了协程“接下来应该怎么做”的状态。
- 让出执行权: 协程将控制权交还给它的调用者(可能是另一个协程,也可能是协程构建器)。如果这个挂起操作涉及到等待(如
delay
),那么协程所在的线程就可以去做其他事情,而不是等待。 - 恢复执行: 当挂起的操作完成(例如,
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
上调用的特殊函数,它们用于创建并启动新的协程。最常用的协程构建器有 runBlocking
、launch
和 async
。
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 (取决于调度器开销)
在这个例子中,task1
和 task2
是几乎同时开始执行的。由于 task2
需要的时间更长 (1500ms),整个过程的总耗时大约是 1500ms,而不是 1000ms + 1500ms = 2500ms。这展示了 async
的并行执行能力(在多核环境下,如果调度器使用线程池,它们可以在不同的线程上并行运行)。
launch
vs async
总结:
- 目的:
launch
用于启动不需要返回结果的协程(fire and forget),async
用于启动需要返回结果的协程。 - 返回值:
launch
返回Job
,async
返回Deferred<T>
。 - 异常处理:
launch
内部的未捕获异常会直接抛出(通常导致应用崩溃或被CoroutineExceptionHandler
捕获)。async
内部的异常会存储在Deferred
对象中,只有在调用await()
时才会重新抛出。这是它们重要的区别之一。
第四章:管理协程的范围和上下文 (Coroutine Scope & Context)
在协程中,有效地管理它们的生命周期和执行环境至关重要,这有助于避免资源泄露、确保异常被正确处理以及实现结构化并发。CoroutineScope
和 CoroutineContext
就是用于此目的的两个核心概念。
4.1 CoroutineScope
(协程作用域)
CoroutineScope
定义了协程的生命周期。协程只能在 CoroutineScope
中启动。当一个 CoroutineScope
被取消时,所有在其内部通过 launch
或 async
启动的协程也会被自动取消。这被称为“结构化并发”(Structured Concurrency)。
-
作用:
- 提供了一个生命周期相关的环境来启动协程。
- 通过结构化并发确保所有子协程都能被跟踪和管理。
- 将协程的生命周期与应用程序的特定部分(如 Activity, ViewModel, Presenter 等)关联起来,以便在其生命周期结束时自动取消协程。
-
如何获取
CoroutineScope
:- 全局作用域:
GlobalScope
。不推荐在应用代码中直接使用GlobalScope
,因为它不受任何父 Job 控制,生命周期与整个应用一致,难以管理和取消,容易导致资源泄露。 - 自定义作用域: 可以通过
CoroutineScope(context)
创建自定义的CoroutineScope
。这通常用于将协程的生命周期绑定到特定的组件。 - 由构建器提供:
runBlocking
和coroutineScope
(一个挂起函数构建器,用于在现有协程中创建子范围)提供了自己的CoroutineScope
。launch
和async
等构建器是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
中使用launch
或async
启动一个新的协程时,新协程的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) return
或if (!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.Default
或Dispatchers.IO
)来实现并行。
协程如何实现并发和并行?
- 并发 (单线程): 可以在同一个单线程调度器(如
Dispatchers.Main
on Android UI thread)上启动多个协程。当一个协程挂起时,调度器可以切换到该线程上的另一个等待执行的协程。这使得 UI 线程可以处理多个异步 UI 更新或事件,而不会阻塞。 - 并行 (多线程): 可以使用
Dispatchers.Default
或Dispatchers.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
并结合 launch
和 async
,我们可以轻松地在协程中实现复杂的并发和并行控制。
第七章:常见误区与实用提示
在使用 Kotlin 协程时,有一些常见的误区和最佳实践需要注意:
- 在主线程/UI 线程上阻塞: 绝对不要 在负责处理用户交互或响应请求的线程上调用阻塞函数或
runBlocking
。这会导致应用无响应(ANR – Application Not Responding)。耗时操作应该切换到合适的后台调度器 (Dispatchers.IO
或Dispatchers.Default
) 上执行,使用withContext
是最优雅的方式。 - 滥用
GlobalScope
:GlobalScope
启动的协程生命周期与应用绑定,不受控制,难以取消,容易导致资源泄露。应尽量使用结构化并发,在适当的CoroutineScope
中启动协程。 - 忽略
Job
的生命周期:Job
是协程生命周期的句柄。在启动协程后,如果你需要管理它的生命周期(如取消、等待),要保留返回的Job
或Deferred
对象。 - 不处理异常: 未处理的协程异常可能会导致应用崩溃。对于
launch
启动的顶层协程,可以使用CoroutineExceptionHandler
。对于async
,异常会在调用await()
时抛出,需要使用try/catch
处理。在结构化并发中,子协程的异常通常会传播给父协程。 - 在取消后执行阻塞或非协作式代码: 在协程的
finally
块中或取消后继续执行长时间运行的非挂起代码时要小心。如果需要执行挂起清理,使用withContext(NonCancellable)
。确保长时间的计算循环有isActive
检查或yield()
调用。 - 混淆
launch
和async
: 根据是否需要获取异步操作的结果来选择。如果需要结果,使用async
和await()
;如果只是执行一个任务而不需要等待结果,使用launch
。 - 过度使用
runBlocking
:runBlocking
主要用于测试或main
函数,不应用于普通的业务逻辑或组件代码。 - 不理解
CoroutineScope
的传播: 在某个CoroutineScope
中启动的子协程会自动继承父 Scope 的Job
和CoroutineContext
(除非覆盖)。理解这种传播有助于正确地组织协程。
实用提示:
- 使用
Job.cancelAndJoin()
取消并等待协程完成清理。 - 利用
CoroutineName
为协程命名,方便调试。 - 在 Android 开发中,使用 Jetpack 提供的
lifecycle-runtime-ktx
和lifecycle-viewmodel-ktx
库,它们提供了绑定到组件生命周期的CoroutineScope
(如lifecycleScope
和viewModelScope
)。 - 对于复杂的异步流处理,考虑使用 Kotlin Flow。
- 学习如何使用
CoroutineExceptionHandler
来统一处理协程中的异常。
第八章:总结与展望
至此,我们已经详细探讨了 Kotlin 协程的基本概念:
- 理解了协程作为轻量级用户空间调度单位的本质,以及它与线程的区别。
- 掌握了
suspend
关键字的含义和作用,它是实现协程非阻塞挂起的基石。 - 学习了
runBlocking
、launch
和async
这三个核心协程构建器,知道如何启动协程以及它们各自的用途和返回值。 - 深入理解了
CoroutineScope
和CoroutineContext
的作用,它们是管理协程生命周期、控制执行环境和实现结构化并发的关键。 - 了解了协程的协作式取消机制,以及如何编写可取消的协程和进行取消后的清理。
- 区分了协程中的并发与并行,并知道如何通过调度器来实现它们。
- 认识了一些常见的协程使用误区,并学习了相应的最佳实践。
Kotlin 协程为异步编程提供了一种强大、简洁且富有表现力的方式。通过将复杂的异步逻辑编写成看起来像同步顺序执行的代码,它显著提高了代码的可读性和可维护性,并有效地解决了传统回调地狱和线程管理难题。结构化并发更是协程的一大亮点,它帮助我们更安全地管理协程的生命周期,避免资源泄露。
掌握了这些基本概念,你就已经为使用 Kotlin 协程处理各种异步任务打下了坚实的基础。然而,协程的世界还有更多精彩的内容等待你去探索,例如:
- 协程间的通信: 通道 (Channels)
- 处理数据流: 协程流 (Flow)
- 更复杂的并发模式: Select 表达式
- 协程的单元测试
- 底层的实现细节
这些高级概念可以帮助你解决更复杂的异步编程场景。
Kotlin 协程是现代 Kotlin 开发中不可或缺的一部分,特别是在 Android、后端开发以及任何需要处理大量异步操作的领域。投入时间学习和实践协程,无疑将大大提升你的开发效率和代码质量。
现在,是时候动手实践了!尝试在你的项目中使用协程来重构现有的异步代码,或者用协程来实现新的异步功能,在实践中加深理解。祝你在协程的学习之旅中取得成功!