-
Notifications
You must be signed in to change notification settings - Fork 60
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
Conversation
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. |
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.
52a6406
to
903bd32
Compare
|
bbb1de3
to
c456abf
Compare
ctx.invalidate() | ||
await reactcore.flush() | ||
|
||
except BaseException: |
There was a problem hiding this comment.
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:
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh hm, good point.
There was a problem hiding this 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!
Co-authored-by: Winston Chang <winston@stdout.org>
b636df7
to
424f578
Compare
Before this PR:
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:
invalidate_later
support, so this PR does that too.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:
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.