Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Android UI dispatcher and isDispatchNeeded #381

Closed
Khang-NT opened this issue Jun 3, 2018 · 19 comments
Closed

Android UI dispatcher and isDispatchNeeded #381

Khang-NT opened this issue Jun 3, 2018 · 19 comments

Comments

@Khang-NT
Copy link

Khang-NT commented Jun 3, 2018

Could we change implementation of method HandlerContext.isDispatchNeeded() to be more flexible:

public class HandlerContext(
    private val handler: Handler,
    private val name: String? = null,
    val alwaysDispatch: Boolean = true
) : CoroutineDispatcher(), Delay {

    override fun isDispatchNeeded(context: CoroutineContext): Boolean {
        return alwaysDispatch || Looper.myLooper() == handler.looper
    }

}
@qwwdfsad
Copy link
Collaborator

qwwdfsad commented Jun 4, 2018

Please elaborate why do you need it.
Maybe it's worth to make HandlerContext open instead?

@Khang-NT
Copy link
Author

Khang-NT commented Jun 4, 2018

I want to consume values of a ReceiveChannel in UI context.

launch(UI) {
    receiveChannel.consumeEach { value ->
        // stuff
    }
}

In the other side, SendChannel can send() on either main thread or background thread, I expect that if send(value) is invoked on main thread and alwaysDispatch == false, the value will pass to consumer immediately without dispatching to next event loop cycle.

I'm not sure 100% if isDispatchNeeded = false then the value will pass to consumer immediately if both send() and receive() are called on main thread. But I need that behavior to make coroutine channel acting like Android LiveData in my library:
https://github.com/Khang-NT/Kotlin-Coroutines-Lifecycle-Aware
(LiveData#setValue(newValue) will dispatch newValue immediately to all observers in same event loop.)

@qwwdfsad
Copy link
Collaborator

qwwdfsad commented Jun 4, 2018

Instead of extending HandlerContext you can use delegation.

// You can name it UI or ConditionalUI or whatever
object ConditionalDispatcher : CoroutineDispatcher() {

    override fun dispatch(context: CoroutineContext, block: Runnable) = UI.dispatch(context, block)

    override fun isDispatchNeeded(context: CoroutineContext): Boolean {
        return Looper.myLooper() !== Looper.getMainLooper()
    }
}

Does it solve your problem?

@Khang-NT
Copy link
Author

Khang-NT commented Jun 4, 2018

I also think of some solutions can solve my problem.

With my opinion, my problem is quite general so I open this issue whether we can official support within the library.

@bohsen
Copy link

bohsen commented Jun 6, 2018

Related to #258

@qwwdfsad
Copy link
Collaborator

qwwdfsad commented Jul 3, 2018

We can't implement it as is, because accidental usage of such dispatcher will lead to spurious StackOverflowError reproducible sometimes.

We should provide something more explicit, e.g. new coroutine start mode or additional extension like UI.unconfined or UI.immediate.

Feel free to provide your option, the only requirement is that in place invocation should be explicit

@GeoffreyMetais
Copy link

I agree dispatching is somehow difficult to apprehend, and explicit dispatching option is mandatory.

I ended up doing a dedicated function to easily manage dispatching in UI:

fun uiJob(dispatch: Boolean = uiDispatch, block: suspend CoroutineScope.() -> Unit) : Job {
    return launch(UI, if (dispatch) CoroutineStart.DEFAULT else CoroutineStart.UNDISPATCHED, block = block)
}

With uiDispatch returns true if current thread is not UI thread. So I can explicitely select dispatching or let it automatic.

Would rather be a kotlinx-coroutines(-android) helper

@qwwdfsad
Copy link
Collaborator

qwwdfsad commented Jul 4, 2018

Thanks for the feedback!
Your workaround works well for your use-case, but it's hard for the library to maintain it: we have a lot of dispatchers, a lot of builders and a lot of [default] parameters.

I'll try to prototype UI.some_extension as it's much easier to generalize

@GeoffreyMetais
Copy link

That would be great, I could get rid of my workaround :)

@LouisCAD
Copy link
Contributor

LouisCAD commented Jul 4, 2018

@qwwdfsad I like the UI.some_extension design. I personally currently have a launchInUi(…) extension method, similar to @GeoffreyMetais's one, except it throws if not called on the UI thread, so there's no doubt about whether it's executed immediately or dispatched. It's started immediately, without initial dispatch, or fails fast.

About the technique to check the UI thread, I dislike using Looper comparison because Lopper.mainLooper() has a synchronized block (that is not really needed), and Looper.myLooper() does a ThreadLocal lookup.
This is some overhead that can be saved by caching the ui thread to a field, and just check it over the current thread, as I do here: https://github.com/LouisCAD/Splitties/blob/beb45da8ede57383aec489ac9d5630e79db60cc9/uithread/src/main/java/splitties/uithread/UiThread.kt#L30

@adamp
Copy link

adamp commented Jul 6, 2018

This might be orthogonal to some of the issues you're trying to address here but...

If the primary issue is latency between posted Handler messages you might look first at skipping the vsync barriers that delay messages until after a pending frame. (The actual Looper message processing is quite fast on its own.) The api Handler.createAsync was added in API 28 to make this easier. Messages posted to an async handler are still ordered relative to one another, but they do not wait for pending frames to measure/layout/draw before running.

The constructor it uses has been there in AOSP nearly since the vsync barriers debuted in Jellybean so a hacky backport is possible in a support/androidx lib, we just haven't added it there yet. You can do your own for testing with something like this:

private val asyncHandlerConstructor = try {
    Handler::class.java.getConstructor(
            Looper::class.java,
            Handler.Callback::class.java,
            Boolean::class.java
    )
} catch (t: Throwable) {
    null
}

fun createAsyncHandler(
        looper: Looper = Looper.myLooper(),
        callback: Handler.Callback? = null
): Handler = when {
    Build.VERSION.SDK_INT >= 28 -> Handler.createAsync(looper, callback)
    Build.VERSION.SDK_INT >= 17 -> asyncHandlerConstructor?.newInstance(looper, callback, true)
            ?: Handler(looper, callback).also {
                Log.d("createAsyncHandler", "Couldn't create Handler as async!")
            }
    else -> Handler(looper, callback)
}

Then you can construct a HandlerContext with an async handler for the main thread that doesn't get blocked by UI operations:
val Main = HandlerContext(createAsyncHandler(Looper.getMainLooper()), "main")

Some folks working with RxAndroid saw some significant performance/latency benefits from this in testing and there's a PR over there in progress to use the same underlying mechanism: ReactiveX/RxAndroid#416

@qwwdfsad
Copy link
Collaborator

qwwdfsad commented Jul 6, 2018

@LouisCAD
Do you have any performance numbers? Was this check so expensive that you've explicitly rewritten your code to mitigate it?

I don't mind additionally optimizing it, but it's good to know it's worth it. Looper comparison is user-friendly: even if javadoc/name is unclear, it's easy to understand what's going on in implementation after looking at single check, while solution with caching threads doesn't have this property.

@adamp Thanks! Let's see how this PR is going. One already can create coroutines dispatcher over any Handler whether it's async or not. If it's really useful, we can provide global constant like UI which uses async handler over main looper, name is the only problem :)

@adamp
Copy link

adamp commented Jul 6, 2018

It might be better in the long term to make the UI dispatcher async by default.

The whole vsync barrier thing was a requirement for us to keep behavioral compatibility with a lot of existing code at the time but it's almost never what you want in new code and the latency hit is pretty huge. The pattern it was made to protect is this:

myView.setText("some text")
myView.post { doSomethingWith(myView.width) }

Where the width of a view isn't updated to reflect the newly set text until after measure/layout. Prior to vsync in jellybean requestLayout as performed by setText was implemented as a simple handler post so the operations were serialized.

I can't think of a good reason why someone would write a suspending version of something like TextView.setText that resumed after the frame such that this behavior would need to be preserved for the UI dispatcher. (And if they did, they could resume based on a post to a non-async Handler internally anyway.) Since the coroutines libs are still experimental it would be nice to push people toward dispatchers that skip them by default before there's a bunch of legacy code to think about. It's too bad RxAndroid can't go back in time and make this the default too.

@qwwdfsad
Copy link
Collaborator

qwwdfsad commented Jul 8, 2018

Created #427 for discussing it

@LouisCAD
Copy link
Contributor

LouisCAD commented Jul 8, 2018

@adamp
Copy link

adamp commented Jul 9, 2018

Regarding the original problem - getting dispatch behavior similar to LiveData where downstream behavior can interrupt or otherwise affect the rest of the dispatch - it sounds like you either want observers that need to affect the dispatch to use Unconfined or the wrapped dispatcher proposed above. However, using anything other than Unconfined here means that under some circumstances that might not be easily reproduced, your observer won't be able to affect the upstream dispatch, but it might assume it can. That sounds like a recipe for bugs.

It also looks like skipping dispatch in the manner proposed here also breaks some ordering guarantees. Consider:

  1. Message A is posted to the Handler
  2. Message B is posted to the Handler
  3. Message A executes, performs some operation that results in processing and dispatch of Message C
  4. Message C skips dispatch and executes immediately since it's on the main thread already
  5. Message B executes after Message C has executed

If message B and C were the result of dequeuing some sort of work from the same queue, they're now out of order. This might happen many layers deep in the associated code and be very difficult to track.

A number of RxJava users in the Android community have written Schedulers that behave this way and got bit by ordering bugs like the above. The answer on that side of things has usually been either, "don't use observeOn if you need it to run synchronously" (Unconfined) or to address the latency issues that come from the vsync barriers if that's the motivation.

@LouisCAD
Copy link
Contributor

LouisCAD commented Jul 9, 2018 via email

@JakeWharton
Copy link
Contributor

Nothing about coroutines precludes the ability to have the above problem. The use a single coroutine represents only a fraction of the interaction with a single-threaded scheduler.

@adamp
Copy link

adamp commented Jul 9, 2018

It's harder, for sure. It could still interact poorly with other code a couple layers away though, and giving it prominent placement in the API could lead people to think that it's a general purpose, "go faster" button when it may not be addressing the right problems.

It seems like the example code at the beginning of the thread assumes that the channel consumer will fully finish processing the item before the producer continues dispatching rather than just receive the item from the channel, this being the LiveData behavior described. Without an API guarantee that the dispatch won't continue until after processing of the item and not just receipt, relying on unconfined dispatch behavior alone to do this seems brittle.

elizarov pushed a commit that referenced this issue Jul 18, 2018
…ks which are invoked from the right context

Fixes #381
elizarov pushed a commit that referenced this issue Jul 18, 2018
…ks which are invoked from the right context

Fixes #381
elizarov pushed a commit that referenced this issue Jul 18, 2018
…ks which are invoked from the right context

Fixes #381
# for free to join this conversation on GitHub. Already have an account? # to comment
Projects
None yet
Development

No branches or pull requests

8 participants