Skip to content
ioob.dev
Go back

Kotlin Part 9 — Coroutines Basics

· 4 min read
Kotlin Series (9/12)
  1. Kotlin Beginner Part 1 — Variables and Types
  2. Kotlin Beginner Part 2 — Conditionals and Loops
  3. Kotlin Beginner Part 3 — Functions
  4. Kotlin Beginner Part 4 — Classes and Objects
  5. Kotlin Beginner Part 5 — Collections and Lambdas
  6. Kotlin Part 6 — Null Safety Advanced
  7. Kotlin Part 7 — Generics
  8. Kotlin Part 8 — sealed class and enum
  9. Kotlin Part 9 — Coroutines Basics
  10. Kotlin Part 10 — Coroutines Advanced
  11. Kotlin Part 11 — DSL and Advanced Functions
  12. Kotlin Part 12 — Practical Patterns
Table of contents

Table of contents

Why Asynchronous Programming

Imagine fetching data from a server takes 2 seconds. If the thread is blocked the entire time, the UI freezes, or the server can’t handle other requests. That’s why asynchronous processing is needed.

In Java, this was addressed with Thread, ExecutorService, CompletableFuture, RxJava, and similar tools, but code tends to get complex. Nested callbacks lead to so-called “callback hell,” and error handling becomes tricky.

Kotlin coroutines let you write asynchronous code as if it were synchronous. That’s the key idea.

suspend Functions

The starting point of coroutines is the suspend keyword. It declares: “this function can be paused during execution.”

import kotlinx.coroutines.*

suspend fun fetchUser(): String {
    delay(1000)  // Wait 1 second (does not block the thread)
    return "Kim"
}

suspend fun fetchAge(): Int {
    delay(1000)
    return 25
}

fun main() = runBlocking {
    val user = fetchUser()
    val age = fetchAge()
    println("$user, age ${age}")  // Kim, age 25 (takes about 2 seconds)
}

delay(1000) looks similar to Thread.sleep(1000), but there’s a crucial difference. Thread.sleep blocks the entire thread, while delay only suspends the coroutine and frees the thread to do other work. This difference is the core of coroutine efficiency.

suspend functions cannot be called directly from regular functions. They must be called from within another suspend function or inside a coroutine builder (runBlocking, launch, async).

runBlocking — The Gateway to Coroutines

runBlocking is a bridge connecting the regular world to the coroutine world. It blocks the current thread and waits until all coroutines inside have finished.

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("Start: ${Thread.currentThread().name}")

    delay(500)

    println("End: ${Thread.currentThread().name}")
}
Start: main
End: main

It’s primarily used to start coroutines from the main function. However, in production you shouldn’t overuse runBlocking since it blocks the thread. The rule of thumb is to use it only in test code or the main function entry point.

CoroutineScope

Coroutines always run within a scope. A scope is the container that manages a coroutine’s lifecycle.

import kotlinx.coroutines.*

fun main() = runBlocking {
    // runBlocking provides the CoroutineScope

    println("Parent start")

    // Coroutines started in this scope terminate when the scope ends
    coroutineScope {
        launch {
            delay(500)
            println("Child 1 complete")
        }
        launch {
            delay(300)
            println("Child 2 complete")
        }
        println("Inside coroutineScope")
    }

    println("Parent end")
}
Parent start
Inside coroutineScope
Child 2 complete
Child 1 complete
Parent end

coroutineScope waits until all coroutines inside it have finished before proceeding to the next line. If one child coroutine fails, the rest are cancelled. This is the basic principle of Structured Concurrency.

Here’s an overview of how coroutine builders and dispatchers work together.

flowchart TB
    subgraph CoroutineScope
        runBlocking["runBlocking\n(blocks main thread)"]

        runBlocking --> launch["launch\n-> returns Job\n(no result)"]
        runBlocking --> async["async\n-> returns Deferred\n(has result)"]
    end

    subgraph Dispatchers
        Default["Dispatchers.Default\nCPU-intensive work\n(threads = core count)"]
        IO["Dispatchers.IO\nNetwork / File / DB\n(up to 64 threads)"]
        Main["Dispatchers.Main\nUI updates\n(main thread)"]
    end

    launch -->|"specify dispatcher"| Default
    launch -->|"specify dispatcher"| IO
    async -->|"specify dispatcher"| Default
    async -->|"specify dispatcher"| IO
    launch -.->|"Android only"| Main

launch — “Fire and Forget”

launch starts a new coroutine and returns a Job. Use it for asynchronous tasks that don’t need a result.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        println("Coroutine started: ${Thread.currentThread().name}")
        delay(1000)
        println("Coroutine finished")
    }

    println("Right after launch call")
    job.join()  // Wait for the coroutine to finish
    println("Main end")
}
Right after launch call
Coroutine started: main
Coroutine finished
Main end

When launch is called, the coroutine starts immediately, but the caller doesn’t wait and moves on to the next line. That’s why “Right after launch call” prints first. If you want to wait for the coroutine to finish, use job.join().

Running multiple coroutines simultaneously results in true parallelism.

import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis

fun main() = runBlocking {
    val time = measureTimeMillis {
        val job1 = launch {
            delay(1000)
            println("Task 1 complete")
        }
        val job2 = launch {
            delay(1000)
            println("Task 2 complete")
        }
        job1.join()
        job2.join()
    }

    println("Total time: ${time}ms")  // About 1000ms (not 2000ms)
}

Two 1-second tasks run simultaneously, so the total time is about 1 second. Running them sequentially would have taken 2 seconds.

async/await — When You Need Results

launch doesn’t return a result. When you need the result of an asynchronous operation, use async.

import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis

suspend fun fetchUserName(): String {
    delay(1000)
    return "Kim"
}

suspend fun fetchUserAge(): Int {
    delay(1000)
    return 25
}

fun main() = runBlocking {
    // Sequential execution — about 2 seconds
    val seqTime = measureTimeMillis {
        val name = fetchUserName()
        val age = fetchUserAge()
        println("Sequential: $name, age ${age}")
    }
    println("Sequential time: ${seqTime}ms")

    // Parallel execution — about 1 second
    val parTime = measureTimeMillis {
        val name = async { fetchUserName() }
        val age = async { fetchUserAge() }
        println("Parallel: ${name.await()}, age ${age.await()}")
    }
    println("Parallel time: ${parTime}ms")
}
Sequential: Kim, age 25
Sequential time: 2012ms
Parallel: Kim, age 25
Parallel time: 1007ms

async returns a Deferred<T>. Calling .await() suspends until the result is ready, then extracts the value. When calling two independent APIs, using async for concurrent requests can cut the total wait time in half.

Dispatchers — Where to Execute

Dispatchers determine which thread a coroutine runs on.

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch(Dispatchers.Default) {
        println("Default: ${Thread.currentThread().name}")
        // Suitable for CPU-intensive work
    }

    launch(Dispatchers.IO) {
        println("IO: ${Thread.currentThread().name}")
        // Suitable for network, file, DB operations
    }

    launch(Dispatchers.Main) {
        // Suitable for UI updates (only available in Android environment)
        // println("Main: ${Thread.currentThread().name}")
    }

    launch(Dispatchers.Unconfined) {
        println("Unconfined start: ${Thread.currentThread().name}")
        delay(100)
        println("Unconfined resumed: ${Thread.currentThread().name}")
        // Thread may change after suspension and resumption
    }
}

Three are commonly used in practice.

DispatcherUse CaseThread Pool
Dispatchers.DefaultCPU-intensive computationNumber of CPU cores
Dispatchers.IONetwork, file, DBUp to 64 (or core count)
Dispatchers.MainUI updatesMain thread (Android)

In most cases, you’ll choose between Dispatchers.IO and Dispatchers.Default. Network calls go with IO; heavy sorting or computation goes with Default.

Coroutine Cancellation

Sometimes you need to stop a running coroutine — when the user navigates away, or a request times out.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        repeat(1000) { i ->
            println("Working... $i")
            delay(200)
        }
    }

    delay(1000)
    println("Cancellation requested")
    job.cancel()       // Request cancellation
    job.join()         // Wait for cancellation to complete
    println("Cancelled")
}
Working... 0
Working... 1
Working... 2
Working... 3
Working... 4
Cancellation requested
Cancelled

Calling cancel() sends a cancellation signal to the coroutine. However, the coroutine doesn’t stop immediately — cancellation is actually processed when it hits a suspension point like delay, yield, or withContext.

If you’re running a CPU-intensive loop with no suspension points, you need to check isActive.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch(Dispatchers.Default) {
        var sum = 0L
        var i = 0
        while (isActive) {  // Check cancellation status directly
            sum += i++
            if (i % 1_000_000 == 0) {
                println("Computing... i=$i")
            }
        }
        println("Final sum: $sum")
    }

    delay(100)
    job.cancelAndJoin()
    println("Done")
}

isActive is a flag indicating whether the current coroutine hasn’t been cancelled. When a cancellation request arrives, it becomes false, and the while loop exits.

Timeouts

When you want automatic cancellation if something doesn’t finish within a given time, use withTimeout.

import kotlinx.coroutines.*

suspend fun slowOperation(): String {
    delay(3000)
    return "Complete"
}

fun main() = runBlocking {
    // Throws exception on timeout
    try {
        val result = withTimeout(1000) {
            slowOperation()
        }
        println(result)
    } catch (e: TimeoutCancellationException) {
        println("Timeout occurred!")
    }

    // Returns null on timeout (no exception)
    val result = withTimeoutOrNull(1000) {
        slowOperation()
    }
    println(result ?: "Timed out")  // Timed out
}

withTimeout throws a TimeoutCancellationException when time runs out. If you prefer not to handle exceptions, withTimeoutOrNull returns null on timeout, pairing nicely with ?:.


We’ve covered the basic tools of coroutines. Create suspendable functions with suspend, start coroutines with launch and async, choose execution location with dispatchers, and manage lifecycles with cancellation and timeouts.

The next part dives into advanced coroutines. We’ll explore Flow, Channel, error handling, and structured concurrency in depth.

-> Part 10: Coroutines Advanced


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Kotlin Part 8 — sealed class and enum
Next Post
Kotlin Part 10 — Coroutines Advanced