《深入理解Kotlin协程》读书笔记二

510 阅读10分钟

第三章

第三章 Kotlin协程的基础设施

协程,就是一个支持挂起和恢复的程序,而Kotlin协程是基于Continuation来实现挂起和恢复的。

协程的构造

协程的创建

标准库中提供了一个createCoroutine函数,我们可以通过它来创建协程,不过这个协程并不会立即执行。我们先来看看它的声明:

public fun <T> (suspend () -> T).createCoroutine(
    completion: Continuation<T>
): Continuation<Unit> =
    SafeContinuation(createCoroutineUnintercepted(completion).intercepted(), COROUTINE_SUSPENDED)
  • suspend()->T是createCoroutine函数的Receiver,Receiver是一个被suspend修饰的挂起函数,这也是协程的执行体,我们不妨称它为协程体
  • 参数completion会在协程执行完成后调用,实际上就是协程的完成回调
  • 返回值是一个Continuation对象,由于现在协程仅仅被创建出来,因此需要通过这个值在之后触发协程的启动。

协程的启动

调用continuation.resume(Unit)之后,协程体会真正开始执行。

  • Continuation,其中回复调用函数意义如下:

  • resumeWith的函数可以接收Result类型的参数。在结果成功获取时,调用resumeWith(Result.success(value))或者调用扩展函数resume(value)。将正常的结果返回;

  • 出现异常时,调用resumeWith(Result.failure(throwable))或者调用扩展函数resumeWithException(throwable)。将异常返回。

  • 创建协程返回的Continuation实例就是套了几层马甲的协程体,因而调用它的resume就可以触发协程体的执行。

  • SafeContinuation类的作用也非常简单,它可以确保只有发生异步调用时才会挂起

    • createCoroutine 的返回里用SafeContinuation进行了包装,这里是返回给外部调用的,外部调用可能不了解里面的逻辑,为了安全用Safe进行包装;
    • 它内部有一个delegate,在它的resumeWith()方法里调用了delegate.resumeWith(result)方法,代码如下:
    •   public actual override fun resumeWith(result: Result<T>) {
            while (true) { // lock-free loop
                val cur = this.result // atomic read
                when {
                    cur === UNDECIDED -> if (RESULT.compareAndSet(this, UNDECIDED, result.value)) return
                    cur === COROUTINE_SUSPENDED -> if (RESULT.compareAndSet(this, COROUTINE_SUSPENDED, RESUMED)) {
                        delegate.resumeWith(result)
                        return
                    }
                    else -> throw IllegalStateException("Already resumed")
                }
            }
        }
      
  • 在调用createCoroutine的时候,我们传入了一个状态为COROUTINE_SUSPENDED,因此在调用SafeContinuation的resumeWith的时候,这里会调用delegate.resumeWith(result),同时把状态置为RESUMED,如果同时意外多调用了resumeWith方法,此时会抛出异常“Already resumed”,因此保证了安全;

  • 通过断点可以看到delegate的真身:

它的类名类似Kt<FunctionName><FunctionName>continuation$1这样的形式,其中和指代的是代码所在的文件名和函数名。如果大家对Java字节码中的匿名内部类的命名方式比较熟悉,就会猜到这其实指代了某一个匿名内部类。那么新的问题产生了,哪儿来的匿名内部类?

答案也很简单,就是我们的协程体,那个用以创建协程的suspend Lambda表达式。编译器在它编译之后对它稍微加了一些“魔法”,生成了一个匿名内部类,这个类继承自SuspendLambda类,而这个类又是Continuation接口的实现类。

最后一个令人疑惑的点是,Suspend Lambda表达式是如何编译的?一个函数如何对应一个类呢?这里其实不难理解,Suspend Lambda有一个抽象函数invokeSuspend(这个函数在它的父类BaseContinuationImpl中声明),编译生成的匿名内部类中这个函数的实现就是我们的协程体。

看字节码,可以看到他是SuspendLambda的子类,同时是一个Function1:

final class com/bennyhuo/kotlin/coroutine/ch03/Listing01Kt$main$continuation$1 extends kotlin/coroutines/jvm/internal/SuspendLambda implements kotlin/jvm/functions/Function1 {
///
}

看下SuspendLambda代码,可以看到在它的父类BaseContinuationImpl中有一个待实现的方法如下:

protected abstract fun invokeSuspend(result: Result<Any?>): Any?

另外,标准库中也提供了一个startCoroutine函数让协程体执行,除了返回值类型不同,其他的完全一致:

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

协程体的Receiver

协程的创建和启动相关的API一共有两组,第二组如下:

public fun <R, T> (suspend R.() -> T).createCoroutine(
    receiver: R,
    completion: Continuation<T>
): Continuation<Unit> =
    SafeContinuation(createCoroutineUnintercepted(receiver, completion).intercepted(), COROUTINE_SUSPENDED)
public fun <R, T> (suspend R.() -> T).startCoroutine(
    receiver: R,
    completion: Continuation<T>
) {
    createCoroutineUnintercepted(receiver, completion).intercepted().resume(Unit)
}
  • Receiver类型R。这个R可以为协程体提供一个作用域,在协程体内我们可以直接使用作用域内提供的函数或者状态等。
  • 作用域可以用来提供函数支持,自然也就可以用来增加限制。如果我们为Receiver对应的类型增加一个RestrictsSuspension注解,那么在它的作用下,协程体内就无法调用外部的挂起函数了。可以避免无效甚至危险的挂起函数的调用。

可挂起的main函数

main可以直接被声明为挂起函数,只需要在main函数的声明前加suspend关键字即可。这意味着Kotlin程序从程序入口处就可以获得一个协程,而我们所有的程序都将在这个协程体里面运行;

suspend fun main() { }

Kotlin编译器无非是帮我们生成了一个真正的main函数,里面调用了一个叫作runSuspend的函数来执行所谓的可挂起的main函数;

main函数是如何获取协程的呢,首先看下suspendMian()函数的字节码

先看下真正的main函数入口:

他在里面new了一个SuspendmainKt$$$main,这是一个什么呢?

final synthetic class SuspendmainKt$$$main extends kotlin/jvm/internal/Lambda implements kotlin/jvm/functions/Function1

构造了这个对象之后呢,就调用了他的init方法,然后调用了runSuspend方法:

这里可以看到,其实在里面直接起了一个协程,而block就是传进来的SuspendmainKt$$$main,也就是上面的suspend main函数本身

而RunSuspend这个类又是什么呢:

private class RunSuspend : Continuation<Unit> {
    override val context: CoroutineContext
        get() = EmptyCoroutineContext
 
    var result: Result<Unit>? = null
 
    override fun resumeWith(result: Result<Unit>) = synchronized(this) {
        this.result = result
        @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") (this as Object).notifyAll()
    }
 
    fun await() = synchronized(this) {
        while (true) {
            when (val result = this.result) {
                null -> @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") (this as Object).wait()
                else -> {
                    result.getOrThrow() // throw up failure
                    return
                }
            }
        }
    }
}

为避免main函数在切换线程挂起的时候直接退出,需要main函数等着挂起恢复,调用run.await()方法,在这个方法我们可以看到是一个while(true)循环,一开始result为null,这里就会调用Object的wait()方法,一直等到调用resumeWith方法,会给result赋值,然后调用notifyAll()方法,结束循环。

函数的挂起

Kotlin协程的挂起和恢复能力本质上就是挂起函数的挂起和恢复。

挂起函数

suspend关键字修饰的函数叫作挂起函数,挂起函数只能在协程体内或其他挂起函数内调用。这样一来,整个Kotlin语言体系内的函数就分为两派:普通函数和挂起函数。其中挂起函数可以调用任何函数,普通函数只能调用普通函数

  • suspend关键字修饰的函数叫作挂起函数,挂起函数只能在协程体内或其他挂起函数内调用。
    • 协程体本身就是一个Continuation实例,正因如此挂起函数才能在协程体内运行。
  • 协程的挂起其实就是程序执行流程发生异步调用时,当前调用流程的执行状态进入等待状态。

挂起点

用通俗的语言讲,只要挂起函数continuation的resumeWith没有在该函数的返回之前调用,那么该函数就会被挂起。

回想一下协程的创建,我们的协程体本身就是一个continuation实例,所以挂起函数才能在协程体内运行,在协程内部挂起函数的调用处被称为挂起点,挂起点如果出现异步调用,那么当前协程就被挂起,直到对应的Continuation的resume函数被调用才会恢复执行。

我们已经知道,通过suspendCoroutine函数获得的Continuation是一个SafeContinuation的实例,与创建协程时得到的用来启动协程的Continuation实例没有本质上的差别。SafeContinuation类的作用也非常简单,它可以确保只有发生异步调用时才会挂起,例如在挂起函数中直接return,虽然也有resume函数的调用,但协程并不会真正挂起。

public suspend inline fun <T> suspendCoroutine(crossinline block: (Continuation<T>) -> Unit): T {    contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }    return suspendCoroutineUninterceptedOrReturn { c: Continuation<T> ->        val safe = SafeContinuation(c.intercepted())        block(safe)        safe.getOrThrow()    }}

从上面可以看到,block就是传入的函数执行体本身,如果我们在函数体里直接执行了continuation的resume方法,在getOrThrow方法中,就可以拿到结果,就相当于直接返回了结果,其实也就相当于并没有挂起。

异步调用是否发生,取决于resume函数与对应的挂起函数的调用是否在相同的调用栈帧上,切换函数调用栈的方法可以是切换到其他线程上执行,也可以是不切换线程但在当前函数返回之后的某一个时刻再执行。前者比较容易理解,后者其实通常就是先将Continuation的实例保存下来,在后续合适的时机再调用。

CPS变换(Continuation-Passing-Style Transformation),是通过传递Continuation来控制异步调用流程的。

程序被挂起时,最关键的是要做什么?是保存挂起点。

Kotlin协程挂起时就将挂起点的信息保存到了Continuation对象中。Continuation携带了协程继续执行所需要的上下文,恢复执行的时候只需要执行它的恢复调用并且把需要的参数或者异常传入即可。作为一个普通的对象,Continuation占用内存非常小,这也是无栈协程能够流行的一个重要原因。

我们在前面讲到,挂起函数如果需要挂起,则需要通过suspendCoroutine来获取Continuation实例。我们已经知道它是协程体,但是这个实例是怎么传进来的呢?

我们通过反射来调用suspend方法,来看看发生了什么

val ref = ::notSuspend
    //ref.call()
    val result = ref.call(object : Continuation<Int> {
        override val context: CoroutineContext = EmptyCoroutineContext
 
        override fun resumeWith(result: Result<Int>) {
            println("resumeWith: ${result.getOrNull()}")
        }
    })
    println(result)

可以看到,原本没有入参的函数,此时需要一个continuation参数,现在大家知道了原来挂起函数就是普通函数的参数中多了一个Continuation实例,难怪挂起函数总是可以调用普通函数,普通函数却不可以调用挂起函数。

同样这里可以看到,我们有一个result的返回值,这里的返回值有两种形式

  • 挂起函数同步返回。作为参数传入的Continuation的resumeWith不会被调用,函数的实际返回值就是它作为挂起函数的返回值。notSuspend尽管看起来似乎调用了resumeWith,不过调用对象是SafeContinuation,这一点我们在前面已经多次提到,因此它的实现属于同步返回。
  • 挂起函数挂起,执行异步逻辑。此时函数的实际返回值是一个挂起标志,通过这个标志外部协程就可以知道该函数需要挂起等到异步逻辑执行。在Kotlin中这个标志是个常量,定义在Intrinsics.kt当中。

cps变换示意图:

协程上下文

Continuation除了可以通过恢复调用来控制执行流程的异步返回以外,还有一个重要的属性context,即协程的上下文。

上下文其实就是数据的载体,我们可以通过上下文获取执行过程中需要的资源等。

协程上下文的集合特征

上面说到了上下文就是数据的载体,是执行环境相关的通用数据资源的统一提供者。而协程的上下文也是如此,他的数据结构特征甚至更加显著,与List,Map这些我们耳熟能详的集合非常类似。

可以根据一些类比来看一下:

那么协程上下文作为一个集合,它的元素类型是什么呢?

  • Element接口中有一个属性key,这个属性很关键。虽然我们前面在往list中添加元素的时候没有明确指出,但我们心知肚明list中的元素都有一个index,表示元素的索引,而这里协程上下文元素的key就是协程上下文这个集合中元素的索引,不同之处是这个索引“长”在了数据里面,这意味着协程上下文的数据在“出生”时就找到了自己的位置。

协程上下文元素的实现

现在我们只知道了接口,实际上它还有一个抽象类,能让我们在实现协程上下文的元素时更加方便:

public abstract class AbstractCoroutineContextElement(public override val key: Key<*>) : Element

看一个CoroutineName的实现:

class CoroutineName(val name: String): AbstractCoroutineContextElement(Key){
    companion object Key: CoroutineContext.Key<CoroutineName>
 
    override fun toString() = name
}

创建元素不难,提供对应的Key即可;

协程上下文的使用

我们把定义好的元素添加到协程上下文中:

var coroutineContext: CoroutineContext = EmptyCoroutineContext
coroutineContext += CoroutineName("co-01")
coroutineContext += CoroutineExceptionHandler {
    ///
}

我们再把这个定义好的上下文赋值给作为完成回调的Continuation实例,这样就可以将它绑定到协程上了,如下代码 1 所示:

使用异常处理器处理未捕获的异常,不管结果如何,Continuation的resumeWith一定会被调用,如果有异常出现,那么我们就从协程上下文中找到我们设置的CoroutineExceptionHandler的实例,调用onError来处理异常,如下 2 所示:

在协程内部可以通过coroutineContext这个全局属性直接获取当前协程的上下文,它也是标准库中的API,如下 3 所示:

suspend {
        println("In Coroutine [${coroutineContext[CoroutineName]}].") // 3
        throw ArithmeticException()
        100
    }.startCoroutine(object : Continuation<Int> {
 
        override val context = coroutineContext  // 1
 
        override fun resumeWith(result: Result<Int>) {
            result.onFailure {
                context[CoroutineExceptionHandler]?.onError(it)  // 2
            }.onSuccess {
                println("Result $it")
            }
        }
    })

CoroutineContext数据结构:

查找时是链表结构:

协程的拦截器

我们通过前面的学习知道了协程的挂起和恢复,还知道可以用绑定上下文来丰富协程执行过程中的数据,那么协程如何能实现线程的调度呢?

协程的标准库为我们提供了拦截器的组件,它允许我们拦截协程异步回调时的恢复调用,那么想要操纵线程调度就不是什么难题了。

拦截的位置

  • 协程启动时调用一次,通过恢复调用来开始执行协程体从开始到下一次挂起之间的逻辑。
  • 挂起点处如果异步挂起,则在恢复时会调用一次。由于这个过程中有两次挂起,因此会调用两次。

由此可知,恢复调用的次数为1+n次,其中n是协程体内真正挂起执行异步逻辑的挂起点的个数。

suspend {
  suspendFunc02("Hello", "Kotlin")
  suspendFunc02("Hello", "Coroutine")
}.startCoroutine(object : Continuation<Int> {
  ... // 省略
})

拦截器的使用

挂起点恢复执行的位置都可以在需要的时候添加拦截器来实现一些AOP操作。拦截器也是协程上下文的一类实现,定义拦截器只需要实现拦截器的接口,并添加到对应的协程的上下文中即可。

class LogInterceptor : ContinuationInterceptor {
    override val key = ContinuationInterceptor
 
    override fun <T> interceptContinuation(continuation: Continuation<T>)
            = LogContinuation(continuation)
}
 
class LogContinuation<T>(private val continuation: Continuation<T>)
    : Continuation<T> by continuation {
    override fun resumeWith(result: Result<T>) {
        println("before resumeWith: $result")
        continuation.resumeWith(result)
        println("after resumeWith.")
    }
}

拦截器的关键拦截函数是interceptContinuation,可以根据需要返回一个新的Continuation实例。我们在LogContinuation的resumeWith中打印日志,接下来把它设置到上下文中,程序运行时就会有相应的日志输出:

suspend {
    ///
}.startCoroutine(object : Continuation<Int> {
    override val context = LogInterceptor()
 
    override fun resumeWith(result: Result<Int>) {
        result.getOrThrow()
    }
})

拦截器的执行细节

还记得startCoroutine的源码吗?它是接收一个Continuation,接着创建一个新的Continuation,然后再调用了一个intercepted函数:

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

intercepted函数会走到ContinuationImpl# intercepted

internal abstract class ContinuationImpl(completion: Continuation<Any?>?, private val _context: CoroutineContext?) : BaseContinuationImpl(completion) {
    // ……
    public fun intercepted(): Continuation<Any?> = intercepted?: (context[ContinuationInterceptor]?.interceptContinuation(this) ?: this).also { intercepted = it }
    // ……
}

我们知道,通过continuation.context[Key]?. Element便可以获到Context中Key对应的Element对象,上面源码中可见intercepted函数最后会调用到我们自定义的CoroutineContex里的interceptContinuation函数是。所以调用startCoroutine函数来启动协程,实际上就是启动了我们自定义CoroutineContex里拦截后的Continuation。

所以整个过程就是协程体在挂起点处先被拦截器拦截,再被SafeContinuation保护了起来。想要让协程体真正恢复执行,先要经过这两个过程,这也为协程支持更加复杂的调度逻辑提供了基础。

之前说的SafeContinuation内部有一个delegate,之前可以称之为协程体,但是添加拦截器之后,delegate就是拦截器拦截之后的Continuation了,就是上面的LogContinuation。

Kotlin协程所属的类别

  • 按调用栈:
    • 如果我们狭义地认为调用栈就只是类似于线程为函数提供的调用栈的话,那么既然无法在任意层次普通函数调用内实现挂起,我们因此就可以将Kotlin协程视为无栈协程的实现;
    • 但从挂起函数可以实现任意层次嵌套调用内挂起的效果来讲,确实也可以将Kotlin协程视为一种有栈协程的实现。
  • 按调度方式
    • Kotlin的挂起函数是非对称调用的例子,但Kotlin一样可以有自己的对称协程的实现。