Kotlin Coroutine Mechanisms part 1: runBlocking v. launch
Introduction to coroutine behavior through playful examples
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!
Navigate the full series here:
- Kotlin Coroutine Mechanisms part 1: runBlocking v. launch
- Kotlin Coroutine Mechanisms part 2: launch v. async
- Kotlin Coroutine Mechanisms part 3: swapping CoroutineContext
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 task1
printing 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
completes and releases its current thread, and when we enter task2
which is runBlocking
which 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. task2
work 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?
Navigate the series here:
- Kotlin Coroutine Mechanisms part 1: runBlocking v. launch
- Kotlin Coroutine Mechanisms part 2: launch v. async
- Kotlin Coroutine Mechanisms part 3: swapping CoroutineContext
For more in-depth content, consider looking into Programming Android with Kotlin: Achieving Structured Concurrency with Coroutines — namely, Chapter 10: Structured Concurrency with Coroutines.