Gyuhyeon Lee
07/31/2024, 5:45 AMrunBlocking
inside coroutines like async
or launch
?
To elaborate...
When using async
, the async block in our code often calls methods with runBlocking
in them (not intentionally, but the methods often have runBlocking to bridge between netty WebClient and synchronous code, because these methods are usually called in "normal" code and not from coroutines).
Reading the docs on runBlocking
, I found the warning that says "This function should not be used from a coroutine.", which sent me down the rabbit hole.
Further explanation in threadGyuhyeon Lee
07/31/2024, 7:43 AMval start = LocalDateTime.now()
runBlocking {
for (i in 1..128) {
async(<http://Dispatchers.IO|Dispatchers.IO>) {
<http://logger.info|logger.info>("$i async - ${currentCoroutineContext()}")
delay(1000)
}
}
}
<http://logger.info|logger.info>("finished, took ${LocalDateTime.now().toUnixMillisecond() - start.toUnixMillisecond()} ms")
-> this code takes 1 second to complete, as expected.
[Let's confirm that runBlocking inside async blocks the thread]
runBlocking {
for (i in 1..128) {
async(<http://Dispatchers.IO|Dispatchers.IO>) {
<http://logger.info|logger.info>("$i async - ${currentCoroutineContext()}")
runBlocking {
<http://logger.info|logger.info>("$i start - ${currentCoroutineContext()}")
delay(1000)
<http://logger.info|logger.info>("$i end - ${currentCoroutineContext()}")
}
}
}
}
-> this code takes 2 seconds to complete, because runBlocking probably blocked the thread of the parent async, and since <http://Dispatchers.IO|Dispatchers.IO>
has 64 threads, it takes 2 seconds completing each batch of 64 async blocks every second. Understandable.
[WTF?]
runBlocking {
for (i in 1..128) {
async {
<http://logger.info|logger.info>("$i async - ${currentCoroutineContext()}")
runBlocking {
<http://logger.info|logger.info>("$i start - ${currentCoroutineContext()}")
delay(1000)
<http://logger.info|logger.info>("$i end - ${currentCoroutineContext()}")
}
}
}
}
-> we can expect this code to take 128 seconds now, since async only has 1 thread (of its parent runBlocking) available. But noooo it takes only 1 second. ???
[Further questionable mysteries]
runBlocking {
repeat(5) {
launch {
<http://logger.info|logger.info>("$it start")
runBlocking {
delay(1000L * it)
}
<http://logger.info|logger.info>("$it end")
}
}
}
one might expect this code to print like below
0 start
...
4 start
0 end
...
4 end
but it's actually like so:
19:48:16.805 [http-nio-11159-exec-1 @coroutine#2] INFO - 0 start
19:48:16.806 [http-nio-11159-exec-1 @coroutine#3] INFO - 1 start
19:48:16.806 [http-nio-11159-exec-1 @coroutine#4] INFO - 2 start
19:48:16.806 [http-nio-11159-exec-1 @coroutine#5] INFO - 3 start
19:48:16.806 [http-nio-11159-exec-1 @coroutine#6] INFO - 4 start
19:48:20.814 [http-nio-11159-exec-1 @coroutine#6] INFO - 4 end
19:48:20.815 [http-nio-11159-exec-1 @coroutine#5] INFO - 3 end
19:48:20.815 [http-nio-11159-exec-1 @coroutine#4] INFO - 2 end
19:48:20.816 [http-nio-11159-exec-1 @coroutine#3] INFO - 1 end
19:48:20.816 [http-nio-11159-exec-1 @coroutine#2] INFO - 0 end
which means that the 5 iterations of runBlocking
were started normally, but their completion is blocked in a Last-In-First-Out manner. This causes unexpected delay when there's additional code after the runBlocking completes. (for example in the example above, 0 start -> 0 end was expected to have 0 second delay, but instead was delayed by 4 seconds.)
I'd like to ask the following:
• what exactly is the issue with runBlocking inside coroutines? Should it never be used in coroutines under any circumstances?
• why does it sometimes seem to block a thread but sometimes not?
• why does the runBlocking in the last example wait to complete until other runBlockings that were called in other coroutines later complete?Sam
07/31/2024, 7:45 AMrunBlocking
is doubly bad, because we're specifically blocking the thread to wait for other coroutines. Inside those coroutines, there might be code that ends up being dispatched to the very same thread that we've just blocked. That creates a deadlock, where the thread is blocked waiting for itself. This situation is much easier to get into than you'd think. I wrote more about it here.
> • why does it sometimes seem to block a thread but sometimes not?
The situation is a bit different when you call runBlocking
inside another call to runBlocking
. It uses a thread-local event loop, so if the thread is already running coroutines via runBlocking
, nested calls to runBlocking
will cooperate with the existing event loop instead of taking over the whole thread. This stops working when you introduce other threads and dispatchers into the mix, like the <http://Dispatchers.IO|Dispatchers.IO>
in your example.
> • why does the runBlocking in the last example wait to complete until other runBlockings that were called in other coroutines later complete?
Think of all these runBlocking
calls as being nested inside one another—not inside the same coroutine, but inside the call stack of the single thread that's actually doing all the work. When task 0 yields at the delay
, task 1 starts running, and makes its own runBlocking
call. This is an actual stack frame, not a suspension point, and the call stack still needs to unwind in LIFO order. Relying on the dispatch order of coroutines is a bad idea in general, so I wouldn't pay too much attention to this implementation detail.Anselmo Alexandre
07/31/2024, 10:45 AMGyuhyeon Lee
08/01/2024, 2:10 AM