3、协程作用域与结构化并发:GlobalScope vs CoroutineScope、结构化并发原则、父子协程的取消与异常传播

协程作用域这个东西,说白了就是给协程一个“家”。没有家,协程就像断了线的风筝,你根本不知道它飞哪去了,什么时候掉下来。我在项目中见过不少新手直接写 GlobalScope.launch,结果 Activity 销毁了协程还在跑,内存泄漏、空指针,各种诡异问题全来了。

嗯,今天我们就来彻底搞懂作用域。我会从最基础的 GlobalScopeCoroutineScope 的区别讲起,然后深入到结构化并发的核心原则,最后聊聊父子协程的取消和异常传播——这部分坑最多,我踩过不少。

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

核心结论:GlobalScope 只适合极少数场景(比如全局的统计上报),日常开发请使用自定义的 CoroutineScope。

3.2 结构化并发原则:协程的“家族树”

结构化并发,说白了就是让协程像函数调用一样有清晰的层级关系。你想想看,一个函数调用另一个函数,调用完了就返回,异常了也能捕获。协程也应该这样。

结构化并发的三个核心原则:

  1. 父子关系明确:每个协程都有明确的父协程
  2. 生命周期绑定:父协程结束,子协程自动结束
  3. 异常传播有序:子协程的异常会向上传播给父协程

来看一个实际例子:

// 结构化并发示例
fun fetchUserData() {
    scope.launch { // 父协程
        val user = async { fetchUser() }  // 子协程1
        val posts = async { fetchPosts() } // 子协程2
        // 两个子协程都完成后,才继续执行
        showData(user.await(), posts.await())
    }
}

这里有个细节:如果 fetchUser() 抛异常了,fetchPosts() 会被自动取消。为什么?因为结构化并发要求:要么全部成功,要么全部失败。我在项目中遇到过这种情况,当时有个同事没理解这个机制,在子协程里单独 try-catch 了异常,结果父协程还是被取消了,排查了半天。

我的建议:如果你希望某个子协程的异常不影响兄弟协程,请使用 SupervisorJob 或 supervisorScope。后面我们会详细讲。

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())才能响应取消。如果你在协程里写了一个死循环,没有挂起点,那取消就无效。

避坑指南:我曾经写过一个协程,里面是纯 CPU 计算,没有挂起点。调用 cancel 后协程还在跑,导致资源泄漏。解决方案是在循环里加 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")
        }
    }
}
关键区别:普通 Job 的异常会向上传播并取消兄弟协程;SupervisorJob 的异常不会向上传播,兄弟协程不受影响。但注意,SupervisorJob 的子协程异常仍然需要你自己处理,否则会被静默吞掉。

3.5 异常处理的三种姿势

在实际项目中,异常处理有三种常见方式:

方式 适用场景 示例
try-catch 包裹 单个协程内的异常 launch { try { ... } catch(e: Exception) { ... } }
CoroutineExceptionHandler 全局异常捕获 CoroutineScope(Job() + CoroutineExceptionHandler { _, e -> ... })
SupervisorJob + 单独处理 子协程独立异常 supervisorScope { launch { ... } }

我个人习惯这样用:

  • 业务逻辑里的异常,用 try-catch 精确处理
  • 全局兜底,用 CoroutineExceptionHandler 记录日志
  • 多个独立子任务,用 SupervisorJob 隔离异常
小技巧:CoroutineExceptionHandler 只在 launch 中有效,async 的异常需要通过 await() 捕获。这个区别我当年调试了好久才发现。

3.6 知识体系总览

下面这张图总结了本章的核心内容,我建议你保存下来,写代码时对照着看:

协程作用域与结构化并发知识体系 作用域选择 • GlobalScope:全局,不推荐 • CoroutineScope:自定义,推荐 • lifecycleScope:Android专用 • viewModelScope:MVVM首选 结构化并发原则 • 父子关系明确 • 生命周期绑定 • 异常传播有序 • 取消自动传递 异常处理策略 • try-catch 精确处理 • CoroutineExceptionHandler • SupervisorJob 隔离 • async 异常需 await() 核心结论 1. 优先使用 CoroutineScope,避免 GlobalScope 2. 结构化并发让协程像函数一样可预测 3. 异常传播是双刃剑,用 SupervisorJob 隔离独立任务 4. 取消不是立即生效,记得在循环中检查取消状态

嗯,到这里,协程作用域和结构化并发的核心内容就讲完了。这些东西看起来简单,但实际项目中用起来还是有不少细节的。我建议你写代码时多想想:这个协程的生命周期是谁管理的?异常会不会影响到不该影响的地方?想清楚了再动手。