event.h — Cross-Platform Event Loop

Introduction

event.h provides a cross-platform, edge-triggered event loop abstraction for I/O multiplexing. It unifies three OS-specific backends — kqueue (macOS/BSD), epoll (Linux), and poll (POSIX fallback) — behind a single API. The event loop is the central coordination point in xbase: it monitors file descriptors for readiness, dispatches timer callbacks, offloads CPU-bound work to thread pools, and watches for POSIX signals — all from a single thread.

Design Philosophy

  1. Edge-Triggered Everywhere — All three backends operate in edge-triggered mode. kqueue uses EV_CLEAR, epoll uses EPOLLET, and poll emulates edge-triggered behavior by clearing the event mask after each notification (requiring the caller to re-arm via xEventMod()). This design encourages callers to drain fds completely, reducing spurious wakeups.

  2. Backend Selection at Compile Time — The backend is chosen via preprocessor macros (MOO_HAS_KQUEUE, MOO_HAS_EPOLL), with poll as the universal fallback. This means zero runtime dispatch overhead.

  3. Integrated Timer Heap — Rather than requiring a separate timer facility, the event loop embeds a min-heap of timer entries. xEventWait() automatically adjusts its timeout to fire the earliest timer, providing sub-millisecond timer resolution without a dedicated timer thread.

  4. Thread-Pool OffloadxEventLoopSubmit() bridges the event loop and the task system: CPU-bound work runs on a worker thread, and the completion callback is dispatched on the event loop thread via a lock-free MPSC queue + cross-thread wake, ensuring single-threaded callback semantics. Offloaded work can be cancelled via xEventLoopWorkCancel() if it hasn't started yet.

  5. Direct Cross-Thread PostingxEventLoopPost() allows any thread to queue a callback for execution on the event loop thread without involving a thread pool. This is the lightest cross-thread communication primitive — ideal for notifying the loop of external events (e.g., ICE/TURN callbacks, inter-module signals) with zero thread-pool overhead.

  6. Self-Pipe Trick for Signals — On epoll and poll backends, signal delivery uses the self-pipe trick (a sigaction handler writes to a pipe) rather than signalfd, avoiding the fragile requirement of blocking signals in every thread. On kqueue, EVFILT_SIGNAL is used natively.

Architecture

graph TD
    subgraph "Event Loop (single thread)"
        WAIT["xEventWait()"]
        DISPATCH["Dispatch I/O callbacks"]
        TIMERS["Fire expired timers"]
        DONE["Drain done-queue"]
        SWEEP["Sweep deleted sources"]
    end

    subgraph "Backend (compile-time)"
        KQ["kqueue"]
        EP["epoll"]
        PO["poll"]
    end

    subgraph "Cross-Thread"
        WAKE["Wake (EVFILT_USER / eventfd / pipe)"]
        MPSC_Q["MPSC Done Queue"]
        WORKER["Worker Thread Pool"]
        POST["xEventLoopPost()"]
    end

    WAIT --> KQ
    WAIT --> EP
    WAIT --> PO
    KQ --> DISPATCH
    EP --> DISPATCH
    PO --> DISPATCH
    DISPATCH --> TIMERS
    TIMERS --> DONE
    DONE --> SWEEP

    WORKER -->|"push result"| MPSC_Q
    POST -->|"push callback"| MPSC_Q
    MPSC_Q -->|"wake"| WAKE
    WAKE -->|"drain"| DONE

    style WAIT fill:#4a90d9,color:#fff
    style DISPATCH fill:#4a90d9,color:#fff
    style TIMERS fill:#f5a623,color:#fff
    style DONE fill:#50b86c,color:#fff

Event Loop Lifecycle

sequenceDiagram
    participant App
    participant EL as xEventLoop
    participant Backend as kqueue / epoll / poll
    participant Timer as Timer Heap

    App->>EL: xEventLoopCreate()
    App->>EL: xEventAdd(fd, mask, callback)
    App->>EL: xEventLoopTimerAfter(fn, 1000ms)
    App->>EL: xEventLoopRun()

    loop Main Loop
        EL->>Timer: Check earliest deadline
        Timer-->>EL: timeout = min(user_timeout, timer_deadline)
        EL->>Backend: wait(timeout)
        Backend-->>EL: ready events
        EL->>App: callback(fd, mask)
        EL->>Timer: Pop & fire expired timers
        EL->>EL: Sweep deleted sources
    end

    App->>EL: xEventLoopStop()
    App->>EL: xEventLoopDestroy()

Implementation Details

Backend Architecture

Each backend is implemented in a separate .c file that provides the full public API:

FileBackendTrigger ModeSelection
event_kqueue.ckqueueEV_CLEAR (native edge)#ifdef MOO_HAS_KQUEUE
event_epoll.cepollEPOLLET (native edge)#ifdef MOO_HAS_EPOLL
event_poll.cpoll(2)Emulated edge (mask cleared after dispatch)Fallback

All backends share a common base structure (struct xEventLoop_) defined in event_private.h, which contains:

  • A dynamic source array with deferred deletion (sweep after dispatch)
  • A cross-thread wake mechanism (EVFILT_USER on kqueue, eventfd on epoll, pipe on poll) with atomic coalescing
  • A min-heap for builtin timers (protected by timer_mu mutex)
  • A lock-free MPSC done-queue for offload completion and posted callbacks
  • Signal watch slots (up to MOO_SIGNAL_MAX = 64)

Deferred Source Deletion

When xEventDel() is called during a callback dispatch, the source is marked deleted = 1 rather than freed immediately. After the dispatch batch completes, source_array_sweep() frees all deleted sources. This prevents use-after-free when multiple events reference the same source in a single xEventWait() call.

Cross-Thread Wake

Each backend uses the lightest available mechanism for cross-thread wakeup:

BackendMechanismFds Used
kqueueEVFILT_USER with NOTE_TRIGGER0 (kernel event, no fd)
epolleventfd (EFD_NONBLOCK | EFD_CLOEXEC)1 (wake_rfd)
pollNon-blocking pipe (wake_rfd / wake_wfd)2 (POSIX fallback)

xEventWake() triggers the backend-specific notification; the event loop drains it and processes the done-queue. Multiple wakes before the next xEventWait() are coalesced via an atomic wake_pending flag — only the first caller after the loop clears the flag performs the actual syscall, subsequent callers skip it entirely. This reduces wake overhead from O(N) syscalls to O(1) in batch completion scenarios.

Timer Integration

Builtin timers are stored in a min-heap inside the event loop. Before each xEventWait() call, the effective timeout is clamped to the earliest timer deadline. After I/O dispatch, expired timers are popped and fired. Timer operations (xEventLoopTimerAfter, xEventLoopTimerAt, xEventLoopTimerCancel) are thread-safe, protected by timer_mu.

Signal Handling

BackendMechanism
kqueueEVFILT_SIGNAL with EV_CLEAR — native kernel support
epollSelf-pipe trick: sigaction handler writes to a per-signal pipe
pollSelf-pipe trick: same as epoll

The self-pipe approach avoids signalfd's requirement to block signals in all threads, which is fragile in the presence of third-party libraries and test frameworks.

API Reference

Types

TypeDescription
xEventMaskBitmask enum: xEvent_Read (1), xEvent_Write (2), xEvent_Timeout (4)
xEventFuncvoid (*)(int fd, xEventMask mask, void *arg) — I/O callback
xEventTimerFuncvoid (*)(void *arg) — Timer callback
xEventSignalFuncvoid (*)(int signo, void *arg) — Signal callback
xEventDoneFuncvoid (*)(void *arg, void *result) — Offload completion callback
xEventPostFuncvoid (*)(void *arg) — Posted callback (via xEventLoopPost)
xEventLoopOpaque handle to an event loop
xEventSourceOpaque handle to a registered event source
xEventTimerOpaque handle to a builtin timer
xEventWorkOpaque handle to a submitted offload work item

Functions

Lifecycle

FunctionSignatureThread Safety
xEventLoopCreatexEventLoop xEventLoopCreate(void)Not thread-safe
xEventLoopCreateWithGroupxEventLoop xEventLoopCreateWithGroup(xTaskGroup group)Not thread-safe
xEventLoopDestroyvoid xEventLoopDestroy(xEventLoop loop)Not thread-safe
xEventLoopRunvoid xEventLoopRun(xEventLoop loop)Not thread-safe (call from one thread)
xEventLoopStopvoid xEventLoopStop(xEventLoop loop)Thread-safe
xEventLoopWaitxErrno xEventLoopWait(xEventLoop loop, int timeout_ms)Not thread-safe (call from one thread)

I/O Sources

FunctionSignatureThread Safety
xEventAddxEventSource xEventAdd(xEventLoop loop, int fd, xEventMask mask, xEventFunc fn, void *arg)Not thread-safe
xEventModxErrno xEventMod(xEventLoop loop, xEventSource src, xEventMask mask)Not thread-safe
xEventDelxErrno xEventDel(xEventLoop loop, xEventSource src)Not thread-safe
xEventWaitint xEventWait(xEventLoop loop, int timeout_ms)Not thread-safe

Timers

FunctionSignatureThread Safety
xEventLoopTimerAfterxEventTimer xEventLoopTimerAfter(xEventLoop loop, xEventTimerFunc fn, void *arg, uint64_t delay_ms)Thread-safe
xEventLoopTimerAtxEventTimer xEventLoopTimerAt(xEventLoop loop, xEventTimerFunc fn, void *arg, uint64_t abs_ms)Thread-safe
xEventLoopTimerCancelxErrno xEventLoopTimerCancel(xEventLoop loop, xEventTimer timer)Thread-safe

Cross-Thread

FunctionSignatureThread Safety
xEventWakexErrno xEventWake(xEventLoop loop)Thread-safe (signal-handler-safe)
xEventLoopPostxErrno xEventLoopPost(xEventLoop loop, xEventPostFunc fn, void *arg)Thread-safe
xEventLoopSubmitxErrno xEventLoopSubmit(xEventLoop loop, xTaskGroup group, xTaskFunc work_fn, xEventDoneFunc done_fn, void *arg, xEventWork *out)Thread-safe
xEventLoopWorkCancelxErrno xEventLoopWorkCancel(xEventLoop loop, xEventWork work)Thread-safe

Signal

FunctionSignatureThread Safety
xEventLoopSignalWatchxErrno xEventLoopSignalWatch(xEventLoop loop, int signo, xEventSignalFunc fn, void *arg)Not thread-safe

Deprecated

FunctionSignatureReplacement
xEventLoopNowMsuint64_t xEventLoopNowMs(void)xMonoMs() from <xbase/time.h>

Usage Examples

Basic Event Loop with Timer

#include <stdio.h>
#include <xbase/event.h>

static void on_timer(void *arg) {
    printf("Timer fired!\n");
    xEventLoopStop((xEventLoop)arg);
}

int main(void) {
    xEventLoop loop = xEventLoopCreate();
    if (!loop) return 1;

    // Fire after 500ms
    xEventLoopTimerAfter(loop, on_timer, loop, 500);

    xEventLoopRun(loop);
    xEventLoopDestroy(loop);
    return 0;
}

Monitoring a File Descriptor

#include <stdio.h>
#include <unistd.h>
#include <xbase/event.h>

static void on_readable(int fd, xEventMask mask, void *arg) {
    char buf[1024];
    ssize_t n;
    // Edge-triggered: must drain completely
    while ((n = read(fd, buf, sizeof(buf))) > 0) {
        fwrite(buf, 1, (size_t)n, stdout);
    }
    (void)mask;
    (void)arg;
}

int main(void) {
    xEventLoop loop = xEventLoopCreate();

    // Monitor stdin for readability
    xEventAdd(loop, STDIN_FILENO, xEvent_Read, on_readable, NULL);

    // Run for up to 10 seconds, then stop
    xEventLoopTimerAfter(loop, (xEventTimerFunc)xEventLoopStop, loop, 10000);
    xEventLoopRun(loop);

    xEventLoopDestroy(loop);
    return 0;
}

Bounded Wait with Timeout

#include <stdio.h>
#include <xbase/event.h>

static void on_done(void *arg) {
    printf("Work complete!\n");
    xEventLoopStop((xEventLoop)arg);
}

int main(void) {
    xEventLoop loop = xEventLoopCreate();

    xEventLoopTimerAfter(loop, on_done, loop, 500);

    // Wait up to 5 seconds — returns xErrno_Ok if stopped,
    // or xErrno_Timeout if the deadline expires.
    xErrno rc = xEventLoopWait(loop, 5000);
    if (rc == xErrno_Timeout) {
        printf("Timed out!\n");
    }

    xEventLoopDestroy(loop);
    return 0;
}

Posting a Callback to the Loop Thread

#include <stdio.h>
#include <pthread.h>
#include <xbase/event.h>

static void on_notify(void *arg) {
    // Runs on the event loop thread — safe to access loop state
    printf("Notified from another thread!\n");
    xEventLoopStop((xEventLoop)arg);
}

static void *background_thread(void *arg) {
    xEventLoop loop = (xEventLoop)arg;
    // Do some work...
    xEventLoopPost(loop, on_notify, loop);
    return NULL;
}

int main(void) {
    xEventLoop loop = xEventLoopCreate();

    pthread_t th;
    pthread_create(&th, NULL, background_thread, loop);

    xEventLoopRun(loop);

    pthread_join(th, NULL);
    xEventLoopDestroy(loop);
    return 0;
}

Offloading Work to a Thread Pool

#include <stdio.h>
#include <xbase/event.h>

static void *heavy_work(void *arg) {
    // Runs on a worker thread
    int *val = (int *)arg;
    *val *= 2;
    return val;
}

static void on_done(void *arg, void *result) {
    // Runs on the event loop thread
    int *val = (int *)result;
    printf("Result: %d\n", *val);
    (void)arg;
}

int main(void) {
    xEventLoop loop = xEventLoopCreate();
    int value = 21;

    xEventLoopSubmit(loop, NULL, heavy_work, on_done, &value, NULL);

    // Run briefly to process the completion
    xEventLoopTimerAfter(loop, (xEventTimerFunc)xEventLoopStop, loop, 1000);
    xEventLoopRun(loop);

    xEventLoopDestroy(loop);
    return 0;
}

Use Cases

  1. Network Servers — Register listening sockets and accepted connections with the event loop. Use edge-triggered callbacks to read/write data without blocking. Combine with xSocket for idle-timeout support.

  2. Timer-Driven State Machines — Use xEventLoopTimerAfter() to schedule state transitions, retries, or heartbeat checks. The timer is integrated into the event loop, so no separate timer thread is needed.

  3. Hybrid I/O + CPU Workloads — Use xEventLoopSubmit() to offload CPU-intensive parsing or compression to a thread pool, then process results on the event loop thread where I/O state is safely accessible. Use xEventLoopWorkCancel() to cancel pending work when the associated resource is being released.

  4. Cross-Thread Notifications — Use xEventLoopPost() to notify the event loop from external callbacks (e.g., ICE/TURN completions, OS notifications) without the overhead of a thread pool round-trip. The callback runs on the loop thread, so no additional synchronisation is needed.

Best Practices

  • Always drain fds in edge-triggered mode. Read/write until EAGAIN in every callback. Missing data means you won't be notified again until new data arrives.
  • Never block in callbacks. The event loop is single-threaded; a blocking call stalls all I/O and timer processing. Offload heavy work via xEventLoopSubmit().
  • Prefer xEventLoopPost() over xEventLoopSubmit() when no worker thread is needed. If you just need to run a callback on the loop thread from another thread, xEventLoopPost() avoids the thread-pool overhead entirely.
  • Use xEventLoopRun() for the main loop. It handles timer dispatch and stop-flag checking automatically. Only use xEventWait() directly if you need custom loop logic. For tests or scenarios where you need a bounded wait, use xEventLoopWait(loop, timeout_ms) — it returns xErrno_Ok when stopped, or xErrno_Timeout if the deadline expires.
  • Cancel offloaded work when releasing resources. If you submit work via xEventLoopSubmit() and the associated resource (passed as arg) is about to be freed, use xEventLoopWorkCancel() to prevent use-after-free. If cancel succeeds (xErrno_Ok), the arg is safe to free immediately. If it fails (xErrno_InvalidState), the work is already running — let done_fn handle cleanup.
  • Cancel timers you no longer need. Uncancelled timers hold memory until they fire. Use xEventLoopTimerCancel() to free them early.
  • Be aware of the poll backend's edge emulation. On systems without kqueue or epoll, the poll backend clears the event mask after dispatch. You must call xEventMod() to re-arm.

Comparison with Other Libraries

Featurexbase event.hlibeventlibevlibuv
Trigger ModeEdge-triggered onlyLevel (default), edge optionalLevel + edgeLevel-triggered
Backendskqueue, epoll, pollkqueue, epoll, poll, select, devpoll, IOCPkqueue, epoll, poll, select, portkqueue, epoll, poll, IOCP
Timer IntegrationBuilt-in min-heapSeparate timer APIBuilt-inBuilt-in
Thread PoolBuilt-in (xEventLoopSubmit)None (external)None (external)Built-in (uv_queue_work)
Signal HandlingSelf-pipe / EVFILT_SIGNALevsignalev_signaluv_signal
API StyleOpaque handles, C99Struct-based, C89Struct-based, C89Handle-based, C99
Binary Size~15 KB~200 KB~50 KB~500 KB
DependenciesNoneNoneNoneNone
Windows SupportNot yetYes (IOCP)Yes (select)Yes (IOCP)
Design GoalMinimal building blockFull-featured frameworkMinimal + performantCross-platform framework

Key Differentiator: xbase's event loop is intentionally minimal — it provides the essential primitives (I/O, timers, signals, thread-pool offload) without buffered I/O, DNS resolution, or HTTP parsing. This makes it ideal as a foundation layer for higher-level libraries (like xhttp) rather than a standalone application framework.

Benchmark

Environment: Apple M3 Pro, 36 GB RAM, macOS 26.4, Release build (-O2), kqueue backend. Source: xbase/event_bench.cpp Full report: docs/bench/event_loop.md

Core Operations

BenchmarkTime (ns)CPU (ns)Iterations
BM_EventLoop_CreateDestroy700700974,157
BM_EventLoop_WakeLatency4134131,717,088
BM_EventLoop_PipeAddDel1,1441,144612,118
  • Create/Destroy takes ~700ns — reduced from ~2.8µs after eliminating the wake pipe (no more pipe() + two extra fds).
  • Wake latency is ~413ns per wake+wait cycle via EVFILT_USER, down from ~879ns with the old pipe mechanism — a 2.1× improvement.

libuv Baseline Comparison

DimensionmoolibuvRatio
Wake Latency413 ns417 nsTied (moo 1.01× faster)
Timer (single)461 ns1,517 nsmoo 3.3× faster
Timer (×1000)43,545 ns68,659 nsmoo 1.6× faster
Offload (single)3,785 ns3,449 nslibuv 1.1× faster (tied)
Offload (×1000)456,426 ns218,513 nslibuv 2.1× faster

Key Observations:

  • Wake latency — Now effectively tied with libuv (413ns vs 417ns) after switching to EVFILT_USER (kqueue) / eventfd (epoll) + atomic wake coalescing. Previously 2.1× slower.
  • Timer — moo now wins across all batch sizes thanks to batch-pop with single lock acquisition and timer struct freelist pooling. Previously libuv was 4–5× faster at batch sizes.
  • Offload round-trip — libuv remains ~2× faster at scale. The gap has narrowed at small batch sizes thanks to wake coalescing and work item pooling.