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

前言

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

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

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

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

不理解结构化并发,你就永远无法解释为什么 coroutineScope 和 supervisorScope 行为不同,为什么有时候一个子协程崩溃会导致整个 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 树是如何长成的?

每一次你调用 launch 或 async,都不仅仅是"启动一个协程"那么简单。你在做的是:以当前协程的 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:结构化并发的"围栏"

除了 launch 和 async,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 --> 启动子协程 启动子协程 --> 子协程执行中 子协程执行中 --> 等待所有完成 等待所有完成 --> 返回结果 : 所有子协程成功 等待所有完成 --> 抛出异常 : 任一子协程失败 返回结果 --> [*] 抛出异常 --> [*]

coroutineScope 与 launch 的关键区别:

  • 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

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

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

常见错误与避坑指南

错误 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 清除后仍在运行,泄漏!
        }
    }
}

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


最佳实践

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

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

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

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

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


总结与下回预告

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

本讲核心收获:

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

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

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


【当前境界修为面板】

  • 当前境界 :[筑基境 · 初阶]
  • 下一突破 :[筑基境 · 中阶] (需领悟:CoroutineContext 的组成结构、Job 与 Dispatcher 的本质、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,看透协程的"命格"。筑基境·中阶见。

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

相关推荐
千里马学框架3 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台3 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone3 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc3 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo3 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077003 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
ai2work3 天前
ch23 综合复刻:从零做一个最小可用版本(capstone)
kotlin
其实防守也摸鱼3 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
ai2work3 天前
ch21 签名、校验与发版
kotlin
AFinalStone3 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui