This is the guide for a program or a library that drives a rotor loop. It says what the loop
promises, what it needs from the caller, and where the limits are. The decision records in
docs/decisions/ say why; this file says what.
rotor is one Zig module. It allocates nothing: the caller hands every loop its memory at init, and every table and queue inside it is bounded by a named limit. A loop belongs to one thread. Every operation ends with exactly one final event. Those three rules shape everything below.
The code here runs. Each Zig block is part of examples/guide.zig or of
the consumer project in examples/consumer/, line for line. zig build test builds and runs both, and the Linux gate runs the guide's program with io_uring and with
epoll.
zig fetch --save git+https://github.com/c4milo/rotor#v0.7.0In build.zig:
const rotor = b.dependency("rotor", .{ .target = target, .release = optimize != .Debug });
exe.root_module.addImport("rotor", rotor.module("rotor"));rotor's build declares release and no optimize option, so a dependency that passes .optimize
prints invalid option: -Doptimize on every build.
Then const rotor = @import("rotor");. The module picks the backend for the host it runs on. On
macOS it is kqueue. On Linux it is io_uring, and epoll where the kernel refuses io_uring, as
Docker's default seccomp profile does and as io_uring_disabled does: the first thing that needs
the answer asks the kernel for a ring, and the answer holds for the life of the process.
rotor.backend() says which one runs (.uring, .epoll or .kqueue), so the fallback is never
silent. Every backend carries the same surface, so the program is the same whichever runs. rotor
builds in Debug and ReleaseSafe; its assertions stay on in production, and it offers no mode that
removes them.
epoll has none of io_uring's speed sources, and rotor makes no speed claim for it: it exists so a
program runs where io_uring does not. A program that must have io_uring checks backend() at
start and refuses to go on without it.
A program that cannot take whichever backend the kernel allows names one itself:
rotor.require_backend(.epoll), or .uring or .kqueue (decision 23). It is the first call the
program makes on the module, because sync, a Registry, a Loop and backend() each make the
choice when none is made, and the choice holds for the life of the process. The call answers
RequireError: OtherBackendChosen when the process already runs another backend, and
Unsupported when this host cannot run the one asked for, which covers .uring on a kernel that
refuses a ring. It never falls back, and an error stores nothing. Requiring the backend the process
already runs succeeds.
What the module exports: Loop, Registry, Remote, the helpers sync and buffers, backend,
require_backend, Backend and RequireError, the flags files_block, post_bounded and
supported, offload_memory_bytes, memory_alignment, and the
types a caller builds operations from and reads events with (Operation, Event, Handle,
Address, Message, LoopId, Descriptor, Code, Error, Delivery, and the namespaces
constants, datagram, offload, statistics).
There is one API, and it is this one. Loop, Registry and Remote are types of this module that
carry exactly the surface named in src/core/surface.zig and forward to the host's backend; the
backend's other public functions serve its own files, the benchmarks and the conformance suite,
which live in rotor's tree, and a dependent package cannot name a backend at all: the build
registers this module alone. A program that needs what the backends keep for themselves is a program
inside this tree.
const options: rotor.Loop.Options = .{ .operations = 1024 };
var memory: [rotor.Loop.memory_bytes(options)]u8 align(rotor.memory_alignment) = undefined;
var loop: rotor.Loop = undefined;
try loop.init(&memory, options);
defer loop.deinit();operationsis the most operations the loop holds in flight at once, and what sizes its tables: at mostconstants.operations_max, 2^20.entriessizes the io_uring submission ring. Left at 0 it isoperationsrounded up to a power of two, capped at 32,768; kqueue takes it and sizes nothing by it.memory_bytesis a function of the options, andinitasserts the block is large enough and aligned tomemory_alignment. The memory is the loop's untildeinit.initmust run on the thread that will own the loop. Every other call on the loop asserts that it comes from that thread, and a call from another thread halts the process: it is a programmer error, not a condition the loop reports.initfails withUnsupportedwhen the host's kernel lacks what the backend needs (below), and withSystemResourceswhen a descriptor or memory limit refuses the ring, the epoll instance or the kqueue. A kernel that refuses io_uring does not failiniton Linux: the process runs epoll.deinitrequires an empty loop: nothing in flight.cancel_allthendraingets there.
The other options: id and registry for a loop that posts to others (below), sampling for the
statistics, spin_budget_ns for a loop that polls before it sleeps, and file_policy, offload
and offload_memory for files on macOS and on epoll (below).
spin_budget_ns is 0 by default, and then a tick that is given a wait blocks at once, as it always
has. With a budget, at most rotor.constants.spin_budget_ns_max, a tick given a longer wait first
ticks without waiting for up to that long, and blocks for the rest of the wait only if nothing came
(decision 13). The window runs from the loop's last event, so a loop whose work has stopped spends
its budget once and then sleeps. A message another loop posts inside the budget arrives without the
kernel waking this one. In that record's measurement on io_uring, loops with a 50 µs budget, each
message 20 µs after the last, made a round trip in 2.7 to 4.2 µs against 18 to 32 µs for loops
that waited (bench/results/decision-13-spin-budget-github-2026-09-24.md). The cost is the CPU:
each message that comes after the budget costs the budget in polling, which at one message every
100 µs was about half a core (bench/results/decision-13-idle-github-2026-09-24.md). A tick given no wait, or a wait no longer than the budget, never polls, and nor
does one whose next timer is due inside the budget.
On macOS a budget costs time as well as CPU. A message that comes after the budget arrives about
11 µs later than it would with no budget: 12.5 to 14.5 µs against 1.0 to 3.5 µs, measured on an
M1 Pro on 2026-09-25 (bench/results/decision-13-idle-kqueue-mac-2026-09-25.md). The loop that
spun is slower to wake when the message comes. Why is not known. A macOS caller should set a budget
only when most messages come inside it.
var handles: [2]rotor.Handle = undefined;
const taken = loop.submit(&.{
rotor.Operation.receive(1, socket, &buffer),
rotor.Operation.timer(2, rotor.constants.ns_per_s, 0),
}, &handles);
var events: [64]rotor.Event = undefined;
const count = try loop.tick(&events, rotor.constants.ns_per_ms);Operation has one constructor per kind (accept, connect, receive, receive_group, send,
shutdown, close, read, write, fdatasync, fsync, timer, post, receive_from, send_to),
each building the common shape in one call. Set timeout_ns or descriptor_registered on the
result when either is wanted, and write the struct out for a receive into a registered buffer.
submittakes a batch of operations, at mostconstants.batch_max(4,096), and returns how many it took. It takes fewer when the table has no room for the rest; the caller submits those again later.handlesis either empty or one per operation, and receives the handle of each operation taken, which is whatcanceltakes.tickmakes the loop's one system call, delivers events intoeventsand returns how many. Withwait_nsof 0 it polls and returns at once; otherwise it blocks until an event is ready, a message arrives, or the wait passes, at mostconstants.wait_ns_max, 10 seconds. A longer wait halts, as does aneventsthat is empty or longer thanbatch_max, on every backend and whether or not the tick would have waited. A tick that already holds events to return does not wait.tickfails only when the kernel refuses the call afterinterrupt_retries_maxsignals, or answers something rotor has no meaning for.now_nsreturns the monotonic clock in nanoseconds as the lasttickread it, at its start or after its wait. Timers expire against that reading. It is 0 until the first tick, and reading it makes no system call. A caller that keeps timeouts of its own can read the time here instead of reading a clock itself. A timer submitted now runs itsafter_nsfromnow_ns, as libuv's and libxev's timers run from the loop's cached time: it is due atnow_nsplus itsafter_ns, and fires no earlier. A caller that works long after a tick before it arms a timer gets a timer that is due that much sooner after the call. A timer submitted before the first tick runs from that tick's reading. An operation's deadline (timeout_ns) still runs from the tick that hands the operation to the kernel.- Batch first. One
submitof many operations and onetickreturning many events is the shape rotor is built for; one operation per call works and costs a system call each.
An Event is 16 bytes:
user_data: the value the caller put in theOperation. rotor never reads it. For a message posted by another loop, the message's payload.result: not negative is a byte count, a new descriptor, or 0; negative is-@intFromEnum(Code), andevent.outcome()turns it intoError!u32. For a message, the tag.flags.more: more events of this operation follow and its slot stays claimed. An event withoutmoreis the operation's final event.flags.message: another loop posted this; it belongs to no operation.flags.bufferandflags.buffer_id: which provided buffer holds this receive's bytes.
The rules every caller relies on (decision 5):
- Every operation ends with exactly one final event, and its slot is freed by that event and by
nothing else. A multishot operation delivers events flagged
moreuntil its final one. cancel(handle)is a request. The answer is the operation's final event:Canceledwhen nothing was transferred, or the bytes that moved.- From
submitto the final event, the buffer an operation names belongs to the loop: do not read, write, reuse or free it. This covers theAddressaconnectnames and theOutboundasend_tonames. Provided buffers change hands differently (below). timeout_nson an operation is a deadline the loop enforces by cancelling it; the final event then saysTimeout.- Timers live in the loop and cost no descriptor.
timer{ .after_ns, .repeat_ns }: one event, or one per period flaggedmoreuntil cancelled, scheduled from the previous deadline and never from the clock. closecancels every operation on that descriptor first, then closes it. Its final event says the descriptor is gone.deinitrequires an empty loop.
Every Operation has user_data, an optional timeout_ns, descriptor_registered (below), and a
kind:
| kind | what it names | result |
|---|---|---|
accept |
a listener, multishot |
the accepted socket, one event per connection when multishot |
connect |
a socket and an *const Address |
0 |
receive |
a socket and a target: a buffer, or a provided-buffer group; multishot with a group |
bytes received, 0 at end of stream, which ends a multishot receive |
send |
a socket and a buffer | bytes sent; a short send is a result under the buffer's length |
shutdown |
a socket and how |
0 |
close |
a descriptor of the process | 0 |
read, write |
a file, a buffer and an offset | bytes moved |
fdatasync |
a file: its data, and the metadata a later read needs, such as its size | 0 |
fsync |
a file: its data and all of its metadata, such as its times and permissions | 0 |
timer |
after_ns, optional repeat_ns |
0 per fire |
post |
a target LoopId and a Message |
0, or mailbox_full, loop_not_found |
nop |
nothing | 0 |
receive_from |
a socket and a datagram group | bytes, one event per datagram, until cancelled |
send_to |
a socket, a buffer and an *const Outbound |
every byte of the buffer, or none |
Descriptors come from rotor.sync, which makes the calls a loop does not: open_socket,
listen(&address, .{ .backlog, .reuse_port }), open_datagram(family, bind_to, .{}),
local_address, set_no_delay, set_option, set_buffer_bytes, close_now, and for files
open_file, file_size, set_file_size, sync_directory. Address is rotor's own type,
IPv4 or IPv6 with a port and a scope id; no kernel type is part of the surface.
sync.set_buffer_bytes(descriptor, .receive, bytes) asks the kernel for that much buffer on the
socket and returns the size it actually set, because the answer is rarely the request: Linux
stores twice what it is asked for and caps it at net.core.rmem_max for a caller without
CAP_NET_ADMIN, and macOS does neither. .send sizes the other one. sync.socket_buffer_bytes_max
is the largest request, which is what setsockopt carries.
A datagram receiver under load is what it is for. A UDP socket whose receive buffer is too small drops what arrives while the loop is elsewhere, and no operation reports that: the datagram is gone before rotor sees it. A resolver or a QUIC stack sizes the buffer at start-up and reads back what it got.
Three ways to hand the loop memory for bytes:
-
A plain
BufferorConstBufferin the operation. Simplest; rule 3 applies. -
Registered buffers:
register_buffers(&loop, buffers)once, before use, at mostregistered_buffers_max(1,024). An operation then names one by index inBuffer.registered, and io_uring skips pinning its pages per operation. kqueue accepts the same calls and gains nothing from them. -
A provided-buffer group:
provide_buffers(&loop, group_id, memory, count, buffer_bytes), withmemoryone block ofbuffers.group_bytes(count, buffer_bytes)bytes aligned tobuffers.group_alignment; the loop keeps its bookkeeping at the front and the buffers after it.countis a power of two.Operation.receive_grouplets the kernel pick a buffer; the event carriesflags.bufferandbuffer_id, andloop.provided_buffer(group_id, buffer_id)is its bytes. The buffer is the caller's from that event untilloop.give_back_buffer(group_id, buffer_id), whether or not the receive has ended. A group that runs out ends a multishot receive withbuffers_exhausted: give buffers back and submit it again. The end of the stream ends it too, with a final event of 0 that names no buffer. At mostbuffer_groups_maxgroups (16) ofbuffers_per_group_max(32,768).Check the alignment you got, do not assume it. io_uring requires the 64 KiB of
buffers.group_alignmentand rotor asserts it, so a misaligned group halts with a named assertion rather than arriving as an errno. Declaringalign(buffers.group_alignment)on a static is enough on Linux, and on macOS such a static came back 16 KiB aligned on 2026-09-22, so a caller that must build on both aligns forward inside a larger block:var backing: [bytes + buffers.group_alignment]u8 align(buffers.group_alignment) = undefined; const start = std.mem.alignForward(usize, @intFromPtr(&backing), buffers.group_alignment); const memory: []align(buffers.group_alignment) u8 = @as([*]align(buffers.group_alignment) u8, @ptrFromInt(start))[0..bytes];
The kqueue backend needs only the alignment of its own free list, so under-aligned memory works there and fails on Linux. That asymmetry is why the assertion on the io_uring side is worth having: it is where a caller who trusted the declaration finds out.
Size a group to the buffers in flight, not to the memory you can spare. On io_uring the kernel takes buffers from the head of the group's ring and
give_back_bufferreturns them at the tail, so a group ofcountbuffers is used in order and every receive lands in the buffer returned longest ago. A large group is therefore a large working set, and every receive writes into memory that is cold in cache and TLB. A 64 MiB group of 64 KiB buffers cost 22 µs of kernel time per echo on theorbstackmachine, and 32 buffers cost 7.5 (bench/results/cpu-orbstack-2026-09-22.md). A connection holds a buffer from its receive's event until the caller gives it back, so two per connection is enough for an echo, and a group that runs out ends the receive withbuffers_exhaustedrather than losing bytes. On kqueue and epoll the loop reuses the buffer returned last, so the size costs only memory there.
Registered descriptors work the same way: register_descriptors(&loop, descriptors) once, at
most registered_descriptors_max (1,024), and an operation with descriptor_registered = true
names an index instead of a descriptor. A close always names a descriptor of the process.
A datagram group is a provided-buffer group with room in front of every buffer for the peer
address and the control messages: provide_datagram_buffers(&loop, group_id, memory, count, buffer_bytes, .{}), from one block as above. One loop serves one datagram shape: the first
datagram group fixes it, and a later group with different options halts, because the loop reads
every datagram at the offset that shape gives. receive_from receives into it, one event per datagram, and loop.datagram(group_id, event) is the only reader
of such a buffer: it returns a Delivery with the peer, the local address when the socket was asked
for it, the ECN
codepoint, the segment size when the kernel coalesced several datagrams into one, and the bytes.
send_to takes an Outbound: the destination, the source address, the codepoint, and whether to
cut the buffer into segments. GSO, GRO and ECN are Linux; macOS answers a segmented send with
unsupported (decision 15).
io_uring performs read, write, fdatasync and fsync without a thread. kqueue and epoll report
readiness and never complete a file operation, so on macOS, and on Linux where the process runs
epoll, the loop needs to be told what to do. rotor.files_block is true on both, because on Linux
a process may run either backend; a caller that sets a policy there is right on both:
file_policy = .refuse, the default: a file operation ends withunsupported. Nobody is quietly slowed..blocking: the loop performs it inline, and the tick stalls for its duration..offload: the loop hands it to the caller's threads.offloadis anOffload: a context, asubmitfunction the loop calls with aWorkthe worker runs, and the number of workers, at mostoffload_workers_max(64).offload_memoryisrotor.offload_memory_bytes(workers)bytes of the caller's, because the caller's threads write it.bench/files/reads_pool.zigis a pool that does this.
offload is required under .offload and must be null under the other two policies. io_uring
checks the options as kqueue and epoll do, so options that halt on one backend halt on all of them,
and then ignores them. A loop that never touches a file needs none of this.
A caller that hands a loop an offload stops the offload's threads before the loop's deinit, and
not before the loop is drained: a worker still inside Work.run reads the loop after its result is
visible (decision 18).
A loop belongs to one thread, holds no lock and starts no thread. The one thing another thread may do to it is post a message:
- Loops that post to each other share a
Registry:Registry.memory_bytes(loops)bytes of the application's memory,registry.init(&memory, loops)once, and each loop gets anidbelowloopsand theregistryin its options.post{ .target, .message }from one loop arrives in the target's next tick as an event withflags.message,user_datathe payload andresultthe tag (at mostmessage_tag_max). The sender's own final event says 0,mailbox_fullorloop_not_found. On every backend the mailbox between two loops holdsmailbox_messages(256), androtor.post_boundedis true. A loop that sleeps is woken by the post that finds it asleep: on io_uring with anIORING_OP_MSG_RINGthat carries no message. Between two loops that are awake a message costs no system call on any backend. - A loop can also send one message with
loop.post(target, message), which takes no operation: the message is in the target's mailbox when it returns, there is no final event, and it returnsLoop.PostError,MailboxFullorLoopNotFound. It costs the sender about half the work of a post operation, because no tick has to hand an event over. A target that sleeps is woken at once on kqueue and epoll, and by the sender's next tick on io_uring. A burst to one loop is cheaper as post operations in one submit, whose tick wakes the target once. - A thread that owns no loop holds a
Remote:remote.init(®istry, id)on that thread, taking one id of the registry, andremote.post(target, message)returnsRemote.PostErrorwhere a loop's post produces an event:MailboxFullorLoopNotFound.SystemResources,UnansweredandUnexpectedare still in the set and no backend returns them since 2026-09-25. ARemotebelongs to one thread as a loop does. - The offload's workers answer through rings of their own, not through a
Remote.
At most loops_max (256) loops and remotes share one registry.
Loops that run in different processes can post to each other in the same way (decision 21). They share a registry that lives in memory every process maps, and each loop keeps its own memory, as a loop in one process does.
- One process, the creator, maps
Registry.memory_bytes(loops)bytes withMAP_SHARED(memfd_create,shm_open, or an anonymous mapping made before it forks) and callsregistry.init_group(memory, loops). It makes one wake per loop: a pipe on macOS, an eventfd on Linux. It returnsRegistry.GroupErrorwhen the process has no descriptor left for one. - The creator then starts the other processes: with
fork, or by spawning them with the wakes kept open at the same numbers. rotor makes the wakes without close-on-exec for this. No descriptor moves between processes after they start. - Each other process maps the same memory and calls
registry.attach(memory), which answersRegistry.AttachErrorwhen the memory holds no group this build of rotor laid out. - Every process runs loops and remotes with ids of that registry, as in one process. Which process runs which ids is the application's choice.
A message is 16 bytes and rotor reads none of them, so between processes a payload that points at memory must be an offset into memory both processes map: a pointer means something in one process only. A message cannot carry a socket to another process either; each process accepts its own connections.
When a process dies, the application learns it (a pidfd on Linux, EVFILT_PROC on macOS, or its
supervisor), and a surviving process calls registry.release(id) for each loop the dead one ran.
Posts to those ids are then refused as loop_not_found. A new process started by the creator may
then run loops with those ids, and is handed what was posted to the dead loop and not read.
release is only for a process known to be dead: one that is only stopped would come back to find
a second loop on its rings. Each process calls registry.close_wakes() when it is done with the
group.
A macOS process holds two descriptors per loop of the group. The default limit a macOS process
starts with was 256 descriptors on an M1 Pro with macOS 26.6.2 (launchctl limit maxfiles,
2026-09-25), so a group of more than about 100 loops needs a higher limit.
Processes that share a registry trust each other as threads of one process do: each can write any byte of it.
A library such as a resolver takes a *Loop from the application and owns no socket, thread or
loop of its own. What that needs:
- The application owns the loop, calls
tick, and hands the library the events that are its. An event carriesuser_dataand nothing else that names its owner, so the two agree on a convention: a range ofuser_data, or a bit in it, that the library's operations use. - The library submits on the loop's thread only. A caller on another thread reaches it through a
Remoteand a message whose tag the library defines. - The library's operations in flight count against the loop's
operations, and its buffers against the loop's groups. The application sizes the loop for both. timeout_nson areceive_fromand atimerper retry are what a query needs;cancelwith the handle ends either early.
Some programs cannot block inside tick, because other things must wake them too: a worker that
runs inside another program's wait, for example. Such a host sleeps in a wait of its own and ticks
the loop when the loop has something to deliver (decision 23).
- On Linux the host requires epoll first:
rotor.require_backend(.epoll), before anything else of the module. An io_uring loop has nothing a host can wait on: the kernel posts a completion only when the loop's own thread enters the ring, so nothing wakes that thread while it sleeps elsewhere. On macOS there is nothing to require. loop.wait_descriptor()is what the host waits on: the loop's kqueue, or its epoll instance. It answers null on io_uring. The host adds it to its own wait for read readiness. rotor's tests wait with a second epoll instance on Linux and a second kqueue on macOS.- The host's cycle: wait for the descriptor to be readable, call
tickwith a wait of 0 until a tick delivers nothing, and wait again. It runs the same ticks after it submits or cancels, before it waits, because an operation reaches the kernel in a tick. - Every tick gets a wait of 0. A tick that blocks arms the loop's wait timer, which makes the descriptor readable later for no operation.
- The host does nothing else to the descriptor. It does not read it, close it, or change what
it holds, and it stops waiting on it before
deinit.
What the descriptor does not report, so that the host wakes for it by its own means:
- A timer or an operation's deadline that comes due. A tick finds those by reading the clock. A host bounds its own wait by its nearest timer, or wakes on a period.
- A message another loop or a
Remoteposts, and an offload worker's result. Only a loop that blocks intickasks to be woken for those.
There is one more limit. While an operation of the loop waits on a descriptor, the wait descriptor is not readable after a tick that delivers nothing. While none waits, a tick with a wait of 0 asks the kernel nothing, so readiness the kernel holds for no operation stays, and the host's wait ends at once each time until an operation waits again. On epoll that is bytes that arrive on a socket whose last receive has completed. On kqueue it is bytes that arrive on a socket whose receive was cancelled or ended by its timeout. A host that keeps an operation waiting, such as a receive on its socket, meets neither. Decision 23 has the measurements under its open questions.
loop.statistics() is sampled: one operation in sample_mask + 1 records its latency into
power-of-two buckets, per kind, and nothing else is counted (decision 9). Nothing logs on the
loop's path.
Every limit is a named constant in rotor.constants, or in a backend's own constants.zig:
| limit | value |
|---|---|
| operations in flight per loop | operations_max, 2^20 |
operations per submit, events per tick |
batch_max, 4,096 |
| a tick's wait | wait_ns_max, 10 s |
| a deadline or a timer | timeout_ns_max, one day |
| one transfer | transfer_bytes_max, 2,147,479,552 bytes |
| loops and remotes per registry | loops_max, 256 |
| offload workers | offload_workers_max, 64 |
| registered descriptors, registered buffers | 1,024 each |
| provided-buffer groups, buffers per group | 16, 32,768 |
| a message's tag | message_tag_max, 0x7fff_f000 |
| messages queued from one loop to another (kqueue) | mailbox_messages, 256 |
| registrations in, readiness out, per tick (kqueue) | changes_max, readiness_max, 256 |
- Linux 6.17 or later, with io_uring:
IORING_FEAT_NODROP,IORING_FEAT_EXT_ARG,MSG_RING, multishot accept and receive, provided buffer rings,SINGLE_ISSUERandDEFER_TASKRUN. A kernel that lacks one of these, or refuses io_uring itself as Docker's default seccomp profile does, gets the epoll backend instead (above): the process asks for a ring with the same flags, features and opcodes a loop needs.tools/uring_probe.zignames the first thing a kernel lacks. - macOS with kqueue. Files need a policy (above). A loop that polls carries its own wake trigger
in the
keventcall, because a poll that finds nothing ready parks the thread for about 12 µs on macOS 26 otherwise (decision 12, point 6).
TLS (chapulin fills that interface for colibri), DNS (cocuyo), Unix sockets, process spawning,
Windows and the std.Io adapter. Loops in several processes are in version one, but rotor starts
no process: the application does. Decision 2 says why, and what would bring each in. The epoll
backend was on this list until decision 20 brought it in.
docs/costs.md holds the measured costs of the operations the design arguments cite, for the
mac, orbstack and github machines. docs/benchmarks.md holds the current comparison against
libuv and libxev and the commands that take it, bench/alternatives/README.md records every
experiment behind it, the losing rows included, and bench/results/ holds every run as printed. The Linux machine rotor is meant to be deployed on is not named yet, so no claim is made
for it.