Skip to content

Commit c2d3fb6

Browse files
authored
perf: avoid flushing h2 socket while holding stream locks (#918)
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. 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.
1 parent e8da527 commit c2d3fb6

9 files changed

Lines changed: 597 additions & 68 deletions

File tree

benches/main.rs

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,63 @@ use http::Request;
88

99
use std::{
1010
error::Error,
11+
future::Future,
12+
pin::Pin,
13+
task::{Context, Poll},
1114
time::{Duration, Instant},
1215
};
1316

1417
use tokio::net::{TcpListener, TcpStream};
1518

1619
const NUM_REQUESTS_TO_SEND: usize = 100_000;
20+
const WRITE_CONTENTION_STREAMS: usize = 512;
21+
const WRITE_CONTENTION_CHUNKS_PER_STREAM: usize = 128;
22+
const WRITE_CONTENTION_CHUNK_SIZE: usize = 16 * 1024;
23+
const WRITE_CONTENTION_WINDOW: u32 = 16 * 1024 * 1024;
24+
const WRITE_CONTENTION_MAX_FRAME_SIZE: u32 = 64 * 1024;
25+
const WRITE_CONTENTION_MAX_SEND_BUFFER: usize = 8 * 1024 * 1024;
26+
const WRITE_CONTENTION_WORKER_THREADS: usize = 4;
27+
28+
#[derive(Clone, Copy)]
29+
struct WriteContentionConfig {
30+
streams: usize,
31+
chunks_per_stream: usize,
32+
chunk_size: usize,
33+
}
34+
35+
impl WriteContentionConfig {
36+
fn from_env() -> Self {
37+
Self {
38+
streams: env_usize("H2_WRITE_CONTENTION_STREAMS", WRITE_CONTENTION_STREAMS),
39+
chunks_per_stream: env_usize(
40+
"H2_WRITE_CONTENTION_CHUNKS_PER_STREAM",
41+
WRITE_CONTENTION_CHUNKS_PER_STREAM,
42+
),
43+
chunk_size: env_usize(
44+
"H2_WRITE_CONTENTION_CHUNK_SIZE",
45+
WRITE_CONTENTION_CHUNK_SIZE,
46+
),
47+
}
48+
}
49+
50+
fn bytes(&self) -> usize {
51+
self.streams * self.chunks_per_stream * self.chunk_size
52+
}
53+
}
54+
55+
fn write_contention_worker_threads() -> usize {
56+
env_usize(
57+
"H2_WRITE_CONTENTION_WORKER_THREADS",
58+
WRITE_CONTENTION_WORKER_THREADS,
59+
)
60+
}
61+
62+
fn env_usize(name: &str, default: usize) -> usize {
63+
std::env::var(name)
64+
.ok()
65+
.and_then(|value| value.parse().ok())
66+
.unwrap_or(default)
67+
}
1768

1869
// The actual server.
1970
async fn server(addr: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
@@ -111,8 +162,174 @@ async fn send_requests(addr: &str) -> Result<(), Box<dyn Error>> {
111162
Ok(())
112163
}
113164

165+
async fn write_contention_benchmark() -> Result<(), Box<dyn Error>> {
166+
let config = WriteContentionConfig::from_env();
167+
let listener = TcpListener::bind("127.0.0.1:0").await?;
168+
let addr = listener.local_addr()?;
169+
170+
println!(
171+
"H2 write contention: {} streams x {} chunks x {}B = {:.1} MiB",
172+
config.streams,
173+
config.chunks_per_stream,
174+
config.chunk_size,
175+
config.bytes() as f64 / (1024.0 * 1024.0),
176+
);
177+
178+
tokio::spawn(async move {
179+
let (socket, _peer_addr) = listener.accept().await.unwrap();
180+
if let Err(e) = serve_write_contention(socket, config).await {
181+
println!("write contention server error: {e:?}");
182+
}
183+
});
184+
185+
let tcp = TcpStream::connect(addr).await?;
186+
let mut builder = client::Builder::new();
187+
builder
188+
.initial_window_size(WRITE_CONTENTION_WINDOW)
189+
.initial_connection_window_size(WRITE_CONTENTION_WINDOW)
190+
.max_frame_size(WRITE_CONTENTION_MAX_FRAME_SIZE)
191+
.max_concurrent_streams(config.streams as u32)
192+
.max_send_buffer_size(WRITE_CONTENTION_MAX_SEND_BUFFER);
193+
194+
let (client, h2) = builder.handshake::<_, Bytes>(tcp).await?;
195+
tokio::spawn(async move {
196+
if let Err(e) = h2.await {
197+
println!("write contention client connection error: {e:?}");
198+
}
199+
});
200+
201+
let mut handles = Vec::with_capacity(config.streams);
202+
let started = Instant::now();
203+
for _ in 0..config.streams {
204+
let client = client.clone();
205+
let expected = config.chunks_per_stream * config.chunk_size;
206+
handles.push(tokio::spawn(async move {
207+
let request = Request::builder().body(()).unwrap();
208+
let mut client = client.ready().await.unwrap();
209+
let (response, _) = client.send_request(request, true).unwrap();
210+
let response = response.await.unwrap();
211+
let mut body = response.into_body();
212+
let mut received = 0;
213+
214+
while let Some(chunk) = body.data().await {
215+
let chunk = chunk.unwrap();
216+
received += chunk.len();
217+
let _ = body.flow_control().release_capacity(chunk.len());
218+
}
219+
220+
assert_eq!(received, expected);
221+
}));
222+
}
223+
224+
for handle in handles {
225+
handle.await.unwrap();
226+
}
227+
228+
let elapsed = started.elapsed();
229+
let mib = config.bytes() as f64 / (1024.0 * 1024.0);
230+
println!("Overall: {}ms.", elapsed.as_millis());
231+
println!("Throughput: {:.1} MiB/s", mib / elapsed.as_secs_f64());
232+
233+
Ok(())
234+
}
235+
236+
async fn serve_write_contention(
237+
socket: TcpStream,
238+
config: WriteContentionConfig,
239+
) -> Result<(), Box<dyn Error + Send + Sync>> {
240+
let mut builder = server::Builder::new();
241+
builder
242+
.initial_window_size(WRITE_CONTENTION_WINDOW)
243+
.initial_connection_window_size(WRITE_CONTENTION_WINDOW)
244+
.max_frame_size(WRITE_CONTENTION_MAX_FRAME_SIZE)
245+
.max_concurrent_streams(config.streams as u32)
246+
.max_send_buffer_size(WRITE_CONTENTION_MAX_SEND_BUFFER);
247+
248+
let mut connection = builder.handshake(socket).await?;
249+
while let Some(result) = connection.accept().await {
250+
let (request, respond) = result?;
251+
tokio::spawn(async move {
252+
if let Err(e) = handle_write_contention_request(request, respond, config).await {
253+
println!("write contention request error: {e}");
254+
}
255+
});
256+
}
257+
258+
Ok(())
259+
}
260+
261+
async fn handle_write_contention_request(
262+
mut request: Request<RecvStream>,
263+
mut respond: SendResponse<Bytes>,
264+
config: WriteContentionConfig,
265+
) -> Result<(), Box<dyn Error + Send + Sync>> {
266+
let body = request.body_mut();
267+
while let Some(data) = body.data().await {
268+
let data = data?;
269+
let _ = body.flow_control().release_capacity(data.len());
270+
}
271+
272+
let response = http::Response::new(());
273+
let mut send = respond.send_response(response, false)?;
274+
let chunk = Bytes::from(vec![b'x'; config.chunk_size]);
275+
276+
for idx in 0..config.chunks_per_stream {
277+
let end_of_stream = idx + 1 == config.chunks_per_stream;
278+
send_chunk(&mut send, chunk.clone(), end_of_stream).await?;
279+
}
280+
281+
Ok(())
282+
}
283+
284+
async fn send_chunk(
285+
send: &mut h2::SendStream<Bytes>,
286+
chunk: Bytes,
287+
end_of_stream: bool,
288+
) -> Result<(), h2::Error> {
289+
let len = chunk.len();
290+
send.reserve_capacity(len);
291+
loop {
292+
if send.capacity() >= len {
293+
send.send_data(chunk, end_of_stream)?;
294+
return Ok(());
295+
}
296+
297+
match (Capacity { send }).await {
298+
Some(Ok(_)) => {}
299+
Some(Err(err)) => return Err(err),
300+
None => return Err(h2::Reason::INTERNAL_ERROR.into()),
301+
}
302+
}
303+
}
304+
305+
struct Capacity<'a> {
306+
send: &'a mut h2::SendStream<Bytes>,
307+
}
308+
309+
impl Future for Capacity<'_> {
310+
type Output = Option<Result<usize, h2::Error>>;
311+
312+
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
313+
self.send.poll_capacity(cx)
314+
}
315+
}
316+
114317
fn main() {
115318
let _ = env_logger::try_init();
319+
let bench = std::env::var("H2_BENCH").unwrap_or_else(|_| "all".to_string());
320+
321+
if bench == "write-contention" {
322+
let worker_threads = write_contention_worker_threads();
323+
println!("H2 write contention worker threads: {worker_threads}");
324+
let rt = tokio::runtime::Builder::new_multi_thread()
325+
.worker_threads(worker_threads)
326+
.enable_all()
327+
.build()
328+
.unwrap();
329+
rt.block_on(write_contention_benchmark()).unwrap();
330+
return;
331+
}
332+
116333
let addr = "127.0.0.1:5928";
117334
println!("H2 running in current-thread runtime at {addr}:");
118335
std::thread::spawn(|| {
@@ -145,4 +362,15 @@ fn main() {
145362
.build()
146363
.unwrap();
147364
rt.block_on(send_requests(addr)).unwrap();
365+
366+
if bench == "all" {
367+
let worker_threads = write_contention_worker_threads();
368+
println!("H2 write contention worker threads: {worker_threads}");
369+
let rt = tokio::runtime::Builder::new_multi_thread()
370+
.worker_threads(worker_threads)
371+
.enable_all()
372+
.build()
373+
.unwrap();
374+
rt.block_on(write_contention_benchmark()).unwrap();
375+
}
148376
}

src/codec/framed_write.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ where
120120
Poll::Ready(Ok(()))
121121
}
122122

123+
/// Returns whether a frame can be buffered without first flushing the
124+
/// underlying I/O object.
125+
pub(crate) fn has_capacity(&self) -> bool {
126+
self.encoder.has_capacity()
127+
}
128+
123129
/// Buffer a frame.
124130
///
125131
/// `poll_ready` must be called first to ensure that a frame may be

src/codec/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,12 @@ where
136136
self.framed_write().poll_ready(cx)
137137
}
138138

139+
/// Returns whether the codec can buffer a frame without flushing the
140+
/// underlying I/O object.
141+
pub(crate) fn has_send_capacity(&mut self) -> bool {
142+
self.framed_write().has_capacity()
143+
}
144+
139145
/// Buffer a frame.
140146
///
141147
/// `poll_ready` must be called first to ensure that a frame may be

src/proto/streams/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ use crate::proto::*;
3131
use bytes::Bytes;
3232
use std::time::Duration;
3333

34+
#[derive(Debug, Eq, PartialEq)]
35+
pub(super) enum BufferStatus {
36+
Complete,
37+
CodecFull,
38+
}
39+
3440
#[derive(Debug)]
3541
pub struct Config {
3642
/// Initial maximum number of locally initiated streams.

src/proto/streams/prioritize.rs

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use bytes::buf::Take;
1010
use std::{
1111
cmp::{self, Ordering},
1212
fmt, io, mem,
13-
task::{Context, Poll, Waker},
13+
task::Waker,
1414
};
1515

1616
/// # Warning
@@ -505,30 +505,30 @@ impl Prioritize {
505505
}
506506
}
507507

508-
pub fn poll_complete<T, B>(
508+
pub fn buffer_pending<T, B>(
509509
&mut self,
510-
cx: &mut Context,
511510
buffer: &mut Buffer<Frame<B>>,
512511
store: &mut Store,
513512
counts: &mut Counts,
514513
dst: &mut Codec<T, Prioritized<B>>,
515-
) -> Poll<io::Result<()>>
514+
) -> io::Result<BufferStatus>
516515
where
517516
T: AsyncWrite + Unpin,
518517
B: Buf,
519518
{
520-
// Ensure codec is ready
521-
ready!(dst.poll_ready(cx))?;
522-
523519
// Reclaim any frame that has previously been written
524520
self.reclaim_frame(buffer, store, dst);
525521

526522
// The max frame length
527523
let max_frame_len = dst.max_send_frame_size();
528524

529-
tracing::trace!("poll_complete");
525+
tracing::trace!("buffer_pending");
530526

531527
loop {
528+
if !dst.has_send_capacity() {
529+
return Ok(BufferStatus::CodecFull);
530+
}
531+
532532
if let Some(mut stream) = self.pop_pending_open(store, counts) {
533533
self.pending_send.push_front(&mut stream);
534534
self.try_assign_capacity(&mut stream);
@@ -544,28 +544,31 @@ impl Prioritize {
544544
}
545545
dst.buffer(frame).expect("invalid frame");
546546

547-
// Ensure the codec is ready to try the loop again.
548-
ready!(dst.poll_ready(cx))?;
549-
550-
// Because, always try to reclaim...
547+
// Small DATA frames can be fully encoded by `buffer`,
548+
// which records completion in a single codec slot. Reclaim
549+
// before accepting another frame so that slot is not
550+
// overwritten.
551551
self.reclaim_frame(buffer, store, dst);
552552
}
553553
None => {
554-
// Try to flush the codec.
555-
ready!(dst.flush(cx))?;
556-
557-
// This might release a data frame...
558-
if !self.reclaim_frame(buffer, store, dst) {
559-
return Poll::Ready(Ok(()));
560-
}
561-
562-
// No need to poll ready as poll_complete() does this for
563-
// us...
554+
return Ok(BufferStatus::Complete);
564555
}
565556
}
566557
}
567558
}
568559

560+
pub fn reclaim_written_frame<T, B>(
561+
&mut self,
562+
buffer: &mut Buffer<Frame<B>>,
563+
store: &mut Store,
564+
dst: &mut Codec<T, Prioritized<B>>,
565+
) -> bool
566+
where
567+
B: Buf,
568+
{
569+
self.reclaim_frame(buffer, store, dst)
570+
}
571+
569572
/// Tries to reclaim a pending data frame from the codec.
570573
///
571574
/// Returns true if a frame was reclaimed.

0 commit comments

Comments
 (0)