Working in Kotlin: Waiting Around with Coroutines

In part one of this series, I discussed the pros and cons of using Kotlin. In part two,  let’s talk about waiting. Your application probably does a lot of it. Waiting for a database, waiting for an API, waiting for a second API because apparently the first one didn’t have enough information. Our computers are impressively fast, and we give them an awful lot of opportunities to sit around.

Kotlin’s Approach to Async

Kotlin’s coroutines let us write that waiting as ordinary-looking function calls. A function declared as suspend fun fetchDisplayName(): String still gives its caller a String. From other suspending code, we write val name = fetchDisplayName() and continue when the result is ready. Our local variables, loops, and try/finally blocks still work. The compiler handles the bookkeeping needed to pause and resume execution. We don’t have to write a callback or add .await() to every suspending call.

A coroutine still executes on a thread. When a nonblocking operation suspends, that thread becomes available to run other work until the coroutine can continue. Many coroutines can therefore share a pool of threads. A blocking call on a platform thread keeps that thread occupied for the wait. Coroutines let us keep the familiar top-to-bottom code without reserving a platform thread for every pending operation.

Then there’s structured concurrency. With kotlinx.coroutines, we can start related operations inside a coroutineScope that waits for its children and passes cancellation down to them. The concurrent work stays tied to the operation that requested it. A background task shouldn’t become a permanent member of the team because we forgot who started it.

First, a Little Suspension

The suspend keyword enables suspension; it doesn’t start a concurrent task or turn blocking code into nonblocking code. Functions such as delay, async, and coroutineScope come from the kotlinx.coroutines library, so these examples need the kotlinx-coroutines-core dependency.

Suppose our dashboard needs a display name and an unread-message count. We’ll simulate two remote calls:

import kotlinx.coroutines.delay

data class Dashboard(
    val displayName: String,
    val unreadMessages: Int,
)

suspend fun fetchDisplayName(): String {
    delay(500)
    return "Matt"
}

suspend fun fetchUnreadMessages(): Int {
    delay(700)
    return 42
}

suspend fun loadDashboard(): Dashboard {
    val name = fetchDisplayName()
    val unread = fetchUnreadMessages()

    return Dashboard(name, unread)
}

suspend fun main() {
    println(loadDashboard())
}

Sequential by Default

Here, delay simulates waiting without blocking the executing thread. No network requests are happening, which also means our fake services have unusually good uptime.

The calls still happen in order. We wait roughly 500 milliseconds for the name, then another 700 for the message count. That’s about 1.2 seconds, plus overhead. While each call is suspended, its thread is available for other work, but loadDashboard still waits for that call to return before moving to the next line. Coroutine code is sequential by default.

If the second call needed a value from the first, this would be exactly what we wanted. In our example, though, those calls are independent. We can overlap the waiting.

Two Operations

Keep the other functions, add these imports, and replace loadDashboard:

import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope

suspend fun loadDashboard(): Dashboard = coroutineScope {
    val name = async { fetchDisplayName() }
    val unread = async { fetchUnreadMessages() }

    Dashboard(name.await(), unread.await())
}

Each async call returns a Deferred<T>, a handle for its eventual result. async is eager by default: it creates and schedules its child coroutine immediately, without waiting for await(). The dispatcher decides when and on which thread the block actually executes. Calling await() gets the result, suspending the caller if it isn’t ready yet. Lazy startup requires opting in with CoroutineStart.LAZY.

Both operations have been scheduled by the time we reach Dashboard(name.await(), unread.await()). Waiting for the name doesn’t stop the message lookup from progressing, so separate await() calls preserve the overlap. We don’t need to gather the results into an array or call a group-join operation. We’d expect roughly 700 milliseconds plus overhead for this version. The name lookup still takes 500 milliseconds. We just stopped making the message lookup wait for it.

Awaiting Too Soon

Putting .await() immediately after each async would make these calls sequential again:

suspend fun loadDashboard(): Dashboard = coroutineScope {
    val name = async { fetchDisplayName() }.await()
    val unread = async { fetchUnreadMessages() }.await()

    Dashboard(name, unread)
}

This version is valid, but we’ve paid for extra syntax and kept all the waiting. A very enterprise solution.

This is concurrency: the operations make progress over overlapping periods. They don’t need to execute on different CPU cores to overlap their waits. A dispatcher controls which threads run the coroutines; we’ll leave thread selection for another post.

Someone Has to Be Responsible

That coroutineScope block also gives the concurrent work an owner. This is called structured concurrency, and it’s the part I think deserves as much attention as the nicer syntax.

For this example, we’ll require both results to build the dashboard. Inside our ordinary coroutineScope:

  • The scope waits for its block and all its child coroutines to finish before it exits.
  • If either lookup throws a non-cancellation exception, the scope cancels the other child if it’s still running and propagates the failure to the caller.
  • If the calling coroutine is cancelled, cancellation reaches both children too.

That means the caller can treat loadDashboard() as one operation, even though we split its work into two coroutines internally. It either gets a dashboard or deals with the failure. It doesn’t need a separate cleanup plan for every task we started along the way.

When the Caller Is Done

For a screen whose coroutine scope is cancelled when the user leaves, that cancellation can flow through loadDashboard to the lookups. We don’t need to keep fetching data for someone who has already decided to look at a different screen.

Cancellation is cooperative. Functions such as delay respond to it, but a long CPU loop needs to check for cancellation, for example with ensureActive(). Cancelling a coroutine also doesn’t undo a request that a remote server has already processed.

The Keyword Isn’t Magic

You can still block a thread inside a suspending function. On the JVM, Thread.sleep still blocks, and so does a blocking database driver. Adding suspend to the function declaration doesn’t change the implementation of the code inside it.

For blocking I/O that you need to keep, Dispatchers.IO provides a pool intended for that work, typically used through withContext(Dispatchers.IO) { ... }. The blocking operation still occupies a thread. A client with a nonblocking suspending API can release its thread while waiting, as our delay example does.

You may also see examples using runBlocking. It bridges ordinary blocking code into coroutine code by blocking the calling thread until the work finishes. Our example uses suspend fun main() instead; inside an existing suspending function, call other suspending functions directly.

A Little Less Waiting

With Kotlin’s coroutines, loadDashboard() can still read like one operation, even when it starts several tasks. Try the example with a println before and after each delay, and watch how the sequential and concurrent versions behave. Start with ordinary suspending calls, then use async when you have independent work worth overlapping.

 
Conversation

Join the conversation

Your email address will not be published. Required fields are marked *