Skip to content

Coroutines

Coroutines are Kotlin’s mechanism for asynchronous programming. They are lightweight — a coroutine Suspends instead of blocking a thread, allowing a small number of OS threads to handle many Concurrent operations.

Key distinction: coroutines are not threads. A coroutine runs on a thread but can be suspended And resumed on a different thread. Thousands of coroutines can run concurrently on a handful of Threads.

Add the coroutines dependency:

dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.9.0") // Reactor
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
}

A suspend function can suspend the execution of a coroutine without blocking the underlying Thread. Only coroutines (or other suspend functions) can call suspend functions.

suspend fun fetchUser(id: Long): User {
delay(1000) // suspends without blocking the thread
return api.getUser(id)
}

suspend functions have no special mechanism — the compiler inserts a state machine at compile Time that saves and restores the local variables and execution position at each suspension point.

delay() is the coroutine equivalent of Thread.sleep() but does not block the thread.

Launches a fire-and-forget coroutine. Returns a Job that can be joined or cancelled.

fun main() = runBlocking {
val job: Job = launch {
delay(1000)
println("World")
}
println("Hello")
job.join()
println("Done")
}
// Output: Hello (immediate), World (after 1s), Done

Launches a coroutine that returns a Deferred<T> (a cancellable future). Use await() to get the Result.

suspend fun fetchAllData(): Data {
val deferred1 = async { fetchUser(1) }
val deferred2 = async { fetchOrders(1) }
val deferred3 = async { fetchPreferences(1) }
return Data(
user = deferred1.await(),
orders = deferred2.await(),
preferences = deferred3.await()
)
}

launch vs async:

  • launch: returns JobNo result value. Use for side effects.
  • async: returns Deferred<T>Produces a result. Use for concurrent computations.

Bridges blocking and non-blocking code. Blocks the current thread until all coroutines inside Complete. Primarily used in main() and tests — avoid in application code.

fun main() = runBlocking {
launch {
delay(1000)
println("Done")
}
}

Switches the coroutine to a different dispatcher and returns a result.

suspend fun loadData(): String = withContext(Dispatchers.IO) {
// runs on IO thread pool
Thread.sleep(1000) // blocking IO -- OK on Dispatchers.IO
"data"
}

Dispatchers determine which thread pool executes the coroutine.

DispatcherPurpose
Dispatchers.DefaultCPU-intensive work (sorting, parsing, JSON)
Dispatchers.IOBlocking IO (network, disk, database)
Dispatchers.MainUI thread (Android, JavaFX)
Dispatchers.UnconfinedStarts on caller thread, resumes on whatever thread resumes it
launch(Dispatchers.Default) {
// CPU work
}
launch(Dispatchers.IO) {
// blocking IO
}
withContext(Dispatchers.Main) {
// update UI
}

Coroutines follow structured concurrency: child coroutines are bound to a parent scope. When the Parent scope is cancelled, all children are cancelled. When all children complete, the parent Completes.

suspend fun processBatch(items: List<Item>) = coroutineScope {
items.map { item ->
async {
processItem(item)
}
}.awaitAll()
}

If any processItem call throws, coroutineScope cancels all other coroutines and propagates the Exception. This prevents resource leaks from orphaned coroutines.

coroutineScope creates a new scope that completes when all children complete. It does not block The current thread — it suspends.

supervisorScope is a variant where the failure of one child does not cancel the others.

suspend fun fetchWithFallback() = supervisorScope {
val primary = async { fetchFromPrimary() }
val fallback = async { fetchFromFallback() }
try {
primary.await()
} catch (e: Exception) {
fallback.await()
}
}

Coroutines cooperate with cancellation by checking isActive or calling cancellable suspend Functions.

launch {
repeat(1000) { i ->
if (!isActive) return@launch // cooperative cancellation check
// delay() is cancellable -- throws CancellationException on cancel
delay(100)
println("Processing $i")
}
}

CancellationException is special — it is used to cancel coroutines and should not be caught in General catch blocks.

launch {
try {
delay(5000)
} catch (e: CancellationException) {
throw e // always rethrow CancellationException
} catch (e: Exception) {
// handle other exceptions
}
}

Flow is a cold asynchronous stream. It is the coroutine equivalent of RxJava’s Observable but Built on coroutines.

fun numbers(): Flow<Int> = flow {
for (i in 1..5) {
delay(100)
emit(i)
}
}
suspend fun main() {
numbers()
.map { it * it }
.filter { it > 10 }
.collect { println(it) }
}
// Output: 16, 25

Flow is cold — nothing happens until collect is called. Each collector gets its own independent Stream.

flow {
emit(1)
emit(2)
emit(3)
}
.map { it * 2 } // [2, 4, 6]
.filter { it > 2 } // [4, 6]
.onEach { println(it) } // side effects
.catch { e -> log(e) } // upstream exception handling
.onCompletion { println("done") }
.collect()

StateFlow is a hot flow that holds a single current value. Use it for state management.

class ViewModel {
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun updateName(name: String) {
_uiState.update { it.copy(userName = name) }
}
}

SharedFlow is a hot flow for event broadcasting.

class EventBus {
private val _events = MutableSharedFlow<Event>(extraBufferCapacity = 64)
val events: SharedFlow<Event> = _events.asSharedFlow()
suspend fun emit(event: Event) {
_events.emit(event)
}
}

Channels provide a way to transfer values between coroutines. They are similar to Go channels.

suspend fun producer(channel: SendChannel<Int>) {
for (i in 1..5) {
channel.send(i)
}
channel.close()
}
suspend fun consumer(channel: ReceiveChannel<Int>) {
for (value in channel) {
println(value)
}
}
TypeDescription
Channel()Rendezvous (buffer size 0)
Channel(64)Buffered with capacity 64
Channel.CONFLATEDDrops previous values, keeps latest
Channel.UNLIMITEDUnbounded buffer (use with caution)
  • ** Using runBlocking in application code. It blocks the thread, defeating the purpose of coroutines. Use CoroutineScope with lifecycle management instead.
  • ** Forgetting to make IO functions suspending. If you wrap blocking calls in withContext(IO) that is fine, but prefer genuinely non-blocking libraries (e.g., ktor-client Retrofit with coroutines) over withContext(IO) wrapping blocking code.
  • ** Not handling cancellation properly. Always rethrow CancellationException. Use ensureActive() or check isActive in long-running CPU loops.
  • ** Using GlobalScope. It creates unbound coroutines that outlive the application scope, making cancellation and lifecycle management impossible. Always use structured concurrency with explicit scopes.
  • ** Collecting multiple flows sequentially. Use combine``mergeOr zip to compose flows concurrently.

Coroutines are a way to write asynchronous code that reads like synchronous code. Instead of blocking a thread while waiting for IO, a coroutine suspends and lets other coroutines run, then resumes when the result is ready. Think of coroutines as workers in a restaurant: instead of one waiter standing idle while a dish cooks, they serve other tables and return when the food is ready. Structured concurrency ensures that when the restaurant closes, all workers go home together.

This topic covers the core concepts of coroutines, including underlying theory, practical implementation, and key applications.

Key concepts include:

  • core concepts and terminology
  • algorithms and computational thinking
  • practical implementation
  • security and ethical considerations
  • applications in the real world

Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.

Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.

  • Coroutines Advanced — Flow error handling, StateFlow, SharedFlow, and testing patterns extend the coroutine basics covered here.
  • Collections — Flow operators (map, filter, fold) mirror collection transformation operations; sequences provide lazy evaluation.
  • Generics — Deferred, Flow, and Channeluse generic type parameters with variance annotations.
  • Delegation and Result — Property delegation and the Result type complement coroutine patterns for state management and error handling.