Kotlin Coroutines Context and Dispatchers

Every coroutine executes within a context represented by CoroutineContext, which is part of the Kotlin standard library. This context is a collection of elements, with the most important being the coroutine's Job and its dispatcher.

Dispatchers and Threads

The coroutine context includes a coroutine dispatcher that determines what threads the coroutine uses for execution. It can confine coroutine execution to a specific thread, dispatch it to a thread pool, or let it run unconfined.

All coroutine builders like launch and async accept an optional CoroutineContext parameter that can be used to explicitly specify the dispatcher for the new coroutine and other context elements.

launch { // inherits context from parent, runBlocking in this case
    println("main runBlocking: executing on thread ${Thread.currentThread().name}")
}

launch(Dispatchers.Unconfined) { // runs on main thread
    println("Unconfined: executing on thread ${Thread.currentThread().name}")
}

launch(Dispatchers.Default) { // uses shared background pool
    println("Default: executing on thread ${Thread.currentThread().name}")
}

launch(newSingleThreadContext("CustomThread")) { // creates new thread
    println("SingleThread: executing on thread ${Thread.currentThread().name}")
}

Output (order may vary):

Unconfined: executing on thread main
Default: executing on thread DefaultDispatcher-worker-1
SingleThread: executing on thread CustomThread
main runBlocking: executing on thread main

When launch is called without parameters, it inherits context (and thus dispatcher) from the CoroutineScope where it was launched. In this example, it inherits from the main thread's runBlocking coroutine. Dispatchers.Unconfined is a special dispatcher that also appears to run on the main thread initially, but operates differently as explanied later.

Unconfined vs Confined Dispatchers

Dispatchers.Unconfined starts a coroutine in the caller thread but only until its first suspension point. After suspension, it resumes in the thread determined by the suspending function. This is useful for operations that don't consume CPU time and don't update shared data confined to specific threads (like UI).

In contrast, the default dispatcher inherited from external scope confines coroutines to the calling thread, providing predictable FIFO scheduling.

launch(Dispatchers.Unconfined) {
    println("Unconfined start: thread ${Thread.currentThread().name}")
    delay(500)
    println("Unconfined resume: thread ${Thread.currentThread().name}")
}

launch {
    println("Confined start: thread ${Thread.currentThread().name}")
    delay(1000)
    println("Confined resume: thread ${Thread.currentThread().name}")
}

Output:

Unconfined start: thread main
Confined start: thread main
Unconfined resume: thread kotlinx.coroutines.DefaultExecutor
Confined resume: thread main

Debugging Coroutines and Threads

Coroutines can suspend on one thread and resume on another, making debugging difficult. The kotlinx.coroutines library provides debugging capabilities through JVM properites.

Running with -Dkotlinx.coroutines.debug:

val taskA = async {
    log("Computing first part")
    6
}

val taskB = async {
    log("Computing second part")
    7
}

log("Result: ${taskA.await() * taskB.await()}")

Output:

[main @coroutine#2] Computing first part
[main @coroutine#3] Computing second part
[main @coroutine#1] Result: 42

Context Switching

newSingleThreadContext("Thread1").use { ctx1 ->
    newSingleThreadContext("Thread2").use { ctx2 ->
        runBlocking(ctx1) {
            log("Started in Thread1")
            withContext(ctx2) {
                log("Working in Thread2")
            }
            log("Back to Thread1")
        }
    }
}

Output:

[Thread1 @coroutine#1] Started in Thread1
[Thread2 @coroutine#1] Working in Thread2
[Thread1 @coroutine#1] Back to Thread1

Job in Context

A coroutine's Job is part of its context and can be retrieved using coroutineContext[Job]:

println("Current job: ${coroutineContext[Job]}")

In debug mode:

Current job: "coroutine#1":BlockingCoroutine{Active}@6d311334

Child Coroutines

When a coroutine is launched within another coroutine's scope, it inherits the context and becomes a child job. When parent is cancelled, all children are recursively cancelled.

val request = launch {
    GlobalScope.launch {
        println("Global job: independent execution")
        delay(1000)
        println("Global job: unaffected by cancellation")
    }
    
    launch {
        delay(100)
        println("Child job: dependent on parent")
        delay(1000)
        println("Child job: won't execute after cancellation")
    }
}

delay(500)
request.cancel()
delay(1000)
println("Main: Checking surviving jobs")

Output:

Global job: independent execution
Child job: dependent on parent
Global job: unaffected by cancellation
Main: Checking surviving jobs

Parent Responsibilities

Parent coroutines wait for all children to complete:

val request = launch {
    repeat(3) { i ->
        launch {
            delay((i + 1) * 200L)
            println("Task $i completed")
        }
    }
    println("Request handler finished without explicit joining")
}

request.join()
println("All processing completed")

Output:

Request handler finished without explicit joining
Task 0 completed
Task 1 completed
Task 2 completed
All processing completed

Named Coroutines for Debugging

For better debugging, coroutines can be explicitly named:

log("Starting main processing")

val calc1 = async(CoroutineName("Calculator1")) {
    delay(500)
    log("Processing calc1")
    252
}

val calc2 = async(CoroutineName("Calculator2")) {
    delay(1000)
    log("Processing calc2")
    6
}

log("Division result: ${calc1.await() / calc2.await()}")

With debugging enabled:

[main @main#1] Starting main processing
[main @Calculator1#2] Processing calc1
[main @Calculator2#3] Processing calc2
[main @main#1] Division result: 42

Combining Context Elements

Multiple context elements can be combined using the + operator:

launch(Dispatchers.Default + CoroutineName("Worker")) {
    println("Executing on ${Thread.currentThread().name}")
}

Output with debugging:

Executing on DefaultDispatcher-worker-1 @Worker#2

Coroutine Scope

CoroutineScope manages coroutine lifecycle:

class Activity {
    private val scope = MainScope()
    
    fun cleanup() {
        scope.cancel()
    }
    
    fun processData() {
        repeat(10) { i ->
            scope.launch {
                delay((i + 1) * 200L)
                println("Task $i completed")
            }
        }
    }
}

Usage:

val activity = Activity()
activity.processData()
println("Tasks launched")
delay(500L)
println("Cleaning up!")
activity.cleanup()
delay(1000)

Output:

Tasks launched
Task 0 completed
Task 1 completed
Cleaning up!

Thread-Local Data

Thread-local data can be passed to coroutines:

threadLocal.set("main")
println("Before launch: thread ${Thread.currentThread().name}, value: '${threadLocal.get()}'")

val job = launch(Dispatchers.Default + threadLocal.asContextElement(value = "worker")) {
    println("Start: thread ${Thread.currentThread().name}, value: '${threadLocal.get()}'")
    yield()
    println("Resume: thread ${Thread.currentThread().name}, value: '${threadLocal.get()}'")
}

job.join()
println("After completion: thread ${Thread.currentThread().name}, value: '${threadLocal.get()}'")

Output:

Before launch: thread Thread[main @coroutine#1,5,main], value: 'main'
Start: thread Thread[DefaultDispatcher-worker-1 @coroutine#2,5,main], value: 'worker'
Resume: thread Thread[DefaultDispatcher-worker-2 @coroutine#2,5,main], value: 'worker'
After completion: thread Thread[main @coroutine#1,5,main], value: 'main'

Tags: kotlin Coroutines Concurrency Dispatchers context

Posted on Thu, 04 Jun 2026 17:52:59 +0000 by gregolson