Kotlin Coroutines: Asynchronous Programming Made Simple

Coroutines are the part of Kotlin I reach for whenever I need to deal with asynchronous work. They keep the code reading top-to-bottom, even when the underlying work is non-blocking, which makes concurrency a lot less painful than juggling raw threads or callbacks.
What are Coroutines?
Coroutines are light-weight threads that allow you to write asynchronous code in a sequential style. Unlike traditional threads, thousands of coroutines can run on a single thread without significant overhead.
Setup
Add the coroutines dependency to your project:
// Gradle (Kotlin DSL)
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
// For Android
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}
Basic Concepts
Suspend Functions
Functions that can pause execution without blocking the thread:
suspend fun fetchUser(): User {
delay(1000) // Non-blocking delay
return User("John")
}
suspend fun longRunningTask(): Long {
val time = measureTimeMillis {
println("Starting...")
delay(2000) // Suspends for 2 seconds
println("Done!")
}
return time
}
runBlocking
Bridges blocking and non-blocking worlds (mainly for main functions and tests):
fun main() = runBlocking {
val time = longRunningTask()
println("Execution time: $time ms")
}
launch
Starts a new coroutine that doesn’t return a result:
fun main() = runBlocking {
launch {
delay(1000)
println("World!")
}
println("Hello,") // Prints immediately
}
// Output: Hello, World!
async
Starts a coroutine that returns a result via Deferred:
fun main() = runBlocking {
val deferred = async {
delay(1000)
"Hello, Async!"
}
println("Waiting...")
val result = deferred.await() // Suspends until result is ready
println(result)
}
Coroutine Context and Dispatchers
Dispatchers
Control which thread(s) the coroutine runs on:
launch(Dispatchers.Default) {
// CPU-intensive work (background thread pool)
}
launch(Dispatchers.IO) {
// I/O operations (larger thread pool)
}
launch(Dispatchers.Main) {
// UI updates (Android main thread)
}
launch(Dispatchers.Unconfined) {
// Runs in caller thread until first suspension
}
Common Pool Pattern
fun main() = runBlocking {
val time = async(Dispatchers.Default) { longRunningTask() }
println("Printed after async launch")
val result = time.await()
println("Time taken: $result ms")
}
Structured Concurrency
Coroutine Scope
Coroutines are scoped, ensuring proper cancellation and resource cleanup:
class MyViewModel : ViewModel() {
private val scope = CoroutineScope(Dispatchers.Main + Job())
fun loadData() {
scope.launch {
val data = fetchData()
updateUI(data)
}
}
override fun onCleared() {
scope.cancel() // Cancel all coroutines
}
}
coroutineScope
Creates a scope that waits for all children to complete:
suspend fun fetchTwoPosts(): Pair<Post, Post> = coroutineScope {
val post1 = async { fetchPost(1) }
val post2 = async { fetchPost(2) }
Pair(post1.await(), post2.await())
}
Exception Handling
try-catch
launch {
try {
riskyOperation()
} catch (e: Exception) {
handleError(e)
}
}
CoroutineExceptionHandler
val handler = CoroutineExceptionHandler { _, exception ->
println("Caught $exception")
}
val scope = CoroutineScope(Dispatchers.Default + handler)
scope.launch {
throw RuntimeException("Boom!")
}
async Exception Handling
Note: Exceptions in async are only thrown when await() is called:
val deferred = async {
throw RuntimeException("Error!")
}
try {
deferred.await() // Exception thrown here
} catch (e: Exception) {
println("Caught: ${e.message}")
}
Cancellation
Checking for Cancellation
suspend fun longTask() = coroutineScope {
repeat(1000) { i ->
if (!isActive) return@coroutineScope // Check cancellation
// or use ensureActive()
delay(100)
println("Processing $i")
}
}
Timeout
val result = withTimeout(1000) {
fetchData() // Throws TimeoutCancellationException if takes > 1s
}
// Or with null on timeout
val result = withTimeoutOrNull(1000) {
fetchData()
}
Flow (Cold Streams)
For asynchronous data streams:
fun numbers(): Flow<Int> = flow {
for (i in 1..3) {
delay(100)
emit(i)
}
}
fun main() = runBlocking {
numbers()
.filter { it % 2 == 1 }
.map { it * it }
.collect { println(it) } // 1, 9
}
Channels (Hot Streams)
For communication between coroutines:
val channel = Channel<Int>()
launch {
for (x in 1..5) channel.send(x * x)
channel.close()
}
launch {
for (y in channel) println(y) // 1, 4, 9, 16, 25
}
Time Measurement
import kotlin.system.measureTimeMillis
val elapsed = measureTimeMillis {
runBlocking {
delay(1000)
}
}
println("Took $elapsed ms")
Kotlin Native Concurrency
For Kotlin Multiplatform, there are special considerations:
Frozen Objects
In Kotlin/Native, objects passed between threads are frozen (immutable):
// Objects are frozen by default
object DataManager {
// Use @ThreadLocal for mutable state
@ThreadLocal
var mutableData: String? = null
}
// Global properties are accessible only from main thread
// unless marked @SharedImmutable or @ThreadLocal
Worker API
// Background work in Kotlin/Native
val worker = Worker.start()
val future = worker.execute(TransferMode.SAFE, { data }) {
// This runs in worker thread
processData(it)
}
val result = future.result // Get result (freezes if needed)
Best Practices for Native
- Use
ensureNeverFrozen()to catch unexpected freezing - Understand that
freeze()applies to object graph - Be careful with closures capturing outer scope
- Use
@ThreadLocalfor thread-specific mutable state
Unit Testing Coroutines
dependencies {
testImplementation("junit:junit:4.13.2")
testImplementation("org.mockito:mockito-core:4.0.0")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
}
@Test
fun `test coroutine`() = runTest {
val result = async { fetchData() }.await()
assertEquals("expected", result)
}
// With TestDispatcher for time control
@Test
fun `test with delay`() = runTest {
var result = ""
launch {
delay(1000)
result = "done"
}
advanceTimeBy(1000)
assertEquals("done", result)
}
Best Practices
- Use structured concurrency: Always scope coroutines properly
- Prefer suspend functions: Make asynchronous operations explicit
- Choose the right dispatcher: IO for network/disk, Default for CPU
- Handle exceptions properly: Use try-catch or exception handlers
- Cancel when appropriate: Clean up resources and stop unnecessary work
- Avoid GlobalScope: Use proper scoping for lifecycle management
Common Patterns
Sequential by Default
suspend fun loadContent() {
val user = fetchUser() // Waits
val posts = fetchPosts(user) // Then executes
display(user, posts)
}
Concurrent with async
suspend fun loadContent() = coroutineScope {
val user = async { fetchUser() }
val posts = async { fetchPosts() }
display(user.await(), posts.await()) // Parallel execution
}
Fire and Forget with launch
fun logAnalytics(event: String) {
scope.launch(Dispatchers.IO) {
analyticsService.log(event) // Don't wait for result
}
}
Wrapping up
Once you get comfortable with scopes, dispatchers, and exception handling, coroutines tend to fade into the background — they just stop being the part of the code you worry about. Flow and channels round out the toolkit for streaming and inter-coroutine communication, and structured concurrency keeps lifecycles tidy without much thought.
Comments