Avoid shared shutdown waiter contention - #3867
Conversation
|
This makes sense, would you mind tying even with Anyway thanks, this looks good. |
|
Hey @mladedav. I tried out that |
There was a problem hiding this comment.
I am a bit surprised that raw Notify would be slower than the current closed-based variant since that one does use it in the background and we would prevent spurious wakes which I suspect were cause of the contention. But I missed the big notify so you're right we might want to just use what you had originally.
Thanks.
Give each connection its own watch receiver instead of polling Sender::closed on cloned senders. This avoids synchronization through a shared waiter while preserving graceful shutdown behavior. Signed-off-by: jthomson04 <jwillthomson19@gmail.com>
Document the receiver-side wait, preserve the channel-close invariant, and add the changelog entry. Keep pre-existing serve cleanup outside this change. Signed-off-by: jthomson04 <jwillthomson19@gmail.com>
Signed-off-by: jthomson04 <jwillthomson19@gmail.com>
4731943 to
38ca471
Compare
Motivation
axum::servecurrently clones the shutdownwatch::Senderfor every accepted connection. Each connection pollsSender::closed(), so all connection shutdown waiters share synchronization state. This becomes expensive when many connections are active across many CPU cores.Initial end-to-end benchmarks used Dynamo, a distributed inference serving framework that uses Axum for HTTP. Its frontend accepts OpenAI-compatible requests, performs tokenization and routing, and streams worker output to clients. These results were measured in a frontend-bound scenario. The only software difference between the two arms was the Axum build: unmodified Axum 0.8.9 versus Axum 0.8.9 with this patch.
I also isolated this behavior in a standalone Axum benchmark. It used 4,096 persistent connections and finite responses with 32 256-byte SSE frames, one scheduler yield between frames, a stock
rewrkclient on another node, a 15-second warmup, and a 60-second measurement.In an on-CPU profile of this benchmark,
Sender::closedused 16.57% inclusive CPU in the control. Its shared mutex used 15.32% inclusive CPU, including 7.12% in the contended lock path. Those stacks disappeared with this change, whileReceiver::changedused 4.14% inclusive CPU.Solution
Keep the shutdown sender in the server task and give each accepted connection its own cloned receiver. The accept loop and connection tasks wait on
Receiver::changed(), and shutdown drops the sender to notify all receivers.This keeps the public API and graceful shutdown behavior unchanged. Existing tests continue to cover graceful draining and custom executors.
Validation
cargo test -p axum --all-features serve::testscargo test --locked --workspace --all-features --all-targetscargo clippy --locked --workspace --all-targets --all-features -- -D warningscargo fmt --all --checkgit diff --check