Coroutines: Suspending State Machines
Coroutines make our asynchronous code look sequential. Coroutines help us escape the callback hell. Coroutines save us from the complex reactive chains.
All good, but have you ever wondered what sort of sorcery happens behind the scenes? How a particular piece of code can actually perform operations asynchronously under the hood and yet give us a feel as if everything is happening sequentially? Is it magic? (Of course, it is, but in some other parallel universe) For us muggles, it all revolves around state machines, suspension points, and continuation passing.
If it is the first time you are hearing all these terms, then do not worry, this article will help you understand them. We will talk about suspension points, CPS (Continuation Passing Style), and state machines. If you are already aware of these terms, then I hope you still enjoy going through the code example and various concepts mentioned in this article.
We will first have a look at a small code snippet implemented using coroutines. Then, we will try to identify the suspension points within the code-snippet. We will then see how the code actually gets compiled into a state machine by extracting various states using suspension points. Finally, we will understand how a Continuation
implemented as a state machine is passed around to achieve something magical.
Problem Statement:
We need to populate the profile screen of a user with user details. In order to do that, we need to make two API calls. First API call we make is to fetchUser()
, after we get our User
, we then hit another API to fetch user Profile
. Using the information we have, we keep populating the UI and finally, we create an Account
object from user
and profile
to persist.
Let us look at the pseudo-code for the API calls:
We are using async
coroutine builder in the above snippet to fetch User
and Profile
from the API. async
coroutine builder returns a Deferred<T>
coroutine so that we can call .await()
on it to get the result at some later point.
In our example,fetchUser()
function returns a Deferred<User>
object of the coroutine that fetches User
from the API. We can then call await()
on the Deferred
coroutine to get the User
result.
Similarly, fetchProfile(userId)
function returns a Deferred<Profile>
, on which we can later call await()
to get the Profile
of User
.
Let us next look at the coroutine which will “sequentially” execute the above functions:
Line 1: We first set loader to visible to show that we are fetching data.
Line 2: We then call, fetchUser()
which returns a Deferred<User>
and we also immediately call .await()
on it. await()
is a suspend
function and thus, we see a suspend function indicator in the left gutter of the IDE as shown in line 2 of Snippet 2. Our coroutine will suspend at this point until the User
is returned.
So, this is the first suspension point in our code, this is where the execution of the coroutine can be suspended and then resumed later on after the async coroutine finishes and the result, i.e. User
is returned.
Let us talk about some terms while our user
is getting fetched from network.
Suspension Points and CPS (Continuation Passing Style)
Every suspend
function is transformed by the compiler to take a Continuation
object. In the above case, .await()
suspend function will be transformed to take a Continuation
object when compiled, i.e .await(continuation)
. So, we save all the state (like local variables, etc.) within this Continuation
object and pass it to the .await(continuation)
suspend function. Similarly, all other suspend functions and blocks get transformed to take a Continuation
object as well.
Continuation
, as the name suggests encapsulates the current state and also the information about how to continue from a particular suspension point later on.
A suspension point in the code is like a check-point, at which the current state (like local variables, etc.) can be saved (inside a Continuation
) to be resumed later on from where we left off. Everything that follows a particular suspension point (lines of code after the suspension point) is also saved within theContinuation
object and passed on to the compiledsuspend
function for later execution. Suspension points are created by using suspend functions like delay
,yield
, suspendCancellableCoroutine
, etc.
So, all the suspend functions, upon transformation, get a Continuation
object passed as a parameter to them in order to resume execution later on. This is referred to as CPS (Continuation Passing Style). This pattern is similar to passing a callback and getting notified when the job is done. The difference is just that, with coroutines, these “callbacks” (or continuations to be precise) are handled for us by the compiler and we get the look and feel of sequential code execution.
Let us now resume our code-snippet, from the last suspension point 😉, i.e Line 2:
Line 2: Now, when User
is fetched from API, execution resumes right after our first suspension-point
Line 3: We can then set user.name
on the UI.
Line 4: Next, we have user.id
which we can use to fetch the Profile
of user from the API. So, we call fetchProfile(user.id)
and again immediately call .await()
on it. This is when we reach our second suspension point due to the .await()
suspend function. Execution again suspends at this point and resumes later on when Profile
is fetched from the API.
Line 5 to 8: Once, the user profile is available and returned from the .await()
function, we can then set the required information on the UI and create an Account
object to persist the session and finally, we hide our progress
indicator once we are done.
Suspension points to states
Now that we have identified, two suspension points in this code, let us have a look at how these suspension points get transformed into different states using labels.
Everything above and including a suspension-point is transformed into a state by the compiler. Everything below the suspension point until the next suspension point or till the end of the suspend function gets transformed into another state.
So, we will have three states in the above Snippet 3, i.e
- L0: until the first suspension point
- L1: until second suspension point
- L2: until the end
Generated State Machine
Now, using these three states as labels, a Continuation
state-machine is generated, which can resume execution from any of the states. Here is how the Continuation
interface looks like :
Now, let us have a look at the pseudo-code from the generated Continuation
state machine implementation by the compiler :
Each state is generated as a label and the execution can goto
a particular label when resumed. Generated Continuation
state machine implementation contains a field holding the current state, i.e. label,
and all the intermediate data, i.e. local variables shared between different states like v1: User
, v2: Profile
are also stored as fields.
Execution of the State Machine
State L0: When the coroutine is started, resumeWith()
is called with label = 0
, and we enter the L0 state as shown in the figure below :
We then do some work like showing a progress indicator and set the label
to 1
before calling .await()
, which suspends the coroutine. When a coroutine is suspended, .await()
function returns the result called COROUTINE_SUSPENDED.
At this point, we pass this state machine to the .await(continuation)
function, to be able to resume state later from where we left off and the coroutine goes in SUSPENDED
state.
State L1: Once User
is fetched from the API, we will then call continuation.resumeWith(user), label=1
on our saved continuation
object and proceed directly to state L1. At this point, we have fetched User
, which we can use to populate the UI, etc. We then set the label
to 2
and suspend execution on profile.await(continuation)
while waiting for user profile and again passing in the continuation
object to resume control later on.
State L2: Finally, when Profile
is fetched, we again call continuation.resumeWith(profile), label=2
on the saved continuation
and get to the state L2. At this point, we have fetched both user
and profile
. We can then create an Account
object using user
and profile,
set the data fetched on the UI, hide loader, persist the session, dance a little, etc. and finally set the label
to -1
, indicating the end of the coroutine.
So, we have seen how suspension points help in determining various states of the state machine. This state machine then allows us to resume execution from various suspension points.
The code appears to us as sequential and behind the scenes it utilizes state machines to suspend and resume execution at various suspension points when needed.
Single suspension point
The earlier example had multiple suspension points within the coroutine. If a suspend function calls another suspend function only at the end, i.e the tail position, in such simple cases of suspend functions containing a single suspension point only and that too at the tail position, then there is no need of generating a state machine, since there is only a single state involved. Such suspend functions are compiled to just take a Continuation
parameter for suspending and resuming execution.
This brings us to the end of this article. Please note that, it is not mandatory to know about such implementation details. But I believe it definitely helps in creating a better mental model of various problems and better utilize the tools at hand.
Further Reading :
The suspend modifier — Under the hood by Manuel Vivo
Kotlin coroutines proposal document : I found that this official document helps in understanding a lot of concepts.
Special thanks to Manuel Vivo for the review :)
Kindly leave some claps if you learned something and share it with your friends. If you find an improvement, kindly leave a comment so that we can all learn together :)