Skip to content

io_uring default (single-shot) recv stalls a quarter to two-thirds of connections under sustained WS inbound flood #533

Description

@FumingPower3925

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions