Coroutine Gotchas - Bridging the Gap between Coroutine and Non-Coroutine Worlds
Coroutines are a wonderful way of writing asynchronous, non-blocking code in Kotlin. Think of them as lightweight threads, because that’s exactly what they are. Lightweight threads aim to reduce context switching, a relatively expensive operation. Moreover, you can easily suspend and cancel them anytime. Sounds great, right?
After realizing all the benefits of coroutines, you decided to give it a try. You wrote your first coroutine and called it from a non-suspendible, regular function... only to find out that your code does not compile! You are now searching for a way to call your coroutine, but there are no clear explanations about how to do that. It seems like you are not alone in this quest: This developer got so frustrated that he’s given up on Kotlin altogether!
Does this sound familiar to you? Or are you still looking for the best ways to link coroutines to your non-coroutine code? If so, then this blog post is for you. In this article, we will share the most fundamental coroutine gotcha that all of us stumbled upon during our coroutines journey: How to call coroutines from regular, blocking code?
We’ll show three different ways of bridging the gap between the coroutine and non-coroutine world:
- GlobalScope (better not)
- runBlocking (be careful)
- Suspend all the way (go ahead)
Before we dive into these methods, we'll introduce you to some concepts that will help you understand the different ways.
Suspending, blocking and non-blocking
Coroutines run on threads and threads run on a CPU . To better understand our examples, it's helpful to visualize which coroutine runs on which thread and which CPU that thread runs on. So, we'll share our mental picture with you in the hopes that it will also help you understand the examples better.
As we mentioned before, a thread runs on a CPU. Let's start by visualizing that relationship. In the following picture, we can see that thread 2 runs on CPU 2, while thread 1 is idle (and so is the first CPU):
Put simply, a coroutine can be in three states, it can either be:
1. Doing some work on a CPU (i.e., executing some code)
2. Waiting for a thread or CPU to do some work on
3. Waiting for some IO operation (e.g., a network call)
These three states are depicted below:
Recall that a coroutine runs on a thread. One important thing to note is that we can have more threads than CPUs and more coroutines than threads. This is completely normal because switching between coroutines is more lightweight than switching between threads. So, let's consider a scenario where we have two CPUs, four threads, and six coroutines. In this case, the following picture shows the possible scenarios that are relevant to this blog post.
Firstly, coroutines 1 and 5 are waiting to get some work done. Coroutine 1 is waiting because it does not have a thread to run on while thread 5 does have a thread but is waiting for a CPU. Secondly, coroutines 3 and 4 are working, as they are running on a thread that’s burning CPU cycles. Lastly, coroutines 2 and 6 are waiting for some IO operation to finish. However, unlike coroutine 2, coroutine 6 is occupying a thread while waiting.
With this information we can finally explain the last two concepts you need to know about: 1) coroutine suspension and 2) blocking versus non-blocking (or asynchronous) IO.
Suspending a coroutine means that the coroutine gives up its thread, allowing another coroutine to use it. For example, coroutine 4 could hand back its thread so that another coroutine, like coroutine 5, can use it. The coroutine scheduler ultimately decides which coroutine can go next.
We say an IO operation is blocking when a coroutine sits on its thread, waiting for the operation to finish. This is precisely what coroutine 6 is doing. Coroutine 6 didn't suspend, and no other coroutine can use its thread because it's blocking.
In this blog post, we’ll use the following simple function that uses sleep to imitate both a blocking and a CPU intensive task. This works because sleep has the peculiar feature of blocking the thread it runs on, keeping the underlying thread busy.
private fun blockingTask(task: String, duration: Long) {
println("Started $tasktask on ${Thread.currentThread().name}")
sleep(duration)
println("Ended $tasktask on ${Thread.currentThread().name}")
}
Coroutine 2, however, is more courteous – it suspended and lets another coroutine use its thread while its waiting for the IO operation to finish. It’s performing asynchronous IO.
In what follows, we’ll use a function asyncTask to simulate a non-blocking task. It looks very similar to our blockingTask, but the only difference is that instead of sleep we use delay. As opposed to sleep, delay is a suspending function – it will hand back its thread while waiting.
private suspend fun asyncTask(task: String, duration: Long) {
println("Started $task call on ${Thread.currentThread().name}")
delay(duration)
println("Ended $task call on ${Thread.currentThread().name}")
}
Now we have explained all the concepts in place, it’s time to look at three different ways to call your coroutines.
Option 1: GlobalScope (better not)
Suppose we have a suspendible function that needs to call our blockingTask three times. We can launch a coroutine for each call, and each coroutine can run on any available thread:
private suspend fun blockingWork() {
coroutineScope {
launch {
blockingTask("heavy", 1000)
}
launch {
blockingTask("medium", 500)
}
launch {
blockingTask("light", 100)
}
}
}
Think about this program for a while: How much time do you expect it will need to finish given that we have enough CPUs to run three threads at the same time? And then there is the big question: How will you call blockingWork suspendible function from your regular, non-suspendible code?
One possible way is to call your coroutine in GlobalScope which is not bound to any job. However, using GlobalScope must be avoided as it is clearly documented as not safe to use (other than in limited use-cases). It can cause memory leaks, it is not bound to the principle of structured concurrency, and it is marked as @DelicateCoroutinesApi. But why? Well, run it like this and see what happens.
private fun runBlockingOnGlobalScope() {
GlobalScope.launch {
blockingWork()
}
}
fun main() {
val durationMillis = measureTimeMillis {
runBlockingOnGlobalScope()
}
println("Took: ${durationMillis}ms")
}
Output:
Took: 83ms
Wow, that was quick! But where did those print statements inside our blockingTask go? We only see how long it took to call the function blockingWork, which also seems to be too short – it should take at least a second to finish, don’t you agree? This is one of the obvious problems with GlobalScope; it’s fire and forget. This also means that when you cancel your main calling function all the coroutines that were triggered by it will continue running somewhere in the background. Say hello to memory leaks!
We could, of course, use job.join() to wait for the coroutine to finish. However, the join function can only be called from a coroutine context. Below, you can see an example of that. As you can see, the whole function is still a suspendible function. So, we’re back to square one.
private suspend fun runBlockingOnGlobalScope() {
val job = GlobalScope.launch {
blockingWork()
}
job.join() //can only be called within coroutine context
}
Another way to see the output would be to wait after calling GlobalScope.launch. Let’s wait for two seconds and see if we can get the correct output:
private fun runBlockingOnGlobalScope() {
GlobalScope.launch {
blockingWork()
}
sleep(2000)
}
fun main() {
val durationMillis = measureTimeMillis {
runBlockingOnGlobalScope()
}
println("Took: ${durationMillis}ms")
}
Output:
Started light task on DefaultDispatcher-worker-4
Started heavy task on DefaultDispatcher-worker-2
Started medium task on DefaultDispatcher-worker-3
Ended light task on DefaultDispatcher-worker-4
Ended medium task on DefaultDispatcher-worker-3
Ended heavy task on DefaultDispatcher-worker-2
Took: 2092ms
The output seems to be correct now, but we blocked our main function for two seconds to be sure the work is done. But what if the work takes longer than that? What if we don’t know how long the work will take? Not a very practical solution, do you agree?
Conclusion: Better not use GlobalScope to bridge the gap between your coroutine and non-coroutine code. It blocks the main thread and may cause memory leaks.
Option 2a: runBlocking for blocking work (be careful)
The second way to bridge the gap between the coroutine and non-coroutine world is to use the runBlocking coroutine builder. In fact, we see this being used all over the place. However, the documentation warns us about two things that can be easily overlooked, runBlocking:
- blocks the thread that it is called from
- should not be called from a coroutine
It’s explicit enough that we should be careful with this runBlocking thing. To be honest, when we read the documentation, we struggled to comprehend how to use runBlocking properly. If you feel the same, it may be helpful to review the following examples that illustrate how easy it is to unintentionally degrade your coroutine performance and even block your program completely.
Clogging your program with runBlocking
Let’s start with this example where we use runBlocking on the top-level of our program:
private fun runBlocking() {
runBlocking {
println("Started runBlocking on ${Thread.currentThread().name}")
blockingWork()
}
}
fun main() {
val durationMillis = measureTimeMillis {
runBlocking()
}
println("Took: ${durationMillis}ms")
}
Output:
Started runBlocking on main
Started heavy task on main
Ended heavy task on main
Started medium task on main
Ended medium task on main
Started light task on main
Ended light task on main
Took: 1807ms
As you can see, the whole program took 1800ms to complete. That’s longer than the second we expected it to take. This is because all our coroutines ran on the main thread and blocked the main thread for the whole time! In a picture, this situation would look like this:
If you only have one thread, only one coroutine can do its work on this thread and all the other coroutines will simply have to wait. So, all jobs wait for each other to finish, because they are all blocking calls waiting for this one thread to become free. See that CPU being unused there? Such a waste.
Unclogging runBlocking with a dispatcher
To offload the work to different threads, you need to make use of Dispatchers. You could call runBlocking with Dispatchers.Default to get the help of parallelism. This dispatcher uses a thread pool that has many threads as your machine’s number of CPU cores (with a minimum of two). We used Dispatchers.Default for the sake of the example, for blocking operations it is suggested to use Dispatchers.IO.
private fun runBlockingOnDispatchersDefault() {
runBlocking(Dispatchers.Default) {
println("Started runBlocking on ${Thread.currentThread().name}")
blockingWork()
}
}
fun main() {
val durationMillis = measureTimeMillis {
runBlockingOnDispatchersDefault()
}
println("Took: ${durationMillis}ms")
}
Output:
Started runBlocking on DefaultDispatcher-worker-1
Started heavy task on DefaultDispatcher-worker-2
Started medium task on DefaultDispatcher-worker-3
Started light task on DefaultDispatcher-worker-4
Ended light task on DefaultDispatcher-worker-4
Ended medium task on DefaultDispatcher-worker-3
Ended heavy task on DefaultDispatcher-worker-2
Took: 1151ms
You can see that our blocking calls are now dispatched to different threads and running in parallel. If we have three CPUs (our machine has), this situation will look as follows:
Recall that the tasks here are CPU intensive, meaning that they will keep the thread they run on busy. So, we managed to make a blocking operation in a coroutine and called that coroutine from our regular function. We used dispatchers to get the advantage of parallelism. All good.
But what about non-blocking, suspendible calls that we have mentioned in the beginning? What can we do about them? Read on to find out.
Option 2b: runBlocking for non-blocking work (be very careful)
Remember that we used sleep to mimic blocking tasks. In this section we use the suspending delay function to simulate non-blocking work. It does not block the thread it runs on and when it is idly waiting, it releases the thread. It can continue running on a different thread when it’s done waiting and ready to work. Below is a simple asynchronous call that is done by calling delay:
private suspend fun asyncTask(task: String, duration: Long) {
println(“Started $task call on ${Thread.currentThread().name}”)
delay(duration)
println(“Ended $task call on ${Thread.currentThread().name}”)
}
The output of the examples that follow may vary depending on how many underlying threads and CPUs are available for the coroutines to run on. To ensure this code behaves the same on each machine, we will create our own context with a dispatcher that has only two threads. This way we simulate running our code on two CPUs even if your machine has more than that:
private val context = Executors.newFixedThreadPool(2).asCoroutineDispatcher()
Let’s launch a couple of coroutines calling this task. We expect that every time the task waits, it releases the underlying thread, and another task can take the available thread to do some work. Therefore, even though the below example delays for a total of three seconds, we expect it to take only a bit longer than one second.
private suspend fun asyncWork() {
coroutineScope {
launch {
asyncTask("slow", 1000)
}
launch {
asyncTask("another slow", 1000)
}
launch {
asyncTask("yet another slow", 1000)
}
}
}
To call asyncWork from our non-coroutine code, we use asyncWork again, but this time we use the context that we created above to take advantage of multi-threading:
fun main() {
val durationMillis = measureTimeMillis {
runBlocking(context) {
asyncWork()
}
}
println("Took: ${durationMillis}ms")
}
Output:
Started slow call on pool-1-thread-2
Started another slow call on pool-1-thread-1
Started yet another slow call on pool-1-thread-1
Ended another slow call on pool-1-thread-1
Ended slow call on pool-1-thread-2
Ended yet another slow call on pool-1-thread-1
Took: 1132ms
Wow, finally a nice result! We have called our asyncTask from a non-coroutine code, made use of the threads economically by using a dispatcher and we blocked the main thread for the least amount of time. If we take a picture exactly at the time all three coroutines are waiting for the asynchronous call to complete, we see this:
Observe that both threads are now free for other coroutines to use, while our three async coroutines are waiting.
However, it should be noted that the thread calling the coroutine is still blocked. So, you need to be careful where to use it. It is good practice to call runBlocking only at the top-level of your application – from the main function or in your tests . What could happen if you would not do that? Read on to find out.
Turning non-blocking calls into blocking calls with runBlocking
Assume you have written some coroutines and you call them in your regular code by using runBlocking just like we did before. After a while your colleagues decided to add a new coroutine call somewhere in your code base. They invoked their asyncTask using runblocking and made an async call in a non-coroutine function notSoAsyncTask. Assume your existing asyncWork function needs to call this notSoAsyncTask:
private fun notSoAsyncTask(task: String, duration: Long) = runBlocking {
asyncTask(task, duration)
}
private suspend fun asyncWork() {
coroutineScope {
launch {
notSoAsyncTask("slow", 1000)
}
launch {
notSoAsyncTask("another slow", 1000)
}
launch {
notSoAsyncTask("yet another slow", 1000)
}
}
}
The main function still runs on the same context you created before. If we now call the asyncWork function, we will see different results than our first example:
fun main() {
val durationMillis = measureTimeMillis {
runBlocking(context) {
asyncWork()
}
}
println("Took: ${durationMillis}ms")
}
Output:
Started another slow call on pool-1-thread-1
Started slow call on pool-1-thread-2
Ended another slow call on pool-1-thread-1
Ended slow call on pool-1-thread-2
Started yet another slow call on pool-1-thread-1
Ended yet another slow call on pool-1-thread-1
Took: 2080ms
You might not even realize the problem immediately because instead of working for three seconds, the code works for two seconds, and this might even seem like a win at first glance. As you can see, our coroutines did not do so much of an async work, did not make use of their suspension points and just worked in parallel as much as they could. Since there are only two threads, one of our three coroutines waited for the initial two coroutines which were hanging on their threads doing nothing, as illustrated by this figure:
This is a significant issue because our code lost the suspension functionality by calling runBlocking in runBlocking.
If you experiment with the code we presented above, you will discover that you lose all the structural concurrency benefits of coroutines. Cancellations and exceptions from children coroutines will be omitted and won’t be handled correctly.
Blocking your application with runBlocking
Can we even do worse? We sure can! In fact, it is easy to break your whole application without realizing. Assume your colleague learned it is good practice to use a dispatcher and decided to use the same context you have created before. That doesn’t sound so bad, does it? But take a closer look:
private fun blockingAsyncTask(task: String, duration: Long) =
runBlocking(context) {
asyncTask(task, duration)
}
private suspend fun asyncWork() {
coroutineScope {
launch {
blockingAsyncTask("slow", 1000)
}
launch {
blockingAsyncTask("another slow", 1000)
}
launch {
blockingAsyncTask("yet another slow", 1000)
}
}
}
Performing the same operation as the previous example but using the context you have created before. Looks harmless enough, why not give it a try?
fun main() {
val durationMillis = measureTimeMillis {
runBlocking(context) {
asyncWork()
}
}
println("Took: ${durationMillis}ms")
}
Output:
Started slow call on pool-1-thread-1
Aha, gotcha! It seems like your colleagues created a deadlock without even realising. Now your main thread is blocked and waiting for any of the coroutines to finish, yet none of them can get a thread to work on.
Conclusion: Be careful when using runBlocking, if you use it wrongly it can block your whole application. If you still decide to use it, then be sure to call it from your main function (or in your tests) and always provide a dispatcher to run on.
Option 3: Suspend all the way (̶g̶o̶ ̶a̶h̶e̶a̶d̶ not just yet)
You are still here, so you didn’t turn your back on Kotlin coroutines yet? Good. We are here for the last and the best option that we think there is: suspending your code all the way up to your highest calling function. If that is your application’s main function, you can suspend your main function. Is your highest calling function an endpoint (for example in a Spring controller)? No problem, Spring integrates seamlessly with coroutines; just be sure to use Spring WebFlux to fully benefit from the non-blocking runtime provided by Netty and Reactor.
Below we are calling our suspendible asyncWork from a suspendible main function:
private suspend fun asyncWork() {
coroutineScope {
launch {
asyncTask("slow", 1000)
}
launch {
asyncTask("another slow", 1000)
}
launch {
asyncTask("yet another slow", 1000)
}
}
}
suspend fun main() {
val durationMillis = measureTimeMillis {
asyncWork()
}
println("Took: ${durationMillis}ms")
}
Output:
Started another slow call on DefaultDispatcher-worker-2
Started slow call on DefaultDispatcher-worker-1
Started yet another slow call on DefaultDispatcher-worker-3
Ended yet another slow call on DefaultDispatcher-worker-1
Ended another slow call on DefaultDispatcher-worker-3
Ended slow call on DefaultDispatcher-worker-2
Took: 1193ms
As you see, it works asynchronously, and it respects all the aspects of structural concurrency. That is to say, if you get an exception or cancellation from any of the parent’s child coroutines, they will be handled as expected.
Conclusion:̶ G̶o̶ ̶a̶h̶e̶a̶d̶ ̶a̶n̶d̶ ̶s̶u̶s̶p̶e̶n̶d̶ ̶a̶l̶l̶ ̶t̶h̶e̶ ̶f̶u̶n̶c̶t̶i̶o̶n̶s̶ ̶t̶h̶a̶t̶ ̶c̶a̶l̶l̶ ̶y̶o̶u̶r̶ ̶c̶o̶r̶o̶u̶t̶i̶n̶e̶ ̶a̶l̶l̶ ̶t̶h̶e̶ ̶w̶a̶y̶ ̶u̶p̶ ̶t̶o̶ ̶y̶o̶u̶r̶ ̶t̶o̶p̶-̶l̶e̶v̶e̶l̶ ̶f̶u̶n̶c̶t̶i̶o̶n̶.̶ ̶T̶h̶i̶s̶ ̶i̶s̶ ̶t̶h̶e̶ ̶b̶e̶s̶t̶ ̶o̶p̶t̶i̶o̶n̶ ̶f̶o̶r̶ ̶c̶a̶l̶l̶i̶n̶g̶ ̶c̶o̶r̶o̶u̶t̶i̶n̶e̶s̶.̶
Read on, there’s more to it!
The gotcha: suspend all the way, but not for main
Did we just told you to suspend all the way? Well, this is partly right. Using suspend on your Spring controller methods is ok. Even though we were quite clear about this in the previous section, you should be careful with suspending your main function, though. It’s not that we’ve been lying to you, this one also bit us. See how tricky this stuff is? Let’s make up for our mistake by explaining why suspending main can get you in trouble.
To illustrate why suspending your main could be a dangerous thing to do, take the following code example:
suspend fun main() {
println("Started task 1 on ${Thread.currentThread().name}")
withContext(Dispatchers.Default) {
println("Started task 2 on ${Thread.currentThread().name}")
}
println("Started task 3 on ${Thread.currentThread().name}")
}
Output:
Started task 1 on main
Started task 2 on DefaultDispatcher-worker-1
Started task 3 on DefaultDispatcher-worker-1
Did you expect that task 3 would start on the main thread? So, did we! What makes this case special is that suspending main, makes it run on an empty coroutine context. This empty coroutine context does not hold information on what dispatcher it uses. It has no idea about which thread it is running on, and that’s why it is not able to switch back to the main thread.
This is also different from suspending a Spring controller method. In that case, Spring will provide you with a non-empty coroutine context with a dispatcher. So, suspending Spring controller methods is just fine.
Now, in case you wonder how to make task 3 run on the main thread... remember our friend runBlocking?
fun main() = runBlocking {
println("Started task 1 on ${Thread.currentThread().name}")
withContext(Dispatchers.Default) {
println("Started task 2 on ${Thread.currentThread().name}")
}
println("Started task 3 on ${Thread.currentThread().name}")
}
Output:
Started task 1 on main
Started task 2 on DefaultDispatcher-worker-1
Started task 3 on main
Updated conclusion: Go ahead and suspend all the functions that call your coroutine all the way up to your top-level function. From there, you could either use runBlocking (be careful) or suspend your top-level function if your framework supports it. This is the best option for calling coroutines.
The safest way of bridging coroutines
We have explored the three flavours of bridging coroutines to the non-coroutine world, and we believe that suspending your calling function is the safest approach (unless you're dealing with main). However, if you prefer to avoid suspending the calling function, you can use runBlocking, but be aware that it requires more caution. With this knowledge, you now have a good understanding of how to call your coroutines safely. Stay tuned for more coroutine gotchas!