Kotlin Coroutine mechanisms: runBlocking v. launch

Introduction to coroutine behavior through playful examples

mvndy
6 min readMar 28, 2024
a pleasant cartoon of a computer desk in a green office.
Sometimes you think you know coroutines and then after a while, you’re like “wait, do I really know coroutines?”

This series serves as spin off from Programming Android with Kotlin: Achieving Structured Concurrency with Coroutines intended to help strengthen everyday coroutine understanding through playful explorations. We [the authors] always had sincere intentions with the book:

While [coroutine] concepts are important if you want to master coroutines, you don’t have to understand everything right now to get started and be productive with coroutines. — Chapter 9: Coroutine concepts p. 127

You might be in beginning stages of learning Kotlin. Or maybe you’ve been using coroutines for a while and want to brush up, maybe you’ve been looking for a sleepy commute read. This series helps build that foundational knowledge through practical examples.

Please note that output might run differently between Kotlin playground and the IntelliJ IDEA! It may be useful to append "current thread: ${Thread.currentThread().name}" in your print statements if following along. For coroutines, logging is your friend, debugging can lead to wonky behavior. If you’re interested in debugging, JetBrains came up with a useful tool for that but haven’t tried it out. If you have used it, let me know in the comments how it is!

But First: an Impractical Example with runBlocking { … }

Suppose we launch a runBlocking coroutine as a main method. With that, we launch 2 child runBlocking subtasks. Each subtask prints task${num}and runs a 1 second delay to simulate a background task running.

runBlocking launches a child coroutine that blocks the current thread until the coroutine work has completed. In this case, the current thread is the main thread. runBlocking is used for main methods and testing. The output of the above program gives the following:

main runBlocking       | current thread: main @coroutine#1
task1 runBlocking | current thread: main @coroutine#2
task1 complete
task2 runBlocking | current thread: main @coroutine#3
task2 complete
Program ends | current thread: main @coroutine#1

In the logging above, all tasks print in order of the program written. The main runBlocking coroutine blocks the main thread until everything within is completed. task1 launches another child coroutine using runBlocking. Thetask1 child coroutine hijacks the main thread and blocks it until the contents. When all tasks are complete and the thread disposes and releases its hold. Then task2 calls another runBlocking, blocking the current until the contents within completes. Last, the program finishes and the main thread is released.

This example is rather impractical, as this code works as if it was written without coroutines. Now let’s make things more interesting and see what happens when we wrap task1 and task2 in a launched coroutine.

Wrapping runBlocking {…} tasks with launch { … }

Using the same code example above, we now wrap both runBlocking tasks within alaunch call. Programming Android with Kotlin: Achieving Structured Concurrency with Coroutines describes launch as the following:

Once [launch is] called, it immediately returns a Job instance, and starts a new coroutine. A Job represents the coroutine itself, like a handle on its lifecycle. The coroutine can be cancelled by calling the cancel method on its Job instance.

A coroutine started with launch will not return a result, but rather, a reference to the background job. — Chapter 9: Coroutine concepts p. 121

For the purpose of this exercise, we leave job.join() commented out.

We have our tasks running within a newly launched coroutine. However, when running the program, we get a strange output:

runBlocking main  | current thread: main @coroutine#1
Start job | current thread: main @coroutine#1
Program ends | current thread: main @coroutine#1
job launched | current thread: DefaultDispatcher-worker-1 @coroutine#2
task1 | current thread: DefaultDispatcher-worker-1 @coroutine#3

Depending on where you run your code, you might get the first task1printing in different places, but never further within. What happened here?

Like before, runBlocking launches a coroutine that uses the main thread. It does this through Dispatchers.Main context. The launched job starts running; however, we don’t call job.join() , so the program doesn’t bother to wait for completion. Next, task1 launches a new coroutine: after printing “task1”, delay(1000) is called and execution with the rest of the program outside of the coroutine resumes, running off with the rest of the show.

Making a call for join ensures that all coroutines associated with the task is complete before moving on. Now if we uncomment job.join() , we get the following output:

runBlocking main  | current thread: main @coroutine#1
Start job | current thread: main @coroutine#1
job launched | current thread: DefaultDispatcher-worker-1 @coroutine#2
task1 | current thread: DefaultDispatcher-worker-1 @coroutine#3
task1 complete
task2 | current thread: DefaultDispatcher-worker-1 @coroutine#4
task2 complete
Program ends | current thread: main @coroutine#1

The main runBlocking thread is running on the main thread of course. When a new coroutine is launched, a coroutine is also running on DefaultDispatcher-worker-1 . Once we enter task1 which is runBlocking, then task1 hijacks the context of the thread it is in, which is DefaultDispatcher-worker-1 — in effect, this suspends the main runBlocking coroutine.

task1 debugging point shows main runBlocking is SUSPENDED, job is RUNNING, task1 runBlocking is RUNNING

task1 completes and releases its current thread, and when we enter task2 which is runBlockingwhich also hijacks the context of the launched coroutine. Upon completion, task2 releases the context of the launched coroutine.

All events complete the job, and finally all that is left at the end of the program is the main runBlocking coroutine.

Changing task1 from runBlocking {…} to launch { … }

Let’s now have task1 make use launch instead of runBlocking. task2 still uses runBlocking.

Running the program gives an interesting effect: task2 completes before task1


runBlocking main | current thread: main @coroutine#1
job launched | current thread: DefaultDispatcher-worker-1 @coroutine#2
Start job | current thread: main @coroutine#1
task1 | current thread: DefaultDispatcher-worker-2 @coroutine#3
task2 | current thread: DefaultDispatcher-worker-1 @coroutine#4
task2 complete
task1 complete
Program ends | current thread: main @coroutine#1

When we start the job and enter the newly launched task1, we have created a child coroutine DefaultDispatcher-worker-2 within the parent. When we run a delay , the child coroutine waits while the callstack jumps to task2 = runBlocking. runBlocking hijacks the parent coroutine, leaving the parent coroutine in SUSPENDED state. task2work completes and disposes itself, leaving the parent coroutine free to continue running and resumes work in task1.

Changing task2 from runBlocking {…} to launch { … }

Lastly, we run an experiment where all tasks within the launched coroutine also launches

launch returns a reference to Job, which references the lifecycle of the coroutine.

In the program, job holds two child launch coroutines. task1 first launches — on delay , task1 coroutine waits while the callstack jumps to task2. Another child coroutine launches. When the callstack hits delay in task2 , the callstack jumps back to task1 execution while task2 coroutine is SUSPENDED. When task1 completes its coroutine is disposed, and task2 resumes. task2 completes and disposes itself.

With no more running tasks job completes and finally disposes itself. Main thread releases and the program finishes. The result nearly looks the same as the last example, only task1 completes before task2

runBlocking main   | current thread: main @coroutine#1
Start job | current thread: main @coroutine#1
job launched | current thread: DefaultDispatcher-worker-1 @coroutine#2
task1 | current thread: DefaultDispatcher-worker-2 @coroutine#3
task2 | current thread: DefaultDispatcher-worker-1 @coroutine#4
task1 complete
task2 complete
Program ends | current thread: main @coroutine#1

Thanks for reading! In the next blurb, we’ll build on to these examples with async tasks and how join might defer from await. See you next time.

Want more content like this?

You can navigate for more coroutine tidbits here:

For more in-depth content, consider looking into Programming Android with Kotlin: Achieving Structured Concurrency with Coroutinesnamely, Chapter 10: Structured Concurrency with Coroutines.

--

--

mvndy

software engineer and crocheting enthusiast. co-author of "Programming Android with Kotlin"