Kotlin 协程源码的地图:如何读源码才不会迷失?

1,328 阅读10分钟

更多协程内容,请看我写的“极客时间”专栏 《Kotlin 编程第一课》

往期文章:

《00. 文章合集目录》

《10. 揭秘 Compose 原理》

《2 小时入门 Jetpack Compose》

你好,我是朱涛。

从这节课开始,我们就要进入源码篇协程的学习了。

通过前面协程篇的学习,我相信你已经可以深刻体会到我在13讲当中说的那句话了:协程是Kotlin最重要、最难学的特性。我们说协程重要,是因为它有千般万般的好:挂起函数、结构化并发、非阻塞、冷数据流,等等……

不过协程真的太抽象、太难学了。即使我们学完前面的协程篇以后,知道了协程的用法,但这还远远不够。这种“知其然,不知其所以然”的感觉,总会让我们心里不踏实。

所以,我们必须搞懂Kotlin协程的源代码。

可是,协程的源码真的太复杂了。如果你尝试去研究过协程的源代码,那么,你对此一定会深有体会。在Kotlin协程1.6.0版本,仅仅是kotlin.coroutines.core跟Jvm相关的源代码,就有27789行。如果算上JS平台、Native平台,以及单元测试相关的代码,Kotlin协程库当中的源代码有接近10万行。面对这么多的源代码,我们根本不可能一行一行去分析。

因此,我们研究Kotlin协程的源代码,也要有一定的技巧。这里主要有两点:

  • 第一点:理解Kotlin协程的源码结构。Kotlin协程的源代码,其实分布在多个模块之中,各个模块之中,会包含特定的协程概念。相应的,它的各个概念,其实是有特定的层级结构的,我们只有弄清楚各个概念之间的关系,并且建立一个类似“地图”的知识结构,我们在研究源码的时候,才不会那么容易迷失。
  • 第二点:明确研究源码的目标。正如我们前面提到的,我们不可能一次性看完协程所有的源代码,因此,我们在读源码的过程中,一定要有明确的目的。

在正式开始学习之前,我还是要提前给你打一剂预防针。课程进行到这个阶段,学习的难度就进一步提升了。不管是什么技术,研究它的底层原理永远不是一件容易的事情。因此,为了提高学习的效率,你一定要跟随课程的内容,去IDE里查看对应的源代码,一定要去实际运行、调试课程中给出的代码Demo。

好,我们正式开始吧!

协程源码的结构

在第13讲当中我们提到过,Kotlin协程是一个独立的框架。如果我们想要使用Kotlin协程,我们需要单独进行依赖。

那么,我们研究Kotlin协程,是不是只研究github.com/Kotlin/kotl…这个GitHub仓库的代码就够了呢?

其实不然。

这是因为,Kotlin的源码,其实分为了三个层级。自底向上,它们分别是:

  • 基础层:Kotlin库当中定义的协程基础元素
  • 中间层:协程框架通用逻辑kotlinx.coroutines-common;
  • 平台层:最后就是,协程在特定平台的实现,比如说JVM、JS、Native。

image.png

接下来,我们一个个来看。

基础层:协程基础元素

Kotlin协程的基础元素,其实是定义在Kotlin标准库当中的。

image.png

可以看到,协程当中的一些基础概念,比如:Continuation、SafeContinuation、CoroutineContext、CombinedContext、CancellationException、intrinsics这些概念,都是定义在Kotlin标准库当中的。

那么,Kotlin官方为什么要这么做呢?这其实是一种解耦的思想。Kotlin标准库当中的基础元素,它们就像是构造协程框架的“砖块”一样。简单的几个基础概念,将它们组合到一起,就可以实现功能强大的协程框架。

实际上,目前的kotlinx.coroutines协程框架,就是基于以上几种协程基础元素构造出来的。如果哪天GitHub上突然冒出一款新的Kotlin协程框架,你也不要觉得意外,因为构造协程的砖块就在那里,我们每个人都可以借助这些基础元素来构建自己的协程框架。

不过,目前来说,还是Kotlin官方封装的协程框架功能最强大,因此,我们开发者都会选择kotlinx.coroutines。我们都知道,Kotlin是支持跨平台的,所以,协程其实也存在跨平台的实现。在kotlinx.coroutines当中,也大致分了两层:common中间层、平台层。

中间层:kotlinx.coroutines-common

在kotlinx.coroutines的源代码当中,有一个common子模块,里面是Kotlin协程框架的通用逻辑。我们前面学过的大部分知识点,都来自于这个模块。比如:launch、async、CoroutineScope、CoroutineDispatcher、Job、Deferred、Channel、Select、Flow。

image.png

虽然,我们开发者使用那些底层的协程基础元素,也能够写代码,但它们终归是不如Flow之类的API好用的。

所以,kotlinx.coroutines其实就是Kotlin官方用底层的基础“砖块”,构造出的一个协程“大楼”。对于我们大部分开发者而言,我们并不希望自己动手用砖块去建造一栋大楼。

在这个common中间层里,只有纯粹的协程框架逻辑,不会包含任何特定的平台特性。我们都知道,Kotlin其实是支持3种平台的:JVM、JavaScript、Native。

对应平台的支持的逻辑,都在平台层当中。

平台层

在kotlinx.coroutines-core之下,有几个与common平级的子模块:JVM、JavaScript、Native。这里面,才是Kotlin协程与某个平台产生关联的地方。

image.png

我们都知道,Kotlin的协程,最终都是运行在线程之上的。所以,当Kotlin在不同的平台上运行的时候,它最终还是需要映射到对应的线程模型之上的。这里我们以JVM、JavaScript为例:

image.png

可以看到,同样的协程概念,在JVM、JavaScript两个平台上会有不同的实现:

  • 同样是线程,在JVM是线程池,而JavaScript则是JS线程;
  • 同样是事件循环,两者也会有不同的实现方式;
  • 同样是异步任务,JVM则是Future,JavaScript则是Promise。

可见,虽然协程的“平台层”是建立在Common层之上的,但它同时又为协程在特定平台上提供了对应的支持。

那么,到这里,我们就已经弄清楚了Kotlin协程的源码结构了。这个源码的结构,其实就相当于协程知识点的“地图”。有了这个地图以后,我们在后面遇到问题的时候,才知道去哪里找答案。

比如说:当我们想知道Kotlin的协程是如何运行在线程之上的,那么我们肯定要到平台层,去找JVM的具体实现。

如何研究协程源码?

读Kotlin协程的源代码,就像是一场原始森林里的探险一样。我们不仅要有一张清晰的地图,同时还要有明确的目标。如果我们没有明确的探索路线与目标,我们很容易就迷失其中,无法自拔。

在我们源码篇当中,之后的每一节课,目标都是非常明确:挂起函数的原理、协程启动原理、Dispatchers原理、CoroutineScope原理、Channel原理、Flow原理

不过,即使有了探索的目标,也还不够,在正式开始之前,我们还需要做一些额外的准备工作。

首先,我们要掌握好协程的调试技巧。在之后的课程当中,我们会编写一些简单的Demo,然后通过运行调试这些Demo,我们一步步去跟踪、分析协程的源代码。因此,如果你还没看过第14讲的内容,请一定要回过头去看看,其中关于协程调试的内容。

其次,我们要彻底弄懂协程的基础元素。前面我们提到过,Kotlin标准库当中的协程基础元素,它们就像是构建协程框架的“砖块”一样。如果我们对协程的基础元素一知半解的话,我们后面分析协程框架的过程中,就会寸步难行。

因此,协程源码的三层结构:基础层、中间层、平台层。我们必须自底向上,一步步进行分析。

image.png

Kotlin源码编译细节

我们在平时用Kotlin协程的时候,一般只会使用依赖的方式:

    implementation "org.jetbrains.kotlin:kotlin-stdlib"
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0'

不过,这种方式,我们会经常遇到某些类,看不到源代码实现。比如:kotlin.coroutines.intrinsics这个包下的代码。

image.png

在这里,我们也分享一下我读Kotlin源码的方式,给你作为参考。

当我们遇到依赖包当中无法查看的类时,这时候,我们就需要去GitHub下载:github.com/JetBrains/k…github.com/Kotlin/kotl…的源代码,然后按照我们上面画的“协程源码地图”去找对应的源代码实现。

在IDE当中导入这两个工程的时候,我们可能会遇到各种各样的问题。这时候,我们需要参考这两个链接里的内容:Coroutine Contributing GuidelinesKotlin Build environment requirements,配置好Kotlin、Coroutines的编译环境。

当我们完成这两个工程的导入工作以后,我们其实就可以看到Kotlin、协程所有的源代码了。这里不仅有它们的核心代码,还会有跨平台实现,编译器实现,以及对应的单元测试代码。

这样一来,我们在读Kotlin源码的时候,才会有更大的自由度。

小结

这节课的内容到这里就差不多结束了。接下来,我们来做一个简单的总结。

研究Kotlin协程的源代码,我们要注意两个要点:理解Kotlin协程的源码结构明确研究源码的目标。如果我们把读源码当做是一次原始森林的探险,那么前者就相当于我们手中的“探险地图”,后者,就相当于地图上的:“探索目标”和“行进路线”。

有了这两个保障以后,我们才不会轻易迷失在浩瀚的协程源码中。

对于协程的源码结构,主要是分为三层:

  • 基础层:Kotlin库当中定义的协程基础元素。如果说协程框架是一栋大楼,那么,这些基础元素,就相当于一个个的“砖块”。
  • 中间层:协程框架通用逻辑kotlinx.coroutines-common。协程框架里的:Job、Deferred、Channel、Flow,它们都是通过协程基础元素组合出来的高级概念。这些概念是跟平台无关的,不管协程运行在JVM、JS,还是Native,这些概念是不会变的。而这些概念的实现,全部都在协程的common中间层。
  • 平台层:最后就是,协程在特定平台的实现,比如说JVM、JS、Native。当协程要在某个平台运行的时候,它总是免不了要跟这个平台打交道。比如JVM,协程并不能脱离线程运行,因此,协程最终还是会运行在JVM的线程池当中。

其实,这节课的作用跟我们《协程篇第13讲》的作用是差不多的。毕竟,在探险之前,我们都是要做一些准备工作。

这节课,如果我一上来就给你贴一大堆源代码,开始跟你分析代码的执行流程,你一定会难以接受。所以,也请你不要轻视这节课的作用,一定要做好充足的准备,再出发。

思考题

在Kotlin协程的基础元素当中,最重要的,其实就是Continuation这个接口。不过,在Continuation.kt这个文件当中,还有两个重要的扩展函数:

public interface Continuation<in T> {
    
    public val context: CoroutineContext

    public fun resumeWith(result: Result<T>)
}

public fun <T> (suspend () -> T).createCoroutine(
    completion: Continuation<T>
): Continuation<Unit> =
    SafeContinuation(createCoroutineUnintercepted(completion).intercepted(), COROUTINE_SUSPENDED)

public fun <T> (suspend () -> T).startCoroutine(
    completion: Continuation<T>
) {
    createCoroutineUnintercepted(completion).intercepted().resume(Unit)
}

请问你能猜到它们的作用是什么吗?这个问题的答案,我会在第28讲给出答案。

fun main() {
    val continuation = suspend {
        println("Suspend Lambda")
        "Hello"
    }.startCoroutine(object : Continuation<String> {
        override val context: CoroutineContext
            get() = EmptyCoroutineContext

        override fun resumeWith(result: Result<String>) {
            println("resume")
        }
    })
}