十、Job树与结构化并发的本质

结构化并发不是编译期由代码嵌套结构决定的语法树,而是一棵由 launch(context) 中的 Job 引用在运行时精确构建的动态树

Job 结构化并发 parent children 协程树 运行时 父子关系 launch

目录导航(点击跳转)

一、双向引用:parent 与 children 构成 Job 树

1最简示例:两个 true 揭示的本质

协程的父子关系由 Job 来管理。当你启动一个协程:

val scope = CoroutineScope(Job())
var innerJob: Job? = null
val job = scope.launch {
    innerJob = launch {
        delay(100)
    }
}
// innerJob.parent === job              → true
// innerJob === job.children.first()    → true

这两个 true 构成了一个双向绑定的树形结构

job(父)
parent 引用 ↑
|
children 列表 ↓
innerJob(子)
2parent 引用:子协程如何找到父节点

innerJob.parent === job 为 true,含义是:子协程明确知道谁是自己的父节点。

当你在 scope.launch { ... } 内部再次调用 launch 时,内部的 launch 会执行以下逻辑:

1
获取当前作用域的 coroutineContext(即外层协程的 this)
2
从中提取 [Job],也就是外层的 job
3
将这个 job 作为 parent 参数传入新协程的构造函数
4
新协程调用 initParentJob(parent),将自己注册为父 Job 的子节点

因此,innerJob.parent 返回的就是创建它的那个外层协程 Job。

3children 列表:父协程如何追踪所有子节点

innerJob === job.children.first() 为 true,含义是:父协程也明确持有所有子节点的引用。

Job 接口定义了 val children: Sequence<Job> 属性。AbstractCoroutine(Job 的实现基类)内部维护了一个子节点列表。当子协程通过 initParentJob 注册时,它会把自己添加到父 Job 的这个列表中。所以从父节点向下遍历,也能准确找到所有子协程。

双向引用构成了"结构化"的本质:子知道父(parent 引用),父也知道子(children 列表)。这棵树不是单向的,而是双向绑定的。正因如此,取消可以从任意节点向两个方向传播——父取消子(cancel)向下级联,子异常向上传播(异常传播)。

二、兄弟还是父子?—— launch 时传入的 Job 决定一切

1三种场景,三种关系

父子关系不是由代码的文本嵌套决定的,而是由 launch 时传入的 Context 中的 Job 引用精确构建出来的。看下面三种场景:

val scope = CoroutineScope(Job())
var innerJob: Job? = null

// ———— 场景1:嵌套 launch,默认继承父 Job ————
val job1 = scope.launch {
    innerJob = launch {
        delay(100)               // innerJob.parent === job1 → 父子关系
    }
}

// ———— 场景2:显式传入 scope 的 Job ————
val job2 = scope.launch {
    innerJob = launch(scope.coroutineContext[Job]!!) {
        delay(100)               // innerJob.parent === scope的Job → 兄弟关系!
    }
}

// ———— 场景3:传入一个孤立的 customJob ————
val job3 = scope.launch {
    val customJob = Job()
    innerJob = launch(customJob) {
        delay(100)               // innerJob.parent === customJob → 与job3无关系!
    }
}

用树形图直观展示三种场景的差异:

  • scope 的 Job
    • job1(父)
      • innerJob(子)← 场景1:父子
    • job2
    • innerJob(子)← 场景2:兄弟(两者都是scope Job的子)
    • job3
  • customJob(孤立)
    • innerJob(子)← 场景3:与job3无父子关系
核心结论:结构化并发不是自动发生的魔法,不受代码结构的约束,而是由 launch 时传入的 Context 中的 Job 引用精确构建出来的。代码的文本嵌套结构只是"默认情况"——因为你省略 context 参数时,launch 自动从当前协程的 coroutineContext 中提取 Job。但一旦你显式传入不同的 Job,关系就完全由你指定的 Job 决定。
2场景2和场景3的深层含义

在场景2和场景3中,innerJobjob(外层 launch)是兄弟关系,甚至没有关系

场景innerJob 的 parentjob 等待 innerJob?job 取消会取消 innerJob?
场景1:默认嵌套 launchjob1
场景2:显式传 scope 的 Jobscope 的 Job否(scope Job 会等)否(job2 不是 innerJob 的父)
场景3:传入孤立 customJobcustomJob否(孤立的 Job,无人管理)
场景3的风险:customJob 是一个孤立的 Job,没有绑定到任何 scope。如果 customJob 没有被显式 cancel 或 join,它的子协程可能永远不会被清理,造成协程泄漏。这正是设计者通过类型系统阻止你随意操作 Job 的原因——如第九章所述。

三、运行时动态验证:从外部注入子协程

1利用父协程等待子协程的特性来验证

父协程总是会等待所有子协程结束才标记为完成。利用这个特性,我们可以设计一个实验来验证:结构化并发是由 Job 引用在运行时精确构建的,与代码的文本嵌套结构无关。

fun main() = runBlocking {
    val test = MyTest()
    test.start()
}

class MyTest {
    val topScope = CoroutineScope(Dispatchers.Default)

    fun start() {
        var innerScope: CoroutineScope? = null
        val job = topScope.launch {
            innerScope = this
            // delay(200) 让协程保持活跃,在此期间子协程可以加入协程树
            delay(200)
        }

        // 等待 100ms 确保 job 协程已经启动并进入 delay
        Thread.sleep(100)

        // 从外部向 job 注入一个子协程!
        // innerScope 绑定的 Job 就是 job 自身(this === job)
        innerScope!!.launch {
            delay(1000)  // 这个子协程比父协程的 delay(200) 更晚完成
        }

        // 阻塞等待 job 完成
        runBlocking {
            val start = System.currentTimeMillis()
            job.join()
            val end = System.currentTimeMillis()
            println("duration: ${end - start}ms")  // 输出约 1002ms
        }
    }
}
输出约 1002ms,而不是 ~200ms。如果结构化并发是由代码嵌套结构在编译期决定的,那么从外部注入的子协程不应该被 job 等待——job 应该只等待 delay(200) 完成(约 200ms)。但实际输出 ~1002ms,证明 job 等待了这个从外部动态注入的子协程
2时序分析:为什么是 1002ms?

用时间线图展示完整的时间推进过程:

0ms
job.launch 启动,进入 delay(200) 挂起
innerScope = this 赋值完成
Job 状态: Active
100ms
Thread.sleep(100) 结束
innerScope!!.launch { delay(1000) }
此时 job 仍然 Active → 子协程成功挂载
200ms
delay(200) 恢复
job 协程体执行完毕
检查 children → 发现有一个子协程还在运行
job 进入 "Completing" 状态,等待子协程
1200ms
子协程 delay(1000) 完成
job 变为 Completed
join() 返回
打印 ≈1002ms

job.join() 等待的时间 ≈ Thread.sleep(100) 之后剩余的时间。子协程的 delay(1000) 从 T≈100 开始,到 T≈1100 结束。job 自身的 delay(200) 在 T≈200 就结束了,但因为有子协程,job 不能完成。所以从 runBlocking 开始计时到 join 返回,大约是 1000ms(加上调度开销约 2ms)。

3核心机制:launch 时的 Job 状态决定了注册是否成功

使用 innerScope.launch 启动的协程,会以 innerScope 绑定的 Job 作为候选 parent。注册结果取决于该 Job 在调用时的状态:

候选 parent Job 的状态注册结果
活跃中(isActive = true)注册为真正的子协程,父协程会等待其完成
完成中(isCompleted = false,但正在等待子协程)仍然可以注册为子协程,父协程继续等待
已完成(isCompleted = true)新协程会被立即取消,不会成为子协程,父协程也不会等待
已取消(isCancelled = true)新协程会被立即取消,不会成为子协程

在我们的实验中,T=100ms 时 job 仍在 delay(200) 中(活跃状态),所以子协程成功注册。如果在 T=300ms(job 已完成后)再尝试 launch,新协程会被立即取消。

// 验证时机敏感性:如果父协程已完成,注入会失败
fun start() {
    var innerScope: CoroutineScope? = null
    val job = topScope.launch {
        innerScope = this
        delay(100)  // 很短,很快完成
    }

    Thread.sleep(500)  // 此时 job 肯定已经完成了

    // job 已完成,这个 launch 创建的协程会被立即取消
    innerScope!!.launch {
        delay(1000)   // 这段代码不会被执行
        println("永远不会打印")
    }

    runBlocking {
        val start = System.currentTimeMillis()
        job.join()    // 几乎立即返回,因为 job 早已完成
        val end = System.currentTimeMillis()
        println("duration: ${end - start}ms")  // 输出约 0ms
    }
}
这恰恰证明了结构化并发的本质:它是一棵运行时动态构建的 Job 树,而不是一棵编译期确定的语法树。launch(context) 中的 context[Job] 就是这棵树的唯一构建指令。只要这条指令在正确的时机指向了正确的 Job,无论代码结构如何,父子关系就会成立。

四、核心思想总结

核心要点

一句话总结

结构化并发不是语法糖——它是运行时由 Job 引用精确构建的一棵动态树。代码嵌套只是默认情况下的便利,真正的父子关系永远由 launch(context) 中的 context[Job] 在那一刻指向谁来决定。