Skip to content

Coroutines | Kotlin - Wyatt's Notes

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.
flowchart TD
    A[Coroutines] --> B[Key Concepts]
    A --> C[Core Principles]
    A --> D[Practical Applications]
    B --> E[Fundamental definitions]
    C --> F[Design patterns]
    D --> G[Real-world usage]

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 are Kotlin’s answer to asynchronous programming without the complexity of callbacks or reactive streams. A suspend function can pause execution without blocking its thread, and the compiler generates a state machine that saves and restores local variables at each suspension point. Structured concurrency ensures that child coroutines are bound to their parent scope — when the parent is cancelled or fails, all children are cancelled too, preventing resource leaks. Dispatchers control which thread pool runs each coroutine: Default for CPU work, IO for blocking operations, Main for UI. Flow is a cold asynchronous stream that processes elements lazily, similar to RxJava’s Observable but built entirely on coroutines.