Skip to content
AgileLogics
7 min read

Structured Concurrency: async and Scopes

Prerequisites: Coroutines: the basics

Structured concurrency is the rule that every coroutine runs inside a scope, and a scope doesn't finish until all its children have. That guarantees no coroutine is forgotten and left running, and errors and cancellation travel through the structure automatically.

async and await — run things concurrently

async starts a coroutine that produces a result; await waits for it:

import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlin.system.measureTimeMillis

suspend fun fetchUser(id: Int): String {
    delay(1000)
    return "User $id"
}

fun main() = runBlocking {
    val time = measureTimeMillis {
        val first = async { fetchUser(1) }
        val second = async { fetchUser(2) }
        println(listOf(first.await(), second.await()))
    }
    println("Took about ${time / 1000} second(s)")
}
[User 1, User 2]
Took about 1 second(s)

Both calls run at the same time, so two one-second calls take about one second. Calling fetchUser(1) and then fetchUser(2) directly would take two.

coroutineScope — structure inside a suspend function

coroutineScope creates a child scope. It waits for everything started inside it, then returns:

import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking

suspend fun square(n: Int): Int {
    delay(100)
    return n * n
}

suspend fun sumOfSquares(numbers: List<Int>): Int = coroutineScope {
    numbers.map { async { square(it) } }.awaitAll().sum()
}

fun main() = runBlocking {
    println(sumOfSquares((1..5).toList()))
}
55

awaitAll() waits for a list of async results. The function looks sequential from the outside but does its work concurrently inside.

Errors cancel siblings

If one child fails, the scope cancels the others and rethrows the error:

import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking<Unit> {   // <Unit>: the block's last expression isn't Unit
    try {
        coroutineScope {
            launch {
                delay(100)
                throw IllegalStateException("payment service down")
            }
            launch {
                delay(1000)
                println("This never prints")
            }
        }
    } catch (e: IllegalStateException) {
        println("Caught: ${e.message}")
    }
}
Caught: payment service down

When one step of an operation fails, the others are usually pointless — structured concurrency stops them for you. (When children should fail independently, use supervisorScope.)

Scopes in real apps

Apps don't use runBlocking. Frameworks provide scopes tied to a lifecycle: on Android, viewModelScope is cancelled when the screen's ViewModel is cleared, and Ktor gives each request its own scope. Avoid GlobalScope — coroutines started there belong to nothing and are never cancelled automatically.