协程的内部实现
有一类人不能接受只是开车,他们需要打开引擎盖来了解汽车的运作方式。我就是其中之一,所以我必须找出协程是如何工作的。如果你也是这样的人,你会喜欢这一章。如果不是,你可以跳过它。
这一章不会介绍任何您可能使用的新工具,它纯粹是解释性的。它试图以令人满意的程度解释协程的工作原理。关键教训是:
- 挂起函数就像状态机一样, 在函数开始和每个挂起函数调用后都对应一个状态值.
- 状态的编号和本地数据都保存在 Continuation 对象中。
- 一个函数的 Continuation 装饰着其调用者函数的 Continuation;因此,所有这些 Continuation 表示一个调用栈,在恢复或恢复的函数完成时使用。
如果你还有兴趣继续, 那我们一起
Continuation是可传递的
有几种方式可以实现暂停函数,但Kotlin团队选择了一种称为"continuation-passing style"的选项。这意味着continuations(在上一章中解释)作为参数从一个函数传递到另一个函数。按照惯例,continuation位于参数列表的最后一个位置。
你可能已经注意到底层的结果类型与最初声明的类型不同。它已经变成了 Any 或 Any?。为什么?原因是,一个挂起函数可能会被挂起,因此它可能不会返回一个声明的类型。在这种情况下,它返回一个特殊的 COROUTINE_SUSPENDED 标记,我们稍后会在实践中看到。现在只需要注意,由于 getUser 可能返回 User? 或 COROUTINE_SUSPENDED(它是 Any 类型),其结果类型必须是 User? 和 Any 最接近的超类型,因此是 Any?。也许有一天 Kotlin 将引入联合类型,在这种情况下我们将有 User? | COROUTINE_SUSPENDED。
A very simple function
深入研究一下,我们从一个非常简单的函数开始,它在延迟之前和之后打印一些东西。
您已经可以推断出 myFunction 函数在底层的函数签名将是什么样的:
kotlin
fun myFunction(continuation: Continuation<*>): Any
myFunction 函数需要自己的 Continuation 来记住它的状态,接下来我们把它命名为 MyFunctionContinuation(实际的 Continuation 是一个对象表达式,没有名字,但这样命名更容易解释)。在 myFunction 函数的函数体开头,它会使用它自己的 Continuation(MyFunctionContinuation)包装传入的 Continuation 参数。
kotlin
val continuation = MyFunctionContinuation(continuation)
这应该只在 continuation 没有被包装过的情况下进行。如果已经被包装了,那么这是恢复过程的一部分,我们应该保持 continuation 不变。(现在可能有点困惑,但稍后你会更清楚为什么。)
kotlin
val continuation =
if (continuation is MyFunctionContinuation) continuation else MyFunctionContinuation(continuation)
或者简单转换为:
kotlin
val continuation = continuation as? MyFunctionContinuation ?: MyFunctionContinuation(continuation)
现在可以讨论下这个具体代码逻辑了
kotlin
suspend fun myFunction() {
println("Before")
delay(1000) // suspending
println("After")
}
该函数可以从两个地方开始执行:从头开始(第一次调用)或者从挂起点后的位置开始执行(在恢复 continuation 时)。为了确定当前状态,我们使用一个叫做 label 的字段。在开始时,它为 0,因此函数将从头开始执行。但是,在每个挂起点之前,我们需要将 label 设置为下一个状态,这样在恢复时就可以从挂起点后面的位置继续执行。这里的实际机制有一点更加复杂,因为标签的第一位也会被更改,并且这个更改会被暂停函数检查。这个机制是为了支持递归而需要的,但为了简单起见,先这么假设.
最后一个重要的部分已经在上面的代码片段中呈现出来了。当 delay 被挂起时,它会返回 COROUTINE_SUSPENDED,然后 myFunction 也会返回 COROUTINE_SUSPENDED;这个调用它的函数也会这样做,以及调用这个函数的函数,以及所有其他函数,一直到调用栈的顶部14。这是一个挂起如何结束所有这些函数,并将线程留给其他可运行项(包括协程)使用的方式。
在我们进一步探讨之前,让我们分析一下上面的代码。如果这个 delay 调用没有返回 COROUTINE_SUSPENDED 会发生什么?如果它只返回 Unit 而不是 COROUTINE_SUSPENDED 呢(我们知道它不会这样做,但是让我们假设)?请注意,如果 delay 只返回 Unit,我们将只是移动到下一个状态,并且该函数将像任何其他函数一样运行。
现在,让我们来谈谈 continuation,它是作为匿名类实现的。简化后,它看起来像这样:
当函数 a 调用函数 b 时,虚拟机需要在某个地方存储 a 的状态,以及 b 完成后应返回执行的地址。所有这些都存储在一个称为调用堆栈的结构中。问题在于,当我们暂停时,我们释放了一个线程,因此清除了我们的调用堆栈。因此,在恢复时,调用堆栈无用。取而代之的是,continuation 作为调用堆栈。每个 continuation 保留我们暂停时的状态(作为标签),函数的本地变量和参数(作为字段),以及引用调用此函数的函数的 continuation。一个 continuation 引用另一个 continuation,依此类推。因此,我们的 continuation 就像一个巨大的洋葱:它保留了通常保存在调用堆栈上的所有内容。请看下面的示例:
例如,想象一个这样的情况:函数a调用函数b,函数b又调用函数c,函数c被挂起。在恢复时,c的 continuation首先恢复c函数。一旦这个函数完成了,c continuation恢复b continuation调用b函数。一旦它完成了,b continuation恢复a continuation,调用a函数。
整个过程可以用以下草图表示:
在异常处理上也是类似的:未捕获的异常在 resumeWith 中被捕获,然后被包装成 Result.failure(e),然后调用我们函数的函数会使用这个结果进行恢复。
希望这些内容能让你了解到当我们进行挂起时所发生的事情。状态需要存储在 continuation 中,并需要支持挂起机制。当我们恢复时,需要从 continuation 中恢复状态,并根据需要使用结果或抛出异常。
希望这些让你了解到了我们在暂停时所做的一切。需要在 continuation 中存储状态,并支持暂停机制。当我们恢复时,需要从 continuation 中恢复状态并使用结果或抛出异常。
The actual code
Continuations和挂起函数实际编译成的代码更加复杂,因为它包括了优化和一些额外的机制,例如:
- 构建更好的异常堆栈跟踪;
- 添加协程挂起截获
- 在不同的级别上进行优化(例如删除未使用的变量或尾递归优化)
挂起函数的性能
使用挂起函数而不是常规函数的成本是多少?当深入了解内部实现时,很多人可能会认为成本很高,但事实并非如此。将函数分成状态是廉价的,因为数字比较和执行跳转几乎不会产生任何开销。保存状态在continuation中也很便宜。我们不会复制本地变量:我们让新变量指向内存中的同一点。唯一需要成本的操作是创建一个continuation类,但这仍然不是大问题。如果你不担心 RxJava 或回调的性能,那么你肯定不用担心挂起函数的性能。
结语
实际上,协程底层的机制比我所描述的更加复杂,但是我希望你能对协程的内部有一些了解
-
挂起函数类似于状态机,有一个可能的状态在函数的开始和每个挂起函数调用后。
-
标识状态的标签和本地数据都存储在continuation对象中。
-
一个函数的 continuation 装饰着它的调用函数的 continuation,因此所有这些 continuation 代表了一个用于恢复或者已恢复函数的调用栈。
👀关注公众号:Android老皮!!!欢迎大家来找我探讨交流👀