7.5 KiB
Cross-Platform Event Notification
Location: lmcache/v1/platform/event_notifier.py
(re-exported from the lmcache.v1.platform package)
Background
LMCache's multiprocess / distributed runtime uses a large number of pollable file descriptors to wake background loops from other threads:
mq.MessageQueueServer._output_efd— notify the poller that a response frame is ready on the thread-pool output queue.StoreListener._event_fd— notify the store-controller loop that L1 has finished writing new keys.PrefetchController._submission_efd— notify the prefetch loop of a newly submitted request.- Every
L2Adapterexposes three event fds (get_store_event_fd(),get_lookup_and_lock_event_fd(),get_load_event_fd()) so controllers canselect.poll()across all adapters in a single loop.
Previously these were all raw os.eventfd(...). os.eventfd is a
Linux-only syscall, which blocks the MP/distributed stack from
running on macOS (and any other non-Linux POSIX system).
Goals
- Let the exact same code paths run on Linux (using
eventfd) and on macOS / other POSIX (using a self-pipe). - Keep the call-site diff small — existing sites should look as
close as possible to the
os.eventfd(...)version they replace. - No global state, no monkey-patching of
os, no hidden process-wide registries. - Give callers a single abstraction rather than each module doing
its own
hasattr(os, "eventfd")dance.
Non-Goals
- Cross-process signaling.
EventNotifieris for in-process thread-to-thread wake-ups. The fd is created in one process and never shared via IPC. - Counting semaphore semantics.
notify()is a binary signal; coalescing is expected.
Design
Public API
# lmcache/v1/platform/event_notifier.py
# (re-exported via `lmcache.v1.platform` for short imports)
class EventNotifier(ABC):
def fileno(self) -> int: ... # poll-able fd
def notify(self) -> None: ... # signal (idempotent)
def consume(self) -> None: ... # drain (non-blocking)
def close(self) -> None: ... # idempotent
def __enter__(self) -> "EventNotifier": ...
def __exit__(self, *exc) -> None: ...
def create_event_notifier() -> EventNotifier: ...
def consume_fd(fd: int) -> None: ...
Two concrete implementations:
| Implementation | Backend | Selected when |
|---|---|---|
EventfdNotifier |
os.eventfd |
HAS_EVENTFD (Linux + Py 3.10+) |
PipeNotifier |
os.pipe |
otherwise (macOS / other POSIX) |
Selection is centralized in create_event_notifier(); callers never
branch on the platform. HAS_EVENTFD is defined as
hasattr(os, "eventfd") in lmcache/v1/platform/event_notifier.py
and re-exported from the lmcache.v1.platform package — the single
source of truth for platform capability detection.
On non-Linux platforms the EventfdNotifier symbol still exists as
a placeholder whose __init__ raises RuntimeError, so
isinstance(..., EventfdNotifier) checks in tests stay valid.
consume_fd(fd) is a module-level helper for callers that only have
a raw fd (typical case: a controller that received an fd via
adapter.get_store_event_fd() and wants to drain it after
poll()). It dispatches to the right syscall based on the same
HAS_EVENTFD flag.
Semantics
EventNotifier models a binary signal:
- After
notify(), the fd is readable viaselect.poll(). consume()drains all pending signals so the fd is no longer readable.- Multiple
notify()calls before aconsume()are coalesced. Callers must not rely on a counter — they must re-check the underlying work queue after being woken. consume()is non-blocking and safe to call on an unsignaled fd.close()is idempotent.
Exception contract
consume() and the module-level consume_fd(fd) helper only
swallow BlockingIOError (i.e. "no data pending on a non-blocking
fd"). Every other OSError subclass — most importantly EBADF from
a stale or already-closed fd — propagates to the caller so caller
bugs surface instead of being silently absorbed.
notify() swallows BlockingIOError in PipeNotifier because a
full pipe means a signal is already pending, which is the
post-condition we want. On EventfdNotifier, the counter only
blocks on overflow (max 2**64 - 2), which we treat as unreachable
in practice.
close() is fully idempotent and swallows any OSError during
os.close, since there is nothing a caller could do about it.
Backend Differences
EventfdNotifier (Linux) wraps a single eventfd opened with
EFD_NONBLOCK | EFD_CLOEXEC. notify() writes 1, consume()
reads the 8-byte counter.
PipeNotifier (macOS / other POSIX) wraps an os.pipe() pair.
Since Python 3.4 (PEP 446) os.pipe() already sets FD_CLOEXEC,
so __init__ only needs to flip both ends to non-blocking via
os.set_blocking(fd, False) — a portable API that avoids pulling
in fcntl and is friendlier to a future Windows pipe backend. If
either set_blocking call fails, both fds are closed before the
error propagates so no descriptors leak.
notify() writes a single \x01 byte; consume() drains the read
end in a loop. If the pipe buffer is full, notify() is a no-op —
the signal is already pending. Both PipeNotifier.consume() and
the module-level consume_fd() treat an empty read (b'', i.e.
the write end was closed) as "drained" and stop looping, so a dead
pipe can never spin a background event loop.
Architecture
graph LR
Caller -->|create_event_notifier| Factory
Factory -->|HAS_EVENTFD| EventfdNotifier
Factory -->|fallback| PipeNotifier
EventfdNotifier -->|os.eventfd| Kernel
PipeNotifier -->|os.pipe| Kernel
Call-Site Migration
Before (Linux-only):
import os
self._event_fd = os.eventfd(0, os.EFD_NONBLOCK | os.EFD_CLOEXEC)
...
os.eventfd_write(self._event_fd, 1)
...
os.eventfd_read(self._event_fd)
...
os.close(self._event_fd)
After (cross-platform):
from lmcache.v1.platform import create_event_notifier
self._event_fd = create_event_notifier()
...
self._event_fd.notify()
...
self._event_fd.consume() # or: consume_fd(raw_fd)
...
self._event_fd.close()
The field name (_event_fd, _output_efd, _submission_efd, etc.)
is kept deliberately — the variable is still "the thing you poll
on", and reusing the name keeps the diff focused on the semantic
change.
Testing
Unit tests live in tests/v1/test_event_notifier.py. They cover:
TestEventNotifierAPI— platform-agnostic contract: factory returns anEventNotifiersubclass;fileno()returns a valid fd;notify()makes the fd readable andconsume()clears it; multiplenotify()calls coalesce;consume()andclose()are idempotent; context-manager usage.TestPipeNotifier— pipe-specific: both read/write fds are released byclose(); pipe-full triggers theBlockingIOErrorswallow path innotify(); many create/close cycles do not leak fds; fd is pollable.TestConsumeFd— theconsume_fd(raw_fd)utility drains a signaled fd and is a no-op on an unsignaled one.TestBackendSelection— asserts the factory picksEventfdNotifierwhenHAS_EVENTFDandPipeNotifierotherwise.TestForcedPipeFallback— on Linux CI the factory would normally returnEventfdNotifier; this classmonkeypatchesHAS_EVENTFDtoFalseso the pipe fallback (andconsume_fd's pipe branch) is exercised on every platform.