协程代码生成-1.Suspend Lambda(译)

coroutines-codegen(协程代码生成)

本文旨在将关于协程代码生成的所有信息汇集到一个地方,这样,程序员就不必阅读编译器代码或编写代码片段并查看生成的字节码,而是可以查阅文档,找到解释编译器行为如何以及更重要的是为什么会这样行为的部分。希望这将帮助那些从事编译器工作和高级 Kotlin 程序员理解特定设计决策背后的原因。

本文以 JVM 为中心,这意味着它解释了 JVM 后端的工作原理,因为这是我最熟悉的领域,而且由于 JVM 有向后兼容性的保证,编译器在所谓的"旧 JVM"后端和新的 JVM_IR 后端都必须遵守这一保证。新后端的命名可能与官方文档不同:本文使用"IR"后缀,而官方文档则省略了它。

如果文档中某个部分的名称具有"旧 JVM:"前缀,则解释了旧 JVM 后端的特定细节;如果前缀是"JVM_IR:",则它是 JVM_IR 后端特定的。如果前缀是常见的"JVM",则说明适用于旧后端和新后端的解释。否则,该部分解释了协程的一般行为,并应适用于所有后端。

本文坚持发布版协程,因为我们在 1.3 版中弃用了实验性协程,而 JVM_IR 不支持它们。此外,在 1.6 版中,编译器还放弃了实验性协程支持。

如果当前的实现不理想(或存在错误),则描述了差异以及实现"正确"版本的步骤。这些小节以"FIXME"开头。

在整个文档中,术语"协程"将代表暂停的 lambda 表达式或暂停函数,这与协程的通常定义------类似轻量级线程是不同的。本文重新使用了该术语,因为"暂停的 lambda 表达式或函数"这样的表述太冗长了,而且当需要典型定义时,它明确指出了"广义上的协程"。

本文经常使用术语"未定义行为",这意味着我们有意拒绝定义它。因此,行为可能因版本而异,因后端而异,因此应该极度谨慎使用。

最后,本文中提供的大多数示例实际上都会暂停,因此可以确保每个部分都在适当的位置,因为协程是一个广泛而复杂的主题,很容易忘记某一部分,这将导致运行时错误,甚至更糟糕的是,语义上错误的代码执行。

Suspend Lambda

让我们首先介绍挂起 lambda。

挂起 lambda 是协程的一个示例,编译器会将 lambda 内部的普通顺序代码转换为可挂起的形式。下面的示例展示了一个简单的挂起 lambda:

kotlin 复制代码
suspend fun dummy() {}

suspend fun main() {
    val lambda: suspend () -> Unit = {
       dummy()
       println(1)
       dummy()
       println(2)
    }
    lambda()
}

在运行时,会按预期输出 1 和 2。

只能从其他挂起函数或挂起 lambda 中调用挂起函数,但它可以调用普通的、非可挂起的函数。例如,在 lambda 内部只使用了 dummy 和 println。因为无法从普通函数中调用可挂起函数,我们可以将其想象为两个世界:可挂起和普通。或者,可以将它们视为两种不同的颜色,并通过使用 "suspend" 修饰符来给程序着色。

这个 lambda,有创意地命名为 lambda,包含两个挂起调用 (dummy),并且从 main 函数到 lambda 自身也有一个挂起调用,但是没有挂起发生。让我们添加上挂起:

kotlin 复制代码
import kotlin.coroutines.*

var c: Continuation<Unit>? = null

suspend fun suspendMe() = suspendCoroutine<Unit> { continuation ->
    println("Suspended")
    c = continuation
}

suspend fun main() {
    val lambda: suspend () -> Unit = {
        suspendMe()
        println(1)
        suspendMe()
        println(2)
    }
    lambda()
}

现在,当我们运行代码时,它会打印 Suspended,然后没有其他输出;它甚至都没有完成程序的执行,因为 lambda 实际上被挂起了,并且它也挂起了 suspend fun main。

为了解决 main 函数挂起的问题,我们需要跨越可挂起和普通世界之间的边界,并使 main 变为普通函数,这样,当它启动一个协程并且协程挂起时,main 不会挂起。由于不能从普通函数中调用可挂起函数,因此有一些特殊的函数,称为协程构建器,它们的唯一目的是创建协程、运行它,并在协程挂起时将执行返回给调用者。除此之外,它们的行为类似于其他普通函数。让我们给我们的协程构建器起一个名字,比如说,builder:

kotlin 复制代码
fun builder(c: suspend () -> Unit) {
    c.startCoroutine(object: Continuation<Unit> {
        override val context = EmptyCoroutineContext
        override fun resumeWith(result: Result<Unit>) {
            result.getOrThrow()
        }
    })
}

有一个单独的部分会详细解释启动协程(广义上)的确切机制以及编写它们的构建器。暂时把 builder 视为跨越两个世界的样板。

现在,当我们将 main 改为使用构建器而不是自身挂起时:

kotlin 复制代码
fun main() {
    val lambda: suspend () -> Unit = {
        suspendMe()
        println(1)
        suspendMe()
        println(2)
    }
    builder {
        lambda()
    }
}

然后运行示例,它将打印预期的 Suspended,但这次它将退出程序。

此外,当我们将 main 更改为恢复 lambda 时:

kotlin 复制代码
import kotlin.coroutines.*

var c: Continuation<Unit>? = null

suspend fun suspendMe() = suspendCoroutine<Unit> { continuation ->
    println("Suspended")
    c = continuation
}

fun builder(c: suspend () -> Unit) {
    c.startCoroutine(object: Continuation<Unit> {
        override val context = EmptyCoroutineContext
        override fun resumeWith(result: Result<Unit>) {
            result.getOrThrow()
        }
    })
}

fun main() {
    val lambda: suspend () -> Unit = {
        suspendMe()
        println(1)
        suspendMe()
        println(2)
    }
    builder {
        lambda()
    }
    c?.resume(Unit)
}

在继续执行 lambda 后,将打印:

text 复制代码
Suspended
1
Suspended

lambda 被恢复,然后再次挂起。如果我们再添加几个 c?.resume(Unit):

kotlin 复制代码
fun main() {
    val lambda: suspend () -> Unit = {
        suspendMe()
        println(1)
        suspendMe()
        println(2)
    }
    builder {
        lambda()
    }
    c?.resume(Unit)
    c?.resume(Unit)
    c?.resume(Unit)
}

我们会得到:

text 复制代码
Suspended
1
Suspended
2
Exception in thread "main" java.lang.IllegalStateException: Already resumed

最后一行是当我们尝试恢复一个已经完成的 continuation 时所得到的结果。

这个小例子中发生了很多事情。接下来的部分逐步解释它,从一个状态机开始。

State-Machine

编译器通过使用状态机将顺序代码转换为可挂起的。它在状态机中将挂起调用分配到各个状态之间。调用和状态之间的关系是一对一的:每个调用都有一个状态,每个状态都有一个调用。状态以调用结束,编译器将在调用之前的所有指令放在同一个状态中。它将在最后一个调用之后的所有指令放在一个单独的状态中。

For example, having

kotlin 复制代码
dummy()
println(1)
dummy()
println(2)

编译器将代码分割如下:

text 复制代码
==========
dummy()
----------
println(1)
dummy()
----------
println(2)
==========

其中函数边界由 ========== 表示,状态边界由 ---------- 表示。编译器在分割函数后生成以下代码(现在简化了):

kotlin 复制代码
val $result: Any? = null
when (this.label) {
    0 -> {
        this.label = 1
        $result = dummy(this)
        if ($result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
        goto 1
    }
    1 -> {
        println(1)
        this.label = 2
        $result = dummy(this)
        if ($result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
        goto 2
    }
    2 -> {
        println(2)
        return Unit
    }
    else -> {
        throw IllegalStateException("call to 'resume' before 'invoke' with coroutine")
    }
}

然后,它将状态机放置在 invokeSuspend 函数内。因此,除了 lambda 捕获的参数、 和 invoke 之外,我们还有 label 字段和 invokeSuspend 函数。

在函数开始时,label 的值为 0。在调用之前,我们将其设置为 1。在调用过程中,有两种可能发生的情况:

1、dummy 返回结果,此时为 Unit。当这种情况发生时,执行将继续,就好像它是顺序代码,跳转到下一个状态。

2、dummy 挂起。当一个挂起函数挂起时,它返回 COROUTINE_SUSPENDED 标记。因此,如果 dummy 挂起,调用者也会挂起,返回相同的 COROUTINE_SUSPENDED。此外,调用堆栈中的所有挂起函数都会挂起,返回 COROUTINE_SUSPEND,直到我们到达协程构建器函数,该函数只是返回。 在恢复时,invokeSuspend 再次被调用,但这次 label 是 1,因此执行直接跳转到第二个状态,并且调用者不会再次执行对 dummy 的第一次调用。这样,lambda 的执行可以被挂起和恢复。因此,lambda 被转换为协程,根据定义,协程是一个可挂起的代码单元。

这就是为什么我们需要将线性代码转换为状态机的原因。

最后,状态机应该是扁平的;换句话说,不应该在状态机状态内部有另一个状态机。否则,内部状态机状态将重写 label,破坏整个挂起-恢复机制,并导致各种奇怪的行为,从 ClassCastException 到无限循环。在协程内联的早期阶段,这两种错误行为都相当普遍。

JVM: Suspend Markers

为了区分挂起调用和普通调用,代码生成器(更具体地说,是 ExpressionCodegen)在调用周围放置所谓的挂起标记。我们需要在调用之前和之后都生成标记,因为被调用方可能是 suspendCoroutineUninterceptedOrReturn intrinsic函数,我们将其调用视为挂起点,即协程可以挂起的位置。因此,挂起点要么是非内联挂起调用,要么是内联的 suspendCoroutineUninterceptedOrReturn 调用。

注:内在函数(intrinsic Function 是由kotlin编译器支持的一类特殊函数,这些函数都是由编译器根据平台语言动态生成的,在kotlin的代码中不需要任何实现

重要的是要注意,对内联函数调用放置标记没有意义,因为编译器会内联它们的函数体,它们的挂起点将成为内联位置的挂起点。请记住,状态机应该是扁平的,没有嵌套的状态机。

这些标记是 kotlin.jvm.internal.InlineMarker.mark 的调用,带有一个整数参数。挂起调用之前和之后的标记值分别为 01。内联挂起函数在生成的字节码中保留这些标记,因此,在内联挂起调用时,它们将包围的调用成为挂起点。嗯,至少在内联函数的一个副本中,技术上将是正确的。因为编译器生成了两个副本:一个带有状态机,因此可以通过反射调用它;另一个没有状态机,因此内联器可以将其内联,而不会将状态机内联到另一个内联器中。由于存在具有内联挂起函数的库,所以传递给 kotlin.jvm.internal.InlineMarker.mark 调用的值不能更改。

The codegen generates the markers by calling addSuspendMarker. After generating MethodNode of the function, it passes the node to CoroutineTransformerMethodVisitor. CoroutineTransformerMethodVisitor collects the suspension points by checking these markers, and then it generates the state-machine. FIXME: I should rename CoroutineTransformerMethodVisitor to StateMachineBuilder already. 代码生成器通过调用 addSuspendMarker 来生成这些标记。在生成函数的 MethodNode 后,它将节点传递给 CoroutineTransformerMethodVisitorCoroutineTransformerMethodVisitor 通过检查这些标记来收集挂起点,然后生成状态机。 FIXME: 我应该把 CoroutineTransformerMethodVisitor 重命名为 StateMachineBuilder

Continuation Passing Style

在关于状态机的部分提到了 COROUTINE_SUSPENDED 标记,并表示挂起函数和 Lambda 在挂起时返回该标记。因此,每个挂起函数都返回 returnType | COROUTINE_SUSPENDED 联合类型。然而,由于 Kotlin 和 JVM 都不支持联合类型,因此每个协程在运行时的返回类型都是 Any?(也称为 java.lang.Object)。

现在让我们仔细看一下恢复过程。假设我们有一对协程,其中一个调用另一个:

kotlin 复制代码
fun main() {
    val a: suspend () -> Unit = { suspendMe() }
    val b: suspend () -> Unit = { a() }
    builder { b() }
    c?.resume(Unit)
}

suspendMe 在这里(就像前面的示例一样)挂起。在 suspendMe 中的堆栈跟踪如下所示(跳过非相关部分):

text 复制代码
suspendMe
main$a$1.invokeSuspend
main$a$1.invoke
main$b$1.invokeSuspend
main$b$1.invoke
// ...
builder
main

正如你所看到的,一切都如预期。main 调用 builderbuilder 调用 b.invoke,依此类推,直到 suspendMe。由于 suspendMe 挂起,它将 COROUTINE_SUSPENDED 返回给 ainvokeSuspend。如状态机部分所解释的那样,调用者检查 suspendMe 是否返回 COROUTINE_SUSPENDED,然后相应地返回 COROUTINE_SUSPENDED。在调用堆栈中的所有函数中,按相反的顺序发生相同的情况。

挂起过程已经解释完毕,接下来是它的对应部分 - 恢复过程。当我们调用 c?.resume(Unit) 时,c 在技术上是 a,因为 suspendMe 是尾调用函数(关于这一点会在相关章节中详述)。resume 调用 BaseContinuationImpl.resumeWithBaseContinuationImpl 是所有协程的超类,不可由用户访问,但几乎用于所有涉及到协程的事务中。它是协程机制的核心,负责恢复过程。BaseContinuationImpl 又调用 ainvokeSuspend

所以,当我们调用 c?.resume(Unit) 时,堆栈跟踪变成了:

text 复制代码
main$a$1.invokeSuspend
BaseContinuationImpl.resumeWith
main

现在,a 继续执行并返回 Unit。但是,执行返回到 BaseContinuationImpl.resumeWith。然而,我们需要继续执行 b,因为 b 调用了 a。换句话说,我们需要在 a 中的某个地方存储到 b 的引用,这样,在 BaseContinuationImpl.resumeWith 中,我们就可以调用 b 的 resumeWith,然后继续执行 b。请记住,b 是一个协程,所有协程都继承自 BaseContinuationImpl,它具有 resumeWith 方法。因此,我们需要将 b 传递给 a。我们唯一可以将 b 传递给 a 的地方是 invoke 函数调用。因此,我们在 invoke 中添加一个参数。a.invoke 的签名变为:

kotlin 复制代码
fun invoke(c: Continuation<Unit>): Any?

现在,a 继续执行并返回 Unit。但是,执行返回到 BaseContinuationImpl.resumeWith。然而,我们需要继续执行 b,因为 b 调用了 a。换句话说,我们需要在 a 中的某个地方存储到 b 的引用,这样,在 BaseContinuationImpl.resumeWith 中,我们就可以调用 bresumeWith,然后继续执行 b。请记住,b 是一个协程,所有协程都继承自 BaseContinuationImpl,它具有 resumeWith 方法。因此,我们需要将 b 传递给 a。我们唯一可以将 b 传递给 a 的地方是 invoke 函数调用。因此,我们在 invoke 中添加一个参数。a.invoke 的签名变为:

kotlin 复制代码
fun invoke(c: Continuation<Unit>): Any?

Continuation 是所有协程的超接口(与 BaseContinuationImpl 不同,它是用户可访问的),在这种情况下是挂起 Lambda。它位于继承链的顶部。continuation的类型参数是挂起 Lambda 的旧返回类型。continuation的类型参数与 resumeWithResult 参数的类型参数相同:resumeWith(result: Result<Unit>)。读者可能会回想起挂起 Lambda 部分的 builder 示例,在那里我们创建了一个continuation对象。该对象使用相同的签名重写了 resumeWith

向挂起 Lambda 和函数添加continuation 参数被称为 Continuation-Passing Style,这种风格在诸如 Scheme 等 Lisp 中被广泛使用。例如,如果在 Scheme 中以 Continuation-Passing Style 返回值,则将值传递给continuation 参数。因此,函数接受continuation 参数,并且调用者通过调用 call/cc 内在函数来传递continuation 。在 Kotlin 中,通过将返回值传递给调用者的continuationresumeWith 来实现相同的目的。然而,与 Scheme 不同,Kotlin 不使用类似 call/cc 的东西。每个协程已经有了一个continuation 。调用者将其作为参数传递给被调用方。由于协程将返回值传递给 resumeWith,因此其参数的类型与协程的返回类型相同。从技术上讲,类型是 Result<T>,但它只是一个联合 T | Throwable;在这种情况下,TUnit。接下来的部分将使用除 Unit 之外的返回类型来说明如何恢复带有值的协程。另一个部分 Throwable 是用于通过异常恢复协程的,在相关部分中进行了解释。

在将父协程的continuation 传递给子协程之后,我们需要在某处存储它。由于"父协程的continuation "对于名称来说有点冗长,我们称之为 'completion'。我们选择这个名称是因为协程在完成时调用它。

由于我们将continuation 参数添加到每个挂起函数和 Lambda 中,我们不能从普通函数调用挂起函数或 Lambda,并且我们也不能通过将 null 作为参数传递给它们来调用它们,因为协程会在其上调用 resumeWith。相反,我们应该使用协程构建器,它们提供根continuation 并启动协程。这就是两个世界模型的原因。

Resume With Result

假设我们考虑以下示例,其中包含一个返回值的挂起函数,而不是 Unit:

kotlin 复制代码
import kotlin.coroutines.*

var c: Continuation<Int>? = null

suspend fun suspendMe(): Int = suspendCoroutine { continuation ->
    c = continuation
}

fun builder(c: suspend () -> Unit) {
    c.startCoroutine(object: Continuation<Unit> {
        override val context = EmptyCoroutineContext
        override fun resumeWith(result: Result<Unit>) {
            result.getOrThrow()
        }
    })
}

fun main() {
    val a: suspend () -> Unit = { println(suspendMe()) }
    builder { a() }
    c?.resume(42)
}

如果运行该程序,它会打印 42。然而,suspendMe 并不返回 42。它只是挂起并且没有返回任何值。顺便提一下,suspendMe 的continuation类型是 Continuation<Int>,即编译器将函数的返回值移到了我在前面关于 Continuation-Passing Style 的部分中提到的 Continuation 接口的类型参数中。

状态机部分涉及到 invokeSuspend 函数中的 $result 变量。该示例显示了 ainvokeSuspend 函数,但与前面的示例不同,它的签名是:

kotlin 复制代码
fun invokeSuspend($result: Any?): Any? {
    when (this.label) {
        0 -> {
            this.label = 1
            $result = suspendMe(this)
            if ($result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
            goto 1
        }
        1 -> {
            println($result)
            return Unit
        }
        else -> {
            throw IllegalStateException("call to 'resume' before 'invoke' with coroutine")
        }
    }
}

代码清单显示 $result 变量既是函数的参数,也是挂起调用的结果。因此,当我们调用 c?.resume(42) 时,值 42 被传递给 BaseContinuationImpl.resumeImpl,它调用 invokeSuspend 并将值传递给它。现在,由于 label 的值为 1suspendMe 已挂起),因此打印出 42。需要注意的是,在第一个状态中,我们忽略了 invokeSuspend 的参数,当我们看到如何启动协程时,这一点变得很重要。

那么,当我们在 suspendCoroutine 中调用 resume 时会发生什么呢?就像这样:

kotlin 复制代码
suspendCoroutine<Int> { it.resume(42) }

按照恢复过程,resume 调用continuation的 resumeWith,它将值 42 传递给 invokeSuspend。然后 $result 包含该值,工作方式与 suspendMe 返回 42 时相同。换句话说,带有无条件恢复的 suspendCoroutine 不会挂起协程,并且在语义上与返回该值相同。

需要注意的是,向continuation的 resumeWith 传递 COROUTINE_SUSPENDED 会导致未定义的行为。

Resume with Exception

在阅读了前面关于带有值的恢复的部分后,有人可能会假设 $result 的类型是 Int | COROUTINE_SUSPENDED,但这并不完全正确。它实际上是 Int | COROUTINE_SUSPENDED | Result$Failue(Throwable),或者更一般地说,它是 returnType | COROUTINE_SUSPENDED | Result$Failue(Throwable)。本节涵盖了最后一部分:Result$Failue(Throwable)

让我们将前面的示例更改为使用异常恢复协程:

kotlin 复制代码
import kotlin.coroutines.*

var c: Continuation<Int>? = null

suspend fun suspendMe(): Int = suspendCoroutine { continuation ->
    c = continuation
}

fun builder(c: suspend () -> Unit) {
    c.startCoroutine(object: Continuation<Unit> {
        override val context = EmptyCoroutineContext
        override fun resumeWith(result: Result<Unit>) {
            println(result.exceptionOrNull())
        }
    })
}

fun main() {
    val a: suspend () -> Unit = { println(1 + suspendMe()) }
    builder { a() }
    c?.resumeWithException(IllegalStateException("BOO"))
}

在运行时,将打印异常。请注意,异常是在 builder 函数内打印的(因为使用了 println(result.exceptionOrNull()))。这里发生了几件事情:在生成的状态机内部以及在 BaseContinuationImplresumeWith 方法内部。

首先,我们改变了生成的状态机。如前所述,$result 变量的类型是 Int | COROUTINE_SUSPENDED | Result$Failure(Throwable),但按照惯例,当我们恢复时,其类型不能是 COROUTINE_SUSPENDED。尽管如此,类型仍然是 Int | Result$Failure(Throwable),我们不能简单地将其传递给 plus 方法,至少在没有检查和 CHECKCAST 的情况下是不行的。否则,我们会在运行时遇到 CCE(ClassCastException)。因此,我们检查 $result 变量,并在变量持有异常时抛出异常。

kotlin 复制代码
fun invokeSuspend($result: Any?): Any? {
    when (this.label) {
        0 -> {
            this.label = 1
            $result = suspendMe(this)
            if ($result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
            goto 1
        }
        1 -> {
            $result.throwOnFailure()
            println(1 + $result)
            return Unit
        }
        else -> {
            throw IllegalStateException("call to 'resume' before 'invoke' with coroutine")
        }
    }
}

其中 throwOnFailure 是一个执行检查和抛出异常部分的函数。

现在,当我们抛出异常时,它应该会到达 main 函数。然而,正如我们从示例中看到的那样,它实际上到达了 builder 的根continuation的 resumeWith。构建器创建了根continuation,与其他continuation不同,它没有完成。我们希望它到达根continuation,因为当我们从一个挂起函数或 Lambda 调用另一个挂起函数或 Lambda 时,我们希望通过挂起堆栈(也称为异步堆栈)从被调用者传播异常到调用者,而不管是否有挂起,除非有显式的 try-catch 块。幸运的是,我们可以通过与执行完成时相同的方式传播异常,通过 completion 字段的链。毕竟,我们应该像返回值一样将它传递给调用者。当 invokeSuspend 抛出异常时,BaseContinuationImpl.resumeWith 捕获它,将其包装成 Result 内联类,它实际上是 T | Result$Failure(Throwable),然后使用结果调用 completionresumeWith(简化):

kotlin 复制代码
abstract class BaseContinuationImpl(
    private val completion: Continuation<Any?>
): Continuation<Any?> {
    public final override fun resumeWith(result: Result<Any?>) {
        val outcome = try {
            val outcome = invokeSuspend(result)
            if (outcome == COROUTINE_SUSPENDED) return
            Result.success(outcome)
        } catch (e: Throwable) {
            Result.failure(e)
        }
        completion.resumeWith(outcome)
    }

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

该函数将异常传递给 invokeSuspendinvokeSuspend 调用 throwOnFailure 并再次抛出异常,然后异常被 BaseContinuationImpl.resumeWith 捕获并再次包装,直到它到达根continuation的 resumeWith,在这种情况下,协程构建器打印它。顺便说一句,resumeWithException 在发布协程中的工作方式与此完全相同(除了捕获部分):它将异常包装成 Result,就像包在墨西哥卷饼里一样。它将其传递给continuation的 resumeWithresume 也将参数包装成 Result 并将其传递给 resumeWith

Variables Spilling(变量溢出)

之前的所有示例都没有局部变量,这是有原因的。当协程挂起时,我们应该保存其局部变量。否则,当它恢复时,它们的值会丢失。因此,在每次挂起调用(更普遍地说,在每个挂起点)之前,我们都要保存它们,然后在恢复后再恢复它们。如果调用未返回 COROUTINE_SUSPENDED,那么在调用后立即恢复它们是没有必要的:它们的值仍然存在于局部变量槽中。

让我们考虑一个简单的例子:

kotlin 复制代码
import kotlin.coroutines.*

data class A(val i: Int)

var c: Continuation<Unit>? = null

suspend fun suspendMe(): Unit = suspendCoroutine { continuation ->
    c = continuation
}

fun builder(c: suspend () -> Unit) {
    c.startCoroutine(object: Continuation<Unit> {
        override val context = EmptyCoroutineContext
        override fun resumeWith(result: Result<Unit>) {
            result.getOrThrow()
        }
    })
}

suspend operator fun A.plus(a: A) = A(i + a.i)

fun main() {
    val lambda: suspend () -> Unit = {
        val a1 = A(1)
        suspendMe()
        val a2 = A(2)
        println(a1 + a2)
    }
    builder {
        lambda()
    }
    c?.resume(Unit)
}

我们应该在 suspendMe 之前保存 a1,在恢复后恢复它。同样,在 + 之前我们应该保存 a1a2,因为编译器通常不知道挂起调用是否会挂起,所以它假设在每个挂起点可能发生挂起。因此,它在每次调用之前溢出局部变量,并在调用之后取消溢出。

因此,编译器生成了以下状态机:

kotlin 复制代码
fun invokeSuspend($result: Any?): Any? {
    when (this.label) {
        0 -> {
            var a1 = A(1)
            this.L$0 = a1
            this.label = 1
            $result = suspendMe()
            if ($result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
            goto 1_1
        }
        1 -> {
            a1 = this.L$0

            1_1:
            var a2 = A(2)
            this.L$0 = null
            this.label = 2
            $result = plus(a1, a2)
            if ($result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
            goto 2_1
        }
        2 -> {
            2_1:
            println($result)
            return Unit
        }
        else -> {
            throw IllegalStateException("call to 'resume' before 'invoke' with coroutine")
        }
    }
}

正如您所看到的,生成的代码不会溢出和取消溢出那些后续不需要的变量,换句话说,它们已经无用了。此外,它会清理引用类型的溢出变量字段,以避免内存泄漏,方法是将 null 推送到字段中,这样 GC 就可以收集对象。

Spilled Variables Naming

有人可能会注意到溢出变量的命名很奇怪。命名方案如下:名称的第一个字母表示变量的类型描述符:

  • L 表示引用类型,即对象和数组
  • J 表示长整型
  • D 表示双精度浮点数
  • F 表示浮点数
  • I 表示布尔值、字节、字符、短整型和整型

重要的是要注意,尽管在 Java 字节码中,我们用整数类型表示布尔变量,并且在 HotSpot 中,将布尔变量分配给类型为 int 的字段是可以的,但在 Dalvik 中,这些类型是不同的。因此,在使用它们之前,我们会将非整数原始整数类型(除了 long)进行强制转换。

第二个字母是 $,这个字符在用户代码中不太可能使用。我们不能以 $ 开头溢出变量的命名,因为编译器使用 $ 前缀来表示捕获的变量,使用相同的前缀表示多个事物会使内联器混淆。

其余的部分只是相同前缀的变量的整数索引。也就是说,同一个挂起 Lambda 对象中可以有变量 I$0L$0L$1

Spilled Variables Cleanup

由于我们将一个引用溢出到 continuation 对象,因此现在我们持有该对象的额外引用。因此,只要还有对 continuation 的引用,GC 就无法清理其内存。当然,持有对不再需要的对象的引用会导致内存泄漏。编译器会清除引用类型的字段,以避免发生泄漏。

考虑以下示例:

kotlin 复制代码
suspend fun blackhole(a: Any?) {}

suspend fun cleanUpExample(a: String, b: String) {
    blackhole(a) // 1
    blackhole(b) // 2
}

在第 (1) 行之后,a 就已经不再需要了,但是 b 仍然是活动的。因此,我们只溢出 b。在第 (2) 行之后,已经没有变量是活动的,但 continuation 对象仍然在 L$0 字段中持有对 b 的引用。因此,为了清理它并避免内存泄漏,我们将 null 推送到其中。

通常,编译器会生成溢出和取消溢出代码,以便仅使用第一个字段。如果有 M 个引用类型字段,但我们仅在挂起点溢出了 N 个(当然,N ≤ M),那么其他一切都应该是 null。但我们不需要在每个挂起点将它们全部设为 null。相反,编译器会检查哪些字段持有引用,并仅清除它们。

此外,编译器还会缩小和分割局部变量表(LVT)记录,以便调试器不会将死变量显示为未初始化。

FIXME: 目前,LVT 中不存在死变量。因此,如果程序员定义了一个变量但未使用它,编译器会删除它的 LVT 记录。我们可以放宽这一限制,并假定变量一直是活动的,直到下一个挂起点。

Stack spilling

在之前的示例中,在调用之前,栈是干净的,这意味着在调用之前只有调用参数,而在调用之后,栈上只有调用结果。

然而,这并不总是正确的。考虑以下示例:

kotlin 复制代码
val lambda: suspend () -> Unit = {
    val a1 = A(1)
    val a2 = A(2)
    val a3 = A(3)
    a1 + (a2 + a3)
}

并仔细观察 a1 + (a2 + a3) 表达式。如果 + 不是挂起的,编译器将生成以下字节码:

text 复制代码
ALOAD 1 // a1
ALOAD 2 // a2
ALOAD 3 // a3
INVOKESTATIC plus
INVOKESTATIC plus
ARETURN

我们不能简单地使这段代码成为可挂起的,因为在恢复之后,栈上只有 $result(它被传递给 resumewith,并且是 invokeSuspend 的参数)。因此,对于第二次调用来说,栈上的变量不足。因此,我们需要在调用之前保存栈,并在调用之后恢复它。我们不需要创建单独的栈到槽溢出逻辑,而是重用两个已经存在的逻辑。一个是栈normalization,在内联器中已经存在。内联器在内联调用之前将栈溢出到局部变量中,并在调用之后将其恢复。

因此,如果我们在这里执行相同的操作,字节码将变为:

text 复制代码
INVOKESTATIC InlineMarker.beforeInlineCall
ALOAD 1 // a1
INVOKESTATIC InlineMarker.beforeInlineCall
ALOAD 2 // a2
ALOAD 3 // a3
ICONST 0
INVOKESTATIC InlineMarker.mark
INVOKESTATIC plus
ICONST 1
INVOKESTATIC InlineMarker.mark
INVOKESTATIC InlineMarker.afterInlineCall
ICONST 0
INVOKESTATIC InlineMarker.mark
INVOKESTATIC plus
ICONST 1
INVOKESTATIC InlineMarker.mark
INVOKESTATIC InlineMarker.afterInlineCall
ARETURN

其中,挂起标记是 ICONST (0|1) INVOKESTATIC InlineMarker.mark;在栈normalization之后(FixStackMethodTransformer normalizes栈),字节码看起来像是:

text 复制代码
ALOAD 1 // a1
ASTORE 4 // a1
ALOAD 2 // a2
ALOAD 3 // a3
ICONST 0
INVOKESTATIC InlineMarker.mark
INVOKESTATIC plus
ICONST 1
INVOKESTATIC InlineMarker.mark
ASTORE 5 // a2 + a3
ALOAD 4 // a1
ALOAD 5 // a2 + a3
ICONST 0
INVOKESTATIC InlineMarker.mark
INVOKESTATIC plus
ICONST 1
INVOKESTATIC InlineMarker.mark
ARETURN

我们需要溢出 a2 + a3,因为我们应该保留 plus 的参数顺序。因此,除了挂起标记外,代码生成器还会放置内联标记。但与挂起标记不同,它们放置在调用参数周围。因此,代码生成器按照以下顺序生成可挂起的调用:

  1. beforeInlineCall 标记
  2. 参数
  3. 在可挂起调用标记之前
  4. 调用本身
  5. 在可挂起调用标记之后
  6. afterInlineCall 标记

再次看看栈normalization,我们发现现在有五个局部变量,但幸运的是,我们并没有将它们全部溢出。a2 + a3 在两个调用期间都不是活跃的,因此在LVT中不存在,所以编译器没有理由将其溢出。对于第四个变量,情况也是一样的:在第二次调用期间,变量处于死亡状态,因此我们只溢出它一次。a2a3 在两个调用期间都处于死亡状态,因此也不会被溢出,就像在第二次调用期间的 a1 一样。

注:LVT,虚拟机栈中栈帧里的局部变量表(Local Variables Table)

FIXME:不要多次溢出相同的变量。我们可以重用一个溢出的变量,并将其放置到几个位置。更好的做法是,在溢出栈时不要创建新的局部变量。在这个示例中,ALOAD 1 可以被移除,因此不需要 ALOAD 4。因此,理想的字节码如下所示:

text 复制代码
ALOAD 2 // a2
ALOAD 3 // a3
ICONST 0
INVOKESTATIC InlineMarker.mark
INVOKESTATIC plus
ICONST 1
INVOKESTATIC InlineMarker.mark
ASTORE 4 // a2 + a3
ALOAD 1 // a1
ALOAD 4 // a2 + a3
ICONST 0
INVOKESTATIC InlineMarker.mark
INVOKESTATIC plus
ICONST 1
INVOKESTATIC InlineMarker.mark
ARETURN

然后我们将只有相同的三个局部变量溢出,而不是四个。

Coroutine Intrinsics(协程内置函数)

前面的示例中使用了一个简单的协程构建器。它们使用所谓的空continuation。现在让我们重新创建 kotlinx.coroutines 的 async 函数,该函数在另一个线程上运行一个协程,然后在其完成后将结果返回到主线程。

首先,我们需要一个在主线程上等待的类:

kotlin 复制代码
class Async<T> {
    suspend fun await(): T = TODO()
}

然后是根continuation:

kotlin 复制代码
class AsyncContinuation<T>: Continuation<T> {
    override val context = EmptyCoroutineContext

    override fun resumeWith(result: Result<T>) {
        TODO()
    }
}

现在,我们可以把这两个类组合起来。在 await 中,我们应该检查协程是否已经计算出结果,如果是,就使用从"使用结果恢复"部分中的 it.resume(value) 技巧返回结果。否则,我们应该保存continuation,以便在结果可用时恢复它。在continuation的 resumeWith 中,我们应该检查是否正在等待结果,并使用计算出的结果恢复等待的continuation;否则,我们保存结果,以便在 await 中访问。在代码中,它看起来像是:

kotlin 复制代码
class AsyncContinuation<T>: Continuation<T> {
    var result: T? = null
    var awaiting: Continuation<T>? = null
    override val context: CoroutineContext
        get() = EmptyCoroutineContext

    override fun resumeWith(result: Result<T>) {
        if (awaiting != null) {
            awaiting?.resumeWith(result)
        } else {
            this.result = result.getOrThrow()
        }
    }
}

class Async<T>(val ac: AsyncContinuation<T>) {
    suspend fun await(): T =
        suspendCoroutine<T> {
            val res = ac.result
            if (res != null)
                it.resume(res)
            else
                ac.awaiting = it
        }
}

最后,我们可以编写builder体:

kotlin 复制代码
fun <T> async(c: suspend () -> T): Async<T> {
    val ac = AsyncContinuation<T>()
    c.startCoroutine(ac)
    return Async(ac)
}

简单的主函数来测试一切是否按预期工作(再次检查suspend以确保我们没有漏掉任何内容):

kotlin 复制代码
fun main() {
    var c: Continuation<String>? = null
    builder {
        val async = async {
            println("Async in thread ${Thread.currentThread().id}")
            suspendCoroutine<String> { c = it }
        }
        println("Await in thread ${Thread.currentThread().id}")
        println(async.await())
    }
    c?.resume("OK")
}

Upon running, it will print

text 复制代码
Async in thread 1
Await in thread 1
OK

在将其变成多线程之前,它将在主线程中运行 async 的协程。然而,在我们让它变成多线程之前,我们需要了解 suspendCoroutine 函数的工作原理。

suspendCoroutine

在深入探讨了resume过程的复杂性以及生成正确状态机所涉及的复杂情况后,让我们现在来探讨一下调用suspendCoroutine时会发生什么。我们知道这个函数在某种程度上会返回COROUTINE_SUSPENDED,并且提供了对延续参数的访问。以下是suspendCoroutine的定义:

kotlin 复制代码
public suspend inline fun <T> suspendCoroutine(crossinline block: (Continuation<T>) -> Unit): T =
    suspendCoroutineUninterceptedOrReturn { c: Continuation<T> ->
        val safe = SafeContinuation(c.intercepted())
        block(safe)
        safe.getOrThrow()
    }

该函数完成了五个不同的任务:

  1. 访问延续参数。
  2. 拦截延续。
  3. 将其包装在SafeContinuation中。
  4. 调用 lambda 参数。
  5. 返回结果、COROUTINE_SUSPENDED,或抛出异常。

suspendCoroutineUninterceptedOrReturn

首先,让我们来看看如何在不暂停当前执行的情况下访问延续参数。 suspendCoroutineUninterceptedOrReturn是一个内置函数,它只做了一件事:内联提供的 lambda 参数并将延续参数传递给它。它的目的是提供对延续参数的访问,而在挂起函数和 lambda 中,延续参数通常是不可见的。因此,我们无法在纯 Kotlin 中编写它。它必须是内置的。

有趣的是,由于 lambda 返回returnType | COROUTINE_SUSPENDED,编译器不会检查其返回类型,因此在运行时可能会出现一些有趣的 ClassCastException,这是由于 Kotlin 类型系统的这种不稳定性导致的:

kotlin 复制代码
import kotlin.coroutines.intrinsics.*

suspend fun returnsInt(): Int = suspendCoroutineUninterceptedOrReturn { "Nope, it returns String" }

suspend fun main() {
    1 + returnsInt()
}

抛出异常

text 复制代码
Exception in thread "main" java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Number

此外,运行时在使用返回值时会抛出 ClassCastException。因此,如果忽略其返回值,甚至更糟的是将该函数调用放在尾递归位置(以启用尾递归优化),则不会抛出异常。因此,下一个示例可以正常运行:

kotlin 复制代码
import kotlin.coroutines.intrinsics.*

suspend fun returnsInt(): Int = suspendCoroutineUninterceptedOrReturn { "Nope, it returns String" }

suspend fun alsoReturnsInt(): Int = returnsInt()

suspend fun main() {
    returnsInt()
    alsoReturnsInt()
}

SafeContinuation

当然,SafeContinuation 的存在是有原因的。让我们考虑以下示例:

kotlin 复制代码
fun builder(c: suspend () -> Unit) {
    c.startCoroutine(object: Continuation<Unit> {
        override val context = EmptyCoroutineContext
        override fun resumeWith(result: Result<Unit>) {
            result.getOrThrow()
        }
    })
}

fun main() {
    builder {
        suspendCoroutineUninterceptedOrReturn {
            it.resumeWithException(IllegalStateException("Boo"))
        }
    }
}

有人可能会认为,我们会得到 IllegalStateException,但事实并非如此:

text 复制代码
Exception in thread "main" kotlin.KotlinNullPointerException
   at kotlin.coroutines.jvm.internal.ContinuationImpl.releaseIntercepted(ContinuationImpl.kt:118)
   at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:39)
   at kotlin.coroutines.ContinuationKt.startCoroutine(Continuation.kt:114)

这是一种未定义的行为。

所以,这里发生了什么,为什么会导致 KNPE?当我们调用 resumeWithException 时,在 BaseContinuationImpl.resumeWith 中我们调用 releaseIntercepted,在其中我们将 intercepted 字段设置为 CompletedContinuation

kotlin 复制代码
protected override fun releaseIntercepted() {
    val intercepted = intercepted
    if (intercepted != null && intercepted !== this) {
        context[ContinuationInterceptor]!!.releaseInterceptedContinuation(intercepted)
    }
    this.intercepted = CompletedContinuation // just in case
}

然后,当我们通过调用 getOrThrow 抛出异常时,BaseContinuationImpl.resumeWith 捕获它(参见有关带异常的恢复的部分),并再次调用 releaseIntercepted,但由于 context 中没有继续拦截器,我们得到了 KNPE。

这就是 SafeContinuation 本质上可以防止的问题。它在其 resumeWith 方法中捕获异常,并保存直到 suspendCoroutine 调用 getOrThrow。此外,getOrThrow 对于尚未完成的协程返回 COROUTINE_SUSPENDED。换句话说,当包装的协程挂起时,getOrThrow 告诉 suspendCoroutine 进行挂起。

startCoroutine

我们已经介绍了协程是如何挂起的,它何时恢复以及编译器如何处理它。然而,我们从未探讨过如何创建或启动协程。在所有先前的示例中,您可以注意到对 startCoroutine 的调用。该函数有两个版本:用于启动没有参数的挂起 lambda 和用一个参数或接收器启动协程。它的定义如下:

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

所以,它

  1. 创建一个协程
  2. 拦截它
  3. 启动它

再次强调,createCoroutineUnintercepted 有两个版本 - 没有参数和只有一个参数。它所做的就是调用挂起 lambda 的 create 函数。在拦截之后,我们使用一个虚拟值恢复协程。正如在使用值恢复部分所解释的那样,状态机会忽略其第一个状态值。因此,这是启动协程的完美方式,而无需调用 invokeSuspend。然而,启动可调用引用的方式是不同的。由于它们是尾调用,换句话说,do not have a continuation inside an object, we wrap them in a hand-written one.

create

create 是由编译器生成的,它

  1. 通过调用带有捕获变量的构造函数创建 lambda 的副本
  2. create 的参数放入参数字段中。

例如,如果我们有一个类似于以下的 lambda:

kotlin 复制代码
fun main() {
    val i = 1
    val lambda: suspend (Int) -> Int = { i + it }
}

生成的 create 将如下所示:

kotlin 复制代码
public fun create(value: Any?, completion: Continuation): Continuation {
    val result = main$lambda$1(this.$i, completion)
    result.I$0 = value as Int
}

注意,构造函数除了捕获的参数外,还接受一个completion对象。

在旧的 JVM BE 中,即使我们不需要,对于每个挂起 lambda,也会生成 create。也就是说,即使对于有多个参数的挂起 lambda 也是如此。createCoroutineUnintercepted 只有两个版本,没有其他地方我们调用 create(除了编译器生成的 invoke)。因此,在 JVM_IR BE 中,我们修复了这个疏漏,它只为没有参数或只有一个参数的函数生成 create 函数。

Lambda Parameters

我们需要将挂起 lambda 的参数放入字段中,因为invokeSuspend 只能有一个参数 - $result。编译器将 lambda 主体移入 invokeSuspend。因此,invokeSuspend 执行所有的计算。我们也为参数重用了用于溢出变量的字段。例如,如果我们有一个类型为 suspend Int.(Long, Any) -> Unit 的 lambda,那么 I$0 保存扩展接收器的值,J$0 - 第一个参数,L$1 - 第二个参数。

这样,我们可以为参数重用溢出变量的清理逻辑。如果我们为参数使用单独的字段,那么当我们不再需要它们时,就需要像对溢出变量字段一样手动将 null 推入它们。

invoke

invoke 实际上就是没有拦截的 startCoroutine。在 invoke 中,我们调用 create,然后通过调用 invokeSuspend 使用虚拟值来恢复一个新的实例。我们不能直接调用 invokeSuspend 而不先调用构造函数,因为那样不会创建所需的用于completion链的continuation,就像在continuation-passing style部分中解释的那样。此外,递归挂起 lambda 调用会重置 label 的值。

FIXME:如果我们可以验证我们不会将它们作为completion传递给自己,那么我们就不需要创建额外的 lambda 副本。但是,这不仅包括递归 lambda。我们可以将 lambda 传递给一个尾调用挂起函数,并在那里调用它。在这种情况下,continuation对象是相同的,我们有与递归相同的问题。

当 lambda 具有多个参数时,在 JVM_IR 中我们没有 create 函数,invoke 创建 lambda 的新实例,并复制所有捕获的变量,然后将 lambda 的参数放入字段中。

Interception

在所有这些枯燥的理论之后,我们终于可以将前面部分的async示例转变为多线程的版本。在之前的所有示例中,我使用EmptyCoroutineContext作为根continuations的contextCoroutineContextcontext属性的类型,本质上是从CoroutineContext.KeyCoroutineContext.Element的哈希映射。程序员可以在其中存储协程本地信息,在这里,"协程"是广义上用来表示轻量级线程的,不仅仅是一个挂起函数或挂起lambda。因此,可以将context视为ThreadLocal的替代品。用户应该使用coroutineContext内部函数来访问它。即使单个上下文元素本身也是一个上下文,因此它们形成一个树。当我们需要将协程从一个线程移动到另一个线程时,即拦截它时,这种上下文中的一个元素本身就是上下文本身的事实非常方便。为了做到这一点,我们需要为上下文提供键和元素。有一个特殊的接口ContinuationInterceptor,它重写了CoroutineContext.Element并具有一个名为key的属性。

让我们创建一个:

kotlin 复制代码
object SingleThreadedInterceptor: ContinuationInterceptor {
    override val key = ContinuationInterceptor.Key

    override fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T> =
        SingleThreadedContinuation(continuation)
}

在其interceptContinuation方法中,我们只需用一个新的continuation包装提供的continuation,在这个continuation中,我们可以在不同的线程上运行协程:

kotlin 复制代码
class SingleThreadedContinuation<T>(val c: Continuation<T>): Continuation<T> {
    override val context: CoroutineContext
        get() = c.context

    override fun resumeWith(result: Result<T>) {
        thread {
            c.resumeWith(result)
        }
    }
}

resumeWith函数内部,正如大家所看到的,我们只是在另一个线程上恢复continuation。

需要注意的是,我们将提供的continuation的context作为我们自己的context传递,这样我们的continuation就继承了来自被包装的continuation的context。这不是必需的,但由于contextThreadLocal的替代品,我们应该保留它。我们只允许添加额外的基础信息,比如ContinuationInterceptor,但不能删除用户添加的任何内容。

重要的是要注意,key属性应该是常量。否则,对于该键的get操作将返回null,将不会进行拦截。

现在,如果我们修改AsyncContinuationasync函数和main来使用这个拦截器:

kotlin 复制代码
class AsyncContinuation<T>(override val context:  CoroutineContext): Continuation<T> {
    var result: T? = null
    var awaiting: Continuation<T>? = null

    override fun resumeWith(result: Result<T>) {
        if (awaiting != null) {
            awaiting?.resumeWith(result)
        } else {
            this.result = result.getOrThrow()
        }
    }
}

fun <T> async(context: CoroutineContext = EmptyCoroutineContext, c: suspend () -> T): Async<T> {
    val ac = AsyncContinuation<T>(context)
    c.startCoroutine(ac)
    return Async(ac)
}

fun main() {
    var c: Continuation<String>? = null
    builder {
        val async = async(SingleThreadedInterceptor) {
            println("Async in thread ${Thread.currentThread().id}")
            suspendCoroutine<String> { c = it }
        }
        println("Await in thread ${Thread.currentThread().id}")
        println(async.await())
    }
    c?.resume("OK")
}

and when we run the program, we get something like

text 复制代码
Async in thread 11
Await in thread 1
OK

as expected.

但是,协程机制的哪个部分调用了拦截器的interceptContinuation函数呢?该函数将continuation包装起来,但是谁调用了这个函数呢?嗯,intercepted调用了。如果我们将async重写为:

kotlin 复制代码
fun <T> async(context: CoroutineContext = EmptyCoroutineContext, c: suspend () -> T): Async<T> {
    val ac = AsyncContinuation<T>(context)
    c.createCoroutineUnintercepted(ac)
//        .intercepted()
        .resume(Unit)
    return Async(ac)
}

(请注意,我注释掉了intercepted的调用),然后运行示例,我们会得到

text 复制代码
Async in thread 1
Await in thread 1
OK

如果没有拦截,continuation将不会被包装,因此它将继续在同一个线程上执行。 但是 intercepted 属性会进行一些间接操作,最终会执行以下操作:

kotlin 复制代码
context[ContinuationInterceptor]?.interceptContinuation(this)

请记住,CoroutineContext.Element 本身就是一个 CoroutineContext,它只有一个元素,如果其键与提供的键相同,则在 get 方法中返回自身。这就是为什么使用常量作为键很重要的原因。我们还将拦截的continuation缓存在 intercepted 字段中。当我们不使用 SafeContinuation 包装延续时,这个字段会导致 KNPE。

Coroutine Superclasses

以下是标准库中定义并由编译器使用的所有 continuation 类的图表:

text 复制代码
   +------------+
   |Continuation|
   +------+-----+
          ^
          |
+---------+----------+
|BaseContinuationImpl+<---------------+
+---------+----------+                |
          ^                           |
          |                           |
  +-------+--------+    +-------------+------------+
  |ContinuationImpl|    |RestrictedContinuationImpl|
  +-------+--------+    +-------------+------------+
          ^                           ^
          |                           |
    +-----+-------+       +-----------+-----------+
    |SuspendLambda|       |RestrictedSuspendLambda|
    +-------------+       +-----------------------+

所有协程的主要超级接口是 Continuation:

kotlin 复制代码
public interface Continuation<in T> {
    public val context: CoroutineContext
    public fun resumeWith(result: Result<T>)
}

它是用户可访问的唯一接口。从本质上讲,它是协程机制的核心。采用continuation-passing style,每个挂起函数和 lambda 都接受额外的continuation参数。

每个编译器生成的continuation都继承BaseContinuationImpl

kotlin 复制代码
abstract class BaseContinuationImpl(
    public val completion: Continuation<Any?>?
) : Continuation<Any?>, CoroutineStackFrame, Serializable {
    public final override fun resumeWith(result: Result<Any?>)
    protected abstract fun invokeSuspend(result: Result<Any?>): Any?
    protected open fun releaseIntercepted()
    public open fun create(completion: Continuation<*>): Continuation<Unit>
    public open fun create(value: Any?, completion: Continuation<*>): Continuation<Unit>
    public override fun toString(): String
    public override val callerFrame: CoroutineStackFrame?
    public override fun getStackTraceElement(): StackTraceElement?
}

请注意,它的 resumeWith 函数是 final 的,但它引入了 invokeSuspend 函数。resumeWith 函数执行以下操作:

  1. 当用户调用其 resumeWith 时,它将带有参数调用 invokeSuspend,以传递传递的结果或异常,从而恢复挂起的协程。
  2. 当协程完成时,它调用 completion 延续的 resumeWith,以便将执行返回给调用者。
  3. 它捕获异常并在其中继续 completion,用 Result 包装异常,从而将异常传播到调用者。

此外,resumeWith 在协程完成时调用 releaseIntercepted 来清除拦截器。

编译器为带有零个或一个参数的挂起 lambda 生成了 create 重载,因为 createCoroutineUnintercepted 调用了它们。

其余部分(callerFramegetStackTraceElement)来自 CoroutineStackFrame 接口,调试器和 kotlinx.coroutines 库使用该接口生成异步堆栈跟踪。

接下来的类是 ContinuationImpl。由编译器生成的每个挂起函数的continuation都扩展了这个类。请注意,编译器目前不会生成受限挂起函数(restricted suspend functions)。

kotlin 复制代码
abstract class ContinuationImpl(
    completion: Continuation<Any?>?,
    private val _context: CoroutineContext?
) : BaseContinuationImpl(completion) {
    public override val context: CoroutineContext
    protected override fun releaseIntercepted()

    private var intercepted: Continuation<Any?>?
    public fun intercepted(): Continuation<Any?>
}

它添加了在相应部分中介绍的 intercepted 字段和 intercepted() 函数。

对于受限挂起函数,有 RestrictedContinuationImpl 类,因此它们的上下文只能是 EmptyCoroutineContext。这允许我们在调用 startCoroutine 时节省几个字节,该调用针对不继承 BaseContinuationImpl 的挂起函数类型。例如,当接收方是对挂起函数的可调用引用时,传递给 startCoroutine 的根continuation的上下文是 EmptyCoroutineContext

kotlin 复制代码
abstract class RestrictedContinuationImpl(
    completion: Continuation<Any?>?
) : BaseContinuationImpl(completion) {
    public override val context: CoroutineContext
}

所有非受限生成的挂起 lambda 都扩展了 SuspendLambda

kotlin 复制代码
internal abstract class SuspendLambda(
    public override val arity: Int,
    completion: Continuation<Any?>?
) : ContinuationImpl(completion), FunctionBase<Any?>, SuspendFunction

由于所有挂起 lambda 都是函数类型,它们实现了 FunctionBase 接口。SuspendFunction 是一个标记接口,用于类型检查和类型转换(见下一小节)。

所有受限生成的挂起 lambda 都扩展了 RestrictedSuspendLambda

kotlin 复制代码
abstract class RestrictedSuspendLambda(
    public override val arity: Int,
    completion: Continuation<Any?>?
) : RestrictedContinuationImpl(completion), FunctionBase<Any?>, SuspendFunction

RestrictedSuspendLambdaSuspendLambda 的唯一区别在于超类。SuspendLambda 继承自 ContinuationImpl,而 RestrictedSuspendLambda 继承自 RestrictedContinuationImpl

SuspendFunction{N}

每个挂起的 lambda 都有一个特殊的挂起函数类型:SuspendFunction{N},其中 {N} 是 lambda 参数的数量。它们仅在编译时存在,并且会被转换为 Function{N+1}SuspendFunction。由于 SuspendFunction{N} 在运行时不存在,如果我们不使用 SuspendFunction 标记接口,将无法区分普通的函数类型和挂起函数类型。更具体地说,它在 is SuspendFunction{N}as SuspendFunction{N} 表达式中使用。例如,如果我们有以下代码:

kotlin 复制代码
fun main() {
    val lambda: suspend () -> Unit = {}
    val a: Any = lambda
    print(a is (suspend () -> Unit))
}

对于 is 表达式,我们生成类似以下内容的代码:

kotlin 复制代码
a is SuspendFunction and TypeIntrinsics.isFunctionOfArity(a, 1)

这也是为什么 SuspendLambda 的构造函数接受 arity 的原因。

当然,所有生成的挂起 lambda 都通过 SuspendLambdaRestrcitedSuspendLambda 实现了 SuspendFunction,而对于挂起函数的可调用引用则直接实现了该接口。

Suspend Lambda Layout

理想的suspend lambda 布局如下所示:

  1. 超类型:kotlin/coroutines/jvm/internal/SuspendLambdakotlin/jvm/functions/Function{N}
  2. 包私有的捕获变量
  3. 私有的 label 字段,类型为 int。私有,因为它仅在 lambda 本身中使用。
  4. 用于溢出变量的私有字段。同样是私有的。
  5. 公共 final 方法 invokeSuspend,类型为 (Ljava/lang/Object;)Ljava/lang/Object;
  6. 重写了 BaseContinuationImplinvokeSuspend 方法。
  7. 公共 final create 方法,类型为 (<params>,Lkotlin/coroutines/Continuation)Lkotlin/coroutines/Continuation<params> 的类型被擦除。换句话说,它们的类型为 Ljava/lang/Object;,只要参数的数量是 0 或 1。这是因为该方法重写了基类的 create 方法。
  8. 公共或包私有构造函数:<init>,类型为 (<captured-variables>,Lkotlin/coroutines/Continuation;)V,在其中调用 SuspendLambda 的构造函数,传入 arity 和完成对象,并初始化捕获变量。 编译器知道 arity,但完成对象是作为构造函数的参数提供的。

kotlin.suspend

suspend 软关键字目前无法与 lambda 表达式一起使用。解析器不支持它。然而,编写变量的类型相当麻烦,所以从 1.3 开始,有一个名为 kotlin.suspend 的函数,可以在没有参数的 lambda 前使用,并将其转换为挂起 lambda。由于 suspend 是一个软关键字,因此可以将函数命名为 suspend。用户可以定义一个名为 suspend 的函数,该函数接受 lambda,但令牌序列 suspend { 只能与 kotlin.suspend 一起使用。这只是过渡期和在解析器不支持挂起 lambda 和函数表达式之前使用的。

FIXME: 在解析器中支持它,停止使用这种方法。

Tail-Call Suspend Lambdas

FIXME:此功能尚未实现。理想情况下,它们应该表现得像对挂起函数的可调用引用。 也就是说,它们应该:

  1. 不应该有 createinvokeSuspend 和除了捕获变量之外的所有字段。只有构造函数和 invoke 方法。
  2. 不应该继承 BaseContinuationImpl 或任何其子类。
相关推荐
人间有清欢10 小时前
十、kotlin的协程
kotlin
吾爱星辰10 小时前
Kotlin 处理字符串和正则表达式(二十一)
java·开发语言·jvm·正则表达式·kotlin
ChinaDragonDreamer10 小时前
Kotlin:2.0.20 的新特性
android·开发语言·kotlin
一丝晨光20 小时前
Java、PHP、ASP、JSP、Kotlin、.NET、Go
java·kotlin·go·php·.net·jsp·asp
500了1 天前
Kotlin基本知识
android·开发语言·kotlin
陈亦康2 天前
Armeria gPRC 高级特性 - 装饰器、无框架请求、阻塞处理器、Nacos集成、负载均衡、rpc异常处理、文档服务......
kotlin·grpc·armeria
奋斗的小鹰3 天前
kotlin 委托
android·开发语言·kotlin
Wency(王斯-CUEB)3 天前
【文献阅读】政府数字治理的改善是否促进了自然资源管理?基于智慧城市试点的准自然实验
android·kotlin·智慧城市
中游鱼3 天前
Visual Studio C# 编写加密火星坐标转换
kotlin·c#·visual studio
500了3 天前
kotlin协程
开发语言·python·kotlin