一、CoroutineContext:协程的"身份证"
1.1 什么是 CoroutineContext?
每个协程都有一个 CoroutineContext(协程上下文) ,它是一组配置信息的集合,定义了协程的行为。可以理解成协程的"身份证"。
CoroutineContext 主要包含四类元素:
| 元素 | 作用 |
|---|---|
Job |
控制协程的生命周期(启动、取消、等待) |
CoroutineDispatcher |
决定协程在哪个线程/线程池上执行 |
CoroutineName |
协程的名字,调试用 |
CoroutineExceptionHandler |
处理未捕获的异常 |
💡 注意 :协程上下文是一个"索引集合",每个元素都有唯一的键(
Key),可以通过键来获取对应的元素。
1.2 使用 + 组合上下文
CoroutineContext 支持用 + 操作符合并,右边的元素会覆盖左边同类型的元素:
kotlin
val ctx1 = Dispatchers.IO + CoroutineName("NetworkTask")
val ctx2 = ctx1 + Dispatchers.Default // 覆盖了 Dispatcher
1.3 协程上下文的继承规则
协程启动时,新协程的上下文 = 默认值 + 父协程上下文 + 显式参数。
重要原则:
Job总是新创建的 (每个协程有自己的Job),但新Job会被加入父Job的子列表中。- 其他元素(Dispatcher、Name、ExceptionHandler)默认从父协程继承。
- 如果显式指定了某个元素,则覆盖继承的同名元素。
kotlin
val parentScope = CoroutineScope(Dispatchers.Main + CoroutineName("Parent"))
parentScope.launch { // 子协程上下文:Main + Parent(Job 是新创建的)
launch(Dispatchers.IO) { // 孙协程:IO + Parent(Dispatcher 被覆盖)
}
}
二、Dispatchers:调度器深度解析
调度器决定协程在哪个线程或线程池上执行。Kotlin 提供了四个内置调度器。
2.1 Dispatchers.Main:主线程
kotlin
viewModelScope.launch(Dispatchers.Main) {
binding.tvResult.text = "Hello" // Android 主线程(UI 线程)
}
- 更新 UI
- 调用 Android 框架的方法(大部分都要在主线程)
- 运行轻量、快速的任务
⚠️ 注意:如果在单元测试中使用
Dispatchers.Main,必须先调用Dispatchers.setMain(Dispatchers.Unconfined)。
2.2 Dispatchers.IO:IO 密集型任务
kotlin
withContext(Dispatchers.IO) {
val data = api.fetchData() // 网络请求
fileWriter.write(data) // 文件 IO
}
适用场景:
- 网络请求
- 数据库读写(Room、SQLite)
- 文件 IO
- 任何阻塞 IO 操作
核心特性:
- 默认线程池大小 =
max(64, CPU 核数) - 适合会阻塞的 IO 操作(这些操作会让线程闲着等数据)
- 当一个线程被阻塞时,IO 调度器会动态扩展线程池
2.3 Dispatchers.Default:CPU 密集型任务
kotlin
withContext(Dispatchers.Default) {
val sorted = bigList.sortedBy { it.score } // 列表排序
val parsed = JSONObject(jsonStr) // JSON 解析
}
适用场景:
- 列表排序
- JSON 解析
- 图片处理(Bitmap 操作)
- 复杂计算
- 任何"占满 CPU"的任务
核心特性:
- 默认线程池大小 = CPU 核数(最少 2)
- 因为 CPU 任务"满载跑",再多线程也没用,反而增加上下文切换开销
2.4 IO vs Default -- 到底怎么选?
⚠️ 注意:面试高频题,记住口诀: "等的活用 IO,算的活用 Default"
| 特征 | IO | Default |
|---|---|---|
| 任务特点 | 阻塞等待(IO) | 消耗 CPU(计算) |
| 线程数 | 多(可到 64+) | 少(CPU 核数) |
| 如果选错了 | CPU 密集型在 IO 也会跑,但可能因线程切换增加开销 | IO 密集型在 Default 会因线程数不足造成排队 |
底层冷知识 :Kotlin 1.6+ 之后,Dispatchers.IO 和 Dispatchers.Default 共享同一个线程池,只是最大并发数不同。因此在大多数情况下,切换开销很低。但语义上仍应按照原则选择,让代码意图清晰。
2.5 Dispatchers.Unconfined -- 不受约束(少用)
- 启动时在调用者线程运行,遇到第一个挂起点后,在哪个线程恢复就继续在哪个线程运行。
- 风险:容易造成线程切换混乱,通常用于测试或极特殊的库代码。
kotlin
// 不推荐在业务代码中使用
launch(Dispatchers.Unconfined) {
println("Before suspend: ${Thread.currentThread().name}") // main
delay(100)
println("After suspend: ${Thread.currentThread().name}") // kotlinx.coroutines.DefaultExecutor
}
2.6 Dispatchers.Main.immediate -- 性能优化
Dispatchers.Main.immediate 是 Dispatchers.Main 的性能优化版本。区别在于:
Dispatchers.Main:无论当前在哪个线程,都会总是通过消息队列调度到主线程执行(有一次 dispatch 开销)Dispatchers.Main.immediate:如果当前已经在主线程,就直接执行,不再经过调度器;否则才通过消息队列调度
换句话说:
immediate会检查当前线程,如果"已经在家",就直接干活,省去了排队上楼的步骤。
使用场景 :在主线程上调用挂起函数时,避免多余的 dispatch 开销。如果你不确定当前在哪个线程,Dispatchers.Main.immediate 是比 Dispatchers.Main 更好的默认选择。
- 行为:如果当前已经在主线程,则立即执行代码块,不再经过调度器;否则就通过消息队列调度。
- 优点:避免不必要的 dispatch 开销,尤其适合从主线程调用自己的场景。
kotlin
// 假设当前已在主线程
withContext(Dispatchers.Main.immediate) {
// 这里会立即执行,不会走调度队列
}
2.7 自定义调度器
你可以基于现有线程池或创建新的线程池来生成调度器:
kotlin
// 限定并发数为 1 的 IO 调度器(串行处理)
val singleIo = Dispatchers.IO.limitedParallelism(1)
// 从 Java 线程池创建
val myDispatcher = Executors.newFixedThreadPool(4).asCoroutineDispatcher()
// ⚠️ 记得关闭:myDispatcher.close()
⚠️ 重要 :自定义线程池用完一定要
myDispatcher.close(),否则线程不会释放。
三、withContext:切换上下文的核心
3.1 withContext 的本质
withContext 是一个挂起函数,它把一段代码切换到指定的上下文执行:
kotlin
suspend fun fetchAndParse(): Result {
// 假设当前在 Main
val raw = withContext(Dispatchers.IO) {
api.fetchRaw() // ← 切换到 IO 线程执行
}
val parsed = withContext(Dispatchers.Default) {
parseJson(raw) // ← 切换到 Default 线程执行
}
return parsed // ← 自动切回原来的上下文(Main)
}
特点:
- 切换到指定调度器执行
block block执行完,自动切回调用时的上下文block的返回值就是withContext的返回值- 跟基于回调的方案相比,没有额外开销
3.2 main-safety:让函数调用方不操心线程
Android 官方推崇的最佳实践之一:让每个挂起函数都是 main-safe(主线程安全)的。
kotlin
class Repository {
// ❌ 这个函数从主线程调用会卡 UI
suspend fun getDocs(): String {
return URL("...").readText() // 阻塞操作,但在主线程执行!
}
// ✅ main-safe 版本
suspend fun getDocsMainSafe(): String = withContext(Dispatchers.IO) {
URL("...").readText() // 明确在 IO 线程执行
}
}
核心思想:挂起函数应该自己负责将耗时操作切换到合适的线程,让调用方可以在主线程直接调用,不用担心阻塞 UI。
3.3 withContext 不阻塞,跟 runBlocking 完全不同
runBlocking:阻塞当前线程,直到内部协程完成withContext:挂起 当前协程,不阻塞底层线程
kotlin
// ❌ runBlocking 会阻塞线程
fun test() = runBlocking {
delay(1000)
}
// ✅ withContext 只是挂起,不阻塞
suspend fun test2() = withContext(Dispatchers.IO) {
delay(1000)
}
3.4 嵌套 withContext 的优化
withContext 是挂起函数,可以嵌套使用,但要注意性能:
kotlin
// ❌ 多次切换,每次 withContext 都有调度开销
suspend fun process() {
val a = withContext(Dispatchers.IO) { fetchA() }
val b = withContext(Dispatchers.IO) { fetchB() }
val c = withContext(Dispatchers.IO) { fetchC() }
}
// ✅ 用 withContext 包一层,只在开始切一次
suspend fun process() = withContext(Dispatchers.IO) {
val a = fetchA() // 已经在 IO,不需要再切
val b = fetchB()
val c = fetchC()
}
不过,上面的 ❌ 示例也没那么糟糕,因为 Dispatchers.IO 和 Dispatchers.Default 共享同一个线程池后端,实际调度开销很小。但从代码可读性来说,第二种写法更清晰。
四、Job:上下文中的生命周期句柄
Job 不仅是 launch 的返回值,也是 CoroutineContext 的核心元素之一,它在上下文里扮演"生命周期控制器"的角色。
kotlin
val job: Job = viewModelScope.launch {
delay(1000)
println("Done")
}
// 状态查询
job.isActive // 是否活跃中
job.isCompleted // 是否已完成
job.isCancelled // 是否被取消
// 生命周期操作
job.cancel() // 取消协程
job.cancelAndJoin() // 取消并等待完成
job.join() // 挂起等待完成(不取消)
job.invokeOnCompletion { e -> } // 完成时回调
// 从上下文中获取当前协程的 Job
val currentJob = coroutineContext[Job]
Job 之间通过父子关系形成层级,这是结构化并发的基础------父 Job 取消会级联取消所有子 Job,父 Job 会等所有子 Job 完成才结束。
Job的层级关系、取消传播,以及JobvsSupervisorJob的区别,是结构化并发的核心内容。记住:Job 是协程在上下文里的"生命周期句柄",每个协程都有自己独立的一个。
五、协程取消(Cancellation)------ 协作式机制
5.1 协作式取消的含义
协程的取消是协作式 的:job.cancel() 只是设置了一个"取消标志",协程本身需要主动检查这个标志才能停止。如果协程内部没有检查点,取消操作就无法生效。
kotlin
val job = scope.launch {
var i = 0
while (i < 1_000_000) { // ⚠️ 没有挂起点,也没有检查 isActive
i++
}
}
delay(100)
job.cancel() // 无效!协程不会停止
5.2 让协程可取消的两种方法
方法一:使用挂起函数(自动响应)
大多数挂起函数(delay、withContext、channel.receive 等)会在挂起点自动检查取消状态,发现已取消会抛出 CancellationException。
kotlin
launch {
repeat(1000) {
delay(500) // ✅ 每次 delay 都会检查取消
println("Running")
}
}
方法二:手动检查 isActive 或调用 ensureActive()
kotlin
launch(Dispatchers.Default) {
var i = 0
while (i < 1_000_000 && isActive) { // 手动检查标志
i++
}
// 或者使用 ensureActive()
ensureActive()
}
5.3 在 finally 中清理资源
协程被取消时,finally 块仍然会执行,所以可以安全地释放资源。
kotlin
val job = scope.launch {
val stream = openFile()
try {
process(stream)
} finally {
stream.close() // ✅ 即使被取消也会关闭
}
}
5.4 在 finally 里不能调用挂起函数
协程取消后,内部挂起函数通常无法正常执行(会直接抛出 CancellationException)。如果必须在清理时调用挂起函数,需要使用 NonCancellable 上下文。
kotlin
val job = scope.launch {
try {
work()
} finally {
withContext(NonCancellable) {
// 这个挂起函数会忽略取消状态,确保执行完成
saveLogs()
}
}
}
5.5 中断阻塞代码:runInterruptible
对于传统的阻塞代码(如 Thread.sleep()、InputStream.read()),协程无法通过协作式取消来打断。runInterruptible 可以将这些阻塞代码包装成可中断的。
kotlin
suspend fun readBlocking() = withContext(Dispatchers.IO) {
runInterruptible {
// 当协程被取消时,此行会抛出 InterruptedException 并转为 CancellationException
Thread.sleep(10000)
}
}
注意:
runInterruptible依赖于底层对中断的响应。某些阻塞操作(如不检查中断标志的死循环)可能仍然无法中断。
5.6 CancellationException 是特殊的异常
CancellationException是用来实现取消的正常控制流,不应该被当作"业务异常"处理。- 通常情况下,你不需要捕获
CancellationException;如果捕获了,应该重新抛出它,避免破坏取消机制。
kotlin
try {
doWork()
} catch (e: CancellationException) {
// 如果是取消导致的异常,通常应该重新抛出
throw e
} catch (e: Exception) {
// 处理其他异常
}
5.7 Prompt Cancellation -- 取消时不返回值
withTimeout 或手动取消后,协程框架会尽快终止,但不会返回任何值(实际上会抛出 CancellationException)。如果你的代码中有资源分配,务必使用 try-finally 或 use 来保证释放。
六、超时(Timeout)
6.1 withTimeout -- 超时抛异常
kotlin
try {
val result = withTimeout(1000) {
longRunningOperation()
}
println(result)
} catch (e: TimeoutCancellationException) {
println("操作超时")
}
- 超时后,
withTimeout内部协程会被取消(同样是协作式的)。 - 超时异常是
CancellationException的子类。
6.2 withTimeoutOrNull -- 超时返回 null
kotlin
val result = withTimeoutOrNull(1000) {
longRunningOperation()
}
if (result == null) {
println("超时或失败")
} else {
println(result)
}
- 更优雅,适合不需要抛异常的场景。
6.3 超时时的资源泄漏陷阱
超时本质上就是自动取消,因此可能带来资源泄漏。例如:
kotlin
// ⚠️ 危险:如果超时发生在 allocate() 之后、返回之前,资源就无法释放
val resource = withTimeoutOrNull(100) {
val r = allocateResource() // 假设分配了一些资源
delay(200) // 模拟工作
r
}
// 超时时 resource 为 null,但 allocateResource() 分配的资源没有释放
解决方案 :在内部使用 use 或 try-finally。
kotli
withTimeoutOrNull(100) {
allocateResource().use { r -> // 无论是否超时,use 都会释放
delay(200)
// 使用 r
} // 自动调用 close()
}
七、综合实战:一个 main-safe 的 Repository
kotlin
class UserRepository(
private val api: ApiService,
private val dao: UserDao
) {
// main-safe:内部切换线程
suspend fun getUser(id: String): User = withContext(Dispatchers.IO) {
val cached = dao.getUser(id)
if (cached != null) {
return@withContext cached
}
val remote = api.fetchUser(id)
dao.saveUser(remote)
remote
}
suspend fun loadUserProfile(id: String): Profile? = withTimeoutOrNull(5000) {
coroutineScope {
val user = async { getUser(id) }
val posts = async { api.getPosts(id) }
Profile(user = user.await(), posts = posts.await())
}
}
}
// ViewModel 调用(主线程)
class UserViewModel(private val repo: UserRepository) : ViewModel() {
fun load(id: String) {
viewModelScope.launch(Dispatchers.Main) {
showLoading()
val profile = repo.loadUserProfile(id) // main-safe + 超时保护
if (profile != null) {
showProfile(profile)
} else {
showTimeoutError()
}
hideLoading()
}
}
}
总结速查表
| 概念 | 一句话总结 |
|---|---|
CoroutineContext |
协程的"身份证",包含 Job、调度器、名称等 |
| 上下文继承 | Job 新创建,其他元素从父协程继承,显式传入覆盖 |
Dispatchers.Main |
UI 线程专用 |
Dispatchers.IO |
网络/文件/数据库,适合等待型任务 |
Dispatchers.Default |
排序/解析/计算,适合 CPU 密集型任务 |
Main.immediate |
已在主线程则立即执行,避免调度开销 |
withContext |
切换线程的挂起函数,执行完自动切回 |
| main-safe | 挂起函数内部切换耗时操作,让调用方放心在主线程调用 |
| 协作式取消 | cancel() 只是设标志,协程需主动检查 isActive 或调用挂起函数 |
finally 里挂起 |
需用 withContext(NonCancellable) 包裹 |
runInterruptible |
将阻塞代码变得可取消 |
CancellationException |
取消专用异常,通常不应捕获或应重新抛出 |
withTimeout |
超时抛异常(TimeoutCancellationException) |
withTimeoutOrNull |
超时返回 null,更友好 |
| 超时资源泄漏 | 在超时块内使用 use 或 try-finally 确保释放 |
最容易踩的坑:
- ❌ 用
kotlin.runCatching包协程代码,捕获了CancellationException破坏取消机制 - ❌ 在
finally里调用挂起函数,没用NonCancellable - ❌ CPU 密集循环不检查
isActive/ensureActive,调了cancel()也不停 - ❌ 把
Dispatchers.IO当万能调度器,CPU 任务也丢进去 - ❌ 误以为
withTimeout超时会取消外层父协程(其实只取消它的内部子作用域
掌握了协程上下文与调度器,你就具备了编写健壮、高效、main-safe 的异步代码的能力。