The Ultimate Breakdown of Kotlin Coroutines. Part 3. Variable Spilling.

In the previous parts of the breakdown I covered all the essential parts of coroutines, or, as I like to call them - three-headed hydra - suspension, resumption and state-machine. This time I will explain local variables spilling - a process of saving a coroutine state before suspension and restoring them after resumption.

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:

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)
}

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 them after it.

Thus, the compiler generates the following state machine

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 to avoid memory leaks by pushing null to it so that GC can collect the object. More on that in the following section.

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:

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.

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:

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:

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

ALOAD 1 // a1
ASTORE 4 // a1
ALOAD 2 // a2
ALOAD 3 // a3
INVOKESTATIC plus
ASTORE 5 // a2 + a3
ALOAD 4 // a1
ALOAD 5 // a2 + a3
INVOKESTATIC plus
ARETURN

we need to spill a2 + a3 since we should preserve the order of plus's arguments.

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.

That concludes a short explanation of variables spilling. In the next blogpost I will walk through coroutine intrinsic function - suspendCoroutineUninterceptedOrReturn and a non-intrinsic createCoroutineUnintercepted, which are responsible for accessing coroutine's continuation object and coroutine creation, respectively. I will also explain the machinery around coroutines interception, which drives multithreaded coroutines.

Comments

Popular posts from this blog

The Ultimate Breakdown of Kotlin Coroutines. Part 1. State-Machines and Suspension.

The Ultimate Breakdown of Kotlin Coroutines. Part 4. Create, Start, Suspend, Intercept.

The Ultimate Breakdown of Kotlin Coroutines. Part 5. Suspend Functions.