结构化并发(Structured Concurrency)是 Kotlin 协程最核心的设计哲学。理解了它,你就能真正驾驭协程的生命周期,避免资源泄漏和任务丢失。
一、为什么需要结构化并发?
在没有结构化并发的时代(比如直接用 ExecutorService 或原始线程),我们经常遇到两个问题:
- 任务泄漏:启动了一个后台任务,但忘记关闭它,导致资源浪费甚至崩溃。
- 父任务无法感知子任务:父任务完成了,但子任务还在后台乱跑,产生了不可预期的行为。
java
// 传统线程池的问题
ExecutorService executor = Executors.newSingleThreadExecutor();
executor.submit(() -> {
// 长时间运行的任务
});
// 如果忘记 shutdown,应用关闭时这个任务还会在后台跑
结构化并发 的核心思想就是:让并发任务像普通代码一样有明确的作用域和生命周期------协程有开始,也有结束,且子协程的生命周期被父协程所管理。
二、结构化并发的核心思想
结构化并发可以概括为三个基本原则:
- 生命周期绑定 :协程的生命周期被限定在某个作用域内(如
CoroutineScope)。作用域结束时,其内部所有协程都会被自动取消。 - 父子关系:每个协程都有父协程,父协程会等待所有子协程完成后再完成自己。
- 错误传播:子协程中的未捕获异常会向上传播给父协程,导致父协程(以及相关兄弟协程)被取消,保持原子性。
结构化并发让异步代码的阅读顺序和执行顺序保持一致,这是协程优于回调的核心所在。
三、结构化协程的层级关系
在协程中,Job 形成了父子层级:
kotlin
val parentJob = scope.launch { // 根协程
println("Parent started")
val childJob = launch { // 子协程1
delay(1000)
println("Child 1 done")
}
launch { // 子协程2
delay(500)
println("Child 2 done")
}
println("Parent waiting children")
}
parentJob.join() // 父协程会等待两个子协程都完成
输出:
text
Parent started
Parent waiting children
Child 2 done
Child 1 done
重要规则:
- 父协程不会自动"结束"自己的执行,直到所有子协程都完成。
- 父协程被取消时,会递归取消所有子协程。
- 子协程抛出异常,默认会向上传播,导致父协程和兄弟协程被取消。
四、CoroutineScope:协程的作用域
CoroutineScope 是一个接口,它持有 CoroutineContext(主要是 Job),用于启动新协程。你可以把它理解成一个协程容器,管理着所有在这个作用域内启动的协程的生命周期。
kotlin
class MyViewModel : ViewModel() {
// viewModelScope 是 Android 提供的 CoroutineScope
// 当 ViewModel 被清除时,这个作用域会被自动取消
fun loadData() {
viewModelScope.launch { // 协程在 viewModelScope 内启动
val data = fetchData()
updateUI(data)
}
}
}
CoroutineScope 本质上只是一个持有 CoroutineContext 的对象。它的作用是:
- 提供启动协程的"容器"
- 通过其内部的 Job 管理协程层级
- 提供取消时的统一入口
创建自定义作用域
kotlin
val customScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + CoroutineName("MyScope"))
customScope.launch {
// 这个协程的生命周期由 customScope 管理
}
// 需要清理时,取消整个作用域
customScope.cancel()
最佳实践 :在类中持有 CoroutineScope,并在类销毁时取消它(如 onCleared、close 等)。
五、coroutineScope vs supervisorScope
5.1 coroutineScope
coroutineScope 是一个挂起函数 ,它创建一个新的子作用域 ,并等待作用域内所有子协程完成,然后才返回。如果一个子协程失败(抛出异常),其他子协程会被取消,并且异常会重新抛出给调用者。
kotlin
suspend fun doWork() = coroutineScope {
val job1 = launch { delay(1000); println("Job1 done") }
val job2 = launch { delay(500); throw RuntimeException("Oops") }
// job2 失败 -> job1 被取消,异常抛出给 doWork 的调用者
}
coroutineScope 的行为很像 runBlocking 的挂起版本 ------ 它保证作用域内所有任务完成后才继续,但不阻塞线程。
5.2 supervisorScope
supervisorScope 与 coroutineScope 很像,但关键区别在于异常处理 :子协程的失败不会影响其他子协程,也不会立即抛出异常,而是像 async 一样,只在调用 await() 时才会抛出。
kotlin
suspend fun doWorkWithSupervisor() = supervisorScope {
val job1 = launch { delay(1000); println("Job1 done") }
val job2 = launch { delay(500); throw RuntimeException("Oops") }
// job2 失败了,但 job1 依然会完成
// 异常不会在这里抛出,除非我们显式 await 或 join 到失败的协程
}
// 调用处可以捕获异常
runBlocking {
try {
doWorkWithSupervisor()
} catch(e: Exception) {
// 只有在 supervisorScope 内的子协程被 join/await 时,异常才会抛出
}
}
5.3 对比表格
| 特性 | coroutineScope |
supervisorScope |
|---|---|---|
| 子协程失败影响其他 | ✅ 会,兄弟协程被取消 | ❌ 不会,兄弟协程继续执行 |
| 异常传播 | 立即抛出,向上传播 | 延迟抛出(只有 await/join 到失败子协程时) |
| 典型用途 | 原子性操作:要么全成功,要么全失败 | 独立子任务:一个失败不影响其他 |
| 类似 | 类似 runBlocking 的挂起版 |
类似 SupervisorJob 的效果 |
六、Job vs SupervisorJob
6.1 Job
- 普通协程的默认
Job。 - 子协程失败会导致父 Job 失败,进而取消所有其他子协程。
- 父 Job 取消会递归取消所有子 Job。
6.2 SupervisorJob
SupervisorJob是一种特殊的Job,它不会因为子协程的失败而自动取消。其他子协程可以继续运行。- 常用于需要保持并行任务的独立性(比如 UI 界面中一个组件加载失败不影响其他组件)。
kotlin
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
scope.launch {
// 子协程 A
delay(1000)
throw RuntimeException("A failed")
}
scope.launch {
// 子协程 B ------ 不会因为 A 失败而被取消
delay(2000)
println("B still works")
}
重要区别 :SupervisorJob 只影响父对子 的异常处理方向,不影响子对父------如果父 Job 被取消,所有子 Job 仍然会被取消。
SupervisorJob 的特性:
- 子协程失败 → 只取消失败的那个协程,不影响兄弟
- 失败不会向上取消父协程(父 scope 不会被取消)
- ⚠️ 但"不向上传播"不等于异常被吞了 :
SupervisorJob的直接子协程在异常处理上被当作根协程 ------异常会交给 context 里的CoroutineExceptionHandler,没有 handler 就走默认未捕获处理器,直接让 App 崩溃
6.3 在不同场景中使用
| 场景 | 推荐使用的 Job |
|---|---|
| 一组任务必须原子完成(全成功或全失败) | 普通 Job(默认) |
| 独立的后台任务,一个失败不应影响其他 | SupervisorJob |
作用域内混合使用 coroutineScope / supervisorScope |
根据需要动态创建子作用域 |
6.4 supervisorScope + Job 的常见误用
kotlin
// ❌ 错误:作用域是 SupervisorJob,但使用 coroutineScope 时仍会取消兄弟?
val scope = CoroutineScope(SupervisorJob())
scope.launch {
coroutineScope { // 这个子作用域内仍使用普通 Job 语义!
launch { throw RuntimeException() }
launch { delay(1000); println("This may not run") }
}
}
// coroutineScope 内部会覆盖外部的 SupervisorJob 语义,仍然会取消兄弟。
七、实战:结构化并发的最佳实践
7.1 使用 viewModelScope 或 lifecycleScope
在 Android 开发中,直接用官方提供的作用域,避免手动管理:
kotlin
class MyFragment : Fragment() {
fun fetchData() {
viewLifecycleOwner.lifecycleScope.launch {
// 当 Fragment 的视图销毁时,这个协程会被自动取消
val data = withContext(Dispatchers.IO) { repo.getData() }
updateUI(data)
}
}
}
7.2 使用 coroutineScope 实现原子并行任务
kotlin
suspend fun fetchThreeResources(): Triple<A, B, C> = coroutineScope {
val a = async { fetchA() }
val b = async { fetchB() }
val c = async { fetchC() }
Triple(a.await(), b.await(), c.await())
// 如果任何一个 async 失败,整个函数失败,其他正在进行的请求会被自动取消
}
7.3 使用 supervisorScope 实现独立加载
kotlin
suspend fun loadWidgets(): List<Widget> = supervisorScope {
val jobs = widgetConfigs.map { config ->
async { loadWidget(config) }
}
// 只返回成功加载的,失败的忽略
jobs.mapNotNull { it.getOrNull() }
}
7.4 自定义作用域的生命周期管理
kotlin
class MyService : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.IO) {
fun start() {
launch { doPeriodicWork() }
}
fun shutdown() {
cancel() // 取消整个作用域,所有子协程自动取消
}
private suspend fun doPeriodicWork() {
while (isActive) {
delay(5000)
println("Working...")
}
}
}
八、总结速查表
| 概念 | 一句话总结 |
|---|---|
| 结构化并发 | 协程有明确的作用域和生命周期,父协程管理子协程,防止任务泄漏 |
| 父子关系 | 父协程等待所有子协程完成;父取消则递归取消子 |
CoroutineScope |
协程的容器,持有上下文,用于启动协程,可统一取消 |
coroutineScope |
挂起函数,创建子作用域,一个失败全部取消,并向上传播异常 |
supervisorScope |
挂起函数,创建子作用域,一个失败不影响其他,异常延迟抛出 |
Job |
普通 Job,子失败导致父失败 |
SupervisorJob |
特殊 Job,子失败不影响父(但父取消仍会取消子) |
| 原子性并行任务 | 使用 coroutineScope + async |
| 独立并行任务(忽略失败) | 使用 supervisorScope + async + getOrNull() |
结构化并发是 Kotlin 协程区别于其他异步框架的最大优势。掌握它,你将写出更安全、更易读、更健壮的并发代码。