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.