Sitemap
Jayakumar Thanakodi

Jayakumar Thanakodi

What It Took to Bring io_uring to FreeBSD

6 min readOct 29, 2026

--

Copying an interface is the easy part. Making it behave is where the work is.

Linux has io_uring, an I/O interface that a growing number of fast servers, databases and runtimes are built on. FreeBSD has kqueue, which is excellent at what it does but works differently. Software written for io_uring doesn’t run on FreeBSD, and porting it means rewriting its I/O layer.

kqring is my attempt to close that gap: a FreeBSD kernel module that speaks io_uring’s language. Programs keep their io_uring code, including the popular liburing library, and kqring carries out the work with FreeBSD’s own kernel machinery. Version 0.1.0 is out as an experimental preview.

This is the story of what that took. Most of it isn’t about the parts that look like io_uring. It’s about everything underneath.

Two ways to ask the kernel for I/O

With kqueue, a server asks: which of my sockets are ready? The kernel answers, and then the server reads from each ready socket itself, one system call per read.

With io_uring, the server says: here are the reads I want done. It writes those requests into a queue shared with the kernel, and later picks up the results from a second queue. Each request is a submission queue entry (SQE); each result is a completion queue entry (CQE). Many requests can go in with one system call, and many results come back together.

That shift, from “tell me when I can” to “do this and tell me when you’re done”, is the whole idea.

Press enter or click to view image in full size
With kqueue, one call asks what is ready and one more reads each socket; with io_uring the three reads go in and their results come back in a single call.

Reproducing the queues is straightforward. They’re fixed memory layouts, and kqring uses io_uring’s exactly. The hard part is that every request now has a life of its own inside the kernel, and FreeBSD had to be taught how to look after it.

Surprise 1: a lock that wasn’t where I expected

When a socket has no data yet, kqring can’t just wait. It hooks a callback onto the socket and retries the read when data arrives. Doing that safely means taking the socket’s receive-buffer lock.

An early version took that lock the obvious way, with a macro that points at the socket’s receive buffer. On a Unix-domain stream socket it crashed the machine. In FreeBSD 15 the mutex that macro expected sits in a union that those sockets reuse for something else, so the pointer was NULL. The fix was a different macro that picks the right lock for every kind of socket.

A second trap was subtler. A program can call listen() on a socket while a read is still waiting on it. That turns the socket into a listener and wipes the very buffers the read was attached to, because a listening socket reuses that memory for its queue of incoming connections. kqring now rechecks, after taking the lock, that the socket is still what it was.

Neither problem shows up in the interface. Both are about the host kernel.

Surprise 2: a counter that capped every thread

One of io_uring’s promises is that it scales: give each thread its own ring and they shouldn’t interfere. So I benchmarked exactly that, and the simplest operation, one empty request per system call, topped out at about 6.5 million per second on eight threads. FreeBSD’s own kevent() call, doing nothing, reached about 24 million.

DTrace found the culprit. A kernel module’s system calls are counted in a shared table entry so the kernel can wait for them before unloading the module. That’s two atomic operations per call on one memory word that every CPU fights over. Built-in system calls skip the count; a module’s are counted unless it asks otherwise.

kqring now registers its hot system call as “static”, which skips the shared count, and keeps its own count per CPU instead. The same benchmark reached 12.5 million per second.

Press enter or click to view image in full size
One ring per thread, one request per system call. The hollow point is the first version, before the per-CPU count.

But that moved the problem to unloading. A per-CPU count can miss a thread that has entered the call but not yet counted itself. So before the module unloads, it now finds every thread currently inside one of its system calls and waits until each has gone back to user space. Fixing a performance problem had created a safety problem, and the safety problem needed its own fix.

Surprise 3: pipes that would block anyway

Reading from a pipe that has data should be instant, with no need to hand it to a background thread. But FreeBSD’s pipe code decides whether to wait based on a flag on the file. Setting that flag would change it for every other program sharing the pipe.

Get Jayakumar Thanakodi’s stories in your inbox

Join Medium for free to get updates from this writer.

The trick: FreeBSD’s pipe functions only read two fields from the file. So kqring calls them through a private copy of the file that shares the pipe and has the “don’t block” flag set. If another thread drained the pipe a moment earlier, the read returns “try again” instead of stalling, and kqring quietly hands it to a worker.

Surprise 4: completions you’re not allowed to lose

io_uring promises that completions are never silently dropped. If the application stops collecting results, the kernel holds them. But “hold everything” is also an easy way for a misbehaving program to eat kernel memory.

kqring holds overflowed results and pushes back: once 16,384 are waiting, it stops accepting new work until the program catches up. That seemed complete until an independent review found a gap. Releasing registered resources also produces results, called tags, and one call that unregisters a table of 200,000 tagged files could flood the overflow and crowd out an unrelated timeout’s result.

The fix counts those results separately, so they can never use up the room kept for ordinary I/O. The test for it registers 440,000 tagged slots, releases them all at once while the queue is full, and checks that a timeout firing afterwards still gets its result. The test fails on the previous version and passes on the current one, which is the point of writing it.

Running someone else’s tests

My own test suite checks what I thought to check. The liburing project ships its own: hundreds of programs written by people who know io_uring far better, against Linux’s implementation.

Of the 168 that build on FreeBSD, 143 pass, 7 skip (they detect a missing feature and say so) and 18 fail. The failures are useful. Some need Linux-only features, such as /proc or a Linux-specific kind of socket address. Some come from deliberate differences: a kqring ring belongs to one process, so a forked child can't submit work through its parent's ring. And several depend on glibc letting one thread unlock another thread's mutex, which FreeBSD's threading library refuses.

Each failure is a precise description of where “compatible” ends.

Is it faster?

Sometimes, by a lot. Sometimes not at all. On a 4-core laptop CPU running FreeBSD 15.1:

  • Many busy connections: collecting one message from each of 16 to 1,024 ready sockets is 14–17% faster than kqueue plus recv().
  • Batching: 32 empty requests per system call cost 64 ns each, against 289 ns for an empty kevent().
  • Big sends: zero-copy sends of 256 KiB from a registered buffer are 38% faster than write() on a Unix-domain socket.
  • Where it loses: a read submitted before its data arrives costs 37–43% more than kqueue, because it takes three system calls and installs and removes a socket callback each time.
Press enter or click to view image in full size
Each bar compares kqring with the usual FreeBSD way of doing the same work, on a 4-core laptop CPU.

These are microbenchmarks on one machine, not application results. The honest summary is that io_uring’s model pays off when there’s batching or overlap to exploit, and adds overhead when there isn’t.

What the tests didn’t catch

Several rounds of independent review mattered more than anything else. They found a race in how the static system call was switched on, a way failed registrations could queue stray results, and the tag overflow above. Each one passed every existing test until someone looked hard at the code. If there’s a lesson in this project beyond FreeBSD, it’s that tests tell you about the cases you imagined, and review tells you about the ones you didn’t.

Trying it

kqring 0.1.0 runs on FreeBSD 15.1-RELEASE for amd64 with the standard kernel. It is an experimental, out-of-tree kernel module that hasn’t had a security audit, so a bug in it can crash the machine. Try it on a test system, not a server you care about.

The code, the test results and the full list of differences from Linux are at github.com/grepjava/kqring. The questions I’d most like help with are practical ones: does your io_uring application port, and what breaks when it does?

--

--