Kotlin Coroutines Basic Concepts

To kick off the series on Kotlin Coroutines, I will explain the fundamental concepts in Coroutines along with practical code examples. Through this, I hope to provide you with a solid foundation in Coroutines so you can learn and apply them faster.

Concepts explained in this article:

  1. Dispatchers
  2. Scope
  3. Context
  4. Suspending Function
  5. Job
  6. Deferred

1. Dispatchers

True to its name, a Dispatcher is responsible for dispatching and assigning one or more threads to execute a coroutine. The available Dispatcher types include:

  • Dispatchers.Default: The default dispatcher used if not explicitly specified in the Scope Builder. It uses a shared pool of background threads and is ideal for CPU-intensive computations.
  • Dispatchers.IO: Designed for IO-intensive blocking operations such as file read/write or blocking socket I/O.
  • Dispatchers.Unconfined: A rather unusual dispatcher. The documentation notes that it is not commonly used in regular code ;)). This dispatcher is not confined to any specific thread—it executes the coroutine initially in the caller thread, and upon resuming after suspension, the resuming thread is decided by the suspending function.
  • Specific ThreadPools created via newSingleThreadContext or newFixedThreadPoolContext.
  • Any Executor converted via asCoroutineDispatcher().
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
fun main() = runBlocking {

  launch { // context of the parent, main runBlocking coroutine
    println("main runBlocking            : I'm working in thread ${Thread.currentThread().name}")
  }
  launch(Dispatchers.Unconfined) { // not confined -- will work with main thread
    println("Unconfined                 : I'm working in thread ${Thread.currentThread().name}")
  }
  launch(Dispatchers.Default) { // will get dispatched to DefaultDispatcher
    println("Default                    : I'm working in thread ${Thread.currentThread().name}")
  }

  launch(newCoroutineContext(Dispatchers.Default)) { // will get its own new thread
    println("newCoroutineContext         : I'm working in thread ${Thread.currentThread().name}")
  }

  //
  val dispatcherWithTwoThread = newFixedThreadPoolContext(2, "ThreadPoll")

  repeat(10) {
    launch(dispatcherWithTwoThread) { // will get its own new thread
      println("dispatcherWithTwoThread${it.padEnd(3)}  : I'm working in thread ${Thread.currentThread().name}")
      delay(Random.nextLong(500, 5000))
      println("dispatcherWithTwoThread${it.padEnd(3)}  : I'm done in thread ${Thread.currentThread().name}")
    }
  }
}

fun Int.padEnd(length: Int, padChar: Char = '0') = this.toString().padStart(length, padChar)

2. Scope

Coroutines are always launched inside a CoroutineScope. The purpose is structured concurrency and resource management. Imagine running a heavy task inside one or more coroutines; if halfway through that task is no longer needed, you can simply call cancel() on the scope containing those coroutines. Another key feature is that a CoroutineScope can nest child CoroutineScopes.

GlobalScope

GlobalScope is considered the parent scope for the entire application. GlobalScope cannot be cancel()ed and exists throughout the application lifecycle. Using it is generally NOT RECOMMENDED. You can read The reason to avoid GlobalScope to understand why.

Scope Builder

As mentioned above, coroutines are launched using these Scope Builders:

  • runBlocking: Runs a new coroutine and blocks[1] the current thread until it completes. Do not use this function inside a coroutine because it is designed as a bridge between regular blocking code and libraries written in a suspending style; typically used in the main function and in tests.

  • coroutineScope: Creates a new CoroutineScope. This scope is a child of the outer scope but overrides its Job. It is designed for parallel decomposition—if any coroutine fails inside this scope, all other awaiting coroutines in this scope are also cancelled.

  • launch: Creates a new CoroutineScope, does not block[2] the current thread, and returns a Job.

  • async: Creates a new CoroutineScope, does not block2 the current thread, and returns a Deferred. Use async when you need a return value from the invocation, whereas launch is fire-and-forget.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
fun main() = runBlocking {

  val job = launch(CoroutineName("parent")) { // parent scope
    println("Start $coroutineContext")

    async(CoroutineName("child-1")) {
      println("Start $coroutineContext")
      delay(1000)
      println("End $coroutineContext")
    }

    async(CoroutineName("child-2")) {
      println("Start $coroutineContext")
      delay(3000)
      println("End $coroutineContext") // not execute this line
    }

    println("End $coroutineContext") // still wait child-1 and child-2 finish
  }

  delay(2000)
  job.cancel() // cancel but child-2 not finish

  println("Done")
}

3. Context

Every coroutine in Kotlin has a context represented by an instance of the CoroutineContext interface. This context is a set of elements configuring the coroutine, of which the two primary components are Job and Dispatcher.

Context is immutable. However, you can use the plus operator to produce a combined new context.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
fun main() = runBlocking<Unit> {

  println("Current context is: $coroutineContext")

  println("New context with name: ${coroutineContext + CoroutineName("test")}")

  println("Job in current context is: ${coroutineContext[Job]}")

  println("Dispatcher in current context is: ${coroutineContext[ContinuationInterceptor]}")
  
  launch(CoroutineName("child")) {
    println("Child context is $coroutineContext}")

    println("CoroutineName in current context is: ${coroutineContext[CoroutineName]}")
  }

}

4. Suspending Function

Suspending Function is the backbone of Kotlin Coroutines. Functions inside a suspending function can pause execution without blocking1 the underlying thread. The thread executing the suspending function is released back to the JVM and can be used for other tasks. Remember, a Suspending Function must run inside a coroutine to take effect ;))

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
fun main() = runBlocking(
  CoroutineName("parent") +
    newSingleThreadContext("SingleThread") // Dispatcher SingleThread only use one thread
) {

  launch(CoroutineName("child")) {

    doSomeThingToWait(3000) // release thread in SingleThread for another function
  }

  doSomeThingToWait(1000) // use thread in SingleThread

}

suspend fun doSomeThingToWait(waitTime: Long) {
  println("Current context when START: $coroutineContext")

  delay(waitTime) // simulator processing

  println("Current context when END  : $coroutineContext")
}

5. Job

As mentioned in Context, a Job is an essential element of the context. It allows you to cancel(), join(), or start() the corresponding coroutine. In addition, the job tracks the lifecycle states of a coroutine as shown below:

                                          wait children
    +-----+ start  +--------+ complete   +-------------+  finish  +-----------+
    | New | -----> | Active | ---------> | Completing  | -------> | Completed |
    +-----+        +--------+            +-------------+          +-----------+
                     |  cancel / fail       |
                     |     +----------------+
                     |     |
                     V     V
                 +------------+                           finish  +-----------+
                 | Cancelling | --------------------------------> | Cancelled |
                 +------------+                                   +-----------+
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
fun main() = runBlocking {

  val timeNotCancel = measureTimeMillis {
    val job = launch {
      delay(3_000) // done after 3000 ms
    }

    delay(1_000)
    job.join() // wait job done
  }

  println("Time to run not cancel $timeNotCancel ms")

  val timeWithCancel = measureTimeMillis {
    val job = launch {
      delay(3_000) // done after 3000 ms
    }

    delay(1_000)

    job.cancel() // not wait delay(3_000)
    job.join() // wait job done
  }

  println("Time to run with cancel $timeWithCancel ms")

}

6. Deferred

A Deferred is also a Job, but it holds a computation result once the coroutine completes. Deferred is created using async (as described in ScopeBuilder) or initialized directly with CompletableDeferred.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
fun main() = runBlocking {

  println("--------- async ---------")

  val deferred = async { getRandomInt() }

  println("Wait async-getRandomInt result")

  println("RandomInt from async-getRandomInt = ${deferred.await()}")

  println("------ CompletableDeferred ------")

  val completableDeferred = CompletableDeferred<Int>()

  launch {
    delay(1000)

    val result = Random.nextInt(1, 10)

    println("completableDeferred done with result $result")

    completableDeferred.complete(result)
  }

  println("Wait completableDeferred result")
  println("RandomInt from completableDeferred = ${completableDeferred.await()}")

}

suspend fun getRandomInt(): Int {
  delay(1000)

  val result = Random.nextInt(1, 10)

  println("async-getRandomInt done with result $result")

  return result
}

I have uploaded the sample code to this repo. Thank you for reading this far!


  1. The current thread will wait until the function finishes before executing the subsequent code.  ↩︎

  2. Immediately continues executing subsequent code without waiting for the function to complete. The result is returned via a callback or a deferred object.  ↩︎

updatedupdated2026-09-052026-09-05
Load Comments?