Kotlin Coroutine Mechanisms part 2: launch v. async
Examining join, await, 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] have always had sincere intentions writing 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
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
Note: if following along, it may be useful to append "current thread: ${Thread.currentThread().name}"
in your print statements, which are not included in the examples. Per the suggestion of Mark McClellan, an extra layer of fun can be had by also adding "time: ${LocalTime.now()}”
to your print statements to make a better how coroutines execute within the concept of chronological time.
println("runBlocking main | current thread: ${Thread.currentThread().name} | Time: ${LocalTime.now()}")
In the world of coroutines, debugging can lead to wonky behavior, but logging is your friend. JetBrains did come up with a useful tool for that but haven’t had a chance to try it out. If you have tried it, let me know in the comments how it is!
It’s helpful to note that compilation between the online Kotlin playground and an IDE, like IntelliJ or Android Studio, runs differently. It’s best to follow along with this article using an IDE, as the results of the examples discussed are used with IDE.
A closer look at join()
In the first article of this series, we explored how launching coroutines and child coroutines might execute lines differently within the context of runBlocking
and launch
. Namely, a parent job launched 2 child co-routines in a fire-and-forget fashion.
Programming Android with Kotlin: Achieving Structured Concurrency with Coroutines describes fire-and-forget as the following:
The launch coroutine builder is “fire and forget” work — in other words, there is no result to return. — Chapter 9: Coroutine concepts p. 120 (TODO double check page on OReilly)
When jobs do not need to wait for a coroutine to complete, it’s possible for sibling jobs to interrupt one another:
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
task1
starts a new child coroutine, but when the delay
hits, the coroutine suspends while task2
launches the 2nd child coroutine. Then, when task2
hits delay, task1
resumes to finish out. Last, task2
finishes out.
This particular example looks like it worked out surface-level, but in reality, it doesn’t take much for things to spin out somewhat unpredictability. Let’s add on to the code example mentioned in the previous article by launching an additional new child coroutine,task3
:
Upon running this program, notice how all 3 tasks start initially. task1
starts a new child coroutine: when the delay
hits, task1
waits while task2
launches the 2nd child coroutine. Then, when task2
hits delay, task3
launches a 3rd child coroutine. Then task1
completes since it has been waiting first. task1
completes, then next in line is task2
finishes out. After that task3
completes, and the parent job
finally completes itself.
runBlocking main | current thread: main @coroutine#1
Start job
job launched | current thread: DefaultDispatcher-worker-1 @coroutine#2
task1 | current thread: DefaultDispatcher-worker-2 @coroutine#3
task2 | current thread: DefaultDispatcher-worker-3 @coroutine#4
task3 | current thread: DefaultDispatcher-worker-4
task1 complete | current thread: DefaultDispatcher-worker-1
task2 complete | current thread: DefaultDispatcher-worker-2
task3 complete | current thread: DefaultDispatcher-worker-2
Program ends
Suppose you want task1
and task2
to finish first. We can call task1.join()
and task2.join()
before the start of task3
in order to indicate to the program that we must wait for task1
and task2
to complete before moving on.
On the IDE, we are given the following output:
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-3 @coroutine#4
task1 complete | current thread: DefaultDispatcher-worker-2 @coroutine#3
task2 complete | current thread: DefaultDispatcher-worker-2 @coroutine#4
task3 | current thread: DefaultDispatcher-worker-1 @coroutine#5
task3 complete | current thread: DefaultDispatcher-worker-1 @coroutine#5
Program ends | current thread: main @coroutine#1
By making a call prior to launching task3
to wait for the coroutines to complete, bothtask1
and task2
completes before starting task3
: note how task1
and task2
themselves both run in fire-and-forget fashion congruent to one another — and once both jobs finish, then task3
runs in fire-and-forget fashion.
Definitely take the time to experiment with all joins in different placements to see what it gets you: the best and easiest way to understand concurrency in coroutines is to keep playing with them!
launch v. async
When we use a launch
, we’re making use of a fire-and-forget coroutine, which returns the lifecycle of the coroutine launched itself, but not necessarily the result that might compute from the coroutine job.
We can use async
for such purposes. async
uses the await
call, which explicitly returns the result of the coroutine, as opposed to just waiting for the Job to return.
Programming Android with Kotlin: Achieving Structured Concurrency with Coroutines compares async coroutines to that of Java Future
and Promise
:
Once called, an
async
returns immediately aDeferred
instance.Deferred
is a specializedJob
, with a few extra methods like await. It’s aJob
with a return value. Very similarly to [Java] Futures and Promises, you invoke theawait
method on theDeferred
instance to get the returned value— Chapter 9: Coroutine concepts p. 122
Let’s change task2
from a launch
to an async
type of coroutine job. As it stands, the job doesn’t actually return anything since it only prints out some statements.
Let’s change task2
from type Deferred<Unit>
to Deferred<String>
so we can do something with it.
val task2: Deferred<String> = async {
println(" task2")
// simulate a background task
delay(1000)
" task2 complete"
}
Remove task2.join()
, and for funsies, we’ll add the following statements below task3
(but still within the parent job
— we can’t await
for a Deferred
type outside of the parent coroutine!):
println("task2 status: $task2")
println(task2.await())
println("task2 status: $task2")
println("task3 status: $task3")
The code snippet now looks like this:
Choosing to print out the task itself is kind of neat, as it gives you the type of coroutine you’re working with and its status:
runBlocking main | current thread: main @coroutine#1}
Start job
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 | current thread: DefaultDispatcher-worker-2 @coroutine#3
task3 | current thread: DefaultDispatcher-worker-2 @coroutine#5
task2 status: "coroutine#4":DeferredCoroutine{Active}@170918d9
task2 complete | current thread: DefaultDispatcher-worker-1 @coroutine#4
task2 status: "coroutine#4":DeferredCoroutine{Completed}@170918d9
task3 status: "coroutine#5":StandaloneCoroutine{Active}@3e971179
task3 complete | current thread: DefaultDispatcher-worker-1 @coroutine#5
Program ends
First, the parent job launches. Then, task1
starts a new coroutine. When the callstack hits delay
, task2
starts. Then when task2
hits delay
, and because task1.join()
signals to wait for the completion of the first child task task1
completes first. Because we have denoted that task2
is to await
for completion later on, the program moves on to launching a coroutine task3
. When task3
hits delay
, we hit the chunk of println
statements next.
The first print statement denotes task2
as a DifferedCoroutine
and currently in Active
state. Then, when we print task2.await()
, we finally complete the coroutine work for task2
. Checking the status of task2
in the next println
statement shows that the coroutine is finally in Completed
state. The final print statement shows the status of task3
as a StandaloneCoroutine
which is currently in Active
state. Finally, task3
resumes and task3
prints out its final statement of work. All child tasks completes, so the parent job
can also complete. Finally, the program ends.
That’s it for now. In the next part of this series, we discuss how to control where the result of an async coroutine goes so that it can be used for working with Android programming i.e. making sure the result of task2
is returned within a certain context so that changes can be made applicable to the UI thread. This can be controlled using coroutine context. Until next time!
Want more content like this?
This article does not cover the scope of the cancellation of launch
and async
coroutines — but if you’re curious to find more on this cancellation, Florina Mutenescu writes an excellent counterpart to this article on how launch/async calls defer, especially in behaviors around cancellation.
Cancellation is a whole can of worms to open in conversation — and for this reason, we will continue establishing other foundational subtopics with coroutines before we feel ready to start looking coroutine lifecycles.
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 related to this material, consider looking into Programming Android with Kotlin: Achieving Structured Concurrency with Coroutines — namely, Chapter 10: Structured Concurrency with Coroutines.