Skip to content

D Language: Existing Async I/O & Event-Loop Options

A survey of what the D ecosystem already provides for asynchronous I/O and event loops — the dominant fiber framework (vibe.d / vibe-core / eventcore), transparent fiber schedulers (Photon), the low-level io_uring binding (during), and the druntime primitives a new loop would build on — to frame what an io_uring-first Sparkles event-horizon library would add.

Scope. This is a landscape / reference doc, not a single-library deep-dive. It maps the field, names real types and file paths from the cloned source trees, verifies version/maintenance status against the upstream repos and the DUB registry, and ends with a gap analysis. For the io_uring mechanics referenced throughout, see io_uring overview; for cross-language framing of the same patterns (Tokio, Glommio, Seastar, libuv, …) see the comparison.


Overview

D occupies an unusual position in the async-I/O design space. Like Rust and C++, it is a systems language with manual-memory and @nogc paths, RAII, and templates; but unlike them, it ships stackful fibers in its runtime (core.thread.Fiber) and a garbage collector by default. That combination has historically pushed D's async story toward fiber-per-connection, direct-style blocking-looking code rather than the async/await state-machine model of Rust or C#. You write code that looks synchronous; the framework parks the fiber on a would-block and resumes it on readiness.

The ecosystem has three layers worth separating:

LayerConcernRepresentative
Runtime primitivesStackful context switch, message passingcore.thread.Fiber, std.concurrency
Event-loop abstractionReadiness/completion demultiplexing across OSeseventcore (drivers), Photon scheduler
High-level frameworkSockets, HTTP, files, RPC with fiber schedulingvibe-core / vibe.d, Photon + photon-http
Raw kernel bindingDirect io_uring SQE/CQE access, no schedulingduring

The crucial observation for Sparkles is that none of the mainstream D frameworks are io_uring-first. vibe.d's default Linux backend is epoll (reactor-style readiness), with an experimental UringEventDriver; Photon's DLang implementation drives epoll on Linux and kqueue on macOS. The only mature, up-to-date io_uring code is during — and it is deliberately a binding, not a loop: it provides SQE/CQE building blocks and leaves scheduling to the caller. There is a clear, unoccupied slot for a @nogc/@safe, completion-first (Proactor) event loop built on a modern io_uring feature set.


Options at a glance

ProjectMechanismBackend(s)@nogc/betterCStatus (mid-2026)Link
vibe.d / vibe-coreStackful fibers, direct-style I/Ovia eventcoreNo (GC, exceptions)Active, dominantrepo · docs
eventcoreProactor (callback) driver abstractionepoll, kqueue, IOCP, CFRunLoop, select, libasync, io_uring (experimental)Partial (@nogc-friendly API, GC handle store)Activerepo · API
Photon (DLang)Stackful fibers + transparent libc syscall interceptionepoll (Linux), kqueue (macOS)No (GC default)Active (v0.19.x)repo · DUB
duringLow-level io_uring SQE/CQE binding (no scheduler)io_uring only (Linux)Yes (@nogc nothrow betterC)Active (v0.5.0)repo · DUB
core.thread.FiberStackful coroutines (no I/O integration)n/anothrow @nogc (most ops)druntime stdlibdocs
std.concurrencyActor-style message passing between threads/fibersn/a (OS threads)No (GC messages)Phobos stdlibdocs
libasyncCallback event loopepoll, kqueue, IOCPNoLow activityrepo

Notes:

  • "Backend(s)" is the OS demultiplexing primitive. eventcore is the only project listing an io_uring driver, and it is flagged experimental, Linux-only in its own README.
  • Photon's DLang project (DmitryOlshansky/photon) is distinct from PhotonLibOS (alibaba/PhotonLibOS, C++), which does drive io_uring. Conflating them is a common error; only the C++ project has an io_uring engine today.

vibe.d / vibe-core / eventcore — the dominant framework

Layering

vibe.d is split into three packages with a clean dependency stack:

vibe.d (HTTP, web, RPC, DB drivers)
  └── vibe-core (fibers, tasks, sockets, files, channels — the scheduler)
        └── eventcore (OS event-loop driver abstraction)

eventcore is described by its authors as a "high-performance native event loop abstraction for D" following a Proactor (callback-on-completion) shape, even though most of its concrete drivers sit on reactor primitives (epoll/kqueue) and synthesize completion callbacks on top. vibe-core is the layer that turns those callbacks into stackful-fiber suspension: a Task (a vibe fiber) issues a read, eventcore registers the FD, the fiber yields, and vibe-core resumes the task from the completion callback. To user code the call simply blocks.

Both eventcore and vibe-core are MIT-licensed (© Sönke Ludwig).

eventcore drivers

eventcore selects a driver per platform. The driver names from its README:

DriverPlatformUnderlying primitive
SelectEventDrivercross-platform fallbackselect(2)
EpollEventDriverLinux / Androidepoll (level/edge readiness)
KqueueEventDrivermacOS / *BSDkqueue
WinAPIEventDriverWindowsIOCP / overlapped I/O
CFRunloopEventDrivermacOS / iOSCoreFoundation run loop (GUI integration)
LibasyncEventDriveranylibasync (experimental)
UringEventDriverLinuxio_uring (experimental)

The EventDriver interface (source/eventcore/driver.d) decomposes the loop into sub-driver interfaces — EventDriverCore, EventDriverSockets, EventDriverFiles, EventDriverTimers, EventDriverEvents, EventDriverSignals, EventDriverDNS — each returning opaque integer-keyed handles (StreamSocketFD, FileFD, TimerID, …) rather than pointers, which keeps the hot API @nogc-friendly even though callback closures and the handle store touch the GC.

io_uring status

The io_uring driver in eventcore is real but not the default. The original integration started as PR #175 ("Use io_uring for files on linux") — a proof of concept that wired io_uring into the existing epoll loop for files only, with timers noted as an easy follow-up. As of mid-2026 the UringEventDriver is shipped but flagged experimental, Linux-only in the README; epoll remains the production Linux backend. This means vibe.d users today are on a reactor (readiness) model on Linux, not a completion model, and do not get io_uring's batched-submission or registered-buffer/registered-file benefits by default.

What it looks like

vibe-core code is direct-style: the fiber blocks, the loop multiplexes.

d
// vibe-core: blocking-looking, fiber-scheduled (illustrative, not betterC)
// cf. vibe-d/vibe-core source/vibe/core/net.d
import vibe.core.net;
listenTCP(8080, (TCPConnection conn) {
    ubyte[256] buf;
    while (!conn.empty) {
        auto n = conn.read(buf[], IOMode.once); // fiber yields here, resumes on data
        conn.write(buf[0 .. n]);
    }
});

The read/write calls suspend the current Task; vibe-core schedules another ready fiber meanwhile. This is the same direct-style ergonomics that Eio brings to OCaml 5 via effects and that Java Loom brings to the JVM via virtual threads — except D achieves it with plain stackful fibers and a hand-written scheduler rather than a language-level effect or a runtime-level continuation.

Strengths / weaknesses for framing Sparkles

  • Strengths: mature, broad protocol support (HTTP/1+2, WebSockets, TLS, Redis, Mongo), cross-platform, direct-style ergonomics, structured Task lifetimes and TaskPool/channels.
  • Weaknesses (relative to an io_uring-first goal): GC-and-exception coupling makes @nogc/betterC use impractical; the Linux backend is reactor/epoll by default; the io_uring path is experimental and file-only in origin; the proactor abstraction adds a callback indirection layer that a native completion loop would not need.

Photon — transparent fiber scheduler

Photon (DmitryOlshansky/photon, Boost license, v0.19.1 on DUB as of May 2026) takes a strikingly different approach: instead of asking you to call its socket types, it overrides the libc syscall wrappers so that any blocking call — including those inside third-party C libraries — is transparently rerouted through its fiber-aware pseudo-blocking runtime. It markets itself as bringing "Golang-style concurrency to D transparently."

Model

  • Stackful fibers (built on core.thread.Fiber), kept cheap with modest stacks.
  • Multi-threaded scheduler: fibers are distributed across OS worker threads (M:N).
  • Transparent interception: the libc syscall trampoline checks whether it is running on a Photon fiber; if so, it issues the non-blocking variant and parks the fiber on the event loop; if not, it passes through to the real syscall. This is what lets unmodified libraries (e.g. std.net.curl, ZeroMQ bindings) become fiber-aware without rewrites.
  • Backends: the Linux backend lives in src/photon/linux/ (core.d, support.d, syscalls.d) and is built on epoll; macOS support (added in the v0.15 line, Sept 2025) uses kqueue. The DLang Photon does not currently use io_uring (that is the separate Alibaba PhotonLibOS C++ project).

API

d
// Photon: transparent scheduling (illustrative)
// cf. DmitryOlshansky/photon README
import photon;
void main() {
    initPhoton();                 // initialize scheduler data structures
    go({                          // root fiber: accept/connect, spawn more fibers
        // ordinary "blocking" socket code here is transparently async
    });
    runScheduler();               // run until all fibers complete
}

vibe.d-lite is an experimental reimplementation of vibe-core on top of Photon's scheduler, showing the appetite for swapping vibe's loop for a different scheduler.

Relevance to Sparkles

Photon's transparent-interception trick is powerful for retrofitting existing code but is fundamentally a readiness/epoll design that fights the grain of a @nogc/betterC library: it depends on libc-symbol overriding and a GC-backed scheduler. Its value to Sparkles is as a design data point (M:N stackful scheduling in D is viable and performant) rather than a foundation.


during — the low-level io_uring binding

during (tchaloupka/during, BSL-1.0, by Tomáš Chaloupka) is the most relevant existing component: a @nogc nothrow betterC-capable idiomatic D wrapper over io_uring that deliberately does not link liburing and does not impose a scheduler. Its README states it is "just a low level wrapper… but attempts to provide building blocks for it." The latest published release is v0.5.0 (DUB, 2026-05-19), with parity up to Linux 6.x and liburing 2.9 — full opcode and register-opcode coverage including zero-copy networking, futex ops, socket lifecycle, vectored fixed I/O, pipe, SQE128, bundles, wait registration, ring resize, buffer cloning, NAPI, and a BPF filter. (The local clone tracks unreleased work past the v0.5.0 changelog, a "Linux 7.1 catch-up" branch adding write_stream, CQE32/F_32, and other 7.0/7.1-era fields — only the published v0.5.0 surface is treated as stable here.)

This is the natural substrate for a Sparkles loop; the rest of this section reads its actual API from source/during/.

The Uring handle

Uring (source/during/package.d) is the central object — a thin, refcounted, RAII wrapper around a UringDesc* payload holding the kernel ring FD plus the mmapped submission/completion rings. It is created by free function setup:

d
// source/during/package.d
int setup(ref Uring uring, uint entries = 128, SetupFlags flags = SetupFlags.NONE) @safe;
int setup(ref Uring uring, uint entries, ref const SetupParameters params) @safe;

Uring then exposes the ring as a D range plus a builder:

MemberRole
put(entry) / put(op) / putWith!(FN)(args)enqueue an SQE (whole entry, custom struct, or fill-in-place) — chainable, returns ref Uring
next() / next128()advance the SQ and hand back a writable ref SubmissionEntry slot (the 128 variant reserves a contiguous SQE128 slot)
submit(want) / submitAndWait(want) / wait(want)call io_uring_enter(2); want > 0 blocks for that many CQEs
empty / front / popFront / lengthInputRange over completed CompletionEntry values
full / capacity / dropped / overflowSQ/CQ backpressure introspection
probe()returns a Probe (wraps IORING_REGISTER_PROBE) to test per-op support at runtime

The range design is what makes the README's example read so cleanly — you copy operations into the ring and iterate completions out of it:

d
// source/during README — range + chaining
Uring io;
io.setup();

io.put(entry)                          // whole SubmissionEntry as-is
  .put(MyOp(Operation.NOP, 2))         // custom struct, fields copied by name
  .putWith!((ref SubmissionEntry e) {  // fill the next slot in place
      e.prepNop();
      e.user_data = 42;
  })
  .submit(1);                          // submit + wait for >=1 completion

assert(io.front.user_data == 1);
io.popFront();                         // consume one CQE

Submission / completion primitives

SubmissionEntry and CompletionEntry (source/during/io_uring.d) are exact, union-rich mirrors of the kernel struct io_uring_sqe / struct io_uring_cqe:

d
// source/during/io_uring.d (abridged)
struct SubmissionEntry {
    Operation            opcode;     // IORING_OP_*
    SubmissionEntryFlags flags;      // IOSQE_* (FIXED_FILE, IO_LINK, …)
    ushort               ioprio;
    int                  fd;
    union { ulong off; ulong addr2; /* cmd_op since 5.19 */ }
    union { ulong addr; ulong splice_off_in; /* level/optname since 6.7 */ }
    uint  len;
    union { ReadWriteFlags rw_flags; TimeoutFlags timeout_flags;
            MsgFlags msg_flags; uint open_flags; uint nop_flags; /* … */ }
    ulong user_data;                 // echoed back in the CQE
    union { ushort buf_index; ushort buf_group; }
    ushort personality;
    union { uint file_index; uint zcrx_ifq_idx; /* … */ }
    union { struct { ulong addr3; ulong[1] __pad2; } ubyte[0] cmd; }
    void clear() @safe nothrow @nogc;
}

struct CompletionEntry {
    ulong    user_data;   // copied from the SQE
    int      res;         // result (>=0) or -errno
    CQEFlags flags;       // BUFFER, MORE, SOCK_NONEMPTY, NOTIF, BUF_MORE, F_32, …
    ulong[0] big_cqe;     // present on CQE32 rings
}

The unions carry per-field since Linux x.y annotations directly in source (e.g. msg_ring_flags "from Linux 6.0", install_fd_flags "from Linux 6.7", write_stream "from Linux 7.1"), so version-gating decisions can be read off the binding. The Operation enum enumerates every opcode with its kernel name in a /// doc-comment — NOP=0, READV=1, … SEND_ZC=47, SENDMSG_ZC=48, RECV_ZC=58, READV_FIXED=60, up to NOP128=63.

Prep helpers

For each opcode during provides a prepXxx UFCS helper that fills a SubmissionEntry in place (chainable, mostly @safe/@trusted as the syscall surface demands). A non-exhaustive map of the families:

FamilyHelpers (examples)
File RWprepRead, prepWrite, prepReadv/prepWritev(2), prepReadFixed, prepWriteFixed, prepReadvFixed, prepWritevFixed
SyncprepFsync, prepSyncFileRange, prepFallocate, prepFtruncate
NetprepAccept/prepAcceptDirect, prepMultishotAccept/prepMultishotAcceptDirect, prepConnect, prepSend/prepRecv(Multishot), prepSendMsg/prepRecvMsg(Multishot), prepShutdown, prepSocket(Direct), prepBind, prepListen
Zero-copyprepSendZc, prepSendZcFixed, prepSendmsgZc, prepRecvZc
Poll/timeoutprepPollAdd, prepPollMultishot, prepPollRemove/Update, prepTimeout(Remove/Update), prepLinkTimeout
Files/FSprepOpenat(Direct/2), prepClose(Direct), prepStatx, prepRenameat, prepUnlinkat, prepMkdirat, prepSymlinkat, prepLinkat, prepFilesUpdate, prepFixedFdInstall
ControlprepNop, prepCancel(Fd), prepSplice, prepTee, prepMsgRing(Fd/FdAlloc), prepProvideBuffers/prepRemoveBuffers, prepEpollCtl/prepEpollWait, prepUringCmd(128), prepFutexWait/Wake/Waitv, prepWaitid
User datasetUserData / setUserDataRaw (store a pointer/value to round-trip via user_data)

Registration API (the io_uring superpowers)

The performance-critical io_uring_register(2) operations are exposed as Uring methods:

MethodWrapsPurpose
registerBuffers(T) / unregisterBuffersREGISTER_BUFFERSpin user buffers so READ_FIXED/WRITE_FIXED (selected via buf_index) skip per-op page pinning
registerFiles / registerFilesUpdate / unregisterFilesREGISTER_FILES(_UPDATE)pre-register FDs; SQEs set IOSQE_FIXED_FILE and use the table index in fd, avoiding per-op fget/fput
registerEventFD / unregisterEventFDREGISTER_EVENTFDwire an eventfd for CQ-change notification (loop integration)
registerPersonality / unregisterPersonalityREGISTER_PERSONALITYrun a request under a credentials snapshot
registerBufRingREGISTER_PBUF_RINGkernel-side provided-buffer ring for recv buffer selection (since Linux 5.19)

The bundled examples/echo_server shows the registered-buffer flow end to end: it mallocs one big slab, calls io.registerBuffers(buf[0..total]), carves per-connection buffers out of it with a fixed pool, and submits prepReadFixed/prepWriteFixed with bufferIndex 0. Its loop is the canonical hand-rolled completion loop — io.wait(1), dispatch on io.front.user_data (a context pointer), io.popFront() — exactly the loop a scheduler would replace.

What during deliberately does not do

It has no scheduler, no fibers, no task model, no cross-platform fallback. There is no notion of "await this op" — you submit, you drain CQEs, you correlate by user_data yourself. That is the line Sparkles would cross.


The druntime / Phobos primitives a new loop builds on

A from-scratch D event loop does not start from zero. Two runtime facilities matter.

core.thread.Fiber — stackful coroutines

D ships stackful fibers in druntime (core/thread/fiber/). A Fiber wraps a void function()/void delegate() body on its own stack and provides a tiny cooperative-scheduling surface:

d
// core/thread/fiber/{base,package}.d (abridged signatures)
class Fiber : FiberBase {
    this(void function() fn, size_t sz = pageSize*defaultStackPages,
         size_t guardPageSize = pageSize) nothrow;
    this(void delegate() dg, size_t sz = pageSize*defaultStackPages,
         size_t guardPageSize = pageSize) nothrow;
    static Fiber getThis() @safe nothrow @nogc;     // currently-running fiber
}
class FiberBase {
    final Throwable call(Rethrow = Rethrow.yes);    // run/resume until next yield/return
    static void yield() nothrow @nogc;              // suspend current fiber
    static void yieldAndThrow(Throwable) nothrow @nogc;
    final void reset();                             // recycle a TERM fiber
    final @property State state() const @safe pure nothrow @nogc;  // HOLD / EXEC / TERM
}

Key properties for a loop designer:

PropertyValue
Switch costa register save/restore + stack-pointer swap (hand-written asm: switch_context_asm.S); no heap alloc per switch
Default stackpageSize * defaultStackPages = 4 pages on Linux (8 on Windows/macOS-x86_64)
State machineState.{HOLD, EXEC, TERM}; a TERM fiber must be reset before reuse
Safetyyield/getThis/state are nothrow @nogc; construction allocates the stack (mmap with a guard page)
Cost driverper-fiber stack reservation — the M:N "memory tax" relative to Rust/C# poll-based state machines

The loop pattern is: each accepted connection runs in a Fiber; on a would-block the fiber's I/O shim submits an op and calls Fiber.yield(); the loop's completion handler looks up the parked fiber from the CQE's user_data and calls fib.call() to resume it. This is exactly how vibe-core and Photon work, and the same stackful-fiber-as-green-thread idea underpins Java Loom virtual threads and OCaml's Eio (though Eio reifies suspension as an algebraic effect rather than a raw context switch).

std.concurrency — actor-style message passing

Phobos's std.concurrency provides Erlang-flavored message passing over OS threads (and integrates with fibers via a scheduler interface):

d
// std/concurrency.d (public surface)
struct Tid;                                  // opaque thread/fiber handle
@property Tid thisTid() @safe;
Tid spawn(F, T...)(F fn, T args);            // start fn(args) in a logical thread
Tid spawnLinked(F, T...)(F fn, T args);      // + LinkTerminated on exit
void send(T...)(Tid tid, T vals);            // typed message send
void prioritySend(T...)(Tid tid, T vals);
... receive((T){...}, ...);                  // pattern-match on message type
T receiveOnly(T)();
bool receiveTimeout(Duration, ...);

It is GC-coupled (messages are heap values) and thread-oriented, so it is not a substrate for a @nogc hot loop — but it is the idiomatic D answer for coarse-grained cross-loop coordination (e.g. handing work between per-core io_uring loops), analogous to channels in Go or the cross-thread wakers in Tokio. A Sparkles design that runs one io_uring ring per core (thread-per-core, as in Glommio/Monoio) would use something like this — or a leaner @nogc channel — for inter-core messages.

The @nogc / @safe / -betterC constraints

Sparkles (per the repo guidelines) targets maximum safety attributes, @nogc/nothrow hot paths, -preview=in and -preview=dip1000 scope semantics, and SmallBuffer-style allocation avoidance. That constraint set rules the existing frameworks out as foundations:

Constraintvibe-corePhotonduringImplication
@nogc hot pathNo (GC Task/closures)No (GC scheduler)Yesonly during is reusable as-is
nothrowNo (exception-based I/O errors)NoYes (returns -errno)error-as-value fits during
-betterCNo (needs druntime/GC)NoYesduring works without druntime
@safe surfacePartialPartialMostly (@trusted at syscall edge)during already isolates unsafe ops
-preview=dip1000 scopen/an/auses return ref/scope in prepsaligns with Sparkles style

The conclusion writes itself: build on during + core.thread.Fiber, not on vibe-core or Photon. during supplies a @nogc nothrow betterC SQE/CQE substrate; Fiber supplies cheap stackful suspension; everything between them — the scheduler, the await/suspend shim, the readiness-vs-completion bookkeeping, the timer wheel, the cancellation model — is the gap.


Gap analysis — what an io_uring-first Sparkles loop could add

Mapping the landscape against an io_uring-first, @nogc/@safe goal, the unoccupied space is concrete:

Capabilityvibe-corePhotonduringSparkles opportunity
Completion (Proactor) model on Linuxepoll default; uring experimentalepoll onlyraw SQE/CQEA loop that is natively completion-based on io_uring, not readiness emulated on epoll
@nogc / betterC end to endNoNoYes (binding only)First full @nogc/betterC loop, not just the binding
Scheduler over during(uses eventcore)(own)NoneThe missing layer: park/resume a Fiber keyed by CQE user_data
Modern io_uring features wired into the APIminimalnoneexposed but unusedRegistered buffers/files, provided-buffer rings, multishot accept/recv, SEND_ZC, MSG_RING cross-ring wakeups, DEFER_TASKRUN/SINGLE_ISSUER
Thread-per-core shardingshared loop + workersM:N schedulern/aOne ring per core + MSG_RING/eventfd handoff (à la Glommio/Seastar)
Structured concurrency / cancellationTask scopesnoneprepCancel/prepLinkTimeout rawFirst-class scopes mapped onto IOSQE_IO_LINK + LINK_TIMEOUT + ASYNC_CANCEL
Direct-style ergonomics without GCyes (but GC)yes (but GC)noBlocking-looking API on stackful fibers with zero GC pressure
Graceful kernel fallbackepoll is the fallbackepoll onlynoneRuntime Uring.probe() gating with an epoll/kqueue path for old kernels and macOS

In short, the D ecosystem already proves the ergonomic model (stackful fibers, direct-style I/O — vibe-core, Photon) and already provides the raw kernel surface (during), but no project unifies them into a completion-first, @nogc/@safe, io_uring-native event loop with modern feature usage and structured cancellation. That union — during's SQE/CQE building blocks driving a Fiber-based scheduler, with registered buffers/files, multishot ops, zero-copy send, and MSG_RING cross-core wakeups exposed as safe primitives — is precisely the Sparkles event-horizon niche.

For how this compares to the same niche in other languages — Tokio and Glommio/Monoio in Rust, Seastar and Boost.Asio in C++, libuv, the Go netpoller, and effect-based loops like Eio — see the cross-language comparison and the io_uring feature/timeline references.


Sources