最后一次,彻底搞懂kotlin协程(三) 什么是《结构化并发》?

订阅Kotlin Coroutine 专栏,获取更新

  1. 最后一次,彻底搞懂kotlin协程(一) 先回到线程
  2. 最后一次,彻底搞懂kotlin协程(二) 线程池,Handler,Coroutine
  3. 最后一次,彻底搞懂kotlin协程(三) CoroutineScope,CoroutineContext,Job: 结构化并发

引言

当我发现我不停的看到关于Kotlin协程的文章的时候,我突然意识到:可能现有的文章并没有很好的解决大家的一些问题。在看了一些比较热门的协程文章之后,我确认了这个想法。此时我想起了一个古老的笑话:当一个程序员看到市面上有50种框可用架的时候,决心开发一种框架把这个框架统一起来,于是市面上有了51种框架我最终还是决定:干,因为异步编程实在是一个过于重要的部分。

我总结了现存资料所存在的一些问题:

  1. 官方文档侧重于描述用法,涉及原理部分较少。如果不掌握原理,很难融会贯通,使用时容易踩坑
  2. 部分博客文章基本是把官方文档复述一遍,再辅之少量的示例,增量信息不多
  3. 不同的博客文章之间内容质量参差不齐,理解角度和描述方式各不相同,甚至有部分概念未经验证就草率列出反而混淆了认知,导致大家更加难以理解
  4. 部分博客文章涉及大量源码相关内容,但描述线索不太容易理解,缺乏循序渐进的讲述和一些关键概念的铺垫和澄清

而为什么 coroutine 如此难以描述清楚呢?我总结了几个原因:

  1. 协程的结构化并发写法(异步变同步的写法)很爽,但与之前的经验相比会过于颠覆,难以理解
  2. 协程引入了不少之前少见的概念,CoroutineScope,CoroutineContext... 新概念增加了理解的难度
  3. 协程引入了一些魔法,比如 suspend,不仅是一个关键字,更在编译时加了料,而这个料恰好又是搞懂协程的关键
  4. 协程的恢复也是非常核心的概念,是协程之所以为协程而不单单只是另一个线程框架的关键,而其很容易被一笔带过
  5. 因为协程的"新"概念较多,技术实现也较为隐蔽,所以其主线也轻易的被掩埋在了魔法之中

那么在意识到了理解协程的一些难点之后,本文又将如何努力化解这些难题呢?我打算尝试以下一些方法:

  1. 从基础的线程开始,澄清一些易错而又对理解协程至关重要的概念,尽量不引入对于理解协程核心无关的细节
  2. 循序渐进,以异步编程的发展脉络为主线,梳理引入协程后解决了哪些问题,也破除协程的高效率迷信
  3. 物理学家费曼有句话:"What I cannot create, I do not understand",所以我会通过一些简陋的模拟实现,来降低协程陡峭的学习曲线
  4. 介绍一些我自己的独特理解,并引入一些日常场景来加强对于协程理解
  5. 加入一些练习。如看不练理解会很浅,浅显的理解会随着时间被你的大脑垃圾回收掉,然后陷入不停学习的陷阱(这也是本系列标题夸口"最后一次"的原因之一)

在上一篇线程池篇中我们破除了关于协程高效的迷思,在最后我们提到了协程真正的优势。在这一篇中我们来讨论其中第一个优势:结构化并发。在进入到正文之前,先想一想像下面的一些问题,带着问题阅读效果更佳。

CoroutineScope 到底是啥,为什么不推荐使用 GlobalScope?Android 中扩展的 viewModelScope,lifeCycleScope 又是怎么来的?为什么看了 CoroutineScope 的源码还是看一头雾水?CoroutineScope 里唯一的 CoroutineContext 又是啥?结构化并发到底是啥意思?

相信很多同学在学习 kotlin 协程的过程中或多或少都曾有类似的困惑,下面我们就从 CoroutineScope 开始,一一解答这些问题。

CoroutineScope

一个最典型的使用协程的场景,就是通过一个 scope 的 launch 方法来启动一个 Coroutine。正如在线程篇中我们澄清了"真实线程"与 Thread 对象的区别,现在我们需要来区分一下协程与 Coroutine,便于后面的描述,后面当提到协程时指代协程框架,提到 Coroutine 时指代在协程框架中运行的一个具体的 Coroutine,在下面的例子中可以认为是 GlobalScope.launch 后面的 lambda 块,我们以一个最简单的例子开始:

kotlin 复制代码
// Coroutine1.kt
fun main() {
    GlobalScope.launch {
        printlnWithThread("end")
    }

    Thread.sleep(100)
}

// log
DefaultDispatcher-worker-1: end

此时如果我们点开 GlobalScope 的源码会发现它实现了 CoroutineScope 接口,其中并没有 launch 方法,只有一个 coroutineContext 属性

kotlin 复制代码
@DelicateCoroutinesApi
public object GlobalScope : CoroutineScope {
    /**
     * Returns [EmptyCoroutineContext].
     */
    override val coroutineContext: CoroutineContext
        get() = EmptyCoroutineContext
}

public interface CoroutineScope {
    public val coroutineContext: CoroutineContext
}

如果我们直接点进 launch 方法会发现这是一个 CoroutineScope 的扩展方法,这是 kotlin 协程实现的一大特点,海量使用扩展方法

kotlin 复制代码
public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
  	// 1.如果未指定 Dispatcher(其实是ContinuationInterceptor), 则默认加上Dispatchers.Default
    val newContext = newCoroutineContext(context)
  	//2. 构造 coroutine
    val coroutine = if (start.isLazy)
        LazyStandaloneCoroutine(newContext, block) else
        StandaloneCoroutine(newContext, active = true)
  	// 3. start coroutine
    coroutine.start(start, coroutine, block)
  	// 4. 返回这个 coroutine
    return coroutine
}

可以看到在 launch 方法里面创建了一个 Coroutine,并且把我们的 lambda block 与 Coroutine 关联起来,然后启动了这个 Coroutine 并立即返回。仔细的同学可能发现 launch 方法的返回类型是 Job,我们来看看这个返回的 Coroutine 是啥:

kotlin 复制代码
// Coroutine2.kt
fun main() {
    val job = GlobalScope.launch {
        printlnWithThread("end")
    }
    println(job)
}

// log
StandaloneCoroutine{Active}@6b09bb57

可以看到 job 的类型是 StandaloneCoroutine,关于 Coroutine 与 Job 的关系我们在下面 Job 那节详细讲述。

如果我们把 Coroutine1.kt 中的示例代码放在 IDE 中会发现 GlobalScope 被标黄警告:This is a delicate API and its use requires care 。大多数情况下我们不应该使用 GlobalScope,因为 GlobalScope 无法被取消,容易造成内存泄漏,其原因如下:

kotlin 复制代码
// 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)
}

根据上面的给出的 GlobalScope 源码,GlobalScope 复写了 coroutineContext 的 get 方法,导致在调用其 cancel 方法时无法获取到真实的 coroutineContext 对象而抛出错误。所以我们一般直接使用扩展的 scope 对象或者构造一个 CoroutineScope 对象来使用,为了方便描述技术细节,本文采用后者。下面我们再来看看 CoroutineScope 中的唯一属性 CoroutineContext。

CoroutineContext

我们同样从一个示例开始认识 CoroutineContext

kotlin 复制代码
// Coroutine3.kt
fun main() {
    val coroutineScope = CoroutineScope(Dispatchers.Default)
    println(coroutineScope.coroutineContext)
}

// log
[JobImpl{Active}@5622fdf, Dispatchers.Default]

打印出来的结果像是一个数组,包含了我们在创建 CoroutineScope 时传入的 Dispatchers.Default,还包含了一个我们并未传入的 JobImpl,看来在创建 CoroutineScope 时发生了什么,我们看看关键源码:

kotlin 复制代码
// 1
public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
    ContextScope(if (context[Job] != null) context else context + Job())

// 2
internal class ContextScope(context: CoroutineContext) : CoroutineScope {
    override val coroutineContext: CoroutineContext = context
}

// 3
public interface Job : CoroutineContext.Element
public interface Element : CoroutineContext

// 4
internal class CombinedContext(
    private val left: CoroutineContext,
    private val element: Element
) : CoroutineContext, Serializable {
    override fun toString(): String =
      "[" + fold("") { acc, element ->
          if (acc.isEmpty()) element.toString() else "$acc, $element"
      } + "]"
}

part1 的 CoroutineScope 并非 CoroutineScope 的构造函数,而是一个公共函数,返回同名对象(在 kotlin 中是常见操作,方法名大写也是合法的),这个函数被称为 builder函数 。在这里创建的实例是 ContextScope(part2)。在 CoroutineScope 方法里传递了 context,这里可以看到 CoroutineContext 的使用方式像一个 Map,可以从中根据 Key 取值,还可以通过重载的 plus operator 加上一个 Job,说明 Job 也是 CoroutineContext,可参考 part3。重载的 plus operator 返回 CombinedContext,即 part4,也是我们 ContextScope 里的 CoroutineContext 实例,其 toString 方法确实打印出了类似数组的格式,符合上面示例的 log 的输出。

从上面的例子可以看出来 CoroutineContext 像一个 Map。那 CoroutineContext 到底是什么,这个 map 里通常又都装些什么呢?第一个问题,我们可以参考 Android 中的 context 可以推测其应该是执行 Coroutine 时的相关上下文。第二个问题,下面我们结合 withContext (奇怪的方法名) 来介绍几种常用的 CoroutineContext。

1. Dispatcher

kotlin 复制代码
// Coroutine4.kt
fun main() {
    val coroutineScope = CoroutineScope(Dispatchers.Default)

    coroutineScope.launch {
        // 1. context[ContinuationInterceptor] = Dispatchers.Default
        printlnWithThread("work1")
        val work2 = withContext(Dispatchers.IO) {
            // 2. context[ContinuationInterceptor] = Dispatchers.IO
            printlnWithThread("do work2 ...")
            "work2"
        }
        // 3. // context[ContinuationInterceptor] = Dispatchers.Default
        printlnWithThread(work2)
    }

    Thread.sleep(100)
}

// log
DefaultDispatcher-worker-1: work1
DefaultDispatcher-worker-1: do work2 ...
DefaultDispatcher-worker-3: work2

Dispatcher 应该是我们使用 withContext 最常使用的参数了,我们可以用 withContext 来改变后面 lambda block 中的执行线程,可以对比一下 work1 和work2 的执行线程。没错,确实改变了!!!那 log 里为什么 work1 和 do work2用的是同一个线程呢?因为 Dispatchers.IO 和 Dispatchers.Default 会共用缓存 的线程池,后面讲协程里的线程池时会详细展开。当 DefaultDispatcher-worker-1 线程执行完 work1 之后线程就空闲出来了,使用 Dispatchers.IO 的时候便复用了这个线程,所以看起来是一样的。那为什么后面打印 work2 又在 DefaultDispatcher-worker-3 线程呢?因为线程的任务执行本身是相互竞争的,withContext 会启动一个新的协程,这个协程可能会被其他线程获取到。如果你执行多次的话会发现每次打印的线程都可能会变化,自己动手试试。

2. CrotoutineName

kotlin 复制代码
// Coroutine5.kt
fun main() {
    val coroutineScope = CoroutineScope(CoroutineName("outer coroutine"))

    coroutineScope.launch {
        // // coroutineContext 来自于 lambda block 的 receiver - CoroutineScope
        println(coroutineContext[CoroutineName].toString()) // 1
        withContext(Dispatchers.IO) {
            println(coroutineContext[CoroutineName].toString()) // 2
        }
        withContext(CoroutineName("inner coroutine")) {
            println(coroutineContext[CoroutineName].toString()) // 3
        }
        println(coroutineContext[CoroutineName].toString()) // 4
    }

    Thread.sleep(100)
}

// log
CoroutineName(outer coroutine)
CoroutineName(outer coroutine)
CoroutineName(inner coroutine)
CoroutineName(outer coroutine)

CrotoutineName 用于为 Coroutine 命名,主要用于 debug。coroutineContext[CoroutineName] 这种写法来自于 Kotlin 对于 companion object 的简便写法,等价于 coroutineContext[CoroutineName.Key], Key 的定义为 CoroutineContext.Key<CoroutineName>,也就是 coroutineContext.get(key: Key<E>) 的参数。part1 在 launch 内从 coroutineContext 中取出 CrotoutineName 是我们构造时传入的。part2 我们通过 withContext 传入 Dispatchers.IO,取出的 CrotoutineName 并未发生变化。part3 在传入新的 CoroutineName("inner coroutine") 之后取出的 CrotoutineName 就变成了新传入的。通过上面的例子我们可以得知:

  1. withContext 会继承外部 Coroutine 的 CoroutineContext(withContext 并不是只能用来流转任务到其他线程,本质是使用传入的参数改变其后面的 Coroutine 的 CoroutineContext,这便是其名字的由来)
  2. withContext 后面的 Coroutine 的 CoroutineContext 会使用传入的参数覆盖对应的 Key 的值
  3. 从 part4 可以看出,withContext 传入的 CoroutineContext 只会影响后面的 Coroutine,不会影响到外部

3. SupervisorJob

SupervisorJob 用于防止直接子协程的异常导致自己和自己的其他直接子协程被取消

kotlin 复制代码
// Coroutine6.kt
fun main() {
    val commonCoroutineScope = CoroutineScope(CoroutineName("non-SupervisorJob"))
    val supervisorJobCoroutineScope = CoroutineScope(SupervisorJob())

    // 1.1
    commonCoroutineScope.launch { throw RuntimeException() }
    // 1.2
    commonCoroutineScope.launch {
        delay(100L)
        println("will not print")
    }

    // 2.1
    supervisorJobCoroutineScope.launch { throw RuntimeException() }
    // 2.2
    supervisorJobCoroutineScope.launch {
        delay(100L)
        println("will print")
    }

    Thread.sleep(200)
}

// log
Exception in thread "DefaultDispatcher-worker-3" Exception in thread "DefaultDispatcher-worker-1" java.lang.RuntimeException
	at com.hunter.kotlin.coroutine.learn.coroutine.Coroutine6Kt$main$3.invokeSuspend(Coroutine6.kt:19)
...
java.lang.RuntimeException
	at com.hunter.kotlin.coroutine.learn.coroutine.Coroutine6Kt$main$1.invokeSuspend(Coroutine6.kt:13)
...
will print

示例中 commonCoroutineScope 启动的的第二个协程会因为第一个启动的协程抛出异常而被 cancel,因而不会打印日志。supervisorJobCoroutineScope 中加入了 SupervisorJob,所以其第二个协程仍然被打印了

这里我们需要停下来看看 child job 是怎么来的。在上面的例子中 SupervisorJob 是 supervisorJobCoroutineScope 的 coroutineContext 中的 Job,前面我们提到当 CoroutineScope launch 时会创建一个 Coroutine,即 Job,当初始化这个 Job 时,会以 CoroutineContext 中的 Job 为新 Job 的 parentJob,我们看一下其中的关键源码:

kotlin 复制代码
// 1. launch
public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
        LazyStandaloneCoroutine(newContext, block) else
        StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}

// 2. LazyStandaloneCoroutine,StandaloneCoroutine 继承自 AbstractCoroutine
public abstract class AbstractCoroutine<in T>(
    parentContext: CoroutineContext,
    initParentJob: Boolean,
    active: Boolean
) : JobSupport(active), Job, Continuation<T>, CoroutineScope {
      // initParentJob 
      init {
        if (initParentJob) initParentJob(parentContext[Job])
    }
}

// 3. SupervisorJobImpl
private class SupervisorJobImpl(parent: Job?) : JobImpl(parent) {
    // Child was cancelled with a cause. In this method parent decides whether it cancels itself
    override fun childCancelled(cause: Throwable): Boolean = false
}

如果我们把上面例子中 commonCoroutineScope 与 supervisorJobCoroutineScope 内的 coroutineContext 和 Job 的关系表示为图,其结构大致如下:

JobImpl vs SupervisorJob

在 commonCoroutineScope 中,因为第一个 launch 中产生了异常,导致取消了 parent 和第二个 launch 的 Job。在 supervisorJobCoroutineScope 中,因为 SupervisorJobImpl override childCancelled,返回了 false,阻断了异常在 Job 链中的传播,所以第二个 launch 中的内容被打印了出来。

我们马上来练习一下,看看下面这个例子中的代码,想想会打印出什么?

kotlin 复制代码
// Coroution7.kt
fun main() {
    val supervisorJobCoroutineScope = CoroutineScope(SupervisorJob())
    
    // 1
    supervisorJobCoroutineScope.launch {
        // 2
        launch { throw RuntimeException() }
        //3
        launch {
            delay(100L)
            println("will print 1?")
        }
    }

    Thread.sleep(200)

    // 4
    supervisorJobCoroutineScope.launch {
        println("will print 2?")
    }

    Thread.sleep(100)
}

按照 launch 出现的顺序编号。在外层的 launch 中就会启动一个 StandaloneCoroutine1,里面的两个 launch 启动的 StandaloneCoroutine 都是以这个外层的 StandaloneCoroutine 为 parent,而不是顶层的 SupervisorJobImpl,所以 StandaloneCoroutine2 的异常自然会影响到 StandaloneCoroutine1 和 StandaloneCoroutine3,而因为 SupervisorImpl 的存在,不会影响到 StandaloneCoroutine4。所以答案是:

kotlin 复制代码
Exception in thread "DefaultDispatcher-worker-2" java.lang.RuntimeException
	at ...
will print 2?

只要我们再画一下整个关系图就会更清楚:

SupervisorJob

4. CoroutineExceptionHandler

根据 CoroutineExceptionHandler 的文档描述,其类似于 Thread 的 uncaughtExceptionHandler。这主要体现在 CoroutineExceptionHandler 只能用于根协程(通过 builder 函数创建的最外层的协程),因为子协程通常把异常委托给父协程处理,直到最外层即根协程,就是一个冒泡模型,看看下面的例子,先想一想会打印出什么

kotlin 复制代码
// Coroutine8.kt
fun main() {
    // handler
    val coroutineExceptionHandler1 =
        CoroutineExceptionHandler { _, throwable -> println("record1 $throwable to log") }
    val coroutineExceptionHandler2 =
        CoroutineExceptionHandler { _, throwable -> println("record2 $throwable to log") }

    // 1
    val handlerCoroutineScope = CoroutineScope(coroutineExceptionHandler1)
    handlerCoroutineScope.launch {
        runThrow("exception 1.1")
        delay(100)
        println("will it print 1.1?")
    }
    Thread.sleep(100) // 在下面的 launch 之前抛出上面的异常
    handlerCoroutineScope.launch {
        launch(coroutineExceptionHandler2) { runThrow("exception 1.2") }
    }
    handlerCoroutineScope.launch {
        delay(100L)
        println("will it print 1.2?")
    }

    Thread.sleep(150)
    println("------------")

    // 2
    val supervisorAndHandlerCoroutineScope =
        CoroutineScope(SupervisorJob() + coroutineExceptionHandler1)
    supervisorAndHandlerCoroutineScope.launch {
        runThrow("exception 2.1")
        delay(100)
        println("will it print 2.1?")
    }
    Thread.sleep(100) // 在下面的 launch 之前抛出上面的异常
    supervisorAndHandlerCoroutineScope.launch {
        launch(coroutineExceptionHandler2) { runThrow("exception 2.2") }
    }
    supervisorAndHandlerCoroutineScope.launch {
        delay(100L)
        println("will it print 2.2?")
    }

    Thread.sleep(200)
}

fun runThrow(exception: String): Unit = throw java.lang.RuntimeException(exception)

上面示例中 part1 因为使用了 CoroutineExceptionHandler,所以并不会抛出异常,而是会走到 coroutineExceptionHandler1 的处理逻辑中,所以 exception 1.1 的处理会被打印出来。但 part1 的第一个 launch 也会被打断,无法恢复 之前的流程(恢复的方式是直接使用 try catch)。注意我们在第一个 launch 之后 sleep 了,这是为了等待第一个 launch 抛出异常,虽然异常被 coroutineExceptionHandler1 处理了,但不会阻止异常传播 ,会导致 parentJob 和 other childJob 被 cancel,所以 part1 中其他 launch 的内容不会执行。

在 part2 中,scope 的 coroutineContext 同样包含了 CoroutineExceptionHandler,所以 exception 2.1 也会被 coroutineExceptionHandler1 所处理打印,同理,第一个 launch 也会被中断而不会输出后续的打印。但因为使用了 SupervisorJob,所以第二个 launch 会运行,并且异常会被打印,但 launch 内部的 luanch 启动的子协程会把异常委托给父协程,所以并不会使用 coroutineExceptionHandler2 来处理异常,最终依然被 coroutineExceptionHandler1 处理并打印,最后的 luanch 也不会被取消,所以最终日志如下:

kotlin 复制代码
// log
record1 java.lang.RuntimeException: exception 1.1 to log
------------
record1 java.lang.RuntimeException: exception 2.1 to log
record1 java.lang.RuntimeException: exception 2.2 to log
will it print 2?

5. NonCancellable

名字很直观,就是让被其作用的协程不可取消。比如当一个协程由于未处理异常或者其 scope 被取消了,导致其被取消,此时需要在一定时间后去释放资源,我们可以使用 try finally,在 finally 中释放资源,如果我们在 finally 中启动一个协程去等待和释放资源,会因为父协程被取消的原因而启动失败,此时就需要 NonCancellable 登场了:

kotlin 复制代码
// Coroutine9.kt
val coroutineExceptionHandler =
    CoroutineExceptionHandler { _, throwable -> println("record $throwable to log") }

fun main() {
    coroutineCancel()
    coroutineNonCancelable()

    Thread.sleep(1100)
}

private fun coroutineCancel() {
    val coroutineScope = CoroutineScope(coroutineExceptionHandler)
    coroutineScope.launch {
        try {
            println("lock resource 1")
            runThrow("exception 1")
        } finally {
            launch {
                delay(1000)
                println("release resource 1")
            }
        }
    }
}

private fun coroutineNonCancelable() {
    val coroutineScope = CoroutineScope(coroutineExceptionHandler)
    coroutineScope.launch {
        try {
            println("lock resource 2")
            runThrow("exception 2")
        } finally {
            withContext(NonCancellable) {
                delay(1000)
                println("release resource 2")
            }
        }
    }
}

// log
lock resource 1
lock resource 2
record java.lang.RuntimeException: exception 1 to log
release resource 2
record java.lang.RuntimeException: exception 2 to log

从 log 中可以看出只有使用了 NonCancellable 的 resource 2 被成功释放。NonCancellable 的文档说其设计是搭配使用 withContext,但我测试发现配合 launch 也可以正常工作。NonCancellable 的原理是复写了其isActive属性为 true,isCancelled 属性为 false,让其可以正常执行而不会被取消。

根据我们介绍的这些常用的 CoroutineContext,可以看出来这些 CoroutineContext 主要与Coroutine 的运行方式,异常处理等相关,并且 CoroutineContext 会在父协程和子协程中传递。在上面的例子中我们已经涉及到了一些 Job,下面我们就详细介绍一下 Job

Job

后台工作。 从概念上讲,工作是一个可以取消的事物,其生命周期最终会完成。

上面是官方文档对于 Job 的定义,从中我们发现有两个关键词:生命周期取消 。正如 CoroutineContext 类似于 Android 的 Context 一样。这里的生命周期也可以对应 Android 组件的生命周期,用图表示 Job 的生命周期如下:

Job Lifecycle

我们来结合一个示例来认识一下 Job 的状态变化:

kotlin 复制代码
// Coroutine10.kt
fun main() {
    val job1 = createJob()
    println("----------")
    job1.complete()
    printJobState(job1)

    println()

    val job2 = createJob()
    println("----------")
    job2.cancel()
    printJobState(job2)
}

private fun createJob(): CompletableJob {
    val job = Job()
    println(job)
    printJobState(job)
    job.invokeOnCompletion { println("$job completed") }
    return job
}

private fun printJobState(job: CompletableJob) {
    println("isActive: ${job.isActive}")
    println("isCompleted: ${job.isCompleted}")
    println("isCancelled: ${job.isCancelled}")
}

// log
JobImpl{Active}@6fb554cc
isActive: true
isCompleted: false
isCancelled: false
----------
JobImpl{Completed}@6fb554cc completed
isActive: false
isCompleted: true
isCancelled: false

JobImpl{Active}@34b7bfc0
isActive: true
isCompleted: false
isCancelled: false
----------
JobImpl{Cancelled}@34b7bfc0 completed
isActive: false
isCompleted: true
isCancelled: true

job1 创建出来就是 ACTIVE,因为 Job 默认会直接 start。当调用 complete 方法后,会把 job1 的状态改变为 COMPLETED。job2 调用 cancel,则 isCompleted 和 isCancelled 都为 true,因为 canceled 也是一种终结状态,自然 isCompleted 也是 true。

那么 NEW 状态什么时候会出现呢?如果查看 Job 源码我们会发现并没有相关的方式可以控制。我们平常并不会直接使用 Job,如果查看 Job 的继承关系会发现主要有三类

  1. Job 类
  2. Coroutine 类
  3. Deferred 类(后面到 async 时再讲)

第一类 Job 中主要有两种,其中一种是 SupervisorJobImpl,在 CoroutineContext 章节中我们已经介绍了,另外一种是 ParentJob 和 ChildJob,主要用来构建 Job 之间的父子关系,这也是结构化并发控制的基础,通常也不会直接使用。

Coroutine 类,是我们平常会使用 Job 方式。也是上面 NEW 状态何时出现的答案,我们看看这个示例:

kotlin 复制代码
// Coroutine11.kt
fun main() {
    val coroutineScope = CoroutineScope(Dispatchers.Default)

    val job = coroutineScope.launch(start = CoroutineStart.LAZY) {
        println("lazy")
    }
    println(job)

    Thread.sleep(100)
    println("will not start automatically")

    job.start()
    Thread.sleep(100)
    println(job)
}

// log
LazyStandaloneCoroutine{New}@6fd02e5
will not start automatically
lazy
LazyStandaloneCoroutine{Completed}@6fd02e5

可以看到 job 一开始的状态并不是 Active 了,而是 NEW。其原因在于我们使用 launch 启动一个协程的时候为 CoroutineStart 参数传入了 CoroutineStart.LAZY,默认参数 CoroutineStart.DEFAULT 会直接启动。使用 CoroutineStart.LAZY 我们需要手动调用 start(),在执行完成之后 job 的状态会自动变为 COMPLETED。在使用 launch,async 等协程 builder 函数时启动的 Coroutine 是我们大多数时候用到 Job 的方式,Coroutine 类都实现了 ChildJob, ParentJob,这也是 Coroutine 支持结构化并发的基础。

结构化并发

讲到这里,结构化并发这个词已经出现了多次,我们有必要正式来认识一下这个概念了。

结构化并发是一种旨在通过使用结构化的并发编程方法来改善计算机程序的清晰度、质量和开发时间的编程范式 - wiki

结构化并发并不神秘,它是一种通用的编程范式,在 kotlin 协程里根据实现方式体现为以下几点:

  1. 父子关系: 在结构化并发中,每个协程都有一个父 Coroutine(除了根 Coroutine)。当在一个 Coroutine 中启动另一个 Coroutine 时,后者就成为前者的子协程,这种父子关系形成了协程的层级结构,如上面的关系图所示。
  2. 自动管理: 结构化并发使得父协程能够自动管理其子协程。当父协程被取消时,所有的子协程也会被取消,避免潜在的内存泄漏问题。
  3. 异常处理: 结构化并发也使得异常处理变得更加容易。当一个子 Coroutine 抛出异常时,它会被传播到父 Coroutine,而父 Coroutine 可以选择如何处理这些异常,例如日志记录、重试或者终止应用程序。

这三种特性我们在上面的例子中都或多或少的有涉及到,下面我们再用一个示例中来集中看看上面这几个特性:

kotlin 复制代码
// Coroutine12.kt
fun main() {
    val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("========================")
        println("record $throwable to log")
    }
    val coroutineScope = CoroutineScope(SupervisorJob() + coroutineExceptionHandler)

    // 1.启动协程
    coroutineScope.launch {
        // coroutineContext 来自上面外层 launch 的 receiver
        val outerCoroutineContext = coroutineContext

        // 2.启动子协程
        launch {
            // coroutineContext 来自上面内层 launch 的 receiver
            val middleCoroutineContext = coroutineContext

            // 3. 子协程再启动孙协程
            launch {
                val innerCoroutineContext = coroutineContext
                printJobAndChild("outerContextJob", outerCoroutineContext)
                printJobAndChild("middleContextJob", middleCoroutineContext)
                printJobAndChild("innerContextJob", innerCoroutineContext)

                delay(100)
                println("grandson1 will not be cancel due to supervisorJob")
                delay(100)
                println("grandson1 will be cancel by coroutineScope.cancel")
            }
        }
    }

    // 4.启动协程 throw exception
    coroutineScope.launch {
        delay(50)
        throw RuntimeException("outerJob2 exception")
    }

    // 5. 150ms 后取消 coroutineScope
    Thread.sleep(150)
    coroutineScope.cancel()
}

fun printJobAndChild(jobName: String, coroutineContext: CoroutineContext) {
    println("$jobName: ${coroutineContext[Job]}")
    println("${jobName}-Child: ${coroutineContext[Job]!!.children.toList()}")
    println()
}

// log
outerContextJob: StandaloneCoroutine{Completing}@5e5243bf
outerContextJob-Child: [StandaloneCoroutine{Completing}@38b6b18d]

middleContextJob: StandaloneCoroutine{Completing}@38b6b18d
middleContextJob-Child: [StandaloneCoroutine{Active}@1af7738f]

innerContextJob: StandaloneCoroutine{Active}@1af7738f
innerContextJob-Child: []

========================
record java.lang.RuntimeException: outerJob2 exception to log
grandson1 will not be cancel due to supervisorJob

我们在 part1 中启动一个 Coroutine,然后在 part4 中又启动了一个 Coroutine。part4 中的 Coroutine 在 delay 50ms 后抛出异常,因为我们使用了 supervisorJob,part1 启动的协程不会被取消,同时因为 coroutineExceptionHandler,part4 中的 Coroutine 抛出的异常不会以打印堆栈信息的方式出现,而会被 coroutineExceptionHandler (异常)处理

回到 part1,在 part2 内部启动了一个 Coroutine,我们在 part3 内部打印了从内到外的三层 job(Coroutine) 以及他们的 childJob,我们从 log 的上半部分可以看出来 outerContextJob -> middleContextJob -> innerContextJob 是一个依次传递的父子关系,并且 innerContextJob 不再有子 Coroutine。

150 ms 后我们调用 coroutineScope.cancel,coroutineScope 内的 coroutine 会被依次自动取消,所以 innerContextJob 的最后一句不会打印。

我们用上面的例子诠释了结构化并发的三个重要概念,最后我们再次用一幅图来表示结构化并发:

Structured Concurrency

总结

通过上面的例子我们知道 CoroutineScope,CoroutineContext,Job 三者关系紧密。 Job 是 CoroutineContext 的一个类型,CoroutineContext 被 CoroutineScope 所包含。 那 CoroutineScope 的意义是什么呢?直接使用 CoroutineContext 好像也可以?我认为这样设计的目的是出于语义准确性 ,Scope 代表了一个范围,这个范围从 Scope 被创建到被 cancel。范围其实也可以用另外一个我们熟悉的词来表达:生命周期,从 onCreate 到 onDestroy。这也是我们能在 android 中轻易使用各种扩展 scope 的原因,因为他们从概念上相容性极高。而 Context 是用于表示这个范围实体的上下文,所以才有了上述的设计结构,这个设计也支持了结构化并发。

在这篇文章之后相信大家对于文章开头的多数问题已经有了答案,关于 Android 扩展的 scope 部分可以通过下面的练习来掌握。下一篇我们介绍 Kotlin 协程最神秘的特性:异步变同步,下一篇见。

最后再谈谈这个文章系列:

  1. 首先从我个人的角度出发,肯定无法完全枚举大家理解协程的难点,大家可以在评论区中回复在学习和使用 Kotlin Coroutine 时最让自己困惑的点,在评论区中交流讨论,我也尽量在后续的系列文章中涵盖

  2. 其次个人的能力有限,难免出错。请大家在评论区中不吝指正,我会及时勘误,把错误的传播范围尽量降低

示例源码:github.com/chdhy/kotli...

练习:

  1. 使用协程为 Android 的 Activity 扩展一个 lifecycleScope 属性,实现安全的并发,避免内存泄漏
  2. 使用线程池的方式,实现 Coroutine12.kt 中同样的并发结构和 log 输出,体会一下结构化并发带来的好处
相关推荐
一丝晨光1 小时前
Java、PHP、ASP、JSP、Kotlin、.NET、Go
java·kotlin·go·php·.net·jsp·asp
GEEKVIP1 小时前
手机使用技巧:8 个 Android 锁屏移除工具 [解锁 Android]
android·macos·ios·智能手机·电脑·手机·iphone
model20053 小时前
android + tflite 分类APP开发-2
android·分类·tflite
彭于晏6893 小时前
Android广播
android·java·开发语言
与衫4 小时前
掌握嵌套子查询:复杂 SQL 中 * 列的准确表列关系
android·javascript·sql
500了11 小时前
Kotlin基本知识
android·开发语言·kotlin
人工智能的苟富贵12 小时前
Android Debug Bridge(ADB)完全指南
android·adb
小雨cc5566ru16 小时前
uniapp+Android面向网络学习的时间管理工具软件 微信小程序
android·微信小程序·uni-app
bianshaopeng18 小时前
android 原生加载pdf
android·pdf
hhzz18 小时前
Linux Shell编程快速入门以及案例(Linux一键批量启动、停止、重启Jar包Shell脚本)
android·linux·jar