Document coroutines codegen: spilling
This commit is contained in:
committed by
Ilmir Usmanov
parent
57e69202e8
commit
0943a31675
+244
-1
@@ -550,4 +550,247 @@ exception to `doResume`, which was the name of `invokeSuspend` in experimental c
|
|||||||
Instead of one parameter with type `returnType | COROUTINE_SUSPENDED | Result$Failure(Throwable)`, experimental
|
Instead of one parameter with type `returnType | COROUTINE_SUSPENDED | Result$Failure(Throwable)`, experimental
|
||||||
coroutines' suspend lambda's `doResume` accepts two parameters: `data` and `exception`. `data` has type `returnType | COROUTINE_SUSPENDED`
|
coroutines' suspend lambda's `doResume` accepts two parameters: `data` and `exception`. `data` has type `returnType | COROUTINE_SUSPENDED`
|
||||||
and `exception` has type `Throwable`. `resume` and `resumeWithException` used to be methods of `Continuation` interface and in 1.3 they
|
and `exception` has type `Throwable`. `resume` and `resumeWithException` used to be methods of `Continuation` interface and in 1.3 they
|
||||||
were replaced by `resumeWith`. `resume` and `resumeWithException` are now extension functions on `Continuation`, which call `resumeWith`.
|
were replaced by `resumeWith`. `resume` and `resumeWithException` are now extension functions on `Continuation`, which call `resumeWith`.
|
||||||
|
|
||||||
|
### Variables Spilling
|
||||||
|
|
||||||
|
All the previous examples did not have local variables, and there is a reason for it. When a coroutine suspends, we should save its local
|
||||||
|
variables. Otherwise, when it resumes, the values of them are lost. So, before the suspension, which can be on each suspend call (more
|
||||||
|
generally, on each suspension point), we save them, and after the resumption, we restore them. There is no reason to restore them right
|
||||||
|
after the call if the call did not return `COROUTINE_SUSPENDED`: their values are still in local variable slots.
|
||||||
|
|
||||||
|
Let us consider a simple example:
|
||||||
|
```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)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
here, we should save `a1` before `suspendMe`, and we should restore it after the resumption. Similarly, we should save both `a1` and `a2`
|
||||||
|
before `+`, since the compiler does not generally know whether suspend call will suspend, so it assumes that the suspension might happen in
|
||||||
|
each suspension point. So, it spills the locals before each call and unspills after it.
|
||||||
|
|
||||||
|
Thus, the compiler generates the following state machine
|
||||||
|
```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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
As one can see, the generated code does not spill and unspill variables, which are dead, in other words, which are not required afterward.
|
||||||
|
Furthermore, it cleans the field for spilled variables of reference types up to avoid memory leaks by pushing `null` to it so that GC can
|
||||||
|
collect the object.
|
||||||
|
|
||||||
|
#### Spilled Variables Naming
|
||||||
|
|
||||||
|
One might notice that the names of the fields for spilled variables are odd. The naming scheme is the following: the first letter of the
|
||||||
|
name represents type descriptor of variable:
|
||||||
|
* L for a reference type, i.e., objects and arrays
|
||||||
|
* J for longs
|
||||||
|
* D for doubles
|
||||||
|
* F for floats
|
||||||
|
* I for booleans, bytes, chars, shorts, and ints
|
||||||
|
|
||||||
|
It is important to note that although in Java Bytecode, we represent boolean variables with integer type and on HotSpot assigning a boolean
|
||||||
|
variable to the field of type `int` is fine, on Dalvik, these types are distinct. Thus, we coerce non-integer primitive integral types
|
||||||
|
(except long) before using them.
|
||||||
|
|
||||||
|
The second letter is `$`, which is unlikely to be used in user code. We cannot start spilled variables with `$`, since the compiler uses
|
||||||
|
the prefix `$` for captured variables and using the same prefix for multiple things would confuse the inliner.
|
||||||
|
|
||||||
|
The rest is just the integer index of the variable with the same prefix. I.e., there can be variables `I$0`, `L$0` and `L$1` inside the
|
||||||
|
same suspend lambda object.
|
||||||
|
|
||||||
|
#### Spilled Variables Cleanup
|
||||||
|
Since we spill a reference to the continuation object, we now hold an additional reference to the object. Thus, GC cannot clean its memory
|
||||||
|
as long as there is a reference to the continuation. Of course, holding a reference to a not-needed object leads to memory leaks. The
|
||||||
|
compiler clears the fields for reference types up to avoid the leaks.
|
||||||
|
|
||||||
|
Consider the following example:
|
||||||
|
```kotlin
|
||||||
|
suspend fun blackhole(a: Any?) {}
|
||||||
|
|
||||||
|
suspend fun cleanUpExample(a: String, b: String) {
|
||||||
|
blackhole(a) // 1
|
||||||
|
blackhole(b) // 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
After line (1) `a` is dead, but `b` is still alive. So, we spill only `b`. There is no variable alive after line (2), but the continuation
|
||||||
|
object still holds a reference to `b` in the `L$0` field. So, to clean it up and avoid memory leaks, we push `null` to it.
|
||||||
|
|
||||||
|
Generally, the compiler generates spilling and unspilling code so that it uses only the first fields. If there are M fields for references,
|
||||||
|
but we spill only N (where N ≤ M, of course) objects at the suspension point, everything else should be `null`. However, we do not need to
|
||||||
|
nullify all of them every suspension point. Instead, the compiler checks which of the fields hold references and clears only them.
|
||||||
|
|
||||||
|
Additionally, the compiler shrinks and splits LVT records for local variables, so a debugger will not show dead variables as uninitialized.
|
||||||
|
|
||||||
|
FIXME: Currently, dead variables do not present in LVT. So, if a programmer defines a variable but does not use it, the compiler removes the
|
||||||
|
LVT record for the variable. We can ease this restriction and assume the variable to be alive until the following suspension point.
|
||||||
|
|
||||||
|
#### Stack spilling
|
||||||
|
In the previous examples, the stack was clean before a call, meaning that there were only call arguments before the call, and only the call
|
||||||
|
result is on the stack after the call.
|
||||||
|
|
||||||
|
However, this is not always true. Consider the following example:
|
||||||
|
```kotlin
|
||||||
|
val lambda: suspend () -> Unit = {
|
||||||
|
val a1 = A(1)
|
||||||
|
val a2 = A(2)
|
||||||
|
val a3 = A(3)
|
||||||
|
a1 + (a2 + a3)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
and have a closer look at `a1 + (a2 + a3)` expression. If `+` were not suspend, the compiler would generate the following
|
||||||
|
bytecode:
|
||||||
|
```text
|
||||||
|
ALOAD 1 // a1
|
||||||
|
ALOAD 2 // a2
|
||||||
|
ALOAD 3 // a3
|
||||||
|
INVOKESTATIC plus
|
||||||
|
INVOKESTATIC plus
|
||||||
|
ARETURN
|
||||||
|
```
|
||||||
|
We cannot just make this code suspendable since, after the resumption, the stack has only `$result` (it is passed to `resumewith` and is the
|
||||||
|
argument of `invokeSuspend`). So, there are not enough variables on the stack for the second call. Consequently, we need to save the stack
|
||||||
|
before the call and then restore it after the call. Instead of creating the separate logic of stack into slots spilling, we reuse two
|
||||||
|
already existing ones. One is stack normalization, which is already present in inliner. The inliner spills the stack into locals before the
|
||||||
|
inline call and restores them after the call.
|
||||||
|
So, if we do the same here, the bytecode becomes
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
where suspend markers are `ICONST (0|1) INVOKESTATIC InlineMarker.mark`; and after stack normalization (`FixStackMethodTransformer`
|
||||||
|
normalizes the stack), the bytecode looks like
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
we need to spill `a2 + a3` since we should preserve the order of `plus`'s arguments. So, along with the suspend markers, the codegen puts
|
||||||
|
inline markers. However, unlike suspend markers, they are put around call arguments as well. So, the order the codegen generates
|
||||||
|
suspendable calls in the following:
|
||||||
|
1. `beforeInlineCall` marker
|
||||||
|
2. arguments
|
||||||
|
3. before suspendable call marker
|
||||||
|
4. the call itself
|
||||||
|
5. after suspendable call marker
|
||||||
|
6. `afterInlineCall` marker
|
||||||
|
|
||||||
|
If we look at stack normalization once more, we see that there are now five locals, but, thankfully, we do not spill all of them. `a2 + a3`
|
||||||
|
is not alive during both calls and is not present in LVT, so there is no reason for the compiler to spill it. The same applies for slot 4:
|
||||||
|
the variable
|
||||||
|
is dead during the second call, so we spill it only once. `a2` and `a3` are dead during both calls, and thus they are not spilled, as well
|
||||||
|
as `a1` during the second call.
|
||||||
|
|
||||||
|
FIXME: do not spill the same variables multiple times. We can reuse one spilled variable and put it to several slots. Even better, do not
|
||||||
|
create new locals while spilling the stack. In this example, `ALOAD 1` can be removed, thus removing the need in `ALOAD 4` So, the ideal
|
||||||
|
bytecode will look like
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
Then we will have the same three locals to spills, instead of four.
|
||||||
|
|||||||
Reference in New Issue
Block a user