【Kotlin 协程修仙录 · 筑基境 · 初阶】 | 根本大法:结构化并发的父子约束与取消传播

前言

炼气四境已过。你已能自如启动协程,也能精准取消它们;你内视过状态机的 label 跳转,也看穿了 CPS 续体的接力真相。你自认为已经掌握了协程。

但一个诡异的现象,可能已经在你心头萦绕多时:

为什么我在 ViewModel 里只调用了 viewModelScope.launch,当 ViewModel 被清除时,里面所有还在运行的网络请求、数据库操作、文件读写,全都自动停止了?我明明没有手动取消任何一个!

这不是魔法。这是 Kotlin 协程设计中最核心、最精妙、也最容易被误解的机制------结构化并发

不理解结构化并发,你就永远无法解释为什么 coroutineScopesupervisorScope 行为不同,为什么有时候一个子协程崩溃会导致整个 Scope 完蛋,而有时候又不会。你写的代码可能"看起来能跑",但迟早会在生产环境给你上一课。

今天,我们将正式踏入筑基境。这一讲,你要领悟的不是某一个 API,而是一套设计哲学------它像万有引力一样,无声却不可违抗地约束着每一个协程的生与灭。

千曲 而后晓声,观千剑 而后识器。虐它千百遍 方能通晓其真意


什么是结构化并发?

在 Kotlin 协程的官方文档中,结构化并发被定义为:

结构化并发 是一组语言特性与编程范式的组合,它确保:协程的生命周期被严格限制在启动它的作用域之内。父协程必须等待所有子协程完成后才能完成自身;取消信号从父向子单向传播;异常从子向父单向传播。

如果你觉得这个定义太抽象,我们用一张图来建立直觉。

graph TD subgraph Scope["🏢 CoroutineScope 疆域"] Root["🌳 根 Job(Scope 的 Job)"] Child1["🚀 子协程 A"] Child2["🚀 子协程 B"] GrandChild1["🌐 孙协程 B-1"] GrandChild2["💾 孙协程 B-2"] end Root --> Child1 Root --> Child2 Child2 --> GrandChild1 Child2 --> GrandChild2 CancelSignal["❌ scope.cancel()"] -->|取消信号| Root Root -->|级联取消| Child1 Root -->|级联取消| Child2 Child2 -->|级联取消| GrandChild1 Child2 -->|级联取消| GrandChild2 style Scope fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px style Root fill:#a5d6a7,stroke:#1b5e20,stroke-width:3px,color:#000 style Child1 fill:#c5e1a5,stroke:#558b2f,stroke-width:2px style Child2 fill:#c5e1a5,stroke:#558b2f,stroke-width:2px style GrandChild1 fill:#dcedc8,stroke:#7cb342,stroke-width:1px style GrandChild2 fill:#dcedc8,stroke:#7cb342,stroke-width:1px style CancelSignal fill:#ffccbc,stroke:#d84315,stroke-width:2px

这张图揭示了结构化并发的第一定律取消向下传播。你只需要取消根节点,整棵树上的所有协程都会自动被取消。没有漏网之鱼。


为什么需要结构化并发?

在传统的多线程编程中,"忘记关掉某个线程"是内存泄漏和资源泄漏的头号元凶。

flowchart LR subgraph 传统线程["❌ 传统线程的混乱"] T1[线程1] T2[线程2] T3[线程3] end subgraph 协程["✅ 结构化并发"] S[Scope] C1[协程1] C2[协程2] C3[协程3] S --- C1 S --- C2 S --- C3 end T1 -.->|手动管理| H1[Handler/Lock] T2 -.->|手动管理| H2[Flag] T3 -.->|手动管理| H3[Future] S -->|自动管理| C1 S -->|自动管理| C2 S -->|自动管理| C3 style 传统线程 fill:#ffcdd2,stroke:#b71c1c,stroke-width:2px style 协程 fill:#c8e6c9,stroke:#1b5e20,stroke-width:2px style T1 fill:#ef9a9a style T2 fill:#ef9a9a style T3 fill:#ef9a9a style S fill:#a5d6a7,stroke:#2e7d32,stroke-width:3px style C1 fill:#c5e1a5 style C2 fill:#c5e1a5 style C3 fill:#c5e1a5

在传统线程模型里,你需要自己维护每一个线程的引用,在合适的时机调用 interrupt() 或检查 volatile boolean 标志。一旦漏掉一个,那个线程就会像幽灵一样在后台游荡,直到应用进程被杀。

结构化并发彻底解决了这个问题:协程的生命周期被作用域严格约束 。Scope 就像一个"括号",所有在其中启动的协程,都必须在 Scope 结束前完成(或被取消)。编译器虽不强制,但运行时会严格保证这一点。


父子关系的建立:Job 树是如何长成的?

每一次你调用 launchasync,都不仅仅是"启动一个协程"那么简单。你在做的是:以当前协程的 Job 为父节点,创建一个新的子 Job,并将它挂到 Job 树上。

sequenceDiagram participant Parent as 🌳 父协程 participant Scope as 🏢 当前 CoroutineScope participant Child as 🚀 新子协程 participant Tree as 🌲 Job 树 Parent->>Scope: launch { } Scope->>Scope: 获取 coroutineContext[Job] Scope->>Child: 创建新的 Job 对象 Child->>Tree: 将新 Job 挂到父 Job 下 Tree-->>Parent: 建立父子关联 Parent->>Child: 启动子协程体 Note over Tree: 此时 Job 树新增一个叶子节点

这个"挂到树上"的动作,是结构化并发所有魔法的起点。一旦父子关系确立,三条铁律即刻生效:

铁律 方向 含义
完成等待 子 → 父 父协程的 join()coroutineScope 会等待所有子协程完成。
取消传播 父 → 子 父 Job 被取消时,所有子 Job 自动被取消。
异常传播 子 → 父 子协程未捕获的异常会向上传递,导致父 Job 被取消(除非使用 SupervisorJob)。

实战演示:取消传播的威力

让我们用一个可运行的示例,亲眼见证取消传播的"多米诺骨牌"效应。

kotlin 复制代码
import kotlinx.coroutines.*

fun main() = runBlocking {
    println("🧘 主协程:启动父协程")
    
    val parentJob = launch {
        println("🌳 父协程:启动子协程 A")
        launch {
            repeat(5) { i ->
                delay(500)
                println("🚀 子协程 A:心跳 $i")
            }
        }
        
        println("🌳 父协程:启动子协程 B")
        launch {
            repeat(5) { i ->
                delay(600)
                println("🚀 子协程 B:心跳 $i")
            }
        }
        
        delay(1500)
        println("🌳 父协程:自身工作完成,等待子协程...")
    }
    
    delay(2000)
    println("🧘 主协程:取消父协程!")
    parentJob.cancelAndJoin()
    
    println("🧘 主协程:父协程已取消,所有子协程也都停止了。")
}

运行结果中,你会看到子协程 A 和 B 各自打印了几次心跳,然后在 parentJob.cancel() 调用后同时停止。你只取消了一个协程,却终结了三个。

sequenceDiagram participant Main as 🧘 主协程 participant Parent as 🌳 父协程 participant ChildA as 🚀 子协程 A participant ChildB as 🚀 子协程 B rect rgb(232, 245, 233) Main->>Parent: launch 启动父协程 Parent->>ChildA: launch 启动子协程 A Parent->>ChildB: launch 启动子协程 B ChildA->>ChildA: 每 500ms 打印心跳 ChildB->>ChildB: 每 600ms 打印心跳 Parent->>Parent: delay(1500) end rect rgb(255, 243, 224) Main->>Main: delay(2000) Main->>Parent: cancelAndJoin() Parent->>ChildA: 取消信号 Parent->>ChildB: 取消信号 ChildA-->>Parent: CancellationException ChildB-->>Parent: CancellationException Parent-->>Main: 取消完成 end

coroutineScope:结构化并发的"围栏"

除了 launchasync,Kotlin 协程还提供了一个专门用于体现结构化并发的构建器:coroutineScope

coroutineScope 是一个挂起函数,它创建一个临时的协程作用域,并挂起当前协程 ,直到作用域内启动的所有子协程全部完成。如果任何子协程失败,coroutineScope 会抛出异常并取消所有其他子协程。

它就像一道"围栏"------围栏内的所有协程都完成之前,围栏外的代码不会执行。

kotlin 复制代码
suspend fun loadMultipleData() = coroutineScope {
    println("🏁 进入 coroutineScope")
    
    val data1 = async { fetchFromNetwork("url1") }
    val data2 = async { fetchFromNetwork("url2") }
    val data3 = async { fetchFromNetwork("url3") }
    
    // 等待所有 async 完成,组合结果
    val combined = "${data1.await()} + ${data2.await()} + ${data3.await()}"
    println("✅ 所有数据加载完成:$combined")
    
    // coroutineScope 结束后,才会返回 combined
    combined
}

suspend fun fetchFromNetwork(url: String): String {
    delay(Random.nextLong(500, 1500))
    return "Data from $url"
}
stateDiagram-v2 [*] --> 进入coroutineScope 进入coroutineScope --> 启动子协程 启动子协程 --> 子协程执行中 子协程执行中 --> 等待所有完成 等待所有完成 --> 返回结果 : 所有子协程成功 等待所有完成 --> 抛出异常 : 任一子协程失败 返回结果 --> [*] 抛出异常 --> [*]

coroutineScopelaunch 的关键区别

  • launch 返回一个 Job,不阻塞当前协程,是"发射后不管"。
  • coroutineScope 是一个 suspend 函数,会挂起当前协程直到内部所有子协程完成,是"等待所有人都到齐再走"。

Android 实战:安全的多网络请求并发

假设你有一个商品详情页,需要同时请求商品信息用户评价推荐列表三个接口。你希望它们并发执行以缩短总耗时,但如果任何一个失败,整个页面应该显示错误状态。

这正是 coroutineScope 的完美应用场景。

kotlin 复制代码
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch

class ProductViewModel(
    private val productRepo: ProductRepository,
    private val reviewRepo: ReviewRepository,
    private val recommendRepo: RecommendRepository
) : ViewModel() {

    sealed class UiState {
        object Loading : UiState()
        data class Success(
            val product: Product,
            val reviews: List<Review>,
            val recommends: List<Product>
        ) : UiState()
        data class Error(val message: String) : UiState()
    }

    var uiState by mutableStateOf<UiState>(UiState.Loading)
        private set

    fun loadProductDetails(productId: String) {
        viewModelScope.launch {
            uiState = UiState.Loading
            
            try {
                // coroutineScope 保证三个请求并发执行
                // 任何一个失败都会抛出异常,进入 catch 块
                val result = coroutineScope {
                    val productDeferred = async { productRepo.getProduct(productId) }
                    val reviewsDeferred = async { reviewRepo.getReviews(productId) }
                    val recommendsDeferred = async { recommendRepo.getRecommends(productId) }
                    
                    // 同时等待三个结果
                    UiState.Success(
                        product = productDeferred.await(),
                        reviews = reviewsDeferred.await(),
                        recommends = recommendsDeferred.await()
                    )
                }
                uiState = result
            } catch (e: Exception) {
                uiState = UiState.Error("加载失败:${e.message}")
                // 注意:coroutineScope 内部任何协程失败,
                // 其他正在执行的协程会被自动取消,无需手动清理
            }
        }
    }
}

这个示例展示了结构化并发的优雅之处

  • 不需要手动维护三个请求的 Job 引用。
  • 任何一个请求失败,其他请求自动被取消,避免浪费资源。
  • 代码结构清晰,完全符合直觉。

异常传播的秘密即将揭晓

在本讲的示例中,你可能注意到了一个细节:

我们在 coroutineScope 内部用 async 启动了三个请求。如果其中一个 await() 抛出异常,coroutineScope 会立刻抛出该异常,并且其他两个还在执行的请求会被自动取消

这种行为是 coroutineScope 的设计使然------它遵循一个失败,全体遭殃的原则。

但在某些场景下,你希望子协程之间相互隔离 :一个子协程的失败不应该影响其他兄弟协程。比如,商品详情的三个接口中,推荐列表失败了,你仍然希望显示商品信息和评价,只是推荐位留空。

这时,你就需要 supervisorScope

flowchart LR subgraph CoroutineScope["coroutineScope"] C1[协程1] C2[协程2] C3[协程3] C1 -.->|失败| C2 C1 -.->|失败| C3 end subgraph SupervisorScope["supervisorScope"] S1[协程1] S2[协程2] S3[协程3] S1 -.->|失败| X[不影响其他] end style CoroutineScope fill:#ffccbc,stroke:#d84315,stroke-width:2px style SupervisorScope fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px style C1 fill:#ef9a9a style C2 fill:#ef9a9a style C3 fill:#ef9a9a style S1 fill:#ef9a9a style S2 fill:#a5d6a7 style S3 fill:#a5d6a7

这正是下一讲 【筑基境·中阶】 的核心内容。我们将深入:

  • JobSupervisorJob 的源码级区别。
  • supervisorScope 的使用场景与最佳实践。
  • 异常处理的三层拦截机制:try-catchCoroutineExceptionHandlersupervisorScope

常见错误与避坑指南

错误 1:在 coroutineScope 外部捕获异常却以为能保护内部

kotlin 复制代码
// ❌ 错误理解:以为 try-catch 能捕获内部协程的异常
try {
    coroutineScope {
        launch {
            throw IOException("网络错误") // 这个异常会传播到 coroutineScope
        }
    }
} catch (e: IOException) {
    // ✅ 实际上能捕获,因为 coroutineScope 会重新抛出内部异常
    println("捕获到了")
}

正确理解coroutineScope 会将内部子协程的未捕获异常重新抛出 ,所以外层 try-catch 是有效的。这一点与 launch 不同------launch 不会向外抛出异常,而是交给 CoroutineExceptionHandler

错误 2:忘记 coroutineScope 会等待所有子协程

kotlin 复制代码
// ❌ 误解:以为 coroutineScope 只是创建一个作用域,不等待
suspend fun loadData() {
    coroutineScope {
        launch {
            delay(10000) // 这个协程会阻塞 loadData 返回
        }
    }
    println("这行字要等 10 秒才能打印") // 实际上需要等待
}

coroutineScope 的本质就是挂起并等待 内部所有协程完成。如果你不需要等待,应该使用 CoroutineScope(Job()).launch 或直接在当前作用域 launch

错误 3:在 ViewModel 中用 GlobalScope

kotlin 复制代码
// ❌ 结构化并发的反面教材
class MyViewModel : ViewModel() {
    fun loadData() {
        GlobalScope.launch {
            // 这个协程不受 ViewModel 生命周期约束
            // ViewModel 清除后仍在运行,泄漏!
        }
    }
}

正确做法 :始终使用 viewModelScopelifecycleScope,它们是结构化并发在 Android 中的具体实现。


最佳实践

  1. 默认使用 viewModelScopelifecycleScope:它们已经为你配置好了 Job 树和生命周期绑定。

  2. 并发任务用 coroutineScope + async:需要同时执行多个任务并等待全部结果时,这是标准模式。

  3. 在挂起函数内部需要启动新协程时,用 coroutineScope 包裹:这样可以保证函数返回前,所有启动的子协程都已经完成。

    kotlin 复制代码
    suspend fun doWork() = coroutineScope {
        launch { /* 子任务1 */ }
        launch { /* 子任务2 */ }
        // 自动等待所有子任务完成
    }
  4. 永远不要直接使用 GlobalScope:除非你在写一个应用级全局任务(如日志上报),并且有明确的清理机制。

  5. 理解 Job 树的取消传播方向:父 → 子。这是你调试"为什么这个协程被取消了"的第一线索。


总结与下回预告

恭喜,你已经领悟了结构化并发的根本大法。

本讲核心收获

  • 结构化并发确保协程的生命周期被严格限制在 Scope 之内,消灭了"野协程"。
  • 取消信号从父向子传播,你只需要取消根 Job,整棵树都会被自动清理。
  • coroutineScope 是结构化并发的核心构建器,它挂起当前协程并等待所有子协程完成。
  • Job 树的三条铁律:取消向下、完成等待向上、异常向上。

在下一讲 【筑基境·中阶】 中,我们将深入 CoroutineContext 的内部构造,拆解 JobDispatcherCoroutineName 等元素是如何组合成协程的"身份证"的。届时你会明白:

为什么 withContext(Dispatchers.IO) 能切换线程却不出 Bug?CoroutineContext+ 运算符到底做了什么?


【当前境界修为面板】

  • 当前境界[筑基境 · 初阶]
  • 下一突破[筑基境 · 中阶] (需领悟:CoroutineContext 的组成结构、JobDispatcher 的本质、withContext 的线程切换原理)
  • 修炼进度[████████████░░░░░░░░] 55%
  • 本讲获得法器结构化并发根本大法Job 树内视术coroutineScope 围栏结界

【本讲思考题】

1、表象题 :以下代码中,println("Done") 会在什么时候执行?

kotlin 复制代码
suspend fun test() = coroutineScope {
    launch {
        delay(5000)
    }
}
suspend fun main() {
    test()
    println("Done")
}

2、场景题:你正在开发一个文件上传功能,需要同时上传三张图片。如果任意一张上传失败,你希望取消其他正在上传的图片。应该使用什么构建器?写出核心代码结构。

3、原理题 :在结构化并发的 Job 树中,如果一个子协程因为 CancellationException 而结束,这个异常会向上传播导致父协程被取消吗?为什么?结合 CancellationException 的特殊性解释。


道友,筑基境的根基已经打下。下一讲,我们将解剖 CoroutineContext,看透协程的"命格"。筑基境·中阶见。

欢迎一键四连关注 + 点赞 + 收藏 + 评论

相关推荐
ifuleyou16682 小时前
《Inter问题》
android·开发语言·kotlin
夏沫琅琊2 小时前
android 通话记录相关
android·kotlin
MonkeyKing2 小时前
蓝蓝牙核心基础概念详解:2.4GHz频段、跳频、信道、广播、连接、配对
android·ios
我命由我123452 小时前
Android 广播 - 显式广播与隐式广播
android·java·开发语言·java-ee·kotlin·android studio·android-studio
YaBingSec3 小时前
玄机网络安全靶场:JBoss 5.x_6.x 反序列化漏洞(CVE-2017-12149)
android·网络·笔记·安全·web安全·ssh
小妖6663 小时前
android studio安装中文语言插件
android·ide·android studio
Kapaseker3 小时前
为什么你写的 Compose 性能不好?
android·kotlin