Understanding Coroutine Builders

Rakibul Huda

25 February, 2025

Kotlin coroutines offer a powerful way to manage concurrency with a more straightforward and readable syntax compared to traditional methods. At the heart of this feature are the coroutine builders—launch, async, and runBlocking. These builders are the entry points to start coroutines, and understanding their internal workings is key to mastering Kotlin’s concurrency model.

A normal function can not call a suspending function. Every suspending function needs to be called from another suspending function, that suspending function needs to be called from another suspending function and so on. So there should be a starting point. Either the main function needs to be a suspend function or a different mechanism needs to be applied. That’s where a coroutine builder comes into play.

There are several coroutine builders available and each one has its significance.

launch

The most basic and easy way to create a coroutine is by using the launch builder function. Launch basically works like fire and forget mechanism. It creates a coroutine and does not wait for the result. It does not block the underlying thread. The launch builder returns a Job which can be used to cancel the coroutine or check the state of that coroutine.

				
					fun main() = runBlocking {
   val startTime = System.currentTimeMillis()


   launch {
       delay(2000L)
       printlnWithTime("World 1!", startTime)
   }
   launch {
       delay(500L)
       printlnWithTime("World 2!", startTime)
   }
   launch {
       delay(1000L)
       printlnWithTime("World 3!", startTime)
   }
   printlnWithTime("Hello,", startTime)
}


//Output
[0.002] Hello,
[0.519] World 2!
[1.014] World 3!
[2.014] World 1!

				
			

By analyzing the source code of launch, we find the following code

				
					public fun CoroutineScope.launch(
   context: CoroutineContext = EmptyCoroutineContext,
   start: CoroutineStart = CoroutineStart.DEFAULT,
   block: suspend CoroutineScope.() -> Unit
): Job {
   val newContext = newCoroutineContext(context)
   val coroutine = if (start.isLazy)
       LazyStandaloneCoroutine(newContext, block) else
       StandaloneCoroutine(newContext, active = true)
   coroutine.start(start, coroutine, block)
   return coroutine
}

				
			

The launch function accepts three arguments: a CoroutineContext, CoroutineStart, and a lambda that defines the coroutine’s actions. Only the lambda is required. The function returns a Job. The CoroutineContext holds information about the coroutine, like its Job and Dispatcher. If not specified, EmptyCoroutineContext is used, inheriting the context from the CoroutineScope. This is customizable, but the default contexts are usually enough. CoroutineStart defines how the coroutine starts, with options like DEFAULT, LAZY, ATOMIC, and UNDISPATCHED. They can be used to decide how a coroutine will be started.

  • DEFAULT: Schedules the coroutine for immediate execution based on its context.
  • LAZY: Lazily starts the coroutine.
  • ATOMIC: Similar to DEFAULT, but the coroutine can’t be cancelled before it begins.
  • UNDISPATCHED: Executes the coroutine until it reaches its first suspension point.

The lambda contains the coroutine’s logic and has a CoroutineScope receiver, enabling nested coroutines within a launch block.

async

Another way to create a coroutine is by using the async builder. Unlike launch, it returns a value  asynchronously. The async function returns an object of Deferred<T> where T is the type of the produced value. Deferred has a suspending function await. This method returns the value when it’s ready.

				
					

fun main() = runBlocking {
   val startTime = System.currentTimeMillis()


   val firstValueDeferred = async {
       delay(500)
       100
   }


   val secondValueDeferred = async {
       delay(2000)
       50
   }


   printlnWithTime(firstValueDeferred.await().toString(), startTime)
   printlnWithTime(secondValueDeferred.await().toString(), startTime)
   printlnWithTime("The summation is ${firstValueDeferred.await() +  secondValueDeferred.await()}", startTime)
}


//Output
[0.516] 100
[2.011] 50
[2.012] The summation is 150

				
			

If we check the source code of async, we find the following snippet

				
					

public fun <T> CoroutineScope.async(
   context: CoroutineContext = EmptyCoroutineContext,
   start: CoroutineStart = CoroutineStart.DEFAULT,
   block: suspend CoroutineScope.() -> T
): Deferred<T> {
   val newContext = newCoroutineContext(context)
   val coroutine = if (start.isLazy)
       LazyDeferredCoroutine(newContext, block) else
       DeferredCoroutine<T>(newContext, active = true)
   coroutine.start(start, coroutine, block)
   return coroutine
}

				
			

The code block is almost identical to that of launch, the only differences are the returned type and what type of coroutine it creates. In this case the return type is Deferred<T> and it creates a DeferredCoroutine

runBlocking

To block the underlying thread, we use the runBlocking  builder. This statement may seem confusing at first because the whole point of using coroutines is not to block the underlying thread. It’s primarily used in unit tests or main functions. 


Let’s use the example from the launch section but without the runBlocking function. What should be the output of this? (Ignore the GlobalScope keyword for now, we will explain this in the next section)

				
					

fun main() {
   val startTime = System.currentTimeMillis()


   GlobalScope.launch {
       launch {
           delay(2000L)
           printlnWithTime("World 1!", startTime)
       }
       launch {
           delay(500L)
           printlnWithTime("World 2!", startTime)
       }
       launch {
           delay(1000L)
           printlnWithTime("World 3!", startTime)
       }
       printlnWithTime("Hello,", startTime)
   }
}

				
			

The program prints nothing. The reason is, as coroutines (launch in this case) do not block the thread, nothing is stopping the program from ending too early. The program starts, the coroutines fire up and the program ends. If we replace launch with the async builder, the result won’t change a bit. The following program will print nothing as well.

				
					

fun main() {
   val startTime = System.currentTimeMillis()


   GlobalScope.async {
       val deferred1 = async {
           delay(2000L)
           "World 1!"
       }


       val deferred2 = async {
           delay(500L)
           "World 2!"
       }


       val deferred3 = async {
           delay(1000L)
           "World 3!"
       }


       printlnWithTime("Hello,", startTime)
       printlnWithTime(deferred1.await(), startTime)
       printlnWithTime(deferred2.await(), startTime)
       printlnWithTime(deferred3.await(), startTime)
   }
}

				
			

So, in both the cases, the main function itself will return almost immediately after starting the coroutines. GlobalScope.launch or GlobalScope.async. Since the main function does not wait for the coroutines to complete, the program will likely exit before the coroutines finishes. 

runBlocking plays an important role in this scenario. It will make the main thread wait for other coroutines to finish. It runs a new coroutine and blocks the underlying thread until the completion.

				
					

fun main() {
   val startTime = System.currentTimeMillis()


   runBlocking {
       launch {
           delay(2000L)
           printlnWithTime("World 1!", startTime)
       }
       launch {
           delay(500L)
           printlnWithTime("World 2!", startTime)
       }
       launch {
           delay(1000L)
           printlnWithTime("World 3!", startTime)
       }
       printlnWithTime("Hello",startTime)
   }
}


//Output
[0.022] Hello
[0.539] World 2!
[1.032] World 3!
[2.036] World 1!

				
			

It blocks the underlying thread it has been started on whenever a coroutine inside it is suspended. As a result, Hello is getting printed at the end. 

				
					fun main() {
   val startTime = System.currentTimeMillis()


   runBlocking {
       delay(1000L)
       printlnWithTime("World 1", startTime)
   }
   runBlocking {
       delay(1000L)
       printlnWithTime("World 2", startTime)
   }
   runBlocking {
       delay(1000L)
       printlnWithTime("World 3", startTime)
   }
   printlnWithTime("Hello", startTime)
}


//Output
[1.030] World 1
[2.046] World 2
[3.053] World 3
[3.054] Hello

				
			

In the next part, different scope functions and structured concurrency will be discussed.

Summary

So, here are the differences among the different builders as a summary

Aspect

launch

async

runBlocking

Purpose

Fire-and-forget, performs side-effects.

Computes a value and returns a result.

Blocks the thread and runs coroutine code.

Return Type

Job 

Deferred<T> (for returning result T)

Directly returns the result of the block.

Blocking Nature

Non-blocking (returns immediately)

Non-blocking (returns immediately)

Blocking (blocks the current thread)

Concurrency Type

Fire-and-forget, runs concurrently

Runs concurrently, designed for parallelism

Sequential, blocking execution.

Result Retrieval

No result, used for tasks with no return value

Call await() on Deferred<T> to get result

Returns the result from the last statement.

Cancellation

Cancelable via Job.cancel()

Cancelable via Deferred.cancel()

Supports cancellation internally, but blocks until done

Exception Handling

Exceptions propagate to parent, unless supervisorScope is used

Exceptions propagate when await() is called

Exceptions thrown directly in the calling thread

Common Usage

Fire-and-forget tasks like UI updates or logging

Tasks where a result is needed (e.g., network requests, calculations)

Testing, top-level main() function

 

Rakibul Huda

25 February, 2025