โ† back to journal

One core of echo: io_uring, epoll and threads

2026-10-14 ยท Kit Wickham

I wrote the same TCP echo server three ways in Rust and pinned each to a single CPU in a Linux 6.18 VM: one blocking thread per connection, one epoll loop, and one io_uring ring doing plain receive and send. With 64-byte messages and 256 connections, io_uring served about 876,000 round trips a second, epoll 629,000 and threads 397,000. That is the best case for io_uring. With 4 connections it was the slowest of the three, and with 4 KB messages its lead over epoll shrank to about 9 to 14%.

The setup

Every client connection sends a message, waits for it to come back, and sends it again, so each connection always has exactly one message in flight and the offered load scales with the connection count. The server reads a whole message and writes it back. The io_uring version keeps one receive or one send outstanding per connection and submits whatever completed in a batch with one system call per loop; connections are accepted with a multishot accept. The epoll version does one read and one write per event. Both use TCP_NODELAY. The client is a separate process, 4 threads each running an epoll loop, pinned to 4 other CPUs of the same 8-CPU VM, over loopback. The server process is pinned to one CPU, and for epoll and io_uring it was saturated: it burned about 4 CPU-seconds in each 4-second run. Each cell is nine runs of 3 seconds (plus 1 s of warm-up in the rate), in alternating order of the three servers.

Round trips per second, median of nine (thousands; range in brackets)

MessageConnectionsThreadsepollio_uring
64 B4176 [175โ€“178]183 [178โ€“185]166 [164โ€“169]
64 B32396 [394โ€“398]612 [608โ€“620]652 [649โ€“655]
64 B256397 [389โ€“399]629 [582โ€“676]876 [567โ€“902]
64 B1024383 [365โ€“386]661 [607โ€“723]880 [763โ€“917]
4 KB4171 [168โ€“189]176 [173โ€“189]155 [154โ€“156]
4 KB32363 [361โ€“364]520 [511โ€“524]567 [561โ€“569]
4 KB256360 [359โ€“365]554 [466โ€“572]633 [508โ€“765]
4 KB1024319 [309โ€“322]562 [505โ€“655]625 [533โ€“727]

Three things stand out. First, threads and epoll are almost the same at 4 connections (176 against 183 thousand) and then threads stop growing at about 400 thousand while epoll keeps climbing: my guess, which I didn't measure, is that a thread per connection pays a context switch for each message while the event loop handles several ready connections per wake-up. Second, io_uring loses at 4 connections. With only four messages in flight there should be little to batch per wake-up, and I think the extra work of building and reaping queue entries shows: 166 against 183 thousand. Third, the io_uring win is large only for small messages with many connections. At 4 KB the kernel presumably spends more of its time copying data and running the TCP stack, which both approaches share; the gap closes to 9% at 32 connections and 11 to 14% at 256 and 1024.

The ranges at 256 connections are wide for io_uring (567 to 902 thousand with 64-byte messages). Four of the nine runs were under 800 thousand and the other five between 875 and 902 thousand. I don't know why; the VM's CPUs are not equal on this host and the pinned CPU numbers don't map to fixed physical cores. As a check I reran 256 connections with the client spread over 7 CPUs instead of 4: epoll stayed around 560 to 670 thousand, io_uring fell to 580 to 735 thousand from 830 to 880 thousand. So the headline ratio of 1.4 depends on where the client runs. Across the whole table the safe reading is "io_uring was ahead of epoll by roughly 7% to 40% when the server had many connections to serve, and behind by 9 to 12% with a few".

What this doesn't say

Real servers do more than echo; the application work per message dilutes any gain from cheaper I/O. These runs use one server thread, so they say nothing about scaling over cores, where epoll servers spread connections with SO_REUSEPORT and a ring per thread can do the same. I used plain receive and send without registered buffers, fixed files, or kernel-side polling, all of which can cut the per-message cost further. And loopback has no network latency, which is the main reason a thread-per-connection server is fine in practice.

Method: Rust with the libc and io-uring crates, built for aarch64 musl; kernel 6.18.35 in a throwaway Linux VM with 8 CPUs; server pinned to CPU 0, client to CPUs 4 to 7. Nine alternating-order repetitions of each of 24 cells (216 runs). Rates are completed round trips over a 3-second window plus warm-up, from the client's counters.