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

Virtual async events 2 #12372

Closed
wants to merge 7 commits into from

Conversation

rayman22201
Copy link
Contributor

@rayman22201 rayman22201 commented Oct 6, 2019

Implemented VirtualAsyncEvent separate from AysncEvent

As suggested by @dom96. This leaves the original AsyncEvent API intact.
This also includes a small test suite for both AsyncEvent and VirtualAsyncEvent.

The code is mostly the same as #12232 except that it uses a single AsyncEvent as the multiplexer, instead of reaching into the global ioselector directly.

Pros:

  • This makes the code simpler and more modular

Cons:

  • This relies on the correctness and performance of the old AsyncEvent
  • There may be a small performance penalty from piggybacking off AsyncEvent instead of using the ioselector directly (likely very small though)

A Few Notes:

  • I still think the original AsyncEvent is inefficient and has some issues.

    • But this PR is much more conservative and may be easier to accept. (It is non-breaking and additive only)
  • A very rough performance measure of the test suite shows that VirtualAsyncEvent is much faster than the old AsyncEvent, as well as being able to handle an order of magnitude more events than the old AsyncEvent. 😛

  • Knowledgeable users will be able decide which version they want to use.

    • I have a TODO: to document the differences between the two types of AsyncEvent.
  • Unlike the previous version, Make Flowvar compatible with Async #11724 will no longer transparently work.

    • It will need a small rewrite to use this new VirtualAsyncEvent API.

Calling @zevv, @dom96, @Araq, @cheatfate for code review and thoughts.

@rayman22201
Copy link
Contributor Author

This is going to need #12371 as a prerequisite. The test suite will fail without that fix.

Copy link
Contributor

@dom96 dom96 left a comment

Choose a reason for hiding this comment

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

Some quick comments for now.

@@ -924,8 +957,8 @@ when defined(windows) or defined(nimdoc):
## receiving notifications.
registerWaitableEvent(fd, cb, FD_WRITE or FD_CONNECT or FD_CLOSE)

template registerWaitableHandle(p, hEvent, flags, pcd, timeout,
handleCallback) =
proc registerWaitableHandle(p: PDispatcher, hEvent: Handle, flags: DWORD, pcd: PostCallbackDataPtr, timeout: int,
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice :)

Comment on lines 1137 to 1148
VirtualFD* = distinct int

VirtualAsyncEventImpl = object
triggered: bool
when compileOption("threads"):
eventLock: RLock
p: PDispatcher
vFD: VirtualFD
cb: Callback
VirtualAsyncEvent* = ptr VirtualAsyncEventImpl

Copy link
Contributor

Choose a reason for hiding this comment

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

These are defined twice. Can you reduce the duplication?

Copy link
Contributor Author

@rayman22201 rayman22201 Oct 14, 2019

Choose a reason for hiding this comment

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

Sadly no. I tried.
TLDR; It has a type dependency loop that forces the duplication.

It might be possible with {.experimental: "codeReordering".}, but here is the problem:

First, the VirtualAsyncEventImpl keeps a reference to a pDispatcher here:

p: PDispatcher

and here:
p: PDispatcher

Which requires VirtualAsyncEventImpl must be declared after PDispatcher.

But,
'VirtualEventDispatcherkeeps references toVirtualAsyncEvent` here:

virtualHandles: Table[VirtualFD, VirtualAsyncEvent] # pseudo handles for custom AsyncEvents.

and here:
virtualHandles: Table[VirtualFD, VirtualAsyncEvent] # pseudo handles for custom AsyncEvents.

And PDispatcher keeps a reference to VirtualEventDispatcher here:

vd: VirtualEventDispatcher

and here:
vd: VirtualEventDispatcher

Which requires VirtualAsyncEventImpl must be declared before PDispatcher.

It's a type dependency loop! The only way this is allowed, is if both VirtualAsyncEventImpl and PDispatcher are declared in the same type definition block.
Because of the when defined(windows): split,
PDispatcher is declared twice, so VirtualAsyncEventImpl must also be declared twice.

Edit: I got the reason why this exists wrong. It has nothing to do with the lazy loading stuff. (Sorry, it's a complicated PR and it's been a few days. Hard to keep it all in my head.)
Even simpler:
The VirtualEventDispatcher needs to hold the physical AsyncEvent (it needs the physical event to multiplex on), and the PDispatcher needs to hold a VirtualEventDispatcher. So all three types rely on each other, and must be declared in the same type block.
If you declare the VirtualEventDispatcher first, then AsyncEvent hasn't been defined yet, and VritualEventDispatcher will error.
If you declare AsyncEvent first, then VirtualEventDispatcher hasn't been declared yet, and PDispatcher will error (note: that PDispatcher and AsyncEvent are declared together in the same type block).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@dom96,
Sorry :/
This is a terrible and convoluted explanation. Hit me up on irc if you can. I might be able to explain it better by walking through it with you live.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@Araq, I haven't been able to catch you on irc the last few days. But I would like to get your thoughts on this type dependency nonsense. I don't see a way around it, but maybe an expert(TM) can help :-P

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I talked to @Araq briefly about this on IRC. He didn't look too hard, but generally this kind of thing will happen in x-platform situations until we get recursive module definitions. So it's a necessary evil for now.

@@ -1851,6 +1907,105 @@ proc send*(socket: AsyncFD, data: string,

return retFuture

proc deInitVirtualEventDispatcher(p: PDispatcher) =
Copy link
Contributor

Choose a reason for hiding this comment

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

This should be called close and take a VirtualEventDispatcher.

withLockIfThreads ev.eventLock:
if ev.triggered:
ev.triggered = false
p.callbacks.addLast(vcbFactory(vfd))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I really want initVirtualEventDispatcher to take a VirtualEventDispatcher object, and not a PDispatcher object.
The main reason I need the PDispatcher here is because I can think of no other way to add a virtual callback into Dispatcher callback queue.

I could have VirtualEventDispatcher maintain a separate dispatch queue. But that could cause all kinds of problems, because callback order matters. As seen here for example: #7197

It is just much safer to insert the callback into the parent PDispatcher queue.

Copy link
Contributor

Choose a reason for hiding this comment

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

Can't you use getGlobalDispatcher?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No. Two reasons:
1.) It's not thread safe.
2.) Related to 1, just like physical AsyncEvents, VirtualAsyncEvents are not assigned a dispatcher by default. They can technically be assigned to different dispatchers.
The API allows for this, so it has to be accounted for.

Copy link
Contributor Author

@rayman22201 rayman22201 Oct 17, 2019

Choose a reason for hiding this comment

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

To clarify this further:

It's ok to use getGlobalDispatcher() on creation. see:

proc addEvent*(ev: VirtualAsyncEvent, cb: Callback) =

But the trigger event may be called from a different thread with a completely different dispatcher!

In that case, we need a way to trace back to the owning PDispatcher instance.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I changed my mind. I thought about this more, and getGlobalDispatcher is thread safe (it returns a thread local global.)

I changed the code to use getGlobalDispatcher and removed the closure requirement.

Copy link
Contributor

@dom96 dom96 left a comment

Choose a reason for hiding this comment

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

More comments, I like this approach overall much better than the previous, so I think we'll be able to get this merged soon :)

@@ -0,0 +1,27 @@
discard """
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe it would make sense to put all these tests into a virtualevent subdirectory?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That would be cool, does testament support this?

try:
runForever()
except ValueError:
echo "runForever should throw ValueError, this is expected"
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is this expected? Add a comment in code to explain.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good question. I stole it from here (where it also doesn't have a comment to explain):
https://github.com/nim-lang/Nim/blob/devel/tests/async/t8982.nim

@narimiran is who you need to yell at for not commenting their code! /rant :-P

The answer is that runOnce() will throw an exception when it has no more pending operations:

raise newException(ValueError,

This is basically a hacky alternative to drain()
Remember I discussed this in this comment: #12371 (comment)

This test was the reason I started looking into drain() in the first place, and discovered #12371

Once #12371 is merged I think I can refactor this test to use drain()


proc close*(ev: VirtualAsyncEvent) =
## Closes event ``ev``.
doAssert(ev.vFD != InvalidVirtualFD, "Must unregister Event before you close it!")
Copy link
Contributor

Choose a reason for hiding this comment

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

IIRC for pretty much all other things in asyncdispatch, the close proc will implicitly unregister the FD. Please keep that consistent.

Copy link
Contributor Author

@rayman22201 rayman22201 Oct 16, 2019

Choose a reason for hiding this comment

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

Actually, this is safer than what happens in other places in asyncdispatch.

If you look at AsyncEvent, the following happens:

It calls close on the underlying ioselector, which closes it at the kernel level, and dealloc's the memory, but it doesn't actually unregister the FD from the select dispatcher:

proc close*(ev: AsyncEvent) =

proc close*[T](s: Selector[T]) =

Maybe that is a bug?

Copy link
Contributor

Choose a reason for hiding this comment

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

sigh

Yeah, it's not consistent with sockets at all then...

Nim/lib/pure/asyncdispatch.nim

Lines 1320 to 1334 in 5288b54

proc closeSocket*(sock: AsyncFD) =
let selector = getGlobalDispatcher().selector
if sock.SocketHandle notin selector:
raise newException(ValueError, "File descriptor not registered.")
let data = selector.getData(sock.SocketHandle)
sock.unregister()
sock.SocketHandle.close()
# We need to unblock the read and write callbacks which could still be
# waiting for the socket to become readable and/or writeable.
for cb in data.readList & data.writeList:
if not cb(sock):
raise newException(
ValueError, "Expecting async operations to stop when fd has closed."
)

# send the signal to wake up the dispatcher thread.
trigger(ev.vd.virtualMuxHandle)

proc addEvent*(ev: VirtualAsyncEvent, cb: Callback) =
Copy link
Contributor

Choose a reason for hiding this comment

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

Argh, I know you're being consistent with the AsyncEvent API here, but it annoys me that this API isn't consistent with the socket API...

In any case, it might be worth to add a signal async proc that works for both VirtualAsyncEvent and AsyncEvent. We aim to avoid callbacks after all...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is arguably a lower level api that you wouldn't use in async level code.
The FlowVar -> Promise integration for example. A user never actually sees AsyncEvent.

IDK if it's necessary, but it might be a nice convenience to have some api that wraps a AsyncEvent / VirtualAsyncEvent in a Promise for you.

I'd rather focus on getting the fundamentals in and making that a separate PR, since I consider that sugar.

Copy link
Contributor

Choose a reason for hiding this comment

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

It's important to think about how the API will evolve, remember that once this is released we won't be able to change it in a breaking manner.

Comment on lines +1993 to +1996
# The main reason we need this is to make hasPendingOperations still work
# without modifying the ioselector code.
Copy link
Contributor

Choose a reason for hiding this comment

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

can you elaborate on this? Don't really understand why this lazy loading is necessary.

Copy link
Contributor Author

@rayman22201 rayman22201 Oct 16, 2019

Choose a reason for hiding this comment

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

It has to do with hasPendingOperations() and the multiplexer io select object.

The simplest approach is to simply create a singleton AsyncEvent object attached to the VirtualEventDispatcher , which you initialize on creation.
This AsyncEvent essentially lives forever and waits for VirtualEvents to trigger it.

This has one big problem. The lives forever part. The io selector is never closed. It must remain alive. This means the underlying ioselector implementation will always have at least one open io select that it is listening on.

If any code relies on all io selects being closed before continuing, that code will never be able to run. hasPendingOperations() and any code that relies on it is a prime example of code like this.

In #12232 I "fixed" this problem by modifying all of the io_selector implementations and asyncdispatch.nim to treat a single open io select as the same as 0 open io selects (no pending events).

After thinking about it, I realized that has approach has big problems:

  • First, it is a very dramatic and invasive change to the ioselector implementations. That code is foundational, so I want to be very careful with changes that I make there. The changes in Virtual async events #12232 seemed too big, and not safe.

  • Second, It's possible that code might rely on the process actually having all physical kernel io selectors closed (for a graceful shutdown or resource management for example). If we treat 1 open io selector the same as 0, we are essentially lying to the user, and that is not ok IMO.

I don't love lazy loading, because it is non-deterministic, but in this case, I think it's the right tool for the job. This approach is a modular solution to all of the above problems:

  • It works with 0 changes to the underyling ioselector code
  • It works with 0 changes to the core asyncdispatch code.
    • By core I mean no changes to AsyncEvent or any core run loop code such as runOnce()
  • hasPendingOperations() works correctly with 0 changes and without lying to the user
  • All changes are contained within the VirtualEvent additions.

All of that adds up to make this a much safer PR.

proc initVirtualEventDispatcher(p: PDispatcher) =
p.vd.virtualMuxHandle = newAsyncEvent()

proc virtualMuxCB(fd: AsyncFD): bool {.closure,gcsafe.} =
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
proc virtualMuxCB(fd: AsyncFD): bool {.closure,gcsafe.} =
proc nativeEventCB(nativeFD: AsyncFD): bool {.closure, gcsafe.} =


proc vcbFactory(vfd: VirtualFD) : proc() {.closure, gcsafe.} =
let ev = p.vd.virtualHandles[vfd]
result = proc() {.closure,gcSafe.} =
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: move the proc () ... down to the next line and indent it.

# add them to the callback list.
# Not very efficient, but requires the least coordination between threads.

proc vcbFactory(vfd: VirtualFD) : proc() {.closure, gcsafe.} =
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit:

Suggested change
proc vcbFactory(vfd: VirtualFD) : proc() {.closure, gcsafe.} =
proc vcbFactory(vfd: VirtualFD): proc() {.closure, gcsafe.} =

But also, I would probably try to avoid this being a closure (and use getGlobalDispatcher if possible like suggested below).

Copy link
Contributor Author

@rayman22201 rayman22201 Oct 16, 2019

Choose a reason for hiding this comment

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

as I mention here: #12372 (comment)
it's not thread-safe or consistent with the API to use getGlobalDispatcher.
A closure is the easiest way to get access to the correct dispatcher given those constraints.

As a general note:
getGlobalDispatcher is not appropriate for a threaded environment. You cannot assume there is only a single asyncdispatcher in a threaded application. IMO this might cause a lot of problems in the future.

@@ -1851,6 +1909,105 @@ proc send*(socket: AsyncFD, data: string,

return retFuture

proc close(vd: VirtualEventDispatcher) =
assert vd.virtualHandles.len == 0, "Cannot de-Init Virtual Event Dispacter. There are still Pending Events."
Copy link
Contributor

Choose a reason for hiding this comment

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

typo

##
## New ``VirtualAsyncEvent`` object is not automatically registered with
## dispatcher like ``AsyncSocket``.
result = cast[VirtualAsyncEvent](allocShared0(sizeof(VirtualAsyncEventImpl)))
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess we can't get around this being shared?

Copy link
Contributor Author

@rayman22201 rayman22201 Oct 16, 2019

Choose a reason for hiding this comment

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

It's meant for multithreading so no :-P

to be more specific:
the trigger proc for VirtualAsyncEvent is meant to be callable from other threads by design. So this is shared memory by design.

…vent

As suggested by @dom96. This leaves the original AsyncEvent api intact.
Also includes a small test suite for both AsyncEvent and
VirtualAsyncEvent
- changed deInitVirtualEventDispatcher(p: PDispatcher) to close(vd: VirtualEventDispatcher)
- changed VirtualEventDispatcher to a ref object and cleaned up some
  code related to this change
@stale
Copy link

stale bot commented Oct 27, 2020

This pull request has been automatically marked as stale because it has not had recent activity. If you think it is still a valid PR, please rebase it on the latest devel; otherwise it will be closed. Thank you for your contributions.

@stale stale bot added the stale Staled PR/issues; remove the label after fixing them label Oct 27, 2020
@ringabout ringabout removed the stale Staled PR/issues; remove the label after fixing them label Nov 9, 2020
@stale
Copy link

stale bot commented Nov 9, 2021

This pull request has been automatically marked as stale because it has not had recent activity. If you think it is still a valid PR, please rebase it on the latest devel; otherwise it will be closed. Thank you for your contributions.

@stale stale bot added the stale Staled PR/issues; remove the label after fixing them label Nov 9, 2021
@dom96 dom96 removed the stale Staled PR/issues; remove the label after fixing them label Nov 9, 2021
@stale
Copy link

stale bot commented Nov 9, 2022

This pull request has been automatically marked as stale because it has not had recent activity. If you think it is still a valid PR, please rebase it on the latest devel; otherwise it will be closed. Thank you for your contributions.

@stale stale bot added the stale Staled PR/issues; remove the label after fixing them label Nov 9, 2022
@stale stale bot closed this Dec 16, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
stale Staled PR/issues; remove the label after fixing them
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants