Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Improve robustness of async reactivity #39

Merged
merged 7 commits into from
Jan 31, 2022
Merged

Conversation

jcheng5
Copy link
Collaborator

@jcheng5 jcheng5 commented Jan 25, 2022

Before this PR:

  • Each WebSocket connection ran its own while/receive()/manageInputs/flush loop in its own independent asyncio.Task, so reactive invalidation/execution from two different Tasks could be interleaved. This level of concurrency makes the reactive loop extremely difficult to reason about, unless each Task's graph of reactive objects is guaranteed to be totally separate from any others (and maybe still problematic then--I haven't looked too closely at global state or ReactiveEnvironment state).
  • In the reactive flush code, the default mode was "concurrent" execution of async observers, with an asyncio.gather() call at the end. I think this is also too aggressive of a default (although I could maybe be convinced otherwise).

After this PR:

  • Each WebSocket still runs its own while/receive()/manageInputs/flush loop, but, only after acquiring an asyncio.Lock on the ReactiveEnvironment. (Currently, we don't prevent anyone from doing reactive reads/writes/flushes without holding the lock, so if you spawn a Task that could be a possibility.) The lock is acquired after an incoming message is deserialized, and released after all sessions' pending output is flushed.
  • The introduction of an asyncio.Lock made it trivial to add invalidate_later support, so this PR does that too.
  • The reactive flush code now only has a sequential mode.

This leaves us with an async execution model that is simple and robust--but not concurrent. It doesn't help you if you actually want to execute logic that doesn't block the (now serial) reactive loop. We're punting on that for right now as the high priority was robustness, and we can add opt-in concurrency later. Some notes regarding how we might want to approach that:

  • Using asyncio.create_task() in an observer and then not awaiting it, seems like an easy way to get execution that's totally separate from the reactive loop, but it's actually quite subtle to get this right.
    • To be safe, an async non-blocking observer probably needs to read any reactive sources it needs while still under the global lock, do its work on a different task, then grab the global lock again before writing the results to anywhere reactive (thus triggering reactive invalidation). And it also probably then needs to trigger a reactive flush and session output flush, all under the lock.
    • An error during the execution of the task should be reflected in the owning session somehow.
    • Might (or might not) want to hold off on flushing of the owning session's output and/or processing of the owning session's incoming messages, until all sub-Tasks for that session are done.
    • This all points to some higher-level wrapper probably being needed to do this kind of thing.
  • The common case could be that each session has totally independent reactive graphs, it would be neat to have a way for app authors to opt into one-loop-per-session (and throw warnings/errors if you "cross the streams" by having reactive objects shared across sessions).

shiny/reactives.py Outdated Show resolved Hide resolved
@wch
Copy link
Collaborator

wch commented Jan 26, 2022

Following up an earlier discussion: I thought more about the possibility of deadlocks. I'm writing this out in part to clarify my thinking about it. (I think the code in this PR is safe.)

When there is just one lock, it is possible for there to be a deadlock, but only if a locked section of code calls (and awaits) other code which tries to acquire the lock.

import asyncio

lock = None

async def bar():
    async with lock:
        print("bar")

async def foo():
    global lock
    lock = asyncio.Lock()
    async with lock:
        print("foo")
        await bar() 

asyncio.run(foo()
#> foo
  [Python hangs]

If we create a task and await it inside the locked section, that also doesn't help:

async def foo2():
    global lock
    lock = asyncio.Lock()
    async with lock:
        print("foo2")
        await asyncio.create_task(bar())

asyncio.run(foo2())
#> foo2
  [Python hangs]

But if it's awaited outside of the locked section, then it's OK:

async def foo3():
    global lock
    lock = asyncio.Lock()
    async with lock:
        print("foo3")
        task = asyncio.create_task(bar())
    await task

asyncio.run(foo3())
#> foo3
#> bar

I think the code in the PR is safe, but this is something to keep in mind in the future.

@jcheng5
Copy link
Collaborator Author

jcheng5 commented Jan 26, 2022

Those are good points. The first can be dealt with using a “reentrant mutex” that allows a Task to reacquire a lock it already has (I don’t think asyncio.Lock is reentrant but it’d be trivial to write a wrapper).

It does feel super weird to run this much user code under a mutex, I have to admit.

This commit puts an asyncio.Lock around invalidation/flush.
The intent is to reduce the possibility of race conditions
when reactive objects have trivial levels of async.

A future commit will make it possible for async observers
to optionally not block whomever is calling the flush from
moving on.
@jcheng5 jcheng5 force-pushed the joe-serialize-reactivity-lock branch from 52a6406 to 903bd32 Compare January 28, 2022 00:49
@jcheng5 jcheng5 marked this pull request as ready for review January 28, 2022 00:49
@jcheng5 jcheng5 marked this pull request as draft January 28, 2022 00:49
@jcheng5 jcheng5 requested a review from wch January 28, 2022 17:23
@jcheng5 jcheng5 marked this pull request as ready for review January 28, 2022 17:23
@jcheng5
Copy link
Collaborator Author

jcheng5 commented Jan 28, 2022

TODO: Change OrderedDict usage in Callbacks/AsyncCallbacks to dict. Winston pointed out traversal order is guaranteed since Python 3.7 Done

@jcheng5 jcheng5 force-pushed the joe-serialize-reactivity-lock branch from bbb1de3 to c456abf Compare January 28, 2022 20:20
ctx.invalidate()
await reactcore.flush()

except BaseException:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want this to be:

Suggested change
except BaseException:
except Exception:

According to the exceptions docs:

Exception: All built-in, non-system-exiting exceptions are derived from this class. All user-defined exceptions should also be derived from this class.

Also see: https://stackoverflow.com/a/63169967/412655

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, even if I'm just printing and re-raising?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh hm, good point.

Copy link
Collaborator

@wch wch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In addition to the code comment I made, it would be good to have tests for invalidate_later.

Other than those things, looks good!

shiny/reactives.py Outdated Show resolved Hide resolved
jcheng5 and others added 2 commits January 31, 2022 08:52
Co-authored-by: Winston Chang <winston@stdout.org>
@jcheng5 jcheng5 force-pushed the joe-serialize-reactivity-lock branch from b636df7 to 424f578 Compare January 31, 2022 18:45
@wch wch merged commit 39ccdef into main Jan 31, 2022
@wch wch deleted the joe-serialize-reactivity-lock branch January 31, 2022 21:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants