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
的调用,带有一个整数参数。挂起调用之前和之后的标记值分别为 0
和 1
。内联挂起函数在生成的字节码中保留这些标记,因此,在内联挂起调用时,它们将包围的调用成为挂起点。嗯,至少在内联函数的一个副本中,技术上将是正确的。因为编译器生成了两个副本:一个带有状态机,因此可以通过反射调用它;另一个没有状态机,因此内联器可以将其内联,而不会将状态机内联到另一个内联器中。由于存在具有内联挂起函数的库,所以传递给 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
后,它将节点传递给 CoroutineTransformerMethodVisitor
。CoroutineTransformerMethodVisitor
通过检查这些标记来收集挂起点,然后生成状态机。 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
调用 builder
,builder
调用 b.invoke
,依此类推,直到 suspendMe
。由于 suspendMe
挂起,它将 COROUTINE_SUSPENDED
返回给 a
的 invokeSuspend
。如状态机部分所解释的那样,调用者检查 suspendMe
是否返回 COROUTINE_SUSPENDED
,然后相应地返回 COROUTINE_SUSPENDED
。在调用堆栈中的所有函数中,按相反的顺序发生相同的情况。
挂起过程已经解释完毕,接下来是它的对应部分 - 恢复过程。当我们调用 c?.resume(Unit)
时,c
在技术上是 a
,因为 suspendMe
是尾调用函数(关于这一点会在相关章节中详述)。resume
调用 BaseContinuationImpl.resumeWith
。BaseContinuationImpl
是所有协程的超类,不可由用户访问,但几乎用于所有涉及到协程的事务中。它是协程机制的核心,负责恢复过程。BaseContinuationImpl
又调用 a
的 invokeSuspend
。
所以,当我们调用 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
中,我们就可以调用 b
的 resumeWith
,然后继续执行 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的类型参数与 resumeWith
的 Result
参数的类型参数相同: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 中,通过将返回值传递给调用者的continuation
的 resumeWith
来实现相同的目的。然而,与 Scheme 不同,Kotlin 不使用类似 call/cc
的东西。每个协程已经有了一个continuation
。调用者将其作为参数传递给被调用方。由于协程将返回值传递给 resumeWith
,因此其参数的类型与协程的返回类型相同。从技术上讲,类型是 Result<T>
,但它只是一个联合 T | Throwable
;在这种情况下,T
是 Unit
。接下来的部分将使用除 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
变量。该示例显示了 a
的 invokeSuspend
函数,但与前面的示例不同,它的签名是:
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
的值为 1
(suspendMe
已挂起),因此打印出 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())
)。这里发生了几件事情:在生成的状态机内部以及在 BaseContinuationImpl
的 resumeWith
方法内部。
首先,我们改变了生成的状态机。如前所述,$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)
,然后使用结果调用 completion
的 resumeWith
(简化):
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?
}
该函数将异常传递给 invokeSuspend
,invokeSuspend
调用 throwOnFailure
并再次抛出异常,然后异常被 BaseContinuationImpl.resumeWith
捕获并再次包装,直到它到达根continuation的 resumeWith
,在这种情况下,协程构建器打印它。顺便说一句,resumeWithException
在发布协程中的工作方式与此完全相同(除了捕获部分):它将异常包装成 Result
,就像包在墨西哥卷饼里一样。它将其传递给continuation的 resumeWith
。resume
也将参数包装成 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
,在恢复后恢复它。同样,在 +
之前我们应该保存 a1
和 a2
,因为编译器通常不知道挂起调用是否会挂起,所以它假设在每个挂起点可能发生挂起。因此,它在每次调用之前溢出局部变量,并在调用之后取消溢出。
因此,编译器生成了以下状态机:
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$0
、L$0
和 L$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
的参数顺序。因此,除了挂起标记外,代码生成器还会放置内联标记。但与挂起标记不同,它们放置在调用参数周围。因此,代码生成器按照以下顺序生成可挂起的调用:
beforeInlineCall
标记- 参数
- 在可挂起调用标记之前
- 调用本身
- 在可挂起调用标记之后
afterInlineCall
标记
再次看看栈normalization,我们发现现在有五个局部变量,但幸运的是,我们并没有将它们全部溢出。a2 + a3
在两个调用期间都不是活跃的,因此在LVT中不存在,所以编译器没有理由将其溢出。对于第四个变量,情况也是一样的:在第二次调用期间,变量处于死亡状态,因此我们只溢出它一次。a2
和 a3
在两个调用期间都处于死亡状态,因此也不会被溢出,就像在第二次调用期间的 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()
}
该函数完成了五个不同的任务:
- 访问延续参数。
- 拦截延续。
- 将其包装在SafeContinuation中。
- 调用 lambda 参数。
- 返回结果、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)
}
所以,它
- 创建一个协程
- 拦截它
- 启动它
再次强调,createCoroutineUnintercepted
有两个版本 - 没有参数和只有一个参数。它所做的就是调用挂起 lambda 的 create
函数。在拦截之后,我们使用一个虚拟值恢复协程。正如在使用值恢复部分所解释的那样,状态机会忽略其第一个状态值。因此,这是启动协程的完美方式,而无需调用 invokeSuspend
。然而,启动可调用引用的方式是不同的。由于它们是尾调用,换句话说,do not have a continuation inside an object, we wrap them in a hand-written one.
create
create
是由编译器生成的,它
- 通过调用带有捕获变量的构造函数创建 lambda 的副本
- 将
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的context
。CoroutineContext
是context
属性的类型,本质上是从CoroutineContext.Key
到CoroutineContext.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
。这不是必需的,但由于context
是ThreadLocal
的替代品,我们应该保留它。我们只允许添加额外的基础信息,比如ContinuationInterceptor
,但不能删除用户添加的任何内容。
重要的是要注意,key
属性应该是常量。否则,对于该键的get
操作将返回null,将不会进行拦截。
现在,如果我们修改AsyncContinuation
、async
函数和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
函数执行以下操作:
- 当用户调用其
resumeWith
时,它将带有参数调用invokeSuspend
,以传递传递的结果或异常,从而恢复挂起的协程。 - 当协程完成时,它调用
completion
延续的resumeWith
,以便将执行返回给调用者。 - 它捕获异常并在其中继续
completion
,用Result
包装异常,从而将异常传播到调用者。
此外,resumeWith
在协程完成时调用 releaseIntercepted
来清除拦截器。
编译器为带有零个或一个参数的挂起 lambda 生成了 create
重载,因为 createCoroutineUnintercepted
调用了它们。
其余部分(callerFrame
和 getStackTraceElement
)来自 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
RestrictedSuspendLambda
与 SuspendLambda
的唯一区别在于超类。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 都通过 SuspendLambda
和 RestrcitedSuspendLambda
实现了 SuspendFunction
,而对于挂起函数的可调用引用则直接实现了该接口。
Suspend Lambda Layout
理想的suspend lambda 布局如下所示:
- 超类型:
kotlin/coroutines/jvm/internal/SuspendLambda
和kotlin/jvm/functions/Function{N}
- 包私有的捕获变量
- 私有的 label 字段,类型为 int。私有,因为它仅在 lambda 本身中使用。
- 用于溢出变量的私有字段。同样是私有的。
- 公共 final 方法
invokeSuspend
,类型为(Ljava/lang/Object;)Ljava/lang/Object;
。 - 重写了
BaseContinuationImpl
的invokeSuspend
方法。 - 公共 final
create
方法,类型为(<params>,Lkotlin/coroutines/Continuation)Lkotlin/coroutines/Continuation
。<params>
的类型被擦除。换句话说,它们的类型为Ljava/lang/Object;
,只要参数的数量是 0 或 1。这是因为该方法重写了基类的create
方法。 - 公共或包私有构造函数:
<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:此功能尚未实现。理想情况下,它们应该表现得像对挂起函数的可调用引用。 也就是说,它们应该:
- 不应该有
create
、invokeSuspend
和除了捕获变量之外的所有字段。只有构造函数和invoke
方法。 - 不应该继承
BaseContinuationImpl
或任何其子类。