一、协程到底解决了什么问题?
在 Kotlin 出现之前,处理并发最常用的方案是多线程。但多线程编程有一个公认的痛点:回调嵌套。一个网络请求里再发起另一个请求,代码很快会变成"回调地狱",阅读和维护成本都很高。
Kotlin 给出的答案是协程(Coroutine) 。它的核心优势是:让我们能够用顺序、同步的写法来表达异步逻辑。一段典型的协程代码长这样:
scss
val user = api.fetchUser() // 网络请求,挂起而非阻塞
val orders = api.fetchOrders(user.id)
display(orders)
代码从上到下读,就像普通同步代码一样自然,但实际上 fetchUser() 不会卡住线程。
几个需要澄清的说法
关于协程,我听过不少"教科书式"的解释,但有些说法其实并不准确。
说法一:协程比线程更轻量,一个线程能跑成千上万个协程。
这个说法要分场景看。协程确实轻量,但"一个线程上能跑成千上万个协程"并不等于它比线程方案优越多少。线程方案同样可以用线程池 来复用线程,让多个任务共享少量线程。反过来,如果你把每个协程都 launch(Dispatchers.IO),那它最终还是要占用线程资源,也不可能无限创建。所以协程真正的优势不在于"比线程省资源",而在于写法的优雅与可维护性。
说法二:线程是阻塞的,协程是非阻塞的。
这里的"阻塞"需要明确对象。它指的并不是阻塞主线程,而是阻塞执行耗时操作的那个工作线程。
- 线程方案:在子线程里发起网络 I/O,线程会进入等待状态,这段时间内这个线程什么事都做不了。
- 协程方案:遇到 I/O 时,协程会被挂起(suspend) ,但它不会占用线程,线程可以切换去执行其他协程任务。
所以"非阻塞"的含义是:线程资源不被空等浪费,而不是说协程本身不会等待结果。
二、启动协程的几种方式
Kotlin 里启动协程常见有三种方式:launch、async、runBlocking。
| 方式 | 返回值 | 用途 |
|---|---|---|
| launch | Job | 启动一个"fire-and-forget"的任务,不关心结果 |
| async | Deferred | 启动一个会返回结果的任务,通过 await() 获取 |
| runBlocking | T | 阻塞当前线程,把协程代码桥接到普通阻塞代码,常用于 main() 或测试 |
async 的异常,真的是"延迟抛出"吗?
很多资料会说:async 内部抛出的异常不会立即抛出,而是被捕获并封装到 Deferred 中,只有调用 .await() 时才会重新抛出。
这句话只对了一半。
1. 普通 Job 父协程下,async 异常会立即上抛
kotlin
fun main() = runBlocking {
val deferred = async {
throw RuntimeException("出错")
}
// 这里程序已经崩溃,异常传播到了 runBlocking
}
原因正是结构化并发:async 作为子协程,失败会立即取消父协程并抛出异常,不需要等到 await()。
2. 只有 SupervisorJob 下,异常才会被延迟到 await()
kotlin
fun main() = runBlocking {
val supervisor = SupervisorJob()
val scope = CoroutineScope(coroutineContext + supervisor)
val deferred = scope.async {
throw RuntimeException("出错") // 这里不会崩溃
}
deferred.await() // 这里才抛出
}
3. 顶层 / 全局 async,异常也延迟到 await()
kotlin
fun main() {
val deferred = GlobalScope.async {
throw RuntimeException("出错")
}
// 不会立即崩溃
}
await() 会阻塞什么?
await() 会阻塞当前协程的后续代码执行,但它不会阻塞底层线程。当前协程挂起后,线程可以去执行其他协程任务。
三、协程的结构化管理
协程的结构化管理,核心规则就一条:子协程的生命周期受父协程约束,父结束则子取消,子失败则父感知。
这个设计让协程的并发变得可控:你不需要手动跟踪每一个后台任务,只要父协程退出,所有子协程都会被自动清理。
但 SupervisorJob 是一个例外。它让子协程的失败不会向上传播,也不会取消其他兄弟协程。
scss
supervisorScope {
launch {
delay(100)
throw RuntimeException("子1失败")
}
launch {
delay(200)
println("子2完成") // 会执行
}
delay(300)
println("supervisorScope 结束") // 会执行
}
为什么 SupervisorJob 能"隔绝"异常?
在源码层面,普通 Job 的 childCancelled() 方法会返回 true,表示"子协程取消会触发父协程取消"。而 SupervisorJob 内部实现了一个特殊的 Job(SupervisorJobImpl),它重写了关键方法,使得 childCancelled 返回 false,意味着父 Job 不会因为子 Job 失败而进入取消状态。
注意:异常本身并没有消失,它只是不再向上传播。对于 async 来说,异常会被暂存在 Deferred 里,等你调用 await() 时再抛出。
实际应用:viewModelScope
Android 开发中常用的 viewModelScope,内部就使用了 SupervisorJob()。它的构造大致等价于:
scss
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
这也是为什么 ViewModel 里的某个协程崩溃了,不会把整个 ViewModel 的协程作用域一起拖垮。
四、CoroutineExceptionHandler
CoroutineExceptionHandler 是协程提供的全局异常处理器,用于捕获那些没有被 try/catch 或 await() 处理的未捕获异常。
协程抛异常的完整处理流程如下:
csharp
协程抛异常
↓
try/catch 是否捕获? → 是 → 结束
↓ 否
是否是 CancellationException? → 是 → 正常取消流程
↓ 否
async/await 模式? → await() 处抛出
↓ 否
向上传播到 Job 树顶层
↓
调用 CoroutineExceptionHandler
↓
没有 handler → 交给线程 UncaughtExceptionHandler
不要把 handler 装在子协程上
这是一个常见的坑:
scss
val scope = CoroutineScope(Job())
scope.launch {
launch(handler) { // handler 不会生效
throw Exception("失败")
}
}
执行结果是:子协程抛异常 → 因为父 Job 是普通 Job,异常立刻向上传播到父 launch → 父 launch 被取消 → 子协程上配置的 handler 不会被调用 → 最终异常交给 scope 层或线程未捕获异常处理器。
为什么子协程的 handler 不生效?
CoroutineExceptionHandler 的设计逻辑是:只在异常不再需要向上传播、到达"处理终点"时才触发。对于普通 Job 的子协程,异常默认要上报给父协程,所以框架不会在当前子协程层面调用它的 handler,而是继续往上抛。
换句话说,CoroutineExceptionHandler 通常只在根协程或 Scope 级别生效,不是任意子协程加上就生效的。
正确用法是把 handler 装在 scope 上:
kotlin
val handler = CoroutineExceptionHandler { _, exception ->
println("捕获到异常:$exception")
}
val scope = CoroutineScope(Job() + handler)
scope.launch {
throw Exception("失败") // 会被 handler 捕获
}
小结
- 协程最大的价值是以同步风格编写异步代码,而不是单纯比线程省资源。
- async 的异常是否延迟抛出,取决于它的父 Job 类型。普通 Job 下会立即上抛,SupervisorJob 或 GlobalScope 下才会延迟到 await()。
- 协程通过结构化并发管理生命周期,父协程退出会自动取消子协程。
- SupervisorJob 可以隔离子协程异常,常用于 viewModelScope 等需要"局部容错"的场景。
- CoroutineExceptionHandler 应该配置在 scope 或根协程上,而不是子协程上。