Learning Kotlin Coroutines as a Java Dev (Part II)
In the last blog, I talked about several misconceptions during my Kotlin coroutines learning journey. And in the conclusion, I pointed out that Kotlin coroutines build around the concept of suspending and resuming around suspension points.
Here I’ll dive deeper into the Kotlin coroutines compilation process and code execution for a breakdown of the inner workings.
Code Blocks
The magic of Kotlin coroutines starts at the compilation stage. During compilation, the code within coroutine and body of “suspending function” is split into smaller “Code Blocks”. The compiler extracts statements from the code and groups them into the body of different “switch-cases” in the compiled Java class bytecode.
This can be demonstrated with a simple coroutine example, the code comments indicate “Code Blocks” grouping generated by the compiler:
At run time, the compiled bytecode is executed by the Java virtual machine. “Code Blocks” classes are instantiated and wrapped as Task/Runnable and executed by “Dispatcher”. The Dispatcher used is determined by the optional CoroutineContext parameter passed to coroutine builders launch
and async
.
In this example, I’m using the runBlocking
coroutine, which uses a Blocking Event Loop as a dispatcher that executes code only within the main thread. Notice the two launch
coroutine builders have no explicit Dispatcher supplied. As a result, they inherit the dispatcher from the main runBlocking
coroutine.
Running the example should print the Checkpoints from 1 to 10 as demonstrated in the animated image.
It is asynchronous (non-blocking) as the code execution switches back and forth between the two child coroutines without getting blocked by the delay
statements:
runBlocking
starts the Event Loop, enqueues Code Block 1- Event Loop dequeues Code Block 1 and executes it
- Prints Checkpoint 1
- The 1st
launch
statement is invoked, this enqueues Code Block 2 - The 2nd
launch
statement is invoked, this enqueues Code Block 4 - Prints Checkpoint 2
- Finishes execution of Code Block 1, passes control back to Event Loop
- Event Loop dequeues Code Block 2 and executes it. Prints Checkpoint 3
- Invokes
mySuspendFunction1
- In the compiled code of
mySuspendFunction1
. It first keeps track of the code from caller to be executed after completion ofmySuspendFunction1
(Code Block 3), then it executes the first Code Block ofmySuspendFunction1
— Code Block 6 - Prints Checkpoint 4
- Function
delay
is invoked, this flags for code suspension. Code Block 7 ofmySuspendFunction1
is enqueued as a delayed task (technically delayed task is put into anotherThreadSafeHeap
) - Finishes execution of Code Block 2 and Code Block 6, passes control back to Event Loop
- Event Loop dequeues Code Block 4 and executes it. Prints Checkpoint 5
- Invokes
mySuspendFunction2
- In the compiled code of
mySuspendFunction2
. It first keeps track of the code from caller to be executed after completion ofmySuspendFunction2
(Code Block 5), then it executes the first Code Block ofmySuspendFunction2
— Code Block 8 - Prints Checkpoint 6
- Function
delay
is invoked, this flags for code suspension. Code Block 9 ofmySuspendFunction2
is enqueued as a delayed task (technically delayed task is put into anotherThreadSafeHeap
) - Finishes execution of Code Block 4 and Code Block 8, passes control back to Event Loop
- Event Loop waits until it’s time to dequeue and execute Code Block 7
- Prints Checkpoint 7
- Finishes execution of Code Block 7. As
mySuspendFunction1
is completed, it triggers Code Block 3 from the caller ofmySuspendFunction1
- Prints Checkpoint 8
- Finishes the execution of Code Block 3, passes control back to Event Loop
- Event Loop waits until it’s time to dequeue and execute Code Block 9
- Prints Checkpoint 9
- Finishes execution of Code Block 9. As
mySuspendFunction2
is completed, it triggers Code Block 5 from the caller ofmySuspendFunction2
- Prints Checkpoint 10
- Finishes execution of Code Block 5, passes control back to Event Loop
- Event Loop is empty and exits
You can see from the code execution that Code Blocks created by the compiler is the secret trick of coroutines’ non-blocking magic!
Code Blocks from different coroutines can be inserted into the Event Loop queue interleaving each other. So even though the Blocking Event Loop uses only the main thread to consume and run the Code Blocks sequentially based on their queuing order, it can execute half of a coroutine (that makes up a Code Block unit) and switch to another coroutine (the next Code Block unit) without blocking.
Other coroutine Dispatchers might use different threads and strategy to execute coroutines, however they all operate on smaller Code Blocks generated by the compiler, which remain the same regardless of the dispatcher used.
Coroutines Continuation
So far, we have explored the logical concept of code execution with Code Blocks. But how does that concept get implemented into the compiled Java bytecode? And what keeps track of the state of Code Block execution (i.e. which Code Block should be executed next)? To answer these questions, let’s look at a decompiled (simplified) version of mySuspendFunction1
:
There are two interesting pieces here:
- The
mySuspendFunction1
function takes aContinuation
argument (vs the original source code signature that takes no argument) - The
mySuspendFunction1
function has a switch-case which each case maps to different logical “Code Block”, the construct switches on thelabel
field of theContinuation
object
The compiler adds a Continuation
parameter to the original parameter list. And it is pretty clear that this Continuation
object is the one that keeps track of the execution state of the Coroutine. Indeed the state is simply kept as an integer field label
of the Continuation
object.
So we might wonder what calls mySuspendFunction1
with the Continuation
argument?
During compilation, some extra classes are generated that extend ContinuationImpl
, each of those extra classes contains information which the suspend function calls, it also passes itself (which is a Continuation
) as an argument.
For example, below is the decompiled (simplified) version of the generated class DemoKt$mySuspendFunction1$1
:
We can see that mySuspendFunction1
is invoked with DemoKt$mySuspendFunction1$1
passing itself as the Continuation
argument.
These Continuation
objects are wrapped as Tasks
and enqueued into the Event Loop I mentioned earlier. They get executed/resumed when the Task
is dispatched/resumed which eventually calls invokeSuspend
on the Continuation
.
Conclusion
We have examined the code execution and the decompiled bytecode of Kotlin coroutines.
As a quick summary:
- Coroutines are split into smaller Code Blocks during compilation
- Each Code Block is a unit of execution (i.e. it should be fully executed before it returns control)
- Code Blocks from different coroutines can then be enqueued and executed interleaving each other with the help of
Continuation
andDispatcher
What’s next?
Are you interested in monitoring your Kotlin Application and coroutines? SolarWinds® AppOptics™ has released version 6.10.0 with out-of-the-box Kotlin coroutines support. With this new version you can easily trace operations handled by Kotlin coroutines in great detail — no code change required for a wide range of supported frameworks (Spring-MVC from Spring-boot for example)! Simply signup and download the AppOptics Java Agent (which works for Kotlin too!). Enable the agent by adding the following JVM options to your Kotlin process:
-javaagent:/usr/local/appoptics/appoptics-agent.jar
That’s it! Restart your Kotlin web application and you should be able to see useful metrics and detailed breakdown per request.
As a quick example, let’s modify our demo code a little bit and insert that into a Spring-boot Controller with coroutines:
This example uses Kotlin’s Ktor asynchronous HttpClient with Apache to make two outbound HTTP requests asynchronously with coroutines. Once installed and enabled, the AppOptics agent automatically traces the traffic to this controller and shows the two outbound requests running simultaneously without blocking:
And just for fun, let us remove the coroutines launch
and see what happens:
As expected, the 2 outbound HTTP requests are no longer executed simultaneously:
This is because when the first mySuspendFunction
hits suspension point request.await
there are no other coroutines available for execution. Take note that the second mySuspendFunction
is not launched/invoked yet, because code within the same coroutine runBlocking
is executed sequentially, the first invocation of mySuspendFunction
has to return first before the second call to mySuspendFunction
is reached.
This concludes my adventure exploring Kotlin coroutine! Special thanks to Bruce MacNaughton, Hunter Sherman and Lin Lin for their contributions and suggestions to these blogs!
About the Author
Patson Luk is a developer with experience in a variety of domains, from large-scale enterprise banking system to lightweight mobile payment solutions. He now leads Java development for SolarWinds AppOptics, an application performance monitoring product. Patson’s focus is on using Java bytecode manipulation technologies to gain greater visibility into the full spectrum of Java-based applications.
If you want to learn more about monitoring your Kotlin Application and coroutines with AppOptics check out Java Monitoring with AppOptics.