3、协程作用域与结构化并发:GlobalScope vs CoroutineScope、结构化并发原则、父子协程的取消与异常传播
协程作用域这个东西,说白了就是给协程一个“家”。没有家,协程就像断了线的风筝,你根本不知道它飞哪去了,什么时候掉下来。我在项目中见过不少新手直接写 GlobalScope.launch,结果 Activity 销毁了协程还在跑,内存泄漏、空指针,各种诡异问题全来了。
嗯,今天我们就来彻底搞懂作用域。我会从最基础的 GlobalScope 和 CoroutineScope 的区别讲起,然后深入到结构化并发的核心原则,最后聊聊父子协程的取消和异常传播——这部分坑最多,我踩过不少。
3.1 GlobalScope vs CoroutineScope:别再用错作用域了
先看一个最简单的对比:
// 错误示范:GlobalScope
GlobalScope.launch {
delay(1000)
// 更新UI?这里可能已经泄漏了
}
// 正确做法:CoroutineScope
class MyActivity : AppCompatActivity() {
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
fun doSomething() {
scope.launch {
delay(1000)
// 安全更新UI
}
}
override fun onDestroy() {
super.onDestroy()
scope.cancel() // 统一取消
}
}
GlobalScope 的问题在哪?
- 它的生命周期跟整个应用一样长
- 你无法控制它何时结束
- 它不遵循结构化并发原则
我曾经在一个项目里看到同事用 GlobalScope 做网络请求,Activity 退出了,请求还在跑。回调回来时 Activity 已经销毁,直接空指针崩溃。从那以后,我团队里就立了个规矩:禁止在业务代码中使用 GlobalScope。
3.2 结构化并发原则:协程的“家族树”
结构化并发,说白了就是让协程像函数调用一样有清晰的层级关系。你想想看,一个函数调用另一个函数,调用完了就返回,异常了也能捕获。协程也应该这样。
结构化并发的三个核心原则:
- 父子关系明确:每个协程都有明确的父协程
- 生命周期绑定:父协程结束,子协程自动结束
- 异常传播有序:子协程的异常会向上传播给父协程
来看一个实际例子:
// 结构化并发示例
fun fetchUserData() {
scope.launch { // 父协程
val user = async { fetchUser() } // 子协程1
val posts = async { fetchPosts() } // 子协程2
// 两个子协程都完成后,才继续执行
showData(user.await(), posts.await())
}
}
这里有个细节:如果 fetchUser() 抛异常了,fetchPosts() 会被自动取消。为什么?因为结构化并发要求:要么全部成功,要么全部失败。我在项目中遇到过这种情况,当时有个同事没理解这个机制,在子协程里单独 try-catch 了异常,结果父协程还是被取消了,排查了半天。
3.3 父子协程的取消机制
取消机制是结构化并发的核心。记住一句话:父协程取消,所有子协程自动取消。反过来,子协程取消,父协程不受影响(除非异常传播)。
看代码:
val parentJob = scope.launch {
val child1 = launch {
repeat(100) {
delay(100)
println("Child1: $it")
}
}
val child2 = launch {
repeat(100) {
delay(100)
println("Child2: $it")
}
}
}
// 取消父协程
parentJob.cancel()
// child1 和 child2 都会自动取消
为什么会这样?因为每个协程内部都有一个 Job 对象,父子协程通过 Job 形成树状结构。取消父 Job 时,Kotlin 会遍历所有子 Job 并调用它们的 cancel() 方法。
这里有个容易踩的坑:取消不是立即生效的。协程必须到达一个挂起点(比如 delay()、withContext())才能响应取消。如果你在协程里写了一个死循环,没有挂起点,那取消就无效。
ensureActive() 或 yield() 检查取消状态。
3.4 异常传播:结构化并发的“双刃剑”
异常传播是结构化并发里最让人头疼的部分。先看一个经典例子:
scope.launch {
launch {
throw RuntimeException("Child failed")
}
launch {
delay(1000)
println("This won't print")
}
}
// 两个子协程都会被取消
当一个子协程抛出未捕获的异常时,这个异常会传播给父协程。父协程收到异常后,会做两件事:
- 取消自己(以及所有其他子协程)
- 将异常继续向上传播
这就是所谓的“协程崩溃传播”。我刚开始用协程时,觉得这个机制很坑——一个子任务失败了,凭什么把其他任务也取消?后来想明白了,这其实是安全设计:如果某个子任务失败了,整个父任务的结果可能已经不可靠了。
那如果我就是想让子协程的异常不影响兄弟协程呢?用 SupervisorJob:
val supervisor = SupervisorJob()
val scope = CoroutineScope(Dispatchers.Main + supervisor)
scope.launch {
launch {
throw RuntimeException("Child failed")
}
launch {
delay(1000)
println("This WILL print")
}
}
// 第二个子协程不受影响
或者用 supervisorScope:
scope.launch {
supervisorScope {
launch {
throw RuntimeException("Child failed")
}
launch {
delay(1000)
println("This WILL print")
}
}
}
3.5 异常处理的三种姿势
在实际项目中,异常处理有三种常见方式:
| 方式 | 适用场景 | 示例 |
|---|---|---|
| try-catch 包裹 | 单个协程内的异常 | launch { try { ... } catch(e: Exception) { ... } } |
| CoroutineExceptionHandler | 全局异常捕获 | CoroutineScope(Job() + CoroutineExceptionHandler { _, e -> ... }) |
| SupervisorJob + 单独处理 | 子协程独立异常 | supervisorScope { launch { ... } } |
我个人习惯这样用:
- 业务逻辑里的异常,用 try-catch 精确处理
- 全局兜底,用 CoroutineExceptionHandler 记录日志
- 多个独立子任务,用 SupervisorJob 隔离异常
3.6 知识体系总览
下面这张图总结了本章的核心内容,我建议你保存下来,写代码时对照着看:
嗯,到这里,协程作用域和结构化并发的核心内容就讲完了。这些东西看起来简单,但实际项目中用起来还是有不少细节的。我建议你写代码时多想想:这个协程的生命周期是谁管理的?异常会不会影响到不该影响的地方?想清楚了再动手。