Hello! Here's a puzzle: In our (Android) app we ha...
# coroutines
f
Hello! Here's a puzzle: In our (Android) app we have a suspending function that we need to execute synchronously (blocking) on the main thread. However, sometimes this function takes too long to complete, therefore we would block the main thread for too long. For this reason, in case it takes too long (let's say 300ms), we cancel it and continue without result. We achieve this using
withTimeoutOrNull
Copy code
val myResult = runBlocking {
	withTimeoutOrNull(300) { myFunction() }
}
What we experienced in the wild, though, is that we are still blocking the main thread for too long. The only reasons that comes to our mind is that it could be because
myFunction
takes some additional time to complete, even when cancelled. This could be caused by some blocking IO or any other non-prompt-when-cancelled code, and structured concurrency. This example shows what could be happening. The only solutions that come to our mind are: • use
runInterruptible
around the blocking code: unfortunately the suspending code we invoke in
myFunction
implementation is in a separate library that is out of our control. • opt-out of structured concurrency using this:
Copy code
val myResultDeferred = GlobalScope.async { myFunction() }
val myResult = try {
	runBlocking {
		withTimeoutOrNull(300) { myResultDeferred.await() }
	}
} finally {
    myResultDeferred.cancel()
}
Example of the solution. However, this is not a very neat solution and is quite error prone. Also we cannot run
myFunction
on the main thread otherwise we would get a deadlock. Q1: Do you see any other possible cause for which we would be blocking the thread for longer than 300ms, in practice? Q2: Do you see other possible solutions? Maybe less tricky? thank you color
r
As I just said there, interruption is a cooperative mechanism so there's a good chance runInterruptible will not help you if the code you're calling is more complex than Thread.sleep
Unfortunately if there's no way to cancel the blocking background work then what you're asking is the very definition of breaking structured concurrency
d
we have a suspending function that we need to execute synchronously (blocking) on the main thread
But the "opt-out" solution doesn't work like this. It will execute
myFunction
on
Dispatchers.Default
, not on the main thread.
Basically, what Robert Williams is saying: if your function must execute on the main thread, and also it must be cancelled on a timeout, then it must know to react to cancellation, there's no way around this. If it doesn't react to cancellation but reacts to interruptions, then
runInterruptible
is the way to go.
r
Good point, there's a subtle but important distinction between "must run on main thread" (because it's touching UI) and "main thread must block while it's running" (because we need a result synchronously)
f
Thanks. I think I understand the limitations. However, we are considering even unstructured solutions that can solve our problem. For example, a dirty solution could be something like this
Copy code
@OptIn(ExperimentalStdlibApi::class)
fun <R> runBlockingWithTimeoutUnstructuredOrNull(
    timeoutMillis: Long,
    block: suspend CoroutineScope.() -> R
): R? = runBlocking {
    val blockingDispatcher = coroutineContext[CoroutineDispatcher]!!
    val fallbackDispatcher = if (Thread.currentThread().isMainThread()) Dispatchers.Main else Dispatchers.Default
    val switchableDispatcher = SwitchableCoroutineDispatcher(blockingDispatcher)
    val unstructuredDeferred = GlobalScope.plus(switchableDispatcher).async(block = block)
    try {
        withTimeoutOrNull(timeoutMillis) { unstructuredDeferred.await() }
    } finally {
        // Before completing, make sure that we don't use the blocking dispatcher anymore
        switchableDispatcher.switchTo(fallbackDispatcher)
        unstructuredDeferred.cancel()
    }
}
Would this work (allowing us to run the code on the main thread, while still unstructurally skipping the awaiting of the completion) (knowing that is ugly and dangerous) Also, if we manage to not require the main thread, is the "opt-out" solution fine?
d
I think it would be best for you to play around with this code a bit, passing various
block
values,
println
-ing
Thread.currentThread()
, etc, and check if it actually works to get a more robust internal model. No amount of hackery will be able to help you in this case. If a thread is busy doing something, it's busy doing that, no matter which dispatcher is used, simple as that. If the main thread is busy doing some computation that doesn't know how to be interrupted, the thread can't stop doing that work, end of story.
f
I agree changing the model would be best. However I don't get the
If the main thread is busy doing some computation that doesn't know how to be interrupted
In this case (and probably our case), it may be that the main thread is not actually busy, but it is suspended until another busy coroutine (on another thread) resumes it, for example:
Copy code
runBlocking {
    withTimeoutOrNull(300) {
        withContext(<http://Dispatcher.IO|Dispatcher.IO>) {
            someBlockingIO() // Busy thread = IO
        }
    }
}
The only reason the main thread is busy here is because
runBlocking
is enforcing structured concurrency, awaiting the
withTimeoutOrNull
coroutine, which awaits
withContext
, which awaits a busy thread. There is no computation going on on the main thread. The hackery above seems to solve the problem indeed.
r
Just to be clear, we have: • suspend fun myFunction which has some parts which need to run on main (and should be fast) but then calls • fun someBlockingIo which is fine to run on a background thread but doesn't support cancellation/ interruption and can't be modified ?
Is it possible to wrap the main stuff in
withContext(Dispatchers.Main)
?
That's generally a good idea if it's required rather than assuming the suspend fun will always be called on Main
f
Whether my function must be executed on the main thread or not is not yet clear (we can assess that probably). However it's common in Android to write code that works on that assumption (non-parallelism-safe or accessing some main-thread API without explicitly wrapping with withContext(Dispatchers.Main). In this case, wrapping with Dispatchers.Main would cause a deadlock AFAIS. Also, in case we would do that, would you still use the originally proposed solution with GlobalScope? Or how would you solve the problem at that point?
r
Yeah, I was thinking if you used withContext(Dispatchers.Main) you'd then be safe to run the whole fun on the Default Dispatcher and GlobalScope which would be a lot simpler than switching dispatchers manually. Agree that it's quite common and safe to assume we're on the main thread for coroutines launched in viewModelScope, lifecycleScope etc. but this would not fit that definition.
I think you wouldn't deadlock because runBlocking makes its own event loop but obviously you'd need to test that yourself
The default [CoroutineDispatcher] for this builder is an internal implementation of event loop that processes continuations in this blocked thread until the completion of this coroutine.