Asynchronous programming with Kotlin coroutines

Thalys Matias Carrara
ProFUSION Engineering
5 min readJun 1, 2023
kotlin coroutines

Before we get into the subject of coroutines, let’s give a short introduction to the differences between synchronous and asynchronous programming. If you already know about the concepts of asynchronous programming, skip to coroutines.

Singletasking vs. Multitasking

Imagine the scenario of early operating systems that only supported the execution of one process at a time. We had a linear flow, as shown in the image below:

single tasking

It was only at the end of the task execution that another one could be loaded into memory and executed.

While a processor performs an operation on the nanosecond scale, a network operation tends to perform at the millisecond scale. So if we make a request synchronously the processor/thread will be idle for a long time and, as we know, time is precious.

Let’s take a real-world example. Supposing you have a card in your application that needs to fetch information from two different endpoints and each request takes 300 milliseconds, it would be necessary to wait for the first request to be finished and then start the second one. So it takes a total of 600ms to get all the required data.

synchronous network request

The solution to this problem is to enable the processor to suspend the execution of a task while waiting for external data or some event to start executing another task, which is called preemption. See the flow chart below:

preemption

What Kotlin coroutines are and how they differ from threads

Coroutines are concurrent processes that run in the same thread and pass the flow to each other, (co)operating with each other.

Threads have the disadvantage of consuming more machine resources because it need to pass information from one thread to another.

We can say that coroutines are “light” threads because they consume less resources. However multiple threads are necessary when running a “heavy” process such as enormous calculations or reading and writing files for example.

We have done enough talking, let’s take a look at some code. Consider the example below displaying a coroutine:

fun main() {
GlobalScope.launch {
delay(1000)
print("message sent after 1 second")
}
}

A coroutine can only be executed inside a scope (in this caseGlobalScope) or a suspend function and also needs a coroutine builder which in this case is launch. We'll mention other scopes throughout this article. Also, note in the next example that every coroutine builder returns a Job:

fun main() {
val job = GlobalScope.launch {
delay(1000)
print("message sent after 1 second")
}

job.cancel()
}

A coroutine can be canceled in two ways, the first is explicitly through job.cancel() or when its parent scope is finished. In this case, the parent scope is the main function.

We can also choose at which point to start our coroutine in the following way:

const val x = 2

fun main() {
val job = GlobalScope.launch(start = CoroutineStart.LAZY) {
delay(1000)
print("message sent after 1 second")
}
if (x == 2) {
job.start()
}
}

See that we added a start parameter to the coroutine builder with the value CoroutineStart.LAZY. This will cause the coroutine to start only with the job.start() function call.

Now that we have seen an introduction to coroutines, let’s solve the problem of multiple sequential requests. We will implement the less performative way by making two sequential requests and then implement the more performative way by using concurrency.

The first step is to create a function to simulate a request:

suspend fun performFakeFetch(number: Int, time: Long = 300L) {
println("request $number starts work")

delay(time)

println("request $number has finished")
}

In this function, each request will take 300 milliseconds. Now we will create a simple function to calculate the total execution time:

fun calculateExecutionTime(initial: Long) {
val now = System.currentTimeMillis()
println("the execution time is: ${now - initial}")
}

Now look at the function below:

fun main() = runBlocking<Unit> {
val initial = System.currentTimeMillis()
performFakeFetch(1)
performFakeFetch(2)
calculateExecutionTime(initial)
}

In this case, we use a different coroutine builder called runBlocking, which is used when you need a coroutine scope to run sequential processes. Basically what it does is lock the thread until the coroutine finishes, without which our main function wouldn't wait for our asynchronous functions to return and would be terminated without getting a response. You'll rarely use it in your day-to-day life, but in this case it fulfills our need to create a coroutine scope so we can run asynchronous functions (suspend).

This is the result of executing the above function:

request 1 starts work
request 1 has finished
request 2 starts work
request 2 has finished
the execution time is: 607

Process finished with exit code 0

Now we will see how to implement these two requests concurrently. See the code below:

fun main() = runBlocking<Unit> {
println("Main starts")
val initial = System.currentTimeMillis()
val job1 = launch {
performFakeFetch(1, 400)
}
val job2 = launch {
performFakeFetch(2)
}
job1.join()
job2.join()
calculateExecutionTime(initial)
}

Notice that in the code above each request was executed in a different coroutine and at different times. By doing this way the processes run concurrently and don’t waste processor time, making the operation more performant.

One important point to note in this code snippet is the .join(). This method makes the parent scope wait until the Job is finished and then runs the calculateExecutionTime() function.

Executing this code will result in the following:

Main starts
request 1 starts work
request 2 starts work
request 2 has finished
request 1 has finished
the execution time is: 412

Process finished with exit code 0

Notice that approximately 200ms of idle processor time has been saved, this approach makes a real difference regarding the quality and user experience of your application. In the Kotlin documentation, you can see more ways to implement concurrency using different coroutine builders.

Conclusion

In conclusion, Kotlin Coroutines offers a powerful solution for writing asynchronous code in a concise and easy-to-understand way. With its lightweight threads and cooperative cancellation mechanism, coroutines enable developers to write highly responsive and efficient applications without the complexity of traditional concurrency models. Whether you are writing a mobile app, a backend service, or a desktop application, Kotlin Coroutines can simplify your code and improve the user experience. By leveraging the power of coroutines, you can write elegant and maintainable code that can handle complex tasks and scale to meet the demands of your users.

--

--