Kotlin Structured Concurrency
Introduction
When writing asynchronous code, managing the lifecycle of operations can quickly become complex. Threads might get leaked, callbacks might never be called, or resources might not be properly released. Kotlin's structured concurrency addresses these problems by providing a framework that ensures all started coroutines complete before their parent completes.
Structured concurrency follows a simple principle: a parent coroutine cannot complete until all of its child coroutines complete. This creates a hierarchy of coroutines that helps prevent resource leaks and makes error handling more predictable.
In this tutorial, we'll explore how structured concurrency works in Kotlin, the benefits it provides, and how to implement it in your own code.
Understanding Coroutine Scopes
At the heart of structured concurrency are coroutine scopes. A scope defines the lifecycle boundaries for all coroutines launched within it.
The CoroutineScope Interface
public interface CoroutineScope {
public val coroutineContext: CoroutineContext
}
Every coroutine is launched in a specific scope, which tracks all coroutines created within it. When you cancel a scope, all coroutines within that scope are canceled as well.
Built-in Scopes
Kotlin provides several built-in scopes:
-
GlobalScope: A scope not tied to any job—coroutines launched in this scope can live as long as the entire application. Use with caution!
-
CoroutineScope(): Create a new scope with custom context.
-
MainScope(): A scope tied to the main UI thread in Android or other UI frameworks.
-
viewModelScope and lifecycleScope: Android-specific scopes that are tied to ViewModel and Lifecycle components.
Creating and Managing Coroutine Scopes
Let's see how to create and use coroutine scopes:
import kotlinx.coroutines.*
fun main() = runBlocking { // this: CoroutineScope
println("Parent coroutine starts")
// Launch a child coroutine
launch {
delay(1000L)
println("Child coroutine completes")
}
println("Parent coroutine continues")
// Parent will wait for the child to complete before finishing
}
// Output:
// Parent coroutine starts
// Parent coroutine continues
// Child coroutine completes
In this example, the parent coroutine (created by runBlocking
) won't complete until its child coroutine (created by launch
) completes.
Creating Custom Scopes
You can create custom scopes to manage the lifecycles of your coroutines:
import kotlinx.coroutines.*
fun main() = runBlocking {
val customScope = CoroutineScope(Dispatchers.Default)
// Launch a coroutine in the custom scope
customScope.launch {
delay(1000L)
println("Task in custom scope")
}
delay(500L)
println("Main coroutine continues")
delay(1000L) // Wait to see the result from the custom scope
// Cancel all coroutines in this scope
customScope.cancel()
println("Custom scope cancelled")
}
// Output:
// Main coroutine continues
// Task in custom scope
// Custom scope cancelled
Coroutine Builders and Structured Concurrency
Kotlin provides several coroutine builders, each with different behaviors regarding structured concurrency:
launch
The launch
builder starts a new coroutine without blocking the current thread and returns a reference to the coroutine as a Job
.
fun main() = runBlocking {
val job = launch {
delay(1000L)
println("Coroutine task completed")
}
println("Waiting for the job to complete...")
job.join() // Waits for the coroutine to complete
println("Job has completed")
}
// Output:
// Waiting for the job to complete...
// Coroutine task completed
// Job has completed
async
The async
builder starts a new coroutine and returns a Deferred
object that will eventually provide a result.
fun main() = runBlocking {
val deferred = async {
delay(1000L)
println("Computing the answer...")
42 // The result of the computation
}
println("Waiting for the result...")
val result = deferred.await() // Waits for the coroutine to complete and returns the result
println("The answer is $result")
}
// Output:
// Waiting for the result...
// Computing the answer...
// The answer is 42
coroutineScope
The coroutineScope
builder creates a new coroutine scope and suspends until all launched children complete.
fun main() = runBlocking {
println("Main start")
coroutineScope {
launch {
delay(1000L)
println("Task 1 completed")
}
launch {
delay(500L)
println("Task 2 completed")
}
println("All tasks launched")
} // Execution will suspend here until both tasks complete
println("All tasks completed")
}
// Output:
// Main start
// All tasks launched
// Task 2 completed
// Task 1 completed
// All tasks completed
Exception Handling in Structured Concurrency
One of the biggest advantages of structured concurrency is predictable exception handling.
Exception Propagation
By default, exceptions in coroutines propagate up to their parent:
fun main() = runBlocking {
try {
coroutineScope {
launch {
delay(500L)
throw RuntimeException("Oops, something went wrong!")
}
launch {
delay(1000L)
println("This will not execute if the other coroutine fails")
}
}
} catch (e: Exception) {
println("Caught exception: ${e.message}")
}
println("Execution continues after handling the exception")
}
// Output:
// Caught exception: Oops, something went wrong!
// Execution continues after handling the exception
SupervisorScope
Sometimes, you want child coroutines to fail independently without affecting their siblings. SupervisorScope
does exactly that:
fun main() = runBlocking {
supervisorScope {
launch {
delay(500L)
throw RuntimeException("Oops, something went wrong!")
println("This won't be printed")
}
launch {
delay(1000L)
println("This will execute even if the other coroutine fails")
}
}
println("All non-failed coroutines completed")
}
// Output:
// This will execute even if the other coroutine fails
// All non-failed coroutines completed
// Exception in thread "main" java.lang.RuntimeException: Oops, something went wrong!
// at MainKt$main$1$1$1.invokeSuspend(Main.kt:6)
// ... (stack trace)
Real-World Application: Concurrent API Calls
Let's implement a practical example of making concurrent API calls with proper structured concurrency:
import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis
// Simulated API functions
suspend fun fetchUserData(): String {
delay(1000L) // Simulating network delay
return "User data"
}
suspend fun fetchUserSettings(): String {
delay(800L) // Simulating network delay
return "User settings"
}
suspend fun fetchUserFriends(): String {
delay(1200L) // Simulating network delay
return "User friends"
}
fun main() = runBlocking {
println("Loading user profile...")
val time = measureTimeMillis {
coroutineScope {
val userData = async { fetchUserData() }
val userSettings = async { fetchUserSettings() }
val userFriends = async { fetchUserFriends() }
// Wait for all data and combine results
val completeProfile = """
Profile loaded:
- ${userData.await()}
- ${userSettings.await()}
- ${userFriends.await()}
""".trimIndent()
println(completeProfile)
}
}
println("Profile loaded in $time ms")
}
// Output:
// Loading user profile...
// Profile loaded:
// - User data
// - User settings
// - User friends
// Profile loaded in 1215 ms
In this example, all three API calls are made concurrently, and the total time is just a little over the longest individual call (1200ms) rather than the sum of all three calls (3000ms).
Resource Cleanup with Structured Concurrency
A major benefit of structured concurrency is automatic resource cleanup. When a coroutine scope ends, all of its coroutines are automatically cancelled:
fun main() = runBlocking {
println("Starting resource management example")
withTimeoutOrNull(2500L) {
coroutineScope {
// Start a long-running task
launch {
try {
repeat(5) { i ->
delay(1000L)
println("Task: I'm working... $i")
}
} finally {
println("Task: I'm cleaning up my resources!")
}
}
delay(2000L)
println("Main: I'm done waiting")
} // All coroutines are cancelled when this scope completes
}
println("Main: Moving on to other work")
}
// Output:
// Starting resource management example
// Task: I'm working... 0
// Task: I'm working... 1
// Main: I'm done waiting
// Task: I'm cleaning up my resources!
// Main: Moving on to other work
Notice how the finally block executes even when the coroutine is cancelled, allowing for proper resource cleanup.
Best Practices for Structured Concurrency
-
Avoid GlobalScope: Always use a structured scope instead of GlobalScope, which doesn't provide lifecycle management.
-
Define clear boundaries: Create dedicated scopes for logical units of work.
-
Close scopes when done: Always cancel or close scopes when their work is finished.
-
Handle exceptions properly: Use try-catch blocks or CoroutineExceptionHandler for error handling.
-
Use supervisorScope when you want child coroutines to fail independently.
-
Choose the right dispatcher for your workload (Default for CPU-intensive, IO for network/disk operations).
Summary
Structured concurrency in Kotlin coroutines provides a powerful model for managing asynchronous operations with clear hierarchies and lifecycles. By ensuring that parent coroutines wait for their children to complete, it prevents resource leaks and makes error handling more predictable.
Key concepts we've covered:
- Coroutine scopes and their lifecycle management
- Different coroutine builders (launch, async, coroutineScope)
- Exception handling and propagation
- SupervisorScope for independent failure handling
- Practical applications like concurrent API requests
- Automatic resource cleanup
By following structured concurrency principles in Kotlin, you can write asynchronous code that's more reliable, easier to reason about, and less prone to bugs and resource leaks.
Further Learning
Exercises
-
Build a function that loads data from three different sources concurrently, with a timeout of 5 seconds for the entire operation.
-
Create a custom scope that cancels all its coroutines when a user navigates away from a screen in an Android app.
-
Implement a retry mechanism that uses structured concurrency to restart failed operations a limited number of times.
Additional Resources
- Kotlin Coroutines Guide
- KotlinConf: Structured Concurrency - Talk by Roman Elizarov
- Kotlin Coroutines Patterns & Practices - Advanced techniques
- GitHub: KotlinX Coroutines - Official repository with examples
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)