1.前言
如果要我拿现实中的一事物与Kotlin协程中的Job做一个类比,那么我会把Job比作成海洋中的冰山。自由漂浮的冰山约有90%体积沉在海水表面下,因此看着浮在水面上的形状并猜不出水下的形状。与冰山一样,Job提供给开发者的功能非常简单,但是在协程框架内部Job却做了大量的工作。至关重要的是,如果开发者不去深入了解Job内部的实现机制,那么在使用协程的过程中,他就犹如开着船与冰山擦肩而过的船长,随时有可能面临系统崩溃的风险。如果你的项目中正在使用协程,如果你想享受协程编程带来的便利的同时又想保证程序的健壮性。那么深入理解Job的内部机制,会让你在遇到协程问题时,更从容,更游刃有余。
为了检验各位读者对协程Job的了解程度,我设计了两段代码,请问哪个Case “CancelJobActivity job2 finished”语句会被打印出来?
程序一:
private fun case1() { val scope = MainScope() scope.launch(Job()) { launch { delay(2000L) println("CancelJobActivity job1 finished") scope.cancel() } launch { delay(3000L) println("CancelJobActivity job2 finished") } } }
程序二:
private fun case2() { val scope = MainScope() scope.launch { launch { delay(2000L) println("CancelJobActivity job1 finished") scope.cancel() } launch { delay(3000L) println("CancelJobActivity job2 finished") } } }
上述两段代码的功能是:通过MainScope启动一个协程,在该协程中启动两个子协程。子协程1在delay 2s后打印语句并取消MainScope启动的协程,子协程2在delay 3s后打印语句。我们的期望是,子协程1在调用scope.cancel方法后,子协程2不会输出语句。程序二如我们所愿,但是程序一在3s后仍然输出了。显然程序一的结果不被我们接受。它们唯一的区别就是在scope.launch处是否有Job()参数。
为什么launch()方法增加Job()参数,就无法取消掉协程呢?要搞懂这个问题,就必须先搞懂,父子协程中的数据结构。引用Google官方公开的图片,我们可以看到,这是典型的树数据结构。
2. 原因分析
- 根据MainScope源码,我们知道scope对应的Job类型是SupervisorJob。
- launch方法创建的协程本身也是Job类型。而且它与启动该协程的CoroutineContext[Job]形成父子关系。
- launch(Job())根据CoroutineContext的plus方法,会用新的newJob代替SupervisorJob。
public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)
- CoroutineScope.cancel方法,正是coroutineContext[Job]对应的Job cancel的。
public fun CoroutineScope.cancel(cause: CancellationException? = null) { val job = coroutineContext[Job] ?: error("Scope cannot be cancelled because it does not have a job: $this") job.cancel(cause) }
程序二的关系图
程序一的Job关系图
从Job关系图,我们看出MainScope对应的SupervisorJob在程序一中,单独游离出来,并未与Job0、Job1、Job2形成有效的树形结构关系,所以通过scope.cancel()无法取消。
最后
Job相关的文章我构思了很久,由于Job比较复杂,一时间竟无从下手。本文权当一个起笔,Job原理与源码相结合的文章,后续会陆续更新。本文中的案例仅仅是Job难题中的冰山一角,cancel和异常处理机制,与Job树形结构关系图可以变化出很多种情况,但是如果能真正理解原理,处理起来也并不麻烦。下次我会从源码的角度,讲解Job的树形结构关系是如何建立起来的,以及Job的cancel机制是如何双向传播和异常处理机制是如何向上传播的。