Kotlin coroutines are a powerful tool that propels efficiency in dealing with concurrent programming. They enable to treat asynchronous code as if it was synchronous, making it easier to manage and understand. In this blog post, I explore some of the core principles of coroutines and compare their performance against conventional blocking calls.
Table of Contents
- Blocking A Thread
- Suspending A Thread
- Ease Of Use
- Boost Of Scalability
- Reliable Cancellation
- Summary
A key advantage of coroutines is their ability to improve the efficiency of executing tasks concurrently. Let’s compare two methods used for suspending operations – Thread.sleep
and delay
.
- Thread.sleep – This function blocks the current thread, meaning that no other tasks can be executed during that sleep period. It can lead to inefficient use of resources and dramatically limits the scalability of your application.
- kotlinx.coroutines.delay – It only suspends the coroutine it’s running in, allowing other coroutines or (blocking) functions within the same thread to continue executing in the meantime.
This key difference between Thread.sleep
and delay
allows coroutines to handle many tasks concurrently without blocking threads, making your application more efficient and easier to scale. Proof?
Blocking A Thread
Thread.sleep
blocks the main thread. It literally waits until the sleep is over before it picks up any other work.
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlin.system.measureTimeMillis
fun main() {
val totalMs = measureTimeMillis {
runBlocking {
launch {
Thread.sleep(2000)
println("Coroutine #1: ${Thread.currentThread().name}")
}
launch {
println("Coroutine #2: ${Thread.currentThread().name}")
}
}
}
println("Hi from ${Thread.currentThread().name}. Processing took $totalMs ms")
}
What is the outcome? Well, first of all, let’s unpack on the building blocks of the code above.
- runBlocking – This function allows to run a coroutine within a traditional blocking code. For example, when running a
main
method or a unit test. As conveyed by its name, it indeed blocks the current thread. Meaning that it halts further processing until the coroutine finishes its task. On its own,runBlocking
obviously fails to prove the full potential of coroutines for concurrency, hence it serves as a stepping stone to our next building block – thelaunch
function. - launch – This function spins up a new coroutine, crucially, without blocking the current thread. It’s suitable for running multiple coroutines in parallel. In theory, when we launch two coroutines, like we do in the code above, they should run independently from each other. What do you think happens when the first launched coroutine makes a blocking call to
Thread.sleep
? - measureTimeMillis – This is just a utility function that measures the amount of time, in milliseconds, the respective (blocking) call takes to execute.
Despite running within a coroutine, Thread.sleep
stops the underlying thread entirely, affecting not only the encompassing coroutine but also all other code sharing the same thread. Therefore, while our coroutines should run separately, the blocking call prevents their concurrent execution.
Once the first launched coroutines makes a call to Thread.sleep
the entire processing holds to a grind for at least two seconds. After that, everything else happens at once. When you run this code your outcome will likely look like this.
Coroutine #1: main
Coroutine #2: main
Hi from main. Processing took 2032 ms
As you can see the calls happen sequentially, exactly in the order as they were declared. Blocking the main thread entirely prevents concurrent execution of the two coroutines.
Suspending A Thread
Let me apply a slight adjustment, replacing Thread.sleep
with delay
.
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlin.system.measureTimeMillis
fun main() {
val totalMs = measureTimeMillis {
runBlocking {
launch {
delay(2000)
println("Coroutine #1: ${Thread.currentThread().name}")
}
launch {
println("Coroutine #2: ${Thread.currentThread().name}")
}
}
}
println("Hi from ${Thread.currentThread().name}. Processing took $totalMs ms")
}
What do you think will be the output? Given that delay
only blocks the coroutine it’s running in and not the entire thread, the two coroutines execute concurrently as expected. While the first coroutine is suspended by at least two seconds, the second coroutine wastes no time to print its message. Subsequently, runBlocking
comes into play, ensuring that it waits for both coroutines to complete their tasks before the final message gets printed to the console.
Coroutine #2: main
Coroutine #1: main
Hi from main. Processing took 2030 ms
Ease Of Use
Coroutines facilitate asynchronous programming, making it not only easier but also more intuitive to write non-blocking code. They are natively integrated into Kotlin, and this results into a simple and intuitive programming model.
Coroutines can be characterized as:
- Lightweight – Kotlin coroutines are less resource-intensive than threads, allowing you to run multiple coroutines concurrently on a single thread due to support for suspension.
- Suspendable – A coroutine is an instance of a suspendable computation, conceptually similar to a thread, but not bound to any specific thread. It may suspend its execution in one thread and resume in another. This leads to a significant improvement in the efficiency of utilizing underlying JVM threads, which would otherwise be costly to create and maintain.
- Asynchronous – Coroutines enable developers to write asynchronous code that appears similar to synchronous code, simplifying the intricacies of concurrent programming.
- Flexible – Coroutines ship with builders that support parallel execution, async-await model etc. It is also possible to define custom builders. Coroutines support a reliable cancellation with the concept of structured concurrency.
Boost Of Scalability
Coroutines don’t block threads. Instead, they leverage them more efficiently through suspending computations. Consequently, they require fewer JVM threads overall. This makes a huge differendce under high loads. Consider this example:
import java.lang.Thread.sleep
import kotlin.concurrent.thread
fun main() {
repeat(50_000) {
thread {
sleep(5000)
}
}
println("Hi from ${Thread.currentThread().name}")
}
What do you think happens? Well, since this code blocks JVM threads the program is prone to running out of memory. Indeed, that is precisely what happened when running the example with 2GB of memory available to JVM.
[0.729s][warning][os,thread] Failed to start the native thread for java.lang.Thread "Thread-8169"
Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
Now, what happens when the program tries to allocate 50,000 coroutines as opposed to threads?
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.lang.Thread.sleep
import kotlin.concurrent.thread
fun main() = runBlocking {
repeat(50_000) {
launch {
delay(5000)
}
}
println("Hi from ${Thread.currentThread().name}")
}
First, it prints “Hi from main” and after a while the computation successfully completes. Coroutines are much cheaper compared to threads, so allocating 50,000 of them presents no issue.
Hi from main
Process finished with exit code 0
Reliable Cancellation
Kotlin coroutines follow a principle known as Structured Concurrency. This principle implies that coroutines can only be launched within a particular scope, meaning the new coroutine cannot outlive the scope it was launched from. This pattern minimizes the risk of lingering coroutines that could lead to memory leaks and thus makes it easier to handle cancellations and exceptions.
Key concepts to grasp:
- Coroutine Context – A set of various elements that define the behaviour of a coroutine. These elements include a job and its dispatcher which controls the thread that the coroutine runs on. A coroutine always runs within a context.
- Coroutine Scope – Determines a coroutine’s lifetime. It controls the coroutine scope and lifecycle. It often defines which coroutine context to use. With Coroutine Scope, we can control when coroutines should be cancelled. When a coroutine scope gets cancelled, all the coroutines within that scope will be cancelled.
- Job – Controls the lifecycle of the coroutine. A job can be cancelled and can have a parent and child jobs that result in a job hierarchy.
Suppose a computation that’s quite time-consuming. To speed up obtaining a result, we can initiate multiple tasks simultaneously. As soon as any one of them completes, we’ll return its result and promptly cancel the remaining tasks.
The code example below starts three different coroutines to execute getDelayedMessage
function with different delays. The select
function helps us decide which coroutine finished first. Once we have the result from a coroutine, we cancel the other coroutines within the same context and print the result.
suspend fun getDelayedMessage(message: String, delayMs: Long): String {
delay(delayMs)
return message
}
fun main() = runBlocking {
val jobs = (1..3).map { i ->
async {
getDelayedMessage(
"Hello from coroutine #$i",
Random.nextLong(100, 500)
)
}
}
val result = select {
jobs.forEach { job -> job.onAwait { it } }
}.also {
coroutineContext.cancelChildren()
}
println("Result: $result")
}
Running this program multiple times should yield a different output each time because the delay is randomly selected between 100 and 500 milliseconds. The message of the finished coroutine gets printed and the others are cancelled.
Summary
In this post, we examined some of the essential characteristics of Kotlin coroutines, which serve as lightweight threads and provide a paradigm for concurrent programming. Unlike traditional threads, coroutines adapt well to high loads and offer fine-grained control. This leads to efficient resource utilization and reduced operating costs for running high-performance applications at scale.
The source code is available on GitHub. Thanks for reading, check it out and let me know your thoughts in the comment section below.