Stephen Edwards
04/24/2025, 8:56 PMyield()
on Main.immediate
will behave like Unconfined and empty the current event loop or continue undispatched if its empty.
Discovered result: yield()
for Main.immediate
on Android always ends up getting a main thread message posted.
Brief investigation: HandlerContext has no dispatchYield()
implementation, which means that it will default to just dispatch
(here). Which means that dispatcherWasUnconfined
will not get set and we will never yieldUndispatched()
for yield()
(here).
public suspend fun yield(): Unit = suspendCoroutineUninterceptedOrReturn sc@ { uCont ->
val context = uCont.context
context.ensureActive()
val cont = uCont.intercepted() as? DispatchedContinuation<Unit> ?: return@sc Unit
if (cont.dispatcher.safeIsDispatchNeeded(context)) {
// this is a regular dispatcher -- do simple dispatchYield
cont.dispatchYield(context, Unit)
} else {
// This is either an "immediate" dispatcher or the Unconfined dispatcher
// This code detects the Unconfined dispatcher even if it was wrapped into another dispatcher
val yieldContext = YieldContext()
cont.dispatchYield(context + yieldContext, Unit)
// Special case for the unconfined dispatcher that can yield only in existing unconfined loop
if (yieldContext.dispatcherWasUnconfined) {
// Means that the Unconfined dispatcher got the call, but did not do anything.
// See also code of "Unconfined.dispatch" function.
return@sc if (cont.yieldUndispatched()) COROUTINE_SUSPENDED else Unit
}
// Otherwise, it was some other dispatcher that successfully dispatched the coroutine
}
COROUTINE_SUSPENDED
}
Initial question: Why is that? Is it a bug? Should not yield()
have the same behavior for Main.immediate
and any other dispatcher using an event-loop?Zach Klippenstein (he/him) [MOD]
04/25/2025, 12:02 AMDmitry Khalanskiy [JB]
04/25/2025, 6:45 AMyield
doesn't describe the exact behavior, so this is just unspecified: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/yield.html
This means that we can potentially change this behavior if we think it's more reasonable that way.
I think that yield
properly scheduling a task is the more useful behavior. Imagine that you observe liveness issues due to a long-running operation; then, you stick a yield()
in the loop, and now the other tasks scheduled on the main thread also get a chance to run. If we go for consistency with Unconfined
instead, then a yield()
should do nothing at all if there is just one coroutine running on Dispatchers.Main.immediate
.
Maybe a hybrid solution could work, where a yield
prioritizes the event loop tasks, but reschedules the task if there are none left. Then, the question becomes: why even use yield
in an event loop? Is this an instance of the "wait for something to happen" pitfall mentioned in the documentation? This usage of yield
is not something we want to encourage.Zach Klippenstein (he/him) [MOD]
04/25/2025, 11:23 AMDelay
which is internal).Dmitry Khalanskiy [JB]
04/25/2025, 11:47 AMrunBlocking
solve the problem? That's what it was made to do, after all, block the thread.Stephen Edwards
04/25/2025, 1:38 PMMaybe a hybrid solution could work, where aprioritizes the event loop tasks, but reschedules the task if there are none left.yield
Stephen Edwards
04/25/2025, 1:38 PMStephen Edwards
04/25/2025, 1:39 PMThen, the question becomes: why even useThis is not waiting for something to happen - this is allowing liveness within the immediate event loop first before allowign it within the Main thread's event loopin an event loop?yield
Stephen Edwards
04/25/2025, 1:40 PMStephen Edwards
04/25/2025, 1:41 PMStephen Edwards
04/25/2025, 1:41 PMStephen Edwards
04/25/2025, 1:41 PMyield()
Stephen Edwards
04/25/2025, 1:42 PMIt sounds like, semantically, you want to appropriate a thread inside a coroutine without allowing other coroutines to use it.I mean
Main.immediate
allows you to do this already, you just can't ever yield()
, so you can't provide liveness guarantees / modify ordering within that immediate event loopStephen Edwards
04/25/2025, 1:44 PMimmediate
but we need to give fairness/liveness to other continuations suspended on immediate
- what do you think is the best way to do that?Dmitry Khalanskiy [JB]
04/25/2025, 1:45 PMmodify orderingThis is not a supported use case for
yield
. In kotlinx.coroutines
, unless it's documented, we do not guarantee the exact number of suspensions, so the ordering may change arbitrarily across releases.
liveness guaranteesThe whole event loop is one big task, and as far as I understand the typical UI architectures, a screen refresh can't happen while that big task executes. I don't understand what kind of liveness you are talking about: liveness is typically relevant for multithreading.
we need to give fairness/liveness to other continuations suspendedCould you provide an example of when that would be needed?
Stephen Edwards
04/25/2025, 1:46 PMStephen Edwards
04/25/2025, 1:46 PMa screen refresh can't happen while that big task executesCorrect on Android (this case), but we use that fact
Stephen Edwards
04/25/2025, 1:47 PMMain.immediate
and each of their collectors sends to a channel.
There is a separate loop consuming off that channel and it wants to empty it before continuing on.
Without yield()
it will remove one from the channel, then continuing on processing it etc. etc. But i want to drain the channel in that loop before continuing on. To do so I need to let the other 3 flow collectors resume in order to send to the channelStephen Edwards
04/25/2025, 1:50 PMStephen Edwards
04/25/2025, 1:51 PMStephen Edwards
04/25/2025, 1:52 PMselect
that resumes from one of them)Dmitry Khalanskiy [JB]
04/25/2025, 1:52 PMBut i want to drain the channel in that loop before continuing on.Why is it a problem if they only get to send their elements after you process the first element?
Stephen Edwards
04/25/2025, 1:54 PMStephen Edwards
04/25/2025, 1:55 PMStephen Edwards
04/25/2025, 1:55 PMStephen Edwards
04/25/2025, 1:55 PMselect
with a final onTimeout(0)
clause so we only process what is immediately availableStephen Edwards
04/25/2025, 1:56 PMStephen Edwards
04/25/2025, 1:56 PMDmitry Khalanskiy [JB]
04/25/2025, 1:56 PMyield
if the collectors suspend at some point. This is quite brittle.Stephen Edwards
04/25/2025, 1:57 PMStephen Edwards
04/25/2025, 1:57 PMThe same will happen even with theWhat do you mean?if the collectors suspend at some point. This is quite brittle.yield
Dmitry Khalanskiy [JB]
04/25/2025, 1:58 PMyield
, the collector may still not get a chance to put its element into the channel, because by suspending, it will miss its chance to run.Stephen Edwards
04/25/2025, 1:59 PMDmitry Khalanskiy [JB]
04/25/2025, 2:01 PMStephen Edwards
04/25/2025, 2:01 PMStephen Edwards
04/25/2025, 2:02 PMMain.immediate
would not drain the inner event loop before dispatching a new task as my mental model was that it would behave like the Unconfined event loop (given the docs say they share this implementation). TBH I tried lookign through the source for that but it did not really look like they shared the event loop implementation?Stephen Edwards
04/25/2025, 2:03 PMDmitry Khalanskiy [JB]
04/25/2025, 2:03 PMfalse
from isDispatchNeeded
.Dmitry Khalanskiy [JB]
04/25/2025, 2:03 PMStephen Edwards
04/25/2025, 2:03 PMfalse
is returned here for isDispatchNeeded
Dmitry Khalanskiy [JB]
04/25/2025, 2:04 PMStephen Edwards
04/25/2025, 2:04 PMMain.immediate
returns false here for isDispatchNeeded
so it should have the same behavior (or at least I thought) but it does not implement dispatchYield()
like Unconfined doesStephen Edwards
04/25/2025, 2:05 PMIs it always the case that all 4 collectors put something into the channel?No, its not always the case. that's why we
onTimeout(0)
in the select. if there is nothing that needs to be processed we continue on immediatelyStephen Edwards
04/25/2025, 2:07 PMMain.immediate
needs to make a choice.
Imagine that you observe liveness issues due to a long-running operation; then, you stick aWrt to this, if I was seeing a liveness issue for other main thread tasks from a long running operation on Main.immediate then I would either stop usign Main.immediate or fix the long running operationin the loop, and now the other tasks scheduled on the main thread also get a chance to run. If we go for consistency withyield()
instead, then aUnconfined
should do nothing at all if there is just one coroutine running onyield()
.Dispatchers.Main.immediate
Stephen Edwards
04/25/2025, 2:08 PMStephen Edwards
04/25/2025, 2:08 PMStephen Edwards
04/25/2025, 2:08 PMhen ashould do nothing at all if there is just one coroutine running onyield()
Dispatchers.Main.immediate
Stephen Edwards
04/25/2025, 2:10 PMDmitry Khalanskiy [JB]
04/25/2025, 2:10 PMThat's the opposite of what is usually expected from a `yield`: it has to ensure fairness across unrelated tasks. In that case, yes, it looks like your use case warrants usingshould do nothing at all if there is just one coroutine running onyield()
Dispatchers.Main.immediate
runBlocking
from a coroutine. What's usually cited as the issues of runBlocking
is exactly the behavior that you seem to want.Dmitry Khalanskiy [JB]
04/25/2025, 2:11 PMcan i check for continuations waiting for dispatch from the context?Not using the public API.
Stephen Edwards
04/25/2025, 2:12 PMThat's the opposite of what is usually expected from a `yield`: it has to ensure fairness across unrelated tasks.I mean depends on your frame of reference right? For
Unconfined
it makes sense I would expect it to drain the unconfined event loop. If immediate dispatchers are like Unconfined then I thought the sameStephen Edwards
04/25/2025, 2:13 PMrunBlocking
with pseudo code? I'm not understandingStephen Edwards
04/25/2025, 2:14 PMDmitry Khalanskiy [JB]
04/25/2025, 2:15 PMStephen Edwards
04/25/2025, 2:16 PMDmitry Khalanskiy [JB]
04/25/2025, 2:19 PMwithContext(Dispatchers.Main.immediate) {
withContext(<http://Dispatchers.IO|Dispatchers.IO>) {
GlobalScope.launch(Dispatchers.Main.immediate) {
println("1")
}
}
println("2")
}
The computation 1
will be scheduled on the Main
dispatcher as if it was not .immediate
. So, it won't enter the same event loop as 2
.Dmitry Khalanskiy [JB]
04/25/2025, 2:21 PMStephen Edwards
04/25/2025, 2:21 PMDmitry Khalanskiy [JB]
04/25/2025, 2:26 PMrunBlocking
, it would be something like this:
val channel = Channel<Int>(5)
runBlocking(Dispatchers.Main.immediate) {
repeat(5) {
if (Random.nextBoolean()) {
launch {
channel.send(it)
}
}
}
}
val elements = channel.toList() // or `tryReceive` in `while (true)` to avoid closing the channel
// process the elements
If you can not structure your code in a way that computations are started in the same call frame, then it's likely that they also don't share the same event loop, which means there's not much that can be done.Stephen Edwards
04/25/2025, 2:30 PM