Coroutines, Flow & Structured Concurrency
Lightweight concurrency: suspend functions, scopes, dispatchers, Flow, cancellation, and the common pitfalls.
suspend Functions
// `suspend` marks a function that may pause without blocking a thread.
// Can only be called from another suspend function or a coroutine builder.
suspend fun fetchUser(id: Long): User {
delay(100) // non-blocking — suspends, frees the thread
return api.getUser(id)
}
// A suspending call compiles to a continuation-passing form. There's no
// magic — the function literally takes a hidden Continuation parameter
// and may return a marker (COROUTINE_SUSPENDED) instead of a value.
// Sequential by default — these run one after another
suspend fun loadProfile(): Profile {
val user = fetchUser(1)
val posts = fetchPosts(user.id)
return Profile(user, posts)
}
// Concurrent — launch in parallel with `async`
suspend fun loadProfileParallel(): Profile = coroutineScope {
val user = async { fetchUser(1) }
val posts = async { fetchPosts(1) }
Profile(user.await(), posts.await())
}Builders: launch / async / runBlocking
// launch — fire-and-forget. Returns Job. Use when you don't need a result.
val job: Job = scope.launch {
saveLog("hi")
}
// async — returns Deferred<T>. Use when you need the result.
val result: Deferred<Int> = scope.async { compute() }
val value: Int = result.await()
// runBlocking — blocks the current thread until the block finishes.
// Use in `main()` or tests; never in production app code.
fun main() = runBlocking {
launch { delay(100); println("a") }
launch { delay(50); println("b") }
} // prints: b, a
// withContext — switch dispatcher within a suspend function
suspend fun readFile(path: String): String = withContext(Dispatchers.IO) {
File(path).readText()
}Dispatchers
// Dispatchers.Main — UI thread on Android/Compose. Updates UI state.
// Dispatchers.Default — CPU-bound. Sorting, parsing, compression.
// Dispatchers.IO — blocking I/O. File, network, JDBC.
// Dispatchers.Unconfined — runs on whatever thread resumed it. Tests only.
// Rule of thumb:
// - call suspend networking APIs on whatever dispatcher (they're already
// non-blocking under the hood — Ktor, Retrofit suspend funcs)
// - wrap blocking JDBC / file reads with `withContext(Dispatchers.IO)`
// - run heavy computation with `withContext(Dispatchers.Default)`
// - never block the main thread
suspend fun summarise(text: String): String = withContext(Dispatchers.Default) {
text.split(" ").groupingBy { it }.eachCount().toString()
}Structured Concurrency & Cancellation
// Every coroutine belongs to a CoroutineScope. The scope owns its
// children — cancelling the scope cancels every coroutine launched from it.
class UserViewModel : ViewModel() {
init {
viewModelScope.launch { observeUser() }
// viewModelScope is cancelled when the ViewModel is cleared.
}
}
// If one child fails, by default the parent cancels and so do its siblings.
// Use `SupervisorJob` / `supervisorScope` when failures should be isolated.
supervisorScope {
launch { riskyA() } // failure here won't cancel riskyB
launch { riskyB() }
}
// Cancellation is cooperative. Suspending functions in stdlib check for
// cancellation; tight loops won't unless you call yield() or ensureActive().
suspend fun crunch() {
repeat(1_000_000) { i ->
if (i % 1000 == 0) yield() // cancellation checkpoint
process(i)
}
}
// Timeouts
val r: String? = withTimeoutOrNull(500) { fetchSlow() }Flow — Cold Async Streams
// Flow is the async/streaming counterpart of Sequence. Cold: produces
// values only when something collects it.
fun tickerFlow(period: Long): Flow<Int> = flow {
var i = 0
while (true) {
emit(i++)
delay(period)
}
}
scope.launch {
tickerFlow(1000)
.map { it * 2 }
.filter { it < 10 }
.collect { println(it) } // 0, 2, 4, 6, 8
}
// StateFlow — hot, holds current value. Like LiveData / Subject
val state = MutableStateFlow<Result<User>>(Result.Loading)
state.value = Result.Success(user) // emit by setting `value`
// SharedFlow — hot, broadcasts to N collectors, configurable replay
val events = MutableSharedFlow<UiEvent>()
events.emit(UiEvent.Saved) // suspending
// Cancelling the collecting coroutine cancels the upstream flow.
// Operators like `combine`, `flatMapLatest`, `debounce`, `distinctUntilChanged`
// are the bread-and-butter of view-model logic.Common Pitfalls
Calling blocking IO without withContext(Dispatchers.IO) — blocks the dispatcher thread pool.
Using GlobalScope — coroutines outlive their owner. Always use a scope tied to lifecycle (viewModelScope, lifecycleScope, custom).
Catching CancellationException — swallowing it breaks structured concurrency. Rethrow after cleanup.
launch { ... }.await() — Job has no await, only Deferred does. Use async if you need the result.
Forgetting that Flow is cold — every collect re-runs the upstream. Use shareIn/stateIn to share.
Keep your own version of these notes — editable, searchable, and organised by your stack.
Start free