What
On a sustained inbound WebSocket flood, the io_uring engine in its default configuration drains
connections so slowly that clients hit their write deadlines and give up. epoll, running the same
receive model (single-shot, one read per readiness) on the same workload, never does.
Measured with TestBackpressureInboundSequenceIntegrity (96 connections x 64000 frames of 126
bytes), linux/arm64, docker --cpus 4, over 10+ runs:
| engine |
framesIn / framesSent |
clientCloseFail |
closedOK |
subtest wall-clock |
| epoll |
6144000 / 6144000, every run |
0 |
96/96 |
~2.8s |
| io_uring (default) |
~3.0-4.6M, only 17-79 of 96 clients finish sending |
81-186 |
17-64 |
~20.1-22.1s |
| io_uring + multishot |
often 6144000/6144000 |
0-52 |
73-95 |
~2.5s |
clientCloseFail counts a client whose 2-second write deadline expired — i.e. the server stopped
draining that socket for over two seconds. epoll records zero of these in every run; the default
io_uring configuration records 81-186.
The wall-clock is the tell: io_uring's subtest sits at ~20s run after run while epoll and io_uring
multishot both finish in under 3s. That is clients timing out, not throughput scaling.
Reader starvation
Instrumenting chanReader.Read for calls that had to block for a chunk:
| engine |
Reads that blocked |
| epoll |
829-1027 |
| io_uring + multishot |
~1300 |
| io_uring (default) |
4023-9418 |
The handler is starved waiting for inbound data that the engine has not delivered.
Why this is the default path
CELERIS_IOURING_MULTISHOT_RECV is opt-in (worker.go), deliberately, because multishot throttled
an aarch64 6.6.10 kernel to ~90 req/s. So the configuration users get by default is the slow one
here, and the fast one carries its own documented regression.
Why it matters beyond this test
It is the mechanism behind the residual failures on the celeris#484 oracle. Because the server falls
behind, clients abandon connections mid-stream; the resulting RST discards data the server had not
yet read, and the handler reports unexpected EOF or a frame-count mismatch. Those read as
corruption but are downstream of this.
Not investigated here: whether the cost is the pause/resume cancel+re-arm cycle (io_uring must
submit an ASYNC_CANCEL to pause and a fresh recv to resume, where epoll simply stops and starts
reading), or something else. pauses/resumes balance exactly on both epoll and default io_uring,
and no pause exceeded 543ms, so it is not a stuck or lost resume.
What
On a sustained inbound WebSocket flood, the io_uring engine in its default configuration drains
connections so slowly that clients hit their write deadlines and give up. epoll, running the same
receive model (single-shot, one read per readiness) on the same workload, never does.
Measured with
TestBackpressureInboundSequenceIntegrity(96 connections x 64000 frames of 126bytes), linux/arm64, docker
--cpus 4, over 10+ runs:clientCloseFailcounts a client whose 2-second write deadline expired — i.e. the server stoppeddraining that socket for over two seconds. epoll records zero of these in every run; the default
io_uring configuration records 81-186.
The wall-clock is the tell: io_uring's subtest sits at ~20s run after run while epoll and io_uring
multishot both finish in under 3s. That is clients timing out, not throughput scaling.
Reader starvation
Instrumenting
chanReader.Readfor calls that had to block for a chunk:The handler is starved waiting for inbound data that the engine has not delivered.
Why this is the default path
CELERIS_IOURING_MULTISHOT_RECVis opt-in (worker.go), deliberately, because multishot throttledan aarch64 6.6.10 kernel to ~90 req/s. So the configuration users get by default is the slow one
here, and the fast one carries its own documented regression.
Why it matters beyond this test
It is the mechanism behind the residual failures on the celeris#484 oracle. Because the server falls
behind, clients abandon connections mid-stream; the resulting RST discards data the server had not
yet read, and the handler reports
unexpected EOFor a frame-count mismatch. Those read ascorruption but are downstream of this.
Not investigated here: whether the cost is the pause/resume cancel+re-arm cycle (io_uring must
submit an
ASYNC_CANCELto pause and a fresh recv to resume, where epoll simply stops and startsreading), or something else.
pauses/resumesbalance exactly on both epoll and default io_uring,and no pause exceeded 543ms, so it is not a stuck or lost resume.