Kotlin - Coroutines (Basics)

Kotlin Coroutines - Getting Started

Coroutines are Kotlin's solution to asynchronous programming. They make it easy to write non-blocking code that looks and feels like synchronous code, eliminating callback hell and making concurrent programming approachable.

What Are Coroutines?

Coroutines are lightweight threads that can be suspended and resumed without blocking the underlying thread. Think of them as functions that can pause execution and resume later.

Traditional Threading vs Coroutines

Traditional Threads (Expensive)

// Heavy, limited by OS threads
Thread {
    Thread.sleep(1000)
    println("Done")
}.start()

// Each thread uses ~1MB memory
// Limited to thousands of threads

Coroutines (Lightweight)

// Light, managed by Kotlin runtime
launch {
    delay(1000)
    println("Done")
}

// Each coroutine uses ~KB memory
// Can have millions of coroutines
Key Insight: Coroutines are not threads! They run on threads but are much more lightweight. You can have millions of coroutines but only thousands of threads.

Setting Up Coroutines

Adding Dependencies

// build.gradle.kts
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") // For Android
}

Basic Imports

import kotlinx.coroutines.*

Your First Coroutine

Hello, Coroutines!

import kotlinx.coroutines.*

fun main() {
    println("Start")
    
    // Launch a coroutine
    GlobalScope.launch {
        delay(1000L)  // Non-blocking delay
        println("World!")
    }
    
    println("Hello")
    Thread.sleep(2000L) // Keep main thread alive
}

// Output:
// Start
// Hello
// World!

Using runBlocking

import kotlinx.coroutines.*

fun main() = runBlocking {  // Blocks until all coroutines complete
    println("Start")
    
    launch {
        delay(1000L)
        println("World!")
    }
    
    println("Hello")
}

// Output:
// Start  
// Hello
// World!
Beginner Note: runBlocking is mainly for main functions and tests. In real applications, you'll use other coroutine builders within existing coroutine scopes.

Suspend Functions

What Makes a Function Suspendable

// Suspend function - can be paused and resumed
suspend fun doSomething(): String {
    delay(1000L)  // This suspends the coroutine
    return "Result"
}

// Regular function - cannot be suspended
fun regularFunction(): String {
    // delay(1000L)  // ❌ This would be a compilation error
    Thread.sleep(1000L)  // ✅ But this blocks the thread
    return "Result"
}

fun main() = runBlocking {
    val result = doSomething()  // Can call suspend functions
    println(result)
}

Creating Your Own Suspend Functions

suspend fun fetchUserData(userId: String): String {
    println("Fetching user data for $userId...")
    delay(1500L)  // Simulate network call
    return "User data for $userId"
}

suspend fun fetchUserPosts(userId: String): List<String> {
    println("Fetching posts for $userId...")
    delay(1000L)  // Simulate network call
    return listOf("Post 1", "Post 2", "Post 3")
}

fun main() = runBlocking {
    val userData = fetchUserData("123")
    println(userData)
    
    val posts = fetchUserPosts("123")
    println("Posts: $posts")
}

Coroutine Builders

1. launch - Fire and Forget

fun main() = runBlocking {
    // Starts a coroutine but doesn't wait for result
    val job = launch {
        repeat(5) { i ->
            println("Coroutine: $i")
            delay(500L)
        }
    }
    
    println("Main thread continues...")
    delay(1000L)
    println("Main thread done")
    
    job.join()  // Wait for coroutine to complete
}

2. async - Concurrent Execution with Results

import kotlin.system.measureTimeMillis

fun main() = runBlocking {
    val time = measureTimeMillis {
        // Sequential execution (slow)
        val user = fetchUserData("123")
        val posts = fetchUserPosts("123")
        println("Sequential: $user, Posts: ${posts.size}")
    }
    println("Sequential time: $time ms")
    
    val time2 = measureTimeMillis {
        // Concurrent execution (fast)
        val userDeferred = async { fetchUserData("123") }
        val postsDeferred = async { fetchUserPosts("123") }
        
        val user = userDeferred.await()
        val posts = postsDeferred.await()
        println("Concurrent: $user, Posts: ${posts.size}")
    }
    println("Concurrent time: $time2 ms")
}

3. runBlocking - Bridge to Blocking World

// Use in main functions and tests
fun main() = runBlocking {
    println("This is a coroutine scope")
    delay(1000L)
    println("Done")
}

// Or as a function call
fun syncFunction() {
    val result = runBlocking {
        fetchUserData("123")
    }
    println(result)
}

Coroutine Scope and Context

Understanding Scope

fun main() = runBlocking {  // Creates a coroutine scope
    
    launch {  // Child coroutine in the same scope
        delay(1000L)
        println("Child 1 done")
    }
    
    launch {  // Another child coroutine
        delay(500L)
        println("Child 2 done")
    }
    
    println("All coroutines launched")
    
    // runBlocking waits for all children to complete
}

// Output:
// All coroutines launched
// Child 2 done  
// Child 1 done

Creating Custom Scopes

import kotlinx.coroutines.*

class MyService {
    // Create a scope tied to the service lifecycle
    private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
    
    fun doWork() {
        scope.launch {
            repeat(3) { i ->
                println("Working: $i")
                delay(1000L)
            }
        }
    }
    
    fun cleanup() {
        scope.cancel()  // Cancel all coroutines
    }
}

fun main() = runBlocking {
    val service = MyService()
    service.doWork()
    
    delay(2000L)
    service.cleanup()
    println("Service cleaned up")
}

Exception Handling

Try-Catch in Coroutines

suspend fun riskyOperation(): String {
    delay(1000L)
    if (kotlin.random.Random.nextBoolean()) {
        throw Exception("Something went wrong!")
    }
    return "Success!"
}

fun main() = runBlocking {
    try {
        val result = riskyOperation()
        println("Result: $result")
    } catch (e: Exception) {
        println("Caught exception: ${e.message}")
    }
}

Exception Handling with async

fun main() = runBlocking {
    val deferred = async {
        riskyOperation()
    }
    
    try {
        val result = deferred.await()  // Exception thrown here
        println("Result: $result")
    } catch (e: Exception) {
        println("Caught exception: ${e.message}")
    }
}

Coroutine Dispatchers

Different Thread Pools

fun main() = runBlocking {
    
    // Default - CPU intensive work
    launch(Dispatchers.Default) {
        println("Default: ${Thread.currentThread().name}")
        // Good for CPU-intensive tasks
    }
    
    // IO - Network/file operations
    launch(Dispatchers.IO) {
        println("IO: ${Thread.currentThread().name}")
        // Good for network calls, file operations
    }
    
    // Main - UI updates (Android/Desktop)
    launch(Dispatchers.Main) {  // May not be available in console apps
        println("Main: ${Thread.currentThread().name}")
        // Good for UI updates
    }
    
    // Unconfined - Inherits caller's thread
    launch(Dispatchers.Unconfined) {
        println("Unconfined: ${Thread.currentThread().name}")
    }
    
    delay(100L)
}

Switching Contexts

suspend fun processData(): String = withContext(Dispatchers.IO) {
    // Switch to IO dispatcher for network call
    println("Processing on: ${Thread.currentThread().name}")
    delay(1000L)  // Simulate network call
    "Processed data"
}

fun main() = runBlocking {
    println("Main: ${Thread.currentThread().name}")
    val result = processData()
    println("Back on: ${Thread.currentThread().name}")
    println("Result: $result")
}
Teacher Note: Dispatchers help you run code on appropriate thread pools. Use Dispatchers.IO for network/file operations, Dispatchers.Default for CPU-intensive work, and Dispatchers.Main for UI updates.

Cancellation and Timeouts

Cancelling Coroutines

fun main() = runBlocking {
    val job = launch {
        repeat(10) { i ->
            println("Working: $i")
            delay(500L)
        }
    }
    
    delay(2000L)  // Let it run for 2 seconds
    job.cancel()  // Cancel the coroutine
    job.join()    // Wait for cancellation to complete
    
    println("Cancelled!")
}

Cooperative Cancellation

fun main() = runBlocking {
    val job = launch {
        repeat(10) { i ->
            // Check if cancelled
            if (!isActive) {
                println("Coroutine was cancelled")
                return@launch
            }
            
            println("Working: $i")
            delay(500L)  // delay checks for cancellation automatically
        }
    }
    
    delay(2000L)
    job.cancel()
    println("Cancelled!")
}

Timeouts

suspend fun longRunningTask(): String {
    delay(3000L)  // Takes 3 seconds
    return "Task completed"
}

fun main() = runBlocking {
    try {
        // Timeout after 2 seconds
        val result = withTimeout(2000L) {
            longRunningTask()
        }
        println(result)
    } catch (e: TimeoutCancellationException) {
        println("Task timed out!")
    }
    
    // Or return null on timeout
    val result = withTimeoutOrNull(2000L) {
        longRunningTask()
    }
    println("Result: $result")  // null
}

Real-World Example: Data Loading

data class User(val id: String, val name: String)
data class Post(val id: String, val title: String, val content: String)

class UserRepository {
    suspend fun getUser(id: String): User {
        delay(1000L)  // Simulate network call
        return User(id, "User $id")
    }
    
    suspend fun getUserPosts(userId: String): List<Post> {
        delay(800L)  // Simulate network call
        return listOf(
            Post("1", "First Post", "Content 1"),
            Post("2", "Second Post", "Content 2")
        )
    }
}

class UserService {
    private val repository = UserRepository()
    
    suspend fun getUserWithPosts(userId: String): Pair<User, List<Post>> {
        // Load user and posts concurrently
        val userDeferred = async { repository.getUser(userId) }
        val postsDeferred = async { repository.getUserPosts(userId) }
        
        return Pair(userDeferred.await(), postsDeferred.await())
    }
}

fun main() = runBlocking {
    val service = UserService()
    
    val startTime = System.currentTimeMillis()
    val (user, posts) = service.getUserWithPosts("123")
    val endTime = System.currentTimeMillis()
    
    println("User: ${user.name}")
    println("Posts: ${posts.size}")
    println("Time taken: ${endTime - startTime} ms")  // ~1000ms instead of 1800ms
}

Best Practices for Beginners

✅ Do

  • Use suspend functions for async operations
  • Use async for concurrent operations that return values
  • Use launch for fire-and-forget operations
  • Handle exceptions properly with try-catch
  • Use appropriate dispatchers for different work types
  • Cancel coroutines when they're no longer needed

❌ Don't

  • Use runBlocking inside coroutines (can cause deadlocks)
  • Use GlobalScope in production (hard to manage lifecycle)
  • Block threads with Thread.sleep() in coroutines
  • Forget to handle cancellation in long-running operations
  • Mix blocking and non-blocking code carelessly
Architecture Note: Coroutines enable writing asynchronous code that's as readable as synchronous code. They're essential for building responsive applications that can handle many concurrent operations efficiently.

Common Patterns

Retry with Exponential Backoff

suspend fun <T> retryWithBackoff(
    maxRetries: Int = 3,
    initialDelay: Long = 1000L,
    maxDelay: Long = 10000L,
    factor: Double = 2.0,
    block: suspend () -> T
): T {
    var currentDelay = initialDelay
    repeat(maxRetries - 1) {
        try {
            return block()
        } catch (e: Exception) {
            println("Attempt failed, retrying in ${currentDelay}ms...")
        }
        delay(currentDelay)
        currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay)
    }
    return block() // Last attempt
}

// Usage
suspend fun unreliableNetworkCall(): String {
    if (kotlin.random.Random.nextFloat() < 0.7) {  // 70% failure rate
        throw Exception("Network error")
    }
    return "Success!"
}

fun main() = runBlocking {
    try {
        val result = retryWithBackoff {
            unreliableNetworkCall()
        }
        println("Result: $result")
    } catch (e: Exception) {
        println("All retries failed: ${e.message}")
    }
}

Practice Exercises

  1. Create a suspend function that simulates downloading a file with progress updates
  2. Write a function that makes multiple concurrent API calls and combines the results
  3. Implement a simple cache that uses coroutines to load data asynchronously
  4. Create a coroutine that periodically checks for updates and can be cancelled
  5. Build a simple producer-consumer pattern using coroutines

Quick Quiz

  1. What keyword makes a function suspendable?
  2. What's the difference between launch and async?
  3. Which dispatcher should you use for network calls?
  4. What happens if you don't call await() on a Deferred?
Show answers
  1. suspend
  2. launch is fire-and-forget (returns Job), async returns a result (returns Deferred)
  3. Dispatchers.IO for network/file operations
  4. The coroutine runs but you don't get the result - it's like not collecting the return value