Kotlin Coroutine Mechanisms part 2: launch v. async

Examining join, await, coroutine behavior through playful examples

Amanda Hinchman
Google Developer Experts

--

Art by lavnatalia

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:

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:

Run example in Androrid Studio or IntelliJ

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.

Run example in Androrid Studio or IntelliJ

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 a Deferred instance. Deferred is a specialized Job, with a few extra methods like await. It’s a Job with a return value. Very similarly to [Java] Futures and Promises, you invoke the await method on the Deferred 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.

I’ve always enjoyed using the red squigglies to give me more hints!

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:

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

--

--

Amanda Hinchman
Google Developer Experts

Kotlin GDE and Android engineer. Co-author of O'Reilly's "Programming Android with Kotlin: Achieving Structured Concurrency with Coroutines"