I ran both models against one hundred thousand idle-ish connections with a small periodic write, on the same kernel and the same hardware, to see where the syscall savings actually show up.
1. The setup
- Single socket, 16 cores pinned, hyperthreading off.
- 100k connections, 64-byte messages, one message per connection per second.
- Measurements taken after a five minute warm-up, cgroup memory limit fixed.
2. What epoll costs
epoll is cheap to wait on and expensive to act on. Every readiness notification is followed by a syscall to actually move the bytes, so throughput scales with syscall count, not with events.
epoll_wait(4, events, 4096, -1) = 512
read(37, "...", 64) = 64
read(41, "...", 64) = 64
3. What io_uring changes
io_uring submits and completes in shared ring buffers, so a batch of reads becomes one submission rather than one syscall each. With IORING_SETUP_SQPOLL the submission syscall disappears entirely in the steady state.
4. Numbers
At this connection count io_uring cut CPU time per message by roughly forty percent, and the tail latency gap widened as batch size grew. Below a few thousand connections the two were indistinguishable — the ring bookkeeping costs as much as the syscalls it removes.
5. When to switch
Switch when you are syscall-bound and you can batch. If your workload is one request per wakeup, epoll remains simpler and just as fast.