Is `runBlocking` an expensive operation? The docs ...
# coroutines
s
Is
runBlocking
an expensive operation? The docs seem to suggest that it’s a good idea to use it once in the main-method and than stay in suspend-country for the rest of the codebase. But how about sprinkling in a bit of concurrent computation here and there with snippets like this:
Copy code
val (a, b) = runBlocking(Dispatchers.Default) {
        val aDeferred = async {getA()}
        val aDeferred = async {getB()}
        return@runBlocking aDeferred.await() to bDeferred.await()
}
Doable or bad idea?
o
the overhead is pretty small, but it does block the thread
it would probably be more efficient to put as much of the function inside the runBlocking as you can
s
sounds like “doable” then 🙂
g
I would also removed default dispatcher, it’s not really necessary there
k
It makes getA and getB run parallel though?
g
depends on how a and b are implemented
apparently, if those a and b are proper suspend functions they will run in parallel
if they are blocking functions, one can run them in IO thread to make them parallel
async(IO) { getA() }
u
apparently, if those a and b are proper suspend functions they will run in parallel
I don't think
getA()
or
getB()
being suspend function affects them executing in parallel in this case. As far as I understand it, it will depend on the dispatcher.
g
if function is proper suspend function, so it doesn’t block the thread, they will run in parallel, with any dispatcher (except some non-existing, which limits amount of coroutines in paralell)
o
actually, if you were to drop the
Dispatchers.Default
, they would run concurrently but not in parallel, because there is only one thread for
runBlocking
g
not necessary, it depends on getA() and getB implementation
just imagine you replace getA and getB with delay, they will run perfectly in parallel
also with any other, even blocking function, if it wrapped to own dispatcher it will run in parallel, which must be the case for all properly designed suspend functions
so Dispatchers.Default will not affect behaviour, until functions are blocking
o
I think you have a misunderstanding of the term 'parallel' -- it means that code is executing at the same time, which
delay
does not represent, no code is executed while the delay is happening (i.e., no CPU usage). using
delay
represents a concurrent process. take for example Javascript, nothing is ever done in parallel with javascript, it is all one single-threaded event loop. that is what I mean by
runBlocking
(with no specified dispatcher) being concurrent but not parallel
g
I know what is parallel
okay, maybe delay is not perfect example, just because nothing is actually happening in parallel, they just run concurrently. Now see:
Copy code
suspend fun getA() = IO { File("A").readBytes() }
suspend fun getB() = IO { File("B").readBytes() }
o
right, but that introduces a new dispatcher, I am specifically talking if you didn't add the I/O dispatcher
g
With any dispatcher in runBlocking they will run in parallel
This is why I said that it depends on getA and getB implementation, isn’t it?
o
that is true, but I want to be clear that it requires them to use a different dispatcher
g
or if they are asyncronous, right?
You trying to say that parallel execution impossible in the same dispatcher with the same thread, but it’s not exactly true
for example you have such things as animations or anything else backed by event queue
of course you may argue it still not parallel, but it does works in parallel, even tho under the hood it dispatched using an even queue
so on practice, from point of view of consumer of this API it run in parallel, exactly what was original intent of this pattern
it’s also true for delay, even tho, I agree, it’s probably to simplistic example, yeah, “Code is not executing”, as you said, but task is executing
o
this code demonstrates the issue:
Copy code
fun main() {
    runBlocking {
        val a = async {
            println("Computing [A]...")
            // be blocking
            Thread.sleep(1000)
            println("Finish [A]!")
            100
        }
        val b = async {
            println("Computing [B]...")
            // be blocking
            Thread.sleep(1000)
            println("Finish [B]!")
            200
        }
        println("Technically concurrent! I can execute with those queued up.")
        println(a.await() + b.await())
    }
}
both computations are not done in parallel, though they are both done concurrently, i.e. it does not stop you from setting up the code to execute later obviously, just using
Dispatchers.Default
here is enough to alleviate the problem
g
I mentioned this problem in my first message, it will run in parallel only if functions are not blocking
o
right, but all code at some level is blocking, it's just for how long
g
Dispatchers.Default will solve it, I agree, but I would say that it’s bad pattern
especially if getA and getB are suspend
right, but all code at some level is blocking, it’s just for how long
Are we talking about absolutes or about practical usage?
it reminds my arguing that non-blocking IO is actually blocking on level of sockets in operation system
Returning to our discussion, I will agree with you, passing dispatcher to runBlocking may be useful if you call a lot of blocking function in parallel inside
But on abstract example, I would say that it’s not the cleanest solution, for suspend functions it shouldn’t be necessary, for blocking functions I would rather wrap them explicitly to new
runInterruptible(IO)
, so usually it’s better to extract them to own suspend function. So it makes usage of dispatcher for runBlocking unnecessary
o
I am kinda talking about practical usage here -- even some operations on non-IO become sufficiently blocking that their performance is measurable. I worked out this example of sorting large lists: https://gist.github.com/octylFractal/5443dc85fbf3673553f30f68fa888f21
results on my 12 core machine are noticeable:
Copy code
872ms for event loop
346ms for Default dispatcher
basically, I just want people to be aware of the fact that the event loop does not run anything in parallel -- if you delegate to another dispatcher, then it can await the results from said dispatcher concurrently but the event loop itself is not parallel-capable
g
But this operation is blocking
you do big chunk of computation
o
yes, that's what I'm getting at
g
so you should wrap this chunk to Dispatcher.Default
o
right, but not everyone does that
g
this chunk, not expect that some user of this API will do this for you
Okay, okay, I agree with you
o
I'm just saying be aware of how things are implemented
g
maybe introducing bad pattern to fix another bad code is fine
I would rather tried to avoid both
o
yes, certainly
👍 1
k
so you should wrap this chunk to Dispatcher.Default
I did not know that was best practice, switching dispatcher close to the expensive code itself.
o
I would think it is, if you're already on the dispatcher it's nearly free
g
if you do not wrap some heavy code to own dispatcher, it may cause issues for consumer
when invoke suspend function you expect that it will not block for significant amount of time
u
basically, I just want people to be aware of the fact that the event loop does not run anything in parallel -- if you delegate to another dispatcher, then it can await the results from said dispatcher concurrently but the event loop itself is not parallel-capable
This is the gist of it. I think it's also important to be aware of what your default dispatcher is especially in multi platform environment, because on JVM it's backed by a thread pool, so you will get parallel execution, but on JS (always single-thread) and Native (uses SingleThreadDispatcher at least in 1.3.5-native-mt-1.4M1) you will get concurrent but not parallel execution.
👍 1
r
Likewise if using
kotlinx-coroutines-android
you will find that
Dispatchers.Main
is backed by a
Handler
using
Looper.getMainLooper()
, which is another example of a single-threaded event loop. This same logic also applies to dispatchers based on a single-threaded Executor. The lack of parallel execution in these cases is one of the reasons you can use thread confinement to safely mutate shared state without need for other synchronization mechanisms.