epoll's core data structure is a "ready list" (rdllist in the kernel): a
queue of file descriptors that currently have a pending event.
epoll_wait() is, underneath, just "drain (some of) the ready list."
What makes epoll interesting is the level-triggered vs. edge-triggered
distinction, controlled per-fd by the EPOLLET flag:
- level-triggered (the default) -- an fd is reported EVERY time you poll, for as long as its underlying condition (e.g. "socket has unread data") stays true.
- edge-triggered -- an fd is reported exactly ONCE per transition from not-ready to ready. Polling again won't re-report it until the condition goes false and then becomes true again.
register(fd, edge_triggered) and set_condition(fd, ready) (given)
simulate an fd being watched and its underlying readiness condition
changing (e.g. data arriving on a socket). set_condition(fd, True) only
enqueues a NEW notification on a false->true transition -- exactly like how
ep_poll_callback() only fires on a real wakeup, not on every check.
Implement EpollInstance.poll(): drain the current ready list and return
the fds that fired, in the order they became ready. Apply the
level/edge-triggered rule to decide which fds go back onto the ready list
for the NEXT poll() call:
- level-triggered: stays on the ready list if its condition is STILL true.
- edge-triggered: never goes back on the ready list from this poll -- it
only reappears via a fresh false->true transition through
set_condition.
The missing link: closing a watched fd
Also implement unregister(fd) (EPOLL_CTL_DEL): the fd is being closed
and must stop being watched entirely -- including being pulled off the
ready list if it's currently sitting there pending report. Without this, a
later poll() could hand the caller back a file descriptor that's already
been closed, which is exactly the kind of use-after-close bug real event
loops have to guard against explicitly (closing a socket doesn't
automatically un-register it from every epoll instance watching it).
Example
ep = EpollInstance()
ep.register(1, edge_triggered=False)
ep.register(2, edge_triggered=True)
ep.set_condition(1, True)
ep.set_condition(2, True)
ep.poll() # -> [1, 2] (both fire)
ep.poll() # -> [1] (1 is LT and still ready; 2 is ET and already reported)
ep.unregister(1)
ep.poll() # -> [] (1 is gone, 2 already reported)
Constraints
- Only report an fd whose condition is CURRENTLY true at poll() time --
don't report a stale entry whose condition was cleared (via
set_condition(fd, False)) since it was queued. - Preserve the order fds appear in
self.ready_listfor the returned list. unregisteron an fd that was never registered (or already unregistered) is a no-op, not an error.
Real-world grounding
- The ready list and its level-triggered re-queueing behavior:
fs/eventpoll.c(seerdllist,ep_poll_callback(), and theEPOLLETbit inEP_PRIVATE_BITS).
Real epoll's ready list is protected by a spinlock (since
ep_poll_callback() can fire from interrupt context when a device
signals readiness) and uses a second overflow list (ovflist) to handle
events that arrive while a scan of rdllist is already in progress -- both
concerns this exercise sidesteps by construction, since nothing here runs
concurrently. The reporting rule itself -- report while level-triggered and
still true, report once per edge for edge-triggered -- is exactly what you
implemented.