ioring
nimony/lib/std/ioring.nim
Descriptors and -EAGAIN
- Readiness backends (epoll, kqueue): the descriptors of reads, writes,
accepts, recvfrom and sendto must be O_NONBLOCK (see submitSetNonBlocking; accepted sockets do not inherit it everywhere). The transfer is a plain read/write/accept/recvfrom/sendto, and a blocking descriptor would block the polling thread. A wake that finds nothing to transfer (EAGAIN, or EINTR) leaves the operation pending and re-armed, so these operations never complete with -EAGAIN.
- io_uring: the kernel honours the descriptor's own mode.
- On an O_NONBLOCK descriptor a read or write can complete with -EAGAIN after a wake that carries no readiness, such as the peer's half-close (POLLRDHUP); callers must handle it. A submitPollAdd for POLLOUT also completes at once with only POLLRDHUP after a half-close, so a poll-then-retry needs a back-off. - On a blocking descriptor the operation parks until it can progress, but it may occupy an io-wq kernel worker while it does, so a peer that never reads can pin one worker per connection.
- IOCP and WSAPoll sockets are unaffected: Winsock completes asynchronously
or reports WSAEWOULDBLOCK, which the WSAPoll backend already treats as "still pending".
proc initIoRing()Bring the default ring up. Idempotent, and it has to be: this module already initialises the ring at import time, so a second call — the usage example above tells callers to make one — would otherwise re-run
initOpQueues/initSlots/gCq = newSeqwhile worker threads are live insidepoll, holding indices into the seqs being replaced. That is a use-after-free plus the loss of every op in flight.proc shutdown()Stop the pool first, then tear the backend down:
closecloses the epoll/kqueue/io_uring descriptors the workers poll, so closing them while a worker is still insidepollleaves it waiting on — or re-registering against — a descriptor number the OS is free to hand to something else.Call it from a non-worker thread: it joins the workers, and a worker that joins itself deadlocks. It also stops the pool for everyone (
std/parforincluded), and the ring cannot be brought back up afterwards.proc submitNop(deadline: Deadline; cont: Continuation; resPtr: ptr int64): uint32proc submitTimeout(deadline: Deadline; cont: Continuation; resPtr: ptr int64): uint32Complete once
deadlinepasses, with no I/O at all. Unlike every other op, reaching the deadline is this one's success: it completes with0rather thanIoTimedOut.This is how a loop gets a turn on a schedule —
next(e, deadline)in the HTTP design is one of these plus whatever else is pending.proc submitRead(fd: int32; buf: pointer not nil; len: int64; deadline: Deadline; cont: Continuation; resPtr: ptr int64): uint32Read up to
lenbytes intobuf. Completes with the byte count (0is end of file) or a negated error. On the readiness backendsfdmust beO_NONBLOCK(submitSetNonBlocking); on iouring an `ONONBLOCKfdcan complete with-EAGAIN` (see the module docs).proc submitWrite(fd: int32; buf: pointer not nil; len: int64; deadline: Deadline; cont: Continuation; resPtr: ptr int64): uint32Write up to
lenbytes frombuf. Completes with the byte count, which may be less thanlen, or a negated error. On the readiness backendsfdmust beO_NONBLOCK(submitSetNonBlocking); on iouring an `ONONBLOCKfdcan complete with-EAGAIN` (see the module docs).proc submitOpen(path: cstring not nil; mode: FileMode; permissions: set[FilePermission]; deadline: Deadline; cont: Continuation; resPtr: ptr int64): uint32Open
pathand complete with the fd (or a negated errno). The work is made by the backend, on the polling thread: opening here would block the caller, and opening has no readiness for the ring to park on anywhere else. On Windows the same op performs the backend'sCreateFileW(see backends/files.nim); each backend translatesmodeinto its platform's arguments, andpermissionsonly matters on POSIX.pathmust stay valid until the op completes — it is read by the backend, not copied. The ring copiesOpContextby value, and astringinside it would be shared across threads for no reason; the caller's frame is parked for the duration, so acstringout of it is safe (the same arrangement assubmitRead's buffer).proc submitSocket(domain: Domain; typ: SockType; proto: Protocol; deadline: Deadline; cont: Continuation; resPtr: ptr int64): uint32Create a socket and complete with its fd, or a negated error. The call is made by the backend: on the readiness backends the polling thread performs socket(2) exactly like
submitOpen; on iouring it is a `IORINGOP_SOCKET` SQE and the fd arrives in the CQE.proc submitSetSockOpt(fd: int32; level: int32; optName: int32; optVal: pointer not nil; optLen: uint32; deadline: Deadline; cont: Continuation; resPtr: ptr int64): uint32Set a socket option (setsockopt(2)/Winsock setsockopt) and complete with
0or a negated error. Configuration, not I/O — and on iouring a `IORINGOPURINGCMD+SOCKETURINGOPSETSOCKOPTSQE (kernel 6.7+) rather than a syscall.optVal` is read while the op runs (during submission on the readiness backends, at issue by the ring on iouring), so it must stay valid until the op completes — like asubmitReadbuffer; the suspended.passivecaller's frame is parked exactly that long.proc setNoDelay(fd: int32; on: bool)Nagle's algorithm off (or on) for a TCP socket: whether the kernel may hold a small write back until the previous one has been acknowledged.
Off is what a server that answers in small messages wants. With it on, a reply of a few hundred bytes waits a whole round trip for the peer's ACK, and the cost is invisible in CPU: an echo server writing ~20 small frames per burst measured 676k messages/s with Nagle and 1.05M without it, idle in both cases, because every reply was held one ACK. Node, nginx and Bun all turn it off on accept; a server moving bulk data does not care either way.
One ring op (
IORING_OP_URING_CMD+SOCKET_URING_OP_SETSOCKOPT, kernel 6.7+) or onesetsockopton the readiness backends. Best effort: an error — including a kernel with no ring form for it — leaves the socket's default in place rather than failing the accept.proc submitBind(fd: int32; sa: Sockaddr_storage; saLen: uint32; deadline: Deadline; cont: Continuation; resPtr: ptr int64): uint32Bind
fdtosaand complete with0or the negated errno. The address is copied into the op, so it does not need to outlive the call; the backend performs bind(2) on its polling thread — one instant syscall, nothing to wait on — making a fully passive socket creation possible.proc submitSetNonBlocking(fd: int32; deadline: Deadline; cont: Continuation; resPtr: ptr int64): uint32Make
fdnon-blocking (fcntl(FSETFL, ... | ONONBLOCK) / ioctlsocket FIONBIO) and complete with0or a negated error. The non-blocking half of socket creation, as its own op: the readiness backends set the flags on the polling thread at issue time (a poller cannot watch for "not blocking"); the iouring backend needs no syscall for it at all — its ring-created sockets are non-blocking from birth (`SOCKNONBLOCKin theIORINGOPSOCKETtype, accepted sockets inherit it), so the op completes0` on arrival there.proc submitReadAt(fd: int32; buf: pointer not nil; len: int64; offset: int64; deadline: Deadline; cont: Continuation; resPtr: ptr int64): uint32Read at an explicit byte offset without changing the file cursor. Keep the buffer live until completion. Short reads and EOF are legal; offsets and lengths must be nonnegative. Synchronous fallbacks occupy the polling lane and cannot be interrupted once the transfer starts.
fdmust be a regular file; on Windows that is a file fromsubmitOpen. illumos runs these as POSIX AIO: a non-regularfdfails with ENOTSUP, and more than 64 MiB in flight per lane with EAGAIN.proc submitWriteAt(fd: int32; buf: pointer not nil; len: int64; offset: int64; deadline: Deadline; cont: Continuation; resPtr: ptr int64): uint32Positioned counterpart of submitWrite; does not move the file cursor. Same
fdrules as submitReadAt. A write that times out or is cancelled after it started completes with what really happened, which can be success: the deadline stops the waiting, not a write already issued.proc submitAccept(listenFd: int32; deadline: Deadline; cont: Continuation; resPtr: ptr int64; peer: ptr Sockaddr_storage): uint32Accept one connection. Completes with the accepted fd, or a negative result.
On the readiness backends (epoll, kqueue)
listenFdmust beO_NONBLOCK; a wake that finds no connection leaves the accept pending.peer, when given, receives the address that connected — the kernel fills it as part of the accept, so asking costs no syscall. It is written only when the accept succeeds, and it must outlive the op: the completion writes through it from whichever lane ran the accept.Without it the address is dropped, which is what an access log, a rate limiter and every
X-Forwarded-Fortrust decision need and cannot reconstruct afterwards.proc submitConnect(fd: int32; sa: Sockaddr_storage; saLen: uint32; deadline: Deadline; cont: Continuation; resPtr: ptr int64): uint32Connect
fdtosa. Completes with0on success, or the negated errno — a refused connection is-ECONNREFUSED, not a generic -1, because the caller usually wants to tell "nobody listening" from "the network ate it".fdmust already be non-blocking (setNonBlocking). The attempt is started on the polling thread, not here, so that the fd is being watched from the moment it is connecting.A connect with no deadline is the classic way to hold a slot forever: a SYN into a black hole never answers. Hence the parameter, and hence no default for it.
proc submitRecvFrom(fd: int32; buf: pointer not nil; len: int64; deadline: Deadline; cont: Continuation; resPtr: ptr int64; peer: ptr Sockaddr_storage): uint32Receive one datagram into
buf. Completes with the number of bytes received, or a negative result. On the readiness backendsfdmust beO_NONBLOCK.peer, when given, receives the sender's address — the kernel fills it as part of the recvfrom, so asking costs no syscall beyond the one that receives the datagram. It is written only when the receive succeeds and it must outlive the op, likesubmitAccept'speer.proc submitSendTo(fd: int32; buf: pointer not nil; len: int64; sa: Sockaddr_storage; saLen: uint32; deadline: Deadline; cont: Continuation; resPtr: ptr int64): uint32Send
bufas one datagram tosa. Completes with the number of bytes sent, or a negative result. On the readiness backendsfdmust beO_NONBLOCK. The address is copied into the op, so it does not need to outlive the call the waysubmitConnect's does.proc submitPollAdd(fd: int32; deadline: Deadline; events: set[IoEvent]; cont: Continuation; resPtr: ptr int64): uint32Register oneshot readiness interest in
fdwithout issuing any I/O. When the fd becomes ready in one of theeventsdirections a single completion fires whoseopisopPollAddand whosereadyEventsare the directions that fired. UnlikesubmitRead/submitWrite, no transfer is performed — the caller decides what to do with the ready fd (e.g. libcurl's multi-socket engine). This is oneshot:completefrees the slot, so re-arm by callingsubmitPollAddagain after handling the event.Pass the direction you actually want. The default watches both, which is right for a probe with no preference — but a caller waiting to read a socket is woken by mere writability on every arm (a connected socket is writable nearly always), and because the op is oneshot its re-arm then spins as fast as the loop can poll. libcurl's multi-socket engine always states its direction (
CURL_POLL_IN/CURL_POLL_OUT); pass it through.A
resPtrreceives the same directions as a bit mask instead of a set, being aptr int; decode it withtoIoEvents.proc pollCompletions(comps: var openArray[IoCompletion]): int64Non-blocking: drive this lane's backend once — issue the ops queued on it and collect whatever the kernel has finished — then hand back up to
comps.lencompletions from the shared queue. Returns 0 when nothing has completed. It used to only drain the queue, so a caller that submitted and then polled had not issued anything, and acloseFdin between found no slot to cancel.proc waitCompletions(comps: var openArray[IoCompletion]): int64pollCompletionsuntil at least one completion has landed.proc submitPollRemove(fd: int32): int64Cancel without closing. Take back every op this lane still has in flight on
fd— each completes withECancelled, so its continuation is resumed and its slot released — and drop the backend's per-fd registration.fditself is left open. Returns how many ops were cancelled;0means nothing was armed, which is the common case.closeFdwas the only way to cancel, and it also closes the descriptor — no use to a library that owns its own sockets and merely wants to stop watching one (libcurl'sCURL_POLL_REMOVE, whose slots leak for the life of the process without this; harnessdoc/ioring-2390-port.md).Named for
IORING_OP_POLL_REMOVE, but it is not asubmit*in behaviour: those enqueue an op and return aSeqNumto wait on. Cancelling is not something you wait for, so this acts on THIS lane and answers at once.âš This lane only, exactly like
closeFd— seecancelPendingOps.⚠The drain is load-bearing; do not remove it.
submitPollAddonly ENQUEUES, and the op becomes a slot when the lane next polls. Without the drain an op submitted moments ago has no slot yet: this reports0, the op is armed immediately afterwards, and the slot leaks — the exact bug the proc exists to fix.pollCompletionsdrains for the same reason. (closeFddrains only on illumos; elsewhere it keeps that narrow gap.)proc setNonBlocking(fd: int32)proc closeFdRaw(fd: int32)proc closeFd(fd: int32)Close
fd: cancel this lane's in-flight ops on it (seecancelPendingOps), deregister it from the backend, then close(2).proc socketNonBlocking(): int32A non-blocking TCP socket, which is what
submitConnectrequires: a blocking one would finish the connect inside the syscall and there would be nothing for the ring to wait on.proc loopbackAddr(sa: var Sockaddr_storage; saLen: var uint32; port: uint16)Fill
sawith127.0.0.1:port, ready forsubmitConnect.proc boundPort(fd: int32): uint16The port
fdis actually bound to. WithlistenTcp(0)the kernel picks one, and asking for it afterwards is the only way a test can listen without inventing a fixed number that a parallel run — or a socket still in TIME_WAIT — will collide with.proc listenTcp(port: uint16; backlog: int64): int32