Opening

You have probably heard it said that coroutines are lightweight threads. And while that gives you a useful mental model, it tells you absolutely nothing about how they actually work. So let us fix that. Today we are going to look at what the Kotlin compiler really does when you write a suspend function. We are going to see the generated code, understand the state machine, and demystify the magic behind suspension and resumption. By the end of this video, suspend will no longer be a keyword you accept on faith. You will know, at the compiler level, exactly what it does to your code and why coroutines can be so much more efficient than threads. This is one of those topics where understanding the internals does not just satisfy curiosity, it genuinely changes how you write coroutines.

Coroutines Are Not Threads

Let us start by being very precise about what coroutines are not. A coroutine is not a thread. When you call launch or async, the system does not create a new operating system thread. No new stack is allocated. No kernel data structure is created. A coroutine is a chunk of suspendable computation. It is an object, a regular heap-allocated object, that knows how to execute its code in pieces. Between those pieces, it can suspend, freeing whatever thread it was running on, and later resume, possibly on a different thread. The coroutine itself does not own a thread. It borrows one when it needs to execute and gives it back when it suspends. This is why you can have a hundred thousand coroutines without running out of memory, something that would be impossible with a hundred thousand threads, each needing a megabyte stack.

What suspend Does to Your Function Signature

Now let us look at what happens at the compiler level. When you write a suspend function, the Kotlin compiler transforms it in a very specific way. The transformation is called Continuation Passing Style, or CPS. Here is the key idea. Every suspend function gets an extra parameter added by the compiler: a Continuation. The Continuation is a callback interface with two members: resumeWith for delivering a result, and a context property. So when you write a suspend function called fetchUser that takes an id and returns a User, the compiler transforms its signature into a regular function that takes an id and a Continuation of User, and returns Any, nullable. That return type of Any nullable is important. The function can now return either the actual User result, or a special sentinel value called COROUTINE_SUSPENDED, which tells the caller that the function did not complete yet and will deliver its result later through the continuation callback.

The State Machine

But CPS alone is not enough. A suspend function can have multiple suspension points. If your function calls delay, then fetches from the network, then calls another suspend function, there are three places where it might suspend. The compiler needs a way to track which suspension point the function is at, so it can resume from the right place. It does this by transforming your entire suspend function into a state machine. The compiler generates a when expression, similar to a switch statement, with labels. Label zero is the initial state, the code before the first suspension point. Label one is the code after the first suspension point. Label two is the code after the second suspension point. And so on. Each time the function is called, it checks which label it is at and jumps to the corresponding section of code. Local variables that need to survive across suspension points are stored as fields in the continuation object, not on the stack. This is crucial: the coroutine's state lives on the heap, not on a thread's stack.

A Concrete Example

Let me walk through a real example. Say you have a suspend function called loadData. It calls delay for one second, then calls fetchFromNetwork, which is also a suspend function, and returns the result. Here is what the compiler generates, conceptually. It creates a continuation class with fields for the label, for any local variables, and for the result. When loadData is called for the first time, label is zero. The code enters the when block at label zero, sets the label to one to prepare for resumption, and calls delay. If delay suspends, our function returns COROUTINE_SUSPENDED. Later, when the delay finishes, the continuation's resumeWith is called, which re-invokes loadData with the same continuation. This time label is one, so the code jumps to the second section, sets label to two, and calls fetchFromNetwork. If that suspends, COROUTINE_SUSPENDED is returned again. When the network result arrives, resumeWith fires again, label is now two, and the function extracts the result and returns it. Three invocations of the same function, each picking up where the last one left off, guided by the label.

How Suspension Works

Let us be very precise about what happens at the moment of suspension. When your coroutine reaches a suspension point, say it calls delay, here is the sequence. First, all local variables that are needed after the suspension point are saved into the continuation object's fields. Second, the label is updated to point to the next section of the state machine. Third, the suspend function being called, delay in this case, receives the continuation as its callback. Fourth, the function returns the special value COROUTINE_SUSPENDED up the call chain. This return propagates all the way back to the dispatcher's run loop. The thread that was executing this coroutine is now free. It is literally returned to the thread pool. The coroutine's state is safely parked in the continuation object on the heap, waiting. No thread is blocked. No stack space is held. Just a small object in memory.

How Resumption Works

Now the other half: resumption. When the thing the coroutine was waiting for completes, say the delay timer fires, the system calls the continuation's resumeWith method, passing the result. Here is what happens. The dispatcher receives the continuation and schedules it to run on an available thread from the pool. When a thread picks it up, it calls the coroutine's function again, passing the same continuation. The function checks the label, jumps to the right section of the state machine, retrieves the saved local variables from the continuation's fields, grabs the result from the resumeWith call, and continues executing from where it left off. The thread executing the resumption might be a completely different thread than the one that ran before the suspension. The coroutine does not care. Its state is in the continuation, not on any particular thread's stack. This is why coroutines can migrate between threads transparently. The thread is just an execution resource. The coroutine's identity and state are in the continuation object.

Why Coroutines Are Lightweight

Now you can see why coroutines are called lightweight. A thread requires about one megabyte for its stack, kernel data structures, and scheduler overhead. A coroutine is just a small object on the heap, typically a few hundred bytes, containing the label, a few saved variables, and a reference to the parent continuation. Creating a coroutine is just allocating an object. Suspending is just saving a few fields and returning. Resuming is just calling a function. There is no kernel involvement, no stack allocation, no context switching. The cost difference is enormous. Creating a thread involves a system call to the kernel, allocating a megabyte of contiguous virtual memory, setting up page mappings, and adding an entry to the scheduler. Creating a coroutine involves allocating a small object on the JVM heap, which takes nanoseconds. This is why you can comfortably create a hundred thousand coroutines but would never create a hundred thousand threads.

Decompiling a Suspend Function

You can actually see this generated code yourself. In Android Studio, write a simple suspend function, go to Tools, then Kotlin, then Show Kotlin Bytecode, then click Decompile. You will see the continuation class with its label field and its invokeSuspend method containing the when block. The label checks, the COROUTINE_SUSPENDED comparisons, the casts, it is all there. It looks verbose and mechanical because it is generated code, not meant for human consumption. But once you know what to look for, it all makes sense. The label drives the state machine. The fields on the continuation store surviving local variables. The COROUTINE_SUSPENDED sentinel controls whether execution continues or pauses. I highly recommend doing this at least once with a function that has two or three suspension points. Seeing the generated state machine with your own eyes makes the entire coroutine model click in a way that no amount of reading documentation can match.

What This Means for Your Code

Understanding the compiler transformation changes how you think about several things. First, suspension is cheap. There is no heavyweight operation happening when you call delay or withContext. It is just saving a few fields and returning. Do not be afraid to suspend frequently. Second, local variables across suspension points are not on the stack. They are on the heap, in the continuation object. This means they survive even when no thread is running your code. Third, every suspend function call is a potential suspension point. The compiler generates the state machine around every call to another suspend function, even if that function does not always actually suspend. Fourth, the continuation object is a callback. Underneath all the syntax sugar, coroutines are callbacks. The compiler just writes the callback code for you, managing state transitions, error handling, and cancellation, so you can write code that looks sequential. This is the genius of Kotlin coroutines: they are compiler-generated callback machines that look like straight-line code.

Recap and What is Next

Let us bring it all together. When you write a suspend function, the compiler adds a Continuation parameter and transforms the function body into a state machine. Each suspension point becomes a label in a when expression. When a coroutine suspends, it saves its state to the continuation, returns COROUTINE_SUSPENDED, and frees the thread. When it resumes, the dispatcher schedules it on an available thread, and the state machine picks up from the saved label. Coroutines are lightweight because they are just heap objects, not OS threads. No kernel involvement, no megabyte stacks. You can decompile any suspend function in Android Studio to see the generated state machine. In the next and final video of this phase, we are going to look at where your coroutine code actually runs: the dispatchers. We will understand Main, Default, IO, and Unconfined, and why choosing the wrong one leads to ANRs or wasted resources. See you there.