协程 Job 父子关系建立原理

前面看异常分发时,经常会提到“子协程把异常交给父协程”“父协程取消其它子协程”。这些说法背后都依赖同一件事:父 Job 和子 Job 在协程启动时就已经建立了联系。

这篇只看这条关系是怎么建立起来的。异常怎么分发、CoroutineExceptionHandler 什么时候回调,后面再接着看。

先看一个例子

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private fun demo() {
    val handler = CoroutineExceptionHandler { _, throwable ->
        Log.d(TAG, "handler:${throwable.message}")
    }
    val parentJob = Job()
    val scope = CoroutineScope(parentJob + handler)

    val job1 = scope.launch {
        delay(1000)
        Log.d(TAG, "job1 end")
    }

    val job2 = scope.launch {
        delay(2000)
        Log.d(TAG, "job2 end")
    }

    job1.invokeOnCompletion {
        Log.d(TAG, "job1 completion:${it?.message}")
    }
    job2.invokeOnCompletion {
        Log.d(TAG, "job2 completion:${it?.message}")
    }
}

这里 parentJob 是父 Jobjob1job2 是两个子 Job。它们不是靠变量名建立关系,而是因为 scopeCoroutineContext 里带着 parentJob。后面每次调用 scope.launch 时,新协程都会从 context 里取出这个父 Job,再把自己挂上去。

launch 创建子 Job

launch 内部会先合并当前 CoroutineScope 的 context 和传进来的 context,然后创建 StandaloneCoroutine

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
    val newContext = newCoroutineContext(context)
    val coroutine = StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}

StandaloneCoroutine 本身就是 Job。所以 launch 返回的 job1job2,实际都是 StandaloneCoroutine 对象。

关键点在构造函数。StandaloneCoroutine 继承自 AbstractCoroutine,而 AbstractCoroutine 初始化时会调用 initParentJob

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public abstract class AbstractCoroutine<in T>(
    parentContext: CoroutineContext,
    initParentJob: Boolean,
    active: Boolean
) : JobSupport(active), Job, Continuation<T>, CoroutineScope {

    init {
        if (initParentJob) initParentJob(parentContext[Job])
    }
}

注意这里取的是 parentContext[Job],不是当前协程自己的 context[Job]。因为当前协程创建出来后,它自己也会作为一个 Job 放进 context,如果从自己的 context 里取,拿到的就会是自己。父子关系必须从创建它之前的 parent context 里取父 Job

initParentJob 做了什么

initParentJob 的逻辑可以简化成这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
protected fun initParentJob(parent: Job?) {
    if (parent == null) {
        parentHandle = NonDisposableHandle
        return
    }

    parent.start()
    val handle = parent.attachChild(this)
    parentHandle = handle
}

如果没有父 Job,比如 GlobalScope.launchparentHandle 就会指向 NonDisposableHandle,说明这个协程没有普通父 Job。

如果能从 parentContext 里拿到父 Job,就会调用 parent.attachChild(this)。这里的 this 是当前正在初始化的子协程,也就是 job1job2

attachChild 如何绑定父子关系

attachChild 会创建一个 ChildHandleNode。这个节点很关键,它同时知道父 Job 和子 Job

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
private class ChildHandleNode(
    val childJob: ChildJob
) : JobNode(), ChildHandle {

    override fun childCancelled(cause: Throwable): Boolean =
        job.childCancelled(cause)

    override fun invoke(cause: Throwable?) =
        childJob.parentCancelled(job)
}

JobNode 里有一个 job 字段,它会指向父 JobChildHandleNode 自己又保存了 childJob,指向当前子 JobattachChild 返回的就是这个节点,子 Job 会把它保存到自己的 parentHandle 里。

所以关系建立完后是这样的:

1
2
3
4
5
parentJob: JobImpl
  state -> ChildHandleNode(job=parentJob, childJob=job1)

job1: StandaloneCoroutine
  parentHandle -> ChildHandleNode(job=parentJob, childJob=job1)

Job 的 state 里挂着子节点,子 JobparentHandle 又反向持有这个节点。这样父找得到子,子也找得到父。

多个子 Job 时 state 会变成 NodeList

如果只有一个子 Job,父 Job 的 state 里可以直接挂一个 ChildHandleNode。如果继续启动 job2job3,父 Job 的 state 会升级成 NodeList,所有子节点都挂在这个链表里:

1
2
3
4
5
6
7
8
parentJob: JobImpl
  state -> NodeList
             |
             +-- ChildHandleNode(job=parentJob, childJob=job1)
             |
             +-- ChildHandleNode(job=parentJob, childJob=job2)
             |
             +-- ChildHandleNode(job=parentJob, childJob=job3)

这也是为什么父 Job 能统一管理多个子协程。它不是直接持有一个 children 集合,而是把子协程对应的 ChildHandleNode 挂在自己的 state 链表里。

子协程如何找到父协程

子协程发生异常时,会进入自己的完成流程。异常处理里有一步是 cancelParent(cause),它会通过 parentHandle 找到父 Job

1
2
3
4
job1.cancelParent(RuntimeException)
  -> parentHandle.childCancelled(cause)
  -> ChildHandleNode.childCancelled(cause)
  -> parentJob.childCancelled(cause)

所以“子协程把异常交给父协程”不是抽象说法,它实际就是通过 parentHandle -> ChildHandleNode -> parentJob.childCancelled 这条链路完成的。

Job 收到异常后,会根据自己的类型决定怎么处理。普通 JobImpl 会进入取消流程,SupervisorJobImplchildCancelled 直接返回 false,所以不会因为某个子协程失败就取消其它子协程。

父协程如何取消子协程

Job 被取消时,会遍历自己 state 里的节点。如果节点是 ChildHandleNode,就会调用它的 invoke,最后执行到子 JobparentCancelled

1
2
3
4
parentJob cancel
  -> 遍历 NodeList
  -> ChildHandleNode.invoke(cause)
  -> childJob.parentCancelled(parentJob)

所以“父协程取消子协程”走的是反方向:父 Job 从自己的 NodeList 找到每个 ChildHandleNode,再通过 childJob 找到对应子协程。

ChildCompletion 不是父子关系本身

后面看异常传播时还会遇到 ChildCompletion。它容易和 ChildHandleNode 混在一起。

ChildHandleNode 是父子关系建立时创建的节点,用来让父子 Job 互相找到。ChildCompletion 是父 Job 完成时发现还有子 Job 没结束,临时挂到子 Job 上的完成回调,用来等子 Job 收尾后继续父 Job 的完成流程。

简单区分:

1
2
ChildHandleNode:建立父子关系,父子互相找得到。
ChildCompletion:父 Job 等子 Job 完成,完成后继续收尾。

总结

CoroutineScope(Job() + context) 启动子协程时,父 Job 会先放在 scope 的 CoroutineContext 里。launch 创建 StandaloneCoroutine 后,AbstractCoroutine 构造阶段会从 parentContext[Job] 取出父 Job,然后调用 initParentJob

如果父 Job 不存在,子协程的 parentHandle 会是 NonDisposableHandle。如果父 Job 存在,initParentJob 会调用 parent.attachChild(child),创建 ChildHandleNode。这个节点的 job 指向父 JobchildJob 指向子 Job,子 JobparentHandle 再反向持有这个节点。

多个子协程启动后,父 Job 的 state 会升级成 NodeList,里面挂着多个 ChildHandleNode。子协程异常时,通过 parentHandle.childCancelled 找到父 Job;父协程取消时,通过遍历 NodeList 找到所有子 Job。协程的父子关系就是靠这条链路建立起来的。

Licensed under CC BY-NC-SA 4.0
使用 Hugo 构建
主题 StackJimmy 设计