Avoid flushing h2 socket while holding stream locks#918
Conversation
Problem
HTTP/2 stream producers and the connection task share the stream-state and send-buffer mutexes. The connection task can currently hold those locks while driving codec flush/write readiness, which reaches the underlying socket. Under high write concurrency on one connection, this can make multi-worker workloads contend on the shared locks while the connection task is doing I/O progress.
Solution
Split stream draining into a locked buffering phase and an unlocked codec flush phase. The connection task now makes required codec/socket progress before taking stream locks, buffers pending frames only while the codec has local capacity, releases the locks, flushes the codec, and then briefly re-locks to reclaim partially or fully written DATA frames.
Add a write-contention benchmark that sends many response body chunks concurrently over a single HTTP/2 connection. The benchmark can vary the number of streams, chunks, chunk size, and Tokio worker threads.
Validation
Ran:
cargo fmt --all --check
cargo check
git diff --check origin/master...HEAD
Ran the new benchmark in WSL Ubuntu 24.04 with 512 streams x 128 chunks x 16 KiB = 1 GiB over one HTTP/2 connection. Median elapsed time over three alternating runs per worker-thread count:
workers baseline patched
1 336 ms 337 ms
2 358 ms 238 ms
4 382 ms 337 ms
8 407 ms 340 ms
The one-worker case is flat, as expected. Multi-worker runs improve by 11.8% to 33.5% by median elapsed time.
On this Windows machine, checking the benchmark target currently stops in the existing tokio-rustls dev-dependency chain because aws-lc-sys needs additional native build tools before reaching h2 code.
41f22f4 to
3a42547
Compare
Restore the previous poll_complete invariant that DATA completion bookkeeping is reclaimed before another frame can be buffered. Small DATA frames can be fully encoded by Codec::buffer, which uses a single last_data_frame slot; draining it immediately prevents a following DATA frame from overwriting the completion record.
Validation:
cargo fmt --all --check
cargo check
cargo test -p h2-tests reserved_capacity_assigned_in_multi_window_updates -- --exact --nocapture
git diff --check origin/master...HEAD
Add a regression test that sends two immediately encodable DATA frames while the stream and connection both have available capacity. Without reclaiming the first frame before buffering the second, debug builds hit the in_flight_data_frame assertion.
Validation:
cargo fmt --all --check
cargo check
cargo test -p h2-tests small_data_frames_reclaimed_before_buffering_next -- --exact
git diff --check origin/master...HEAD
The same test fails on the pre-fix commit 3a42547 with the in_flight_data_frame assertion.
|
Very cool! Also, a few weeks ago I put a significant rewrite in #917. (Notably, I don't personally have great multi threaded testing environments.) |
|
Ya I realized that after working on this :) maybe not needed then? I'll leave it up to you - I'm still reading through the PR to understand how everything is changing. Is this still required after the refactor? |
|
Well, I can't guarantee what I did is good, though conceptually it moves more towards queues which tends to help. Some reports in that issue suggest good numbers, now that I've fixed up most bugs in it. If you have an environment that can stress test any sorts of real world loads, more data is always welcome! (If it turns out what I did is bad, what you did here would be a great step!) |
|
Let me see what I can do - should be able to take a glance at this at some point this coming week. Really excited to see how the perf looks :) |
Avoid flushing h2 socket while holding stream locks
Related: #531
There is also an active broader draft refactor in #917. This PR is intentionally smaller: it targets the specific path discussed in #531 where
Streams::poll_completecan hold stream/send-buffer locks while the codec flush path reaches the socket.Summary
This change splits HTTP/2 stream draining into two phases so the connection task no longer holds the shared stream-state and send-buffer mutexes while flushing or polling writes on the underlying socket.
Previously,
Streams::poll_completeheldInnerandSendBufferlocks whilePrioritize::poll_completecould call intoCodec::poll_ready/Codec::flush, which can reachpoll_write_bufon the socket. Under high write concurrency on one HTTP/2 connection, stream producer tasks contend with the connection task while the connection task is doing I/O progress.The new flow is:
The patch also adds a targeted write-contention benchmark that sends many response body chunks concurrently over a single HTTP/2 connection. It can be run with:
The benchmark is configurable:
Benchmark
Run environment:
Median elapsed time over 3 alternating runs per worker-thread count:
Median throughput:
The one-worker case is effectively flat, as expected: there is little cross-thread lock contention. The multi-worker runs show the intended effect.
Testing
The new
small_data_frames_reclaimed_before_buffering_nextregression test fails on the pre-fix PR commit (3a42547) with thein_flight_data_frameassertion, and passes after reclaiming the DATA frame before buffering another frame.The write-contention benchmark was run before/after this change in WSL Ubuntu 24.04. On this Windows machine, checking the benchmark target currently stops in the existing
tokio-rustlsdev-dependency chain becauseaws-lc-sysneeds additional native build tools (cmake/NASM) before it reaches h2 code.