Closed Math2 closed 1 year ago
Thanks for this, I'll take a look today.
In epoll.c
you can enable DEBUG:
enum {
DEBUG = 1,
};
Then we can get some more detailed logs:
epoll_flags_from_events events=1 flags=1073741849
<- fiber=0x7f6227f7fae0 descriptor=7
epoll_flags_from_events events=1 flags=1073741849
<- fiber=0x7f6227f7f158 descriptor=7
-> ptr=0x7f6227f7f158 events=1
io_wait_transfer errno=17
io_wait_transfer flags=1
events_from_epoll_flags flags=1
-> ptr=0x7f6227f7fae0 events=1
/home/samuel/Projects/socketry/io-event/test.rb:17: [BUG] Segmentation fault at 0x0000000000000028
In theory, when unwinding, the event should be removed from EPoll. It does not look like that is happening in this case, so I'll review it in more detail.
To run your test.rb
with the local build, it must be like this:
#!/usr/bin/env ruby
$LOAD_PATH << ::File.expand_path("ext", __dir__)
require_relative 'lib/io/event'
require 'socket'
require 'async'
Sync do
s1, s2 = Socket.pair(:UNIX, :STREAM)
pid = Process.spawn("sleep 10")
t1 = Async do
s1.wait_readable
ensure
system 'sleep 2'
end
t2 = Async do
s1.wait_readable
t1.stop
end
s2.write('x')
end
And run it like this:
> IO_EVENT_SELECTOR=EPoll bundle exec ./test.rb
I understand the problem now.
The issue is, the events are already in the application buffer and EPOLL_CTL_DEL
effectively has no impact since the events are already generated.
The way to fix this, which I was trying to avoid, is to use an explicit handle which can be cancelled, rather than storing the fiber directly into the event data.ptr
itself. The handle can get cancelled, which does not affect the epoll itself.
This is the approach polyphony takes. I did know about this race condition, but couldn't come up with a way to expose it. I think because this test is pretty straight forward, we do need to move to a handle based approach, which sucks, because it requires extra allocations.
I'll think about whether there are any alternative approaches we could take to avoid this.
The way to fix this, which I was trying to avoid, is to use an explicit handle which can be cancelled, rather than storing the fiber directly into the event
data.ptr
itself. The handle can get cancelled, which does not affect the epoll itself.This is the approach polyphony takes. I did know about this race condition, but couldn't come up with a way to expose it. I think because this test is pretty straight forward, we do need to move to a handle based approach, which sucks, because it requires extra allocations.
I'll think about whether there are any alternative approaches we could take to avoid this.
Those "handles" could deal with multiple fibers waiting on the same IO too (and eliminate the dup() hack in EPoll and EV_UDATA_SPECIFIC in KQueue (which is OS X-only).
I think another way could be to have the fibers directly remove themselves from the selector's events buffer (if it is currently processing one). Directly in the array of epoll_event/kevent structs I mean. When the selector starts iterating, it would point a global (per-selector?) pointer variable to the buffer (and set it to NULL when it's done iterating). Then when a fiber unwinds, it checks the buffer to see if it's in it. And marks its entry as disabled (by setting the user data pointer to NULL I guess). And then the selector skips it if it encounters it. If the buffer isn't too big (64 events in EPoll/KQueue's cases) a linear search wouldn't be too bad. And it would only need to be done when they unwind due to an exception.
You mentioned linking together waiter objects allocated directly on the fibers' stacks too. But maybe it's not necessary at all.
I tried to come up with more tests BTW but it's pretty hard to get something that reliably demonstrates something and it's getting more confusing the more I try. I think the one in this PR exposes the root cause of it though.
Thanks for all those ideas, it's really helpful to have a 2nd brain working on the problem.
In terms of performance, the only implementation I actually really care about is io_uring
. Everything else can sacrifice for correctness, including kqueue
. In fact, I believe io_uring
is the only interface capable of implementing every operation asynchronously, anyway, and it's the one we expect most (99%+) in production environments going forward. So, I'm okay with sacrificing a little performance for correctness.
Maybe it's impossible, but, my general model for the design has been to try and keep the individual fibers as isolated as possible, to avoid a combinatorial explosion of program states - when fibers can share a single epoll
event, things could potentially get very tricky - e.g. different sets of events, etc. Because, AFAIK, this is a non-issue with io_uring
, my gut feeling is just to aim for the simplest and most correct implementation in all cases. In other words, I'm less interested in trying to be clever for the sake of performance, because I feel the chance of bugs is much higher.
In summary, the cost of bugs at this level is significantly more than the value of performance at this point in time, especially for anything other than io_uring
.
Regarding tests, the test.rb
you provided is actually really decent and reproduces the problem reliably. We can extrapolate on that if needed.
No problem! Glad to be helping fixing the bugs/limitations. I always tried to avoid multi-threaded programming like the plague. And non-blocking fibers really seem to me like how things always should have worked. But they're hard to get right, you need like a in-process mini-OS to manage them...
Yeah AFAIK to go "full asynchronous" with kqueue (and epoll), you have to use AIO for vnode descriptors. But that's a huge hassle and almost no one does it.
I'm running into another issue.
At what point can we guarantee kqueue/epoll is done with the user data (token)?
It almost seems like we'd need a reference count...
Because there is "fiber-side" cancellation, as well as "select-side" completion. If the select side has already completed it, it can't deallocate it if the fiber side might still need to cancel, etc.
For example, if you close the file descriptor while events are in flight, I assume they never resolve, in which case you'd never know to release the underlying resources/token.
I think the correct solution is a reference count... damn.
Not sure I fully understand the design yet. But, I think you could "cancel" the tokens in the fibers, when they unwind. But "release" them in the selector, after a full pass over the received event. You'd do a second pass just to free them. First pass: resume the fibers with non-cancelled tokens. Second pass, release the tokens.
Ah, yes, a two pass approach might avoid the need for reference counting, nice idea. After the first pass, all the tokens should be effectively consumed. I think we still have to deal with cancellation too thought, let me see what I can come up with.
During cancellation, on the fiber side, we can ask, e.g. epoll_ctl(EPOLL_CTL_DEL) -> ENOENT / success
. ENOENT
means the token was not in epoll
, and success
means it was. In the case of ENOENT
, we don't know if that's because it's still in the selector side list waiting to be processed, or has been processed. If it is still in the list, we can wait for the selector to release it, otherwise we should release it, but we can't tell which. If we try to deallocate the token because epoll_ctl -> ENOENT
, it might still be in use by the selector = crash.
Oh I think I see the problem now. Yeah, what if the fibers unwind for whatever reason (and unregister themselves from the event mechanism) before the selector could process their events (and thus free their token). So the fibers have to be able to release their tokens themselves. But then they could release their tokens before the selector was done with them...
I think I'd have a "pinned" flag to the tokens. In the selector, first pass pins tokens, second pass dispatches, third pass unpins and releases tokens. Fibers only release if not pinned. And that's reference counting pretty much, just with only one reference possible. Yeah I wonder if there's a better way...
In my experience, trying to make a general purpose interface with OS interfaces like epoll is insanely difficult due to the fact that almost any combination of behaviour is possible. If you were directly implementing a client/server etc, you'd just avoid these cases as part of the technical implementation. Because we can't rule out any odd combination of behaviour, in the end, reference counting may be the only option.
For io_uring
and kqueue
(with unique udata), there is a little more predictability, but not much.
I've been playing around with this over the weekend.
I'm not much closer to a solution.
It seems to me, without some "token"-based approach for event registration, there is a chance of spurious wake ups no matter what.
io_wait
is actually 2 filters. In theory, both could occur in the same call to kevent
and this could resume the same fiber twice.io_uring
has better semantics around cancellation, but might still have CQEs during cancellation the same as Epoll. I started working through the implementation of the token-based approach but it's looking almost impossible to get right, for example with kevent
, we need to discretely add and remove individual filters to correctly reference count the token.
I'm also concerned about whether non-token based approaches can work - i.e. the proposed approach of storing pending events to be processed, and scanning them on cancellation, assumes that if the event is not pending (e.g. in the CQE list), it can be cancelled. I'm not sure this is always true.
There are two ideas I think might work:
Using a token is the most generally correct option, but we have to correctly reference count it, i.e. we need to know exactly how many times it's referenced by the OS in order to implement it correctly. One specific challenge here is dealing with close
and how it impacts the selector (e.g. it can clear internal events).
Scanning pending events during cancellation might also work, but for kqueue
, due to using multiple filters, would actually require us to potentially scan the pending event list for every io_wait
.
I took a look at how libev
works.
Essentially they have a per-selector array of size == maximum fd, and each entry is a linked list of watchers. Addition and removal is O(N) but we can assume N is normally pretty small.
void
ev_io_start (EV_P_ ev_io *w) EV_NOEXCEPT
{
int fd = w->fd;
if (ecb_expect_false (ev_is_active (w)))
return;
assert (("libev: ev_io_start called with negative fd", fd >= 0));
assert (("libev: ev_io_start called with illegal event mask", !(w->events & ~(EV__IOFDSET | EV_READ | EV_WRITE))));
#if EV_VERIFY >= 2
assert (("libev: ev_io_start called on watcher with invalid fd", fd_valid (fd)));
#endif
EV_FREQUENT_CHECK;
ev_start (EV_A_ (W)w, 1);
array_needsize (ANFD, anfds, anfdmax, fd + 1, array_needsize_zerofill);
wlist_add (&anfds[fd].head, (WL)w);
See the last line in the above code. It does not look like this list is ever resized and only ever expands as higher FDs are encountered.
This avoids life time issues by simply never removing entries for valid FDs. Once a file descriptor is registered for an event, the "token" is valid forever. However, this only works for system calls that use file descriptors.
I spent a while thinking about it. And I looked at the selector implementations. Mostly KQueue, since I'm most familiar with it. But EPoll is very similar. And I hope that anything that can be made to work with those can work with io_uring too, since it's more flexible. But I don't know much about it.
I have an idea, but I'm not 100% clear on everything... I'm sure there are other complications I'm not thinking of but I think it works and it could even be made to be not too complicated.
First, the close() issue seems really hard to deal with. And not having a clear way of dealing with it makes it much harder to think about the rest of the design. So I started from there.
If a fiber has its waited on FD close(2)'d by another fiber, I think it just cannot end well. IIUC, select(2)/poll(2) can fail the syscall as a whole, maybe even report events for a different IO object (if the FD is reallocated). kqueue deletes pending events and stops polling the FD. But the epoll manpages say that it only reacts to closed FDs once the underlying kernel file it points to had its last reference closed. And it only mentions the "interest list", not the "ready list". You can manually remove individual FDs, but if they get closed by another fiber that won't happen. And even if this could be dealt with and there's no FD confusion and the in-kernel pending events list gets properly pruned, it's still a big problem that we'll never get any event for it anymore and the waiting fiber won't ever be resumed. Not by the selector anyway. It'll just stall there.
I might be wrong but I think that trying to design the selectors to correctly deal with this is impossible. The only solution is to not let the situation happen in the first place.
Not sure know how realistic this is, but I think that to deal with this, the #close
on Ruby-level IO objects would have to be hooked into. And make it do what it must to purge it from the event mechanism, and resume the corresponding waiting fiber (with an IO exception, since its waited on IO object became invalid). It's why closing an IO object unexpectedly only does limited damage on Ruby. It's because you don't work directly with FDs. The IO object knows it got closed. You don't have to worry about accidentally using another FD, or stalling forever. But to keep it working like that with an asynchronous event system, #close
is going to have to get more complicated...
And to make this work, the fibers, their polled IO objects, and the pending events, would all have to be linked together in some intermediate data structure. They'd be kind of like the Waiter objects that Select has. Unfortunately it's more complicated than the tokens but I think it's what it takes to deal with #close
'd IO objects.
It's possible to attach arbitrary instance variables to IO objects and use them from CRuby extensions right? I think every IO object should act as a kind of "hub" for the fibers that wait on them. The fibers would be queued in a doubly linked list (with the nodes allocated on their stacks). Let's call those nodes "Waiters". They'd work like for the Select selector's Waiters pretty much. You'd poll every IO object only once, no matter how many fibers are waiting on it. You'd point the events' user data pointers directly to IO objects, and iterate over its Waiters when they're triggered. And since the waiters are available directly from the IO objects, #close
would be able to find them and deal with the queued fibers. When a fiber unwinds, it unlinks its Waiter from the queue, and if the queue becomes empty, it cancels the polled event. When the IO is #close
'd, it raises an exception on all of the waiting fibers and lets them unwind.
If that takes the #close
issue out of the way, everything becomes easier to reason about. The fibers register/unregister the IO object with the event mechanism before they block and when they unwind, respectively. It should be the only way it can happen. And they always know when they're the last fiber to be waiting on a particular event-type for a given FD, it's all in the same queue.
No reference counting I think. Just the usual "safe" linked-list iteration trick for the loop when you dispatch triggered events to the queued fibers to let the fibers safely unlink themselves even if they're the iteration's current node.
There is a disabled scheduler hook for IO#close
, but it's difficult to use, because anyone can write IO.for_fd(n).close
and that isn't necessarily going to hook into the scheduler correctly.
However, I think your general idea makes sense. I'll think about it more this week. I might write some test code for epoll
to see exactly how it behaves in the case of close
, as I'm not sure the documentation is clear enough as to what to expect.
Failing test case:
I tried to explain what I think is going on in the PR #64.
But I think I got it a little wrong wrt the EWOULDBLOCK problems it could lead to. Because even though the fibers might get resumed sooner than they should, the EWOULDBLOCK should still be dealt with correctly afterward (IIUC). It's not just an ordinary "spurious wake up" issue like that. Because like you said they have to be dealt with anyway in certain contexts. If you get an event that isn't really true (which can always happen on most OSes apparently), if you're still expecting this kind of event, you can deal with it (and CRuby does that, it deals with EWOULDBLOCK errors and waits/retries). But in this case, the selector can resume fibers in a way that they don't expect, while they're in a different context. The fibers could be waiting on a different kind of event now. Or waiting on some arbitrary user logic that would resume them manually somehow.
For example, this crashes here (on 3.1 and with
IO_EVENT_SELECTOR=EPoll
). CRuby must not be happy to have its fiber resumed as if it had IO ready when it was really asking if a process had exited.The problem doesn't just happen when waiting on IO too, it could have been the other way around. A fiber waiting on a process, unwinding and no longer waiting on it, and then being resumed by the selector as if the process had exited while the fiber was now waiting on something else.
What's annoying is that fixing this would probably require to rework all of the selectors (except Select which always dealt with this correctly with its Waiter objects IIUC) and make them more complicated...