← All posts

Nagle's Algorithm Ruined My Afternoon: 40 Milliseconds from Nowhere

The client and the server run on the same machine. A request is twenty bytes, the reply is eight, and the real path through loopback takes microseconds. From a protocol like this you naturally expect tens of thousands of requests per second.

The first measurement showed twenty-four.

Not twenty-four thousand — twenty-four requests per second. The median round trip took 41 ms, as if a long network route lay between the processes instead of loopback. Meanwhile one request in the same session completed in 54 µs: the network and the server clearly knew how to be fast.

The cause was not Rust, not CPU load, and not bandwidth. The client split one message between two write calls and then immediately waited for the reply. That traffic shape collided two independent TCP optimizations: Nagle's algorithm on the sender and delayed ACK on the receiver.

Nagle's algorithm is a TCP sender rule: while a small segment remains unacknowledged, the next small write is held back until an ACK arrives or a full segment accumulates. This is how TCP reduces the number of tiny packets — but it can also hold an unfinished request in the buffer.

First we will reproduce the delay, then remove it in two different ways, and reconstruct the whole journey of those twenty bytes from the socket's own state. At the end we will check the opposite case, where disabling Nagle does not speed things up but slows them down. The complete source files are collected in the appendix at the end.

Two writes Turn Twenty Bytes into 41 Milliseconds

The protocol is a four-byte big-endian length, the request body, and an eight-byte counter in the reply. The server reads the header first, validates the length, then waits for the whole body, and only then replies:

let len = u32::from_be_bytes(header) as usize;
ensure!(len <= MAX_BODY, "body length {len} exceeds {MAX_BODY}");

stream.read_exact(&mut body[..len]).context("read body")?;
requests += 1;
stream.write_all(&requests.to_be_bytes()).context("write reply")?;

The client sends the header and the body separately, then blocks reading the reply:

let start = Instant::now();
stream.write_all(&header).context("write header")?;
stream.write_all(&body).context("write body")?;
stream.read_exact(&mut reply).context("read reply")?;
samples.push(start.elapsed());

At the application level this looks harmless. Both buffers are small, the connection is local, and write_all returns almost immediately.

The server picks a free port:

$ cargo build --release
$ ./target/release/wwr-server
listening on 127.0.0.1:46087

A thousand sequential requests show not a random spike but a stable plateau:

$ ./target/release/wwr-naive --port 46087 --iters 1000
naive write-write-read: 1000 iterations, header 4 B + body 16 B
min 54 µs | p50 41.00 ms | p90 41.98 ms | p99 42.55 ms | max 46.63 ms
total 41.1 s, 24 req/s

Only a millisecond and a half separates p50 from p99. That is far too even for scheduler noise or a transient load: almost every request is waiting for one and the same timer.

The 54 µs minimum belongs to the connection's first request. Linux starts a connection in quick-ACK mode, so the first acknowledgment comes back immediately. After that the usual acknowledgment economy kicks in, and the remaining requests settle onto the plateau near 40 ms.

TCP_NODELAY Removes the Wait

The best-known remedy against small-write latency in TCP is TCP_NODELAY. In Rust it is one line after connect:

stream.set_nodelay(true).context("set TCP_NODELAY")?;

The server, the protocol, and the two separate writes stay exactly the same:

$ ./target/release/wwr-nodelay --port 46087 --iters 5000
TCP_NODELAY write-write-read: 5000 iterations, header 4 B + body 16 B
min 18 µs | p50 22 µs | p90 27 µs | p99 40 µs | max 95 µs
total 116 ms, 43221 req/s

The median drops from 41.00 ms to 22 µs1,864 times. Five thousand requests finish in 116 ms; in that time the naive client managed fewer than three.

TCP_NODELAY disables Nagle's algorithm for the socket. That removes the delay, but it does not yet explain why the algorithm held back the second write at all — or why the wait was almost exactly 40 ms every time.

The Whole Request in One write Works Even Better

There is a second cure: leave Nagle on, but hand the kernel the complete message in a single operation. The header and body can be assembled into one buffer in advance:

let mut request = Vec::with_capacity(header.len() + body.len());
request.extend_from_slice(&header);
request.extend_from_slice(&body);

stream.write_all(&request).context("write request")?;

The delay disappears without touching any socket options:

$ ./target/release/wwr-onewrite --port 46087 --iters 5000
single write (one buffer), Nagle still on: 5000 iterations, 4+16 B
min 14 µs | p50 15 µs | p90 25 µs | p99 32 µs | max 87 µs
total 92 ms, 54125 req/s

If copying the header and body into a shared buffer is inconvenient, write_vectored passes several slices in one writev system call:

let slices = [IoSlice::new(&header), IoSlice::new(&body)];
let sent = stream.write_vectored(&slices).context("writev request")?;
ensure!(sent == request.len(), "short vectored write: {sent}");
$ ./target/release/wwr-onewrite --port 46087 --iters 5000 --vectored
single write (writev, two iovecs), Nagle still on: 5000 iterations, 4+16 B
min 14 µs | p50 16 µs | p90 21 µs | p99 37 µs | max 399 µs
total 89 ms, 56486 req/s

One buffer and writev deliver medians of 15–16 µs. In this session both variants came out roughly a quarter faster than two writes with TCP_NODELAY: one send syscall per request instead of two, and the kernel receives the whole message at once.

The fast paths drifted between runs: 22–26 µs for TCP_NODELAY and 15–16 µs for the single write. Individual microseconds are not a universal characteristic here. The reliable result is different: both approaches completely remove the 40 ms timer, and sending the request whole does not require disabling Nagle.

Now we can take apart what was happening in the original version.

write Returned, but the Bytes Were Not Sent

strace -T shows the client's system calls and the time spent in each:

$ strace -T -e trace=sendto,recvfrom -o naive.trace \
      ./target/release/wwr-naive --port 46087 --iters 30 >/dev/null
$ tail -n 13 naive.trace
sendto(3, "\0\0\0\20", 4, MSG_NOSIGNAL, NULL, 0) = 4 <0.000022>
sendto(3, "BBBBBBBBBBBBBBBB", 16, MSG_NOSIGNAL, NULL, 0) = 16 <0.000009>
recvfrom(3, "\0\0\0\0\0\0\0\33", 8, 0, NULL, NULL) = 8 <0.040813>
sendto(3, "\0\0\0\20", 4, MSG_NOSIGNAL, NULL, 0) = 4 <0.000103>
sendto(3, "BBBBBBBBBBBBBBBB", 16, MSG_NOSIGNAL, NULL, 0) = 16 <0.000046>
recvfrom(3, "\0\0\0\0\0\0\0\34", 8, 0, NULL, NULL) = 8 <0.040648>
sendto(3, "\0\0\0\20", 4, MSG_NOSIGNAL, NULL, 0) = 4 <0.000023>
sendto(3, "BBBBBBBBBBBBBBBB", 16, MSG_NOSIGNAL, NULL, 0) = 16 <0.000050>
recvfrom(3, "\0\0\0\0\0\0\0\35", 8, 0, NULL, NULL) = 8 <0.040675>
sendto(3, "\0\0\0\20", 4, MSG_NOSIGNAL, NULL, 0) = 4 <0.000025>
sendto(3, "BBBBBBBBBBBBBBBB", 16, MSG_NOSIGNAL, NULL, 0) = 16 <0.000008>
recvfrom(3, "\0\0\0\0\0\0\0\36", 8, 0, NULL, NULL) = 8 <0.040886>
+++ exited with 0 +++

Both sendto calls return within 8–103 µs. All of the 40 ms sit inside recvfrom, where the client is already waiting for the reply.

A successful write on a TCP socket means the kernel accepted the bytes into the send buffer. It does not promise that the bytes have become a segment and reached the other side. So the fast sendto calls do not vindicate the network: by the time of the second call, the kernel may have accepted the body and left it sitting in the queue.

To see that queue, we need to look at the live socket during the delay.

Nagle and Delayed ACK, Both Visible in One Socket

Start the naive client in the background and capture the state of both ends of the connection:

$ ./target/release/wwr-naive --port 46087 --iters 150 >/dev/null &
$ sleep 2
$ ss -tin "dport = :46087 or sport = :46087"
State Recv-Q Send-Q Local Address:Port  Peer Address:Port
ESTAB 0      0          127.0.0.1:46087    127.0.0.1:38214
	 cubic wscale:10,10 rto:201 rtt:0.047/0.011 ato:40 mss:32768 pmtu:65535 rcvmss:536 advmss:65483 cwnd:10 bytes_sent:392 bytes_acked:392 bytes_received:984 segs_out:147 segs_in:102 data_segs_out:49 data_segs_in:99 send 55775319149bps lastsnd:28 lastrcv:28 lastack:28 pacing_rate 111550638296bps delivery_rate 16384000000bps delivered:50 app_limited rcv_space:65483 rcv_ssthresh:65483 minrtt:0.016 snd_wnd:65536 rcv_wnd:65536
ESTAB 0      20         127.0.0.1:38214    127.0.0.1:46087
	 cubic wscale:10,10 rto:220 rtt:19.159/21.769 ato:40 mss:32768 pmtu:65535 rcvmss:536 advmss:65483 cwnd:10 bytes_sent:984 bytes_acked:981 bytes_received:392 segs_out:102 segs_in:148 data_segs_out:99 data_segs_in:49 send 136825513bps pacing_rate 273651024bps delivery_rate 37449142856bps delivered:99 app_limited busy:2012ms unacked:1 rcv_space:65495 rcv_ssthresh:65495 notsent:16 minrtt:0.007 snd_wnd:65536 rcv_wnd:65536

The second entry is the client's socket. Three fields reconstruct the request's state.

Send-Q 20 shows twenty bytes in the send queue, and notsent:16 clarifies that sixteen of them the kernel has not even tried to transmit. That is the request body. The unacked:1 field shows one segment sent but not yet acknowledged — the four-byte header. Finally, rtt:19.159 is already inflated by the waiting, while minrtt:0.007 records the real path of seven microseconds.

The header left as the first segment. By the time the client wrote the body, unacknowledged data was already in flight, so Nagle kept the new sixteen bytes in the queue. A full segment cannot be assembled from them either: ss shows mss:32768.

That leaves the question of why the acknowledgment for a four-byte header does not come back immediately. The answer is in the ato:40 field on both sockets.

Delayed ACK is a TCP receiver optimization: an acknowledgment may be briefly postponed so it can ride along with response data, or confirm several segments at once. In this session Linux showed a timer of ato:40 — that is, 40 ms.

The server's TCP stack received the header and postponed the ACK, hoping response data would appear shortly. The server itself cannot produce a response yet: its read_exact is waiting for the sixteen-byte body. The TCP stack knows nothing about the application protocol — and nothing about the fact that those bytes are being held by Nagle on the client.

This forms a waiting cycle. The client's TCP will not send the body until the header is acknowledged. The server's TCP postpones the acknowledgment, hoping to combine it with outgoing data. The server application will not create outgoing data until it receives the body.

After 40 ms the delayed-ACK timer fires and sends a bare ACK. Nagle releases the body, the server finishes reading the request and returns the counter. One request waits for one timer tick — hence the even median of 41.00 ms.

Neither optimization is a bug on its own. The delay is created by their combination with a write-write-read protocol, where one logical message is split into small writes and the reply is needed before the next operation.

Every Request Really Does Wait for the Timer

One socket's state explains the mechanism, but a system counter lets us verify it across the whole series. TcpExtDelayedACKs counts fired delayed-acknowledgment timers:

$ nstat -asz TcpExtDelayedACKs
#kernel
TcpExtDelayedACKs               105068             0.0
$ ./target/release/wwr-naive --port 46087 --iters 200 >/dev/null
$ nstat -asz TcpExtDelayedACKs
#kernel
TcpExtDelayedACKs               105268             0.0

Across two hundred naive requests the value grew from 105068 to 105268 — by exactly two hundred. One expired timer per request.

A control run with TCP_NODELAY does not move the counter:

$ nstat -asz TcpExtDelayedACKs
#kernel
TcpExtDelayedACKs               105268             0.0
$ ./target/release/wwr-nodelay --port 46087 --iters 200 >/dev/null
$ nstat -asz TcpExtDelayedACKs
#kernel
TcpExtDelayedACKs               105268             0.0

With Nagle off, the body follows the header immediately. The server receives the complete request, produces the reply, and the ACK departs together with the response data before the timer expires.

TcpExtDelayedACKs is a system-wide counter, so on a busy machine parallel connections can add noise. During the recorded session the background was zero; a separate run of the same scenario produced a delta of 201 instead of 200. What confirms the mechanism is not perfect numbers by themselves but the agreement of three observations: notsent:16 with unacked:1 in the socket, ato:40 on the receiver, and one delayed ACK per iteration.

When Nagle's Algorithm Saves Packets

It is tempting to draw an overly broad conclusion from the experiment and switch on TCP_NODELAY for every connection. But Nagle's original job is not to accelerate request/response — it is to coalesce a stream of small writes when the application does not wait for a reply after each one.

Let us test exactly that traffic. The client performs 200000 one-byte writes back to back, then closes the write side and waits until the server confirms receiving all 200000 bytes.

The segment count comes not from an external sniffer but from the socket's own state:

let counts = segment_counts(&stream)?;
let per_segment = sent as f64 / f64::from(counts.data_segs_out);

TCP_INFO is a Linux structure with the state and counters of a specific TCP socket. A getsockopt(TCP_INFO) call reads, among other things, the number of data segments sent.

First, leave Nagle on:

$ ./target/release/flood-server
listening on 127.0.0.1:42837
$ ./target/release/flood-client --port 42837 --writes 200000
Nagle on: 200000 writes x 1 B = 200000 bytes
write loop 146 ms, all bytes at the server after 146 ms
data segments out: 17071 (12 payload bytes per segment); segs_out 17076

Now disable it:

$ ./target/release/flood-client --port 42837 --writes 200000 --nodelay
TCP_NODELAY: 200000 writes x 1 B = 200000 bytes
write loop 938 ms, all bytes at the server after 938 ms
data segments out: 194850 (1 payload bytes per segment); segs_out 194855

With Nagle, two hundred thousand one-byte writes coalesced into 17071 segments — twelve payload bytes each on average. Without it, nearly every write became its own segment: 194850 segments, 11.4 times more. The whole stream reached the server in 146 ms with Nagle and in 938 ms with TCP_NODELAY — disabling the algorithm made this scenario 6.4 times slower.

Even with TCP_NODELAY the result was not exactly 200000 segments. About 2.6% of the writes still merged, because the application managed to append a byte to a segment the kernel had not yet sent.

The Nagle-on result also depends on the environment. On loopback an ACK returns in about 10 µs and re-enables sending, so only about twelve bytes accumulated between acknowledgments. On a path with an RTT around 1 ms, the same write rate would coalesce far more. Loopback understates Nagle's benefit here — and even so, the segment counts differ by an order of magnitude.

RTT (round-trip time) is the full there-and-back time: data reaches the other side and the acknowledgment returns to the sender. The larger the RTT, the more writes accumulate between ACKs.

Between runs the absolute numbers drifted: 16326–17197 segments with Nagle and 194850–199927 without. The ratio of roughly 11–12× held.

The Traffic Shape Decides the Right Setting

The delay in the opening was created neither by a slow loopback nor by an unconditionally harmful algorithm. The client transmitted an unfinished request as two small writes and waited for a reply. The header departed, the body stayed in the send queue because of Nagle, and the receiver postponed its ACK for 40 ms. The server could not reply without the body, so every request waited for the timer.

That traffic shape has two measured fixes.

If the header and body form one message, send them with one write or writev. In the experiment this gave a median of 15–16 µs, kept Nagle on, and reduced the number of system calls.

If the application genuinely must send small complete messages as separate writes, and latency matters more than segment count, use TCP_NODELAY. With it, the original two writes gave a median of 22 µs instead of 41 ms.

But for a continuous stream of small writes with no reply on the critical path, Nagle does exactly the job it was built for. In our flood it cut the segment count 11.4× and the transfer time 6.4×.

TCP_NODELAY is therefore not a universal speed button. First determine the message boundaries and look at the application's sequence of operations.

If the sequence looks like write-write-read and the percentiles form an even plateau near 40 ms, ss -ti is the shortest path to a diagnosis. The combination of unacked, notsent and ato shows not an abstract "slow TCP" but a concrete waiting cycle between the two sides.

The twenty bytes from the beginning of this article never disappeared and never traveled for forty milliseconds. Sixteen of them lay in a local socket buffer the whole time.

Appendix: Full Source Files

wwr/src/server.rs — 67 lines

//! Request/response server for the write-write-read demo.
//!
//! Protocol: 4-byte big-endian body length, then the body; the reply is the
//! 8-byte big-endian per-connection request counter. The server never sets
//! any socket options — every stall in this demo is produced (and later
//! cured) purely on the client side.

use std::{
    io::{ErrorKind, Read, Write},
    net::{TcpListener, TcpStream},
};

use anyhow::{Context, Result, ensure};

/// Largest body the server agrees to read, so a corrupt header cannot ask
/// for an absurd allocation.
const MAX_BODY: usize = 65536;

fn main() -> Result<()> {
    // Port 0 = let the kernel pick a free high port; print it for clients.
    let listener = TcpListener::bind("127.0.0.1:0").context("bind 127.0.0.1:0")?;
    let addr = listener.local_addr().context("query local addr")?;
    println!("listening on {addr}");

    for stream in listener.incoming() {
        let stream = stream.context("accept")?;
        let peer = stream.peer_addr().context("query peer addr")?;
        match serve(stream) {
            Ok((requests, bytes)) => {
                println!("conn {peer}: {requests} requests, {bytes} body bytes");
            }
            Err(error) => println!("conn {peer}: error: {error:#}"),
        }
    }
    Ok(())
}

/// Serve one connection until the client closes it; count what went through.
fn serve(mut stream: TcpStream) -> Result<(u64, u64)> {
    let mut requests = 0u64;
    let mut bytes = 0u64;
    let mut body = vec![0u8; MAX_BODY];

    loop {
        let mut header = [0u8; 4];
        // A clean EOF between requests is the normal end of a session.
        match stream.read_exact(&mut header) {
            Ok(()) => {}
            Err(error) if error.kind() == ErrorKind::UnexpectedEof => {
                return Ok((requests, bytes));
            }
            Err(error) => return Err(error).context("read header"),
        }

        let len = u32::from_be_bytes(header) as usize;
        ensure!(len <= MAX_BODY, "body length {len} exceeds {MAX_BODY}");

        // The reply is written only after the WHOLE request arrived — this
        // is what keeps the server silent while delayed ACK counts to 40 ms.
        stream.read_exact(&mut body[..len]).context("read body")?;
        requests += 1;
        bytes += len as u64;
        stream
            .write_all(&requests.to_be_bytes())
            .context("write reply")?;
    }
}

wwr/src/client.rs — 64 lines

//! Naive client: header and body leave in two separate write calls, then the
//! client blocks on the reply. No socket options touched — this is the shape
//! of code that walks straight into the Nagle + delayed ACK trap.

use std::{
    io::{Read, Write},
    net::TcpStream,
    time::{Duration, Instant},
};

use anyhow::{Context, Result, ensure};
use clap::Parser;

mod stats;

use stats::Summary;

/// Write-write-read client: header write, body write, blocking read.
#[derive(Parser)]
struct Args {
    /// Server port on 127.0.0.1 (printed by wwr-server on start).
    #[arg(long)]
    port: u16,
    /// Number of request/response round trips.
    #[arg(long, default_value_t = 1000)]
    iters: u64,
    /// Body size in bytes (the header is always 4 bytes).
    #[arg(long, default_value_t = 16)]
    body: u32,
}

fn main() -> Result<()> {
    let args = Args::parse();
    let mut stream = TcpStream::connect(("127.0.0.1", args.port))
        .with_context(|| format!("connect to 127.0.0.1:{}", args.port))?;
    stream
        .set_read_timeout(Some(Duration::from_secs(10)))
        .context("set read timeout")?;

    let header = args.body.to_be_bytes();
    let body = vec![0x42u8; args.body as usize];
    let mut reply = [0u8; 8];
    let mut samples = Vec::with_capacity(args.iters as usize);

    let begin = Instant::now();
    for expected in 1..=args.iters {
        let start = Instant::now();
        stream.write_all(&header).context("write header")?; // write #1
        stream.write_all(&body).context("write body")?; // write #2
        stream.read_exact(&mut reply).context("read reply")?;
        samples.push(start.elapsed());

        let counter = u64::from_be_bytes(reply);
        ensure!(counter == expected, "reply counter {counter} != {expected}");
    }
    let total = begin.elapsed();

    println!(
        "naive write-write-read: {} iterations, header 4 B + body {} B",
        args.iters, args.body
    );
    println!("{}", Summary::from_samples(&mut samples, total)?);
    Ok(())
}

wwr/src/stats.rs — 75 lines

//! Latency summary for one client session: percentiles plus wall clock.

use std::{fmt, time::Duration};

use anyhow::{Result, ensure};

/// Percentile snapshot of one run.
pub struct Summary {
    min: Duration,
    p50: Duration,
    p90: Duration,
    p99: Duration,
    max: Duration,
    total: Duration,
    count: usize,
}

impl Summary {
    /// Sort the samples in place and pick the percentile landmarks.
    pub fn from_samples(samples: &mut [Duration], total: Duration) -> Result<Self> {
        ensure!(!samples.is_empty(), "no samples recorded");
        samples.sort_unstable();
        let last = samples.len() - 1;
        let at = |q: f64| samples[(last as f64 * q).round() as usize];
        Ok(Self {
            min: samples[0],
            p50: at(0.50),
            p90: at(0.90),
            p99: at(0.99),
            max: samples[last],
            total,
            count: samples.len(),
        })
    }
}

impl fmt::Display for Summary {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        let rate = self.count as f64 / self.total.as_secs_f64();
        writeln!(
            formatter,
            "min {} | p50 {} | p90 {} | p99 {} | max {}",
            pretty(self.min),
            pretty(self.p50),
            pretty(self.p90),
            pretty(self.p99),
            pretty(self.max),
        )?;
        write!(
            formatter,
            "total {}, {rate:.0} req/s",
            pretty_total(self.total)
        )
    }
}

/// Adaptive unit: microseconds for the fast path, milliseconds for stalls.
fn pretty(duration: Duration) -> String {
    let micros = duration.as_secs_f64() * 1e6;
    if micros >= 1000.0 {
        format!("{:.2} ms", micros / 1000.0)
    } else {
        format!("{micros:.0} \u{b5}s")
    }
}

/// Seconds once the total crosses one second, milliseconds below.
fn pretty_total(duration: Duration) -> String {
    let seconds = duration.as_secs_f64();
    if seconds >= 1.0 {
        format!("{seconds:.1} s")
    } else {
        format!("{:.0} ms", seconds * 1e3)
    }
}

wwr-nodelay/src/client.rs — 66 lines

//! Same write-write-read client as stage 01, one line apart: TCP_NODELAY
//! disables Nagle, so the second write leaves the machine immediately
//! instead of waiting for the ACK of the first.

use std::{
    io::{Read, Write},
    net::TcpStream,
    time::{Duration, Instant},
};

use anyhow::{Context, Result, ensure};
use clap::Parser;

mod stats;

use stats::Summary;

/// Write-write-read client with Nagle's algorithm switched off.
#[derive(Parser)]
struct Args {
    /// Server port on 127.0.0.1 (printed by wwr-server on start).
    #[arg(long)]
    port: u16,
    /// Number of request/response round trips.
    #[arg(long, default_value_t = 5000)]
    iters: u64,
    /// Body size in bytes (the header is always 4 bytes).
    #[arg(long, default_value_t = 16)]
    body: u32,
}

fn main() -> Result<()> {
    let args = Args::parse();
    let mut stream = TcpStream::connect(("127.0.0.1", args.port))
        .with_context(|| format!("connect to 127.0.0.1:{}", args.port))?;
    stream
        .set_read_timeout(Some(Duration::from_secs(10)))
        .context("set read timeout")?;
    // The one-line cure: no more waiting for ACKs before sending small data.
    stream.set_nodelay(true).context("set TCP_NODELAY")?;

    let header = args.body.to_be_bytes();
    let body = vec![0x42u8; args.body as usize];
    let mut reply = [0u8; 8];
    let mut samples = Vec::with_capacity(args.iters as usize);

    let begin = Instant::now();
    for expected in 1..=args.iters {
        let start = Instant::now();
        stream.write_all(&header).context("write header")?; // write #1
        stream.write_all(&body).context("write body")?; // write #2
        stream.read_exact(&mut reply).context("read reply")?;
        samples.push(start.elapsed());

        let counter = u64::from_be_bytes(reply);
        ensure!(counter == expected, "reply counter {counter} != {expected}");
    }
    let total = begin.elapsed();

    println!(
        "TCP_NODELAY write-write-read: {} iterations, header 4 B + body {} B",
        args.iters, args.body
    );
    println!("{}", Summary::from_samples(&mut samples, total)?);
    Ok(())
}

wwr-onewrite/src/client.rs — 84 lines

//! The other cure: keep Nagle ON, but hand the kernel the whole request in
//! one syscall — either a single concatenated buffer, or writev with two
//! iovecs. Nagle never sees a lonely unfinished segment, so nothing stalls.

use std::{
    io::{IoSlice, Read, Write},
    net::TcpStream,
    time::{Duration, Instant},
};

use anyhow::{Context, Result, ensure};
use clap::Parser;

mod stats;

use stats::Summary;

/// One-syscall client: whole request per write, Nagle left enabled.
#[derive(Parser)]
struct Args {
    /// Server port on 127.0.0.1 (printed by wwr-server on start).
    #[arg(long)]
    port: u16,
    /// Number of request/response round trips.
    #[arg(long, default_value_t = 5000)]
    iters: u64,
    /// Body size in bytes (the header is always 4 bytes).
    #[arg(long, default_value_t = 16)]
    body: u32,
    /// Use write_vectored (writev) with two iovecs instead of one buffer.
    #[arg(long)]
    vectored: bool,
}

fn main() -> Result<()> {
    let args = Args::parse();
    let mut stream = TcpStream::connect(("127.0.0.1", args.port))
        .with_context(|| format!("connect to 127.0.0.1:{}", args.port))?;
    stream
        .set_read_timeout(Some(Duration::from_secs(10)))
        .context("set read timeout")?;

    let header = args.body.to_be_bytes();
    let body = vec![0x42u8; args.body as usize];

    // The concatenated form: header and body glued once, outside the loop.
    let mut request = Vec::with_capacity(header.len() + body.len());
    request.extend_from_slice(&header);
    request.extend_from_slice(&body);

    let mut reply = [0u8; 8];
    let mut samples = Vec::with_capacity(args.iters as usize);

    let begin = Instant::now();
    for expected in 1..=args.iters {
        let start = Instant::now();
        if args.vectored {
            // Two logical buffers, one syscall, one segment on the wire.
            let slices = [IoSlice::new(&header), IoSlice::new(&body)];
            let sent = stream.write_vectored(&slices).context("writev request")?;
            ensure!(sent == request.len(), "short vectored write: {sent}");
        } else {
            stream.write_all(&request).context("write request")?;
        }
        stream.read_exact(&mut reply).context("read reply")?;
        samples.push(start.elapsed());

        let counter = u64::from_be_bytes(reply);
        ensure!(counter == expected, "reply counter {counter} != {expected}");
    }
    let total = begin.elapsed();

    let how = if args.vectored {
        "writev, two iovecs"
    } else {
        "one buffer"
    };
    println!(
        "single write ({how}), Nagle still on: {} iterations, 4+{} B",
        args.iters, args.body
    );
    println!("{}", Summary::from_samples(&mut samples, total)?);
    Ok(())
}

flood/src/server.rs — 43 lines

//! Sink server for the tiny-write flood: drain everything until EOF, then
//! confirm the byte count back to the client so it knows every segment has
//! been sent and delivered before it reads its own TCP_INFO counters.

use std::{
    io::{Read, Write},
    net::{TcpListener, TcpStream},
};

use anyhow::{Context, Result};

fn main() -> Result<()> {
    let listener = TcpListener::bind("127.0.0.1:0").context("bind 127.0.0.1:0")?;
    let addr = listener.local_addr().context("query local addr")?;
    println!("listening on {addr}");

    for stream in listener.incoming() {
        let stream = stream.context("accept")?;
        let peer = stream.peer_addr().context("query peer addr")?;
        match drain(stream) {
            Ok(total) => println!("conn {peer}: drained {total} bytes"),
            Err(error) => println!("conn {peer}: error: {error:#}"),
        }
    }
    Ok(())
}

/// Read until the client half-closes, then echo the total byte count back.
fn drain(mut stream: TcpStream) -> Result<u64> {
    let mut buf = vec![0u8; 65536];
    let mut total = 0u64;
    loop {
        let count = stream.read(&mut buf).context("read")?;
        if count == 0 {
            break;
        }
        total += count as u64;
    }
    stream
        .write_all(&total.to_be_bytes())
        .context("write confirmation")?;
    Ok(total)
}

flood/src/client.rs — 88 lines

//! The flood where Nagle is the hero: a stream of one-byte writes with no
//! read on the critical path. With Nagle the kernel coalesces them into a
//! handful of segments; with TCP_NODELAY every byte pays for its own packet.

use std::{
    io::{Read, Write},
    net::{Shutdown, TcpStream},
    time::{Duration, Instant},
};

use anyhow::{Context, Result, ensure};
use clap::Parser;

mod tcpinfo;

use tcpinfo::segment_counts;

/// Tiny-write flood client with per-socket segment accounting.
#[derive(Parser)]
struct Args {
    /// Server port on 127.0.0.1 (printed by flood-server on start).
    #[arg(long)]
    port: u16,
    /// Number of write calls to issue back to back.
    #[arg(long, default_value_t = 200_000)]
    writes: u64,
    /// Bytes per write call.
    #[arg(long, default_value_t = 1)]
    size: u32,
    /// Disable Nagle's algorithm for the flood.
    #[arg(long)]
    nodelay: bool,
}

fn main() -> Result<()> {
    let args = Args::parse();
    let mut stream = TcpStream::connect(("127.0.0.1", args.port))
        .with_context(|| format!("connect to 127.0.0.1:{}", args.port))?;
    stream
        .set_read_timeout(Some(Duration::from_secs(10)))
        .context("set read timeout")?;
    stream
        .set_nodelay(args.nodelay)
        .context("set TCP_NODELAY")?;

    let chunk = vec![0x2au8; args.size as usize];
    let sent = args.writes * u64::from(args.size);

    let begin = Instant::now();
    for _ in 0..args.writes {
        stream.write_all(&chunk).context("write chunk")?;
    }
    let write_loop = begin.elapsed();

    // Half-close, then wait for the server's byte-count confirmation: once
    // it arrives, every data segment has been sent, delivered and counted.
    stream.shutdown(Shutdown::Write).context("shutdown write")?;
    let mut reply = [0u8; 8];
    stream.read_exact(&mut reply).context("read confirmation")?;
    let total = begin.elapsed();

    let drained = u64::from_be_bytes(reply);
    ensure!(drained == sent, "server drained {drained} of {sent} bytes");

    let counts = segment_counts(&stream)?;
    ensure!(counts.data_segs_out > 0, "no data segments counted");
    let per_segment = sent as f64 / f64::from(counts.data_segs_out);

    let mode = if args.nodelay {
        "TCP_NODELAY"
    } else {
        "Nagle on"
    };
    println!(
        "{mode}: {} writes x {} B = {sent} bytes",
        args.writes, args.size
    );
    println!(
        "write loop {:.0} ms, all bytes at the server after {:.0} ms",
        write_loop.as_secs_f64() * 1e3,
        total.as_secs_f64() * 1e3,
    );
    println!(
        "data segments out: {} ({:.0} payload bytes per segment); segs_out {}",
        counts.data_segs_out, per_segment, counts.segs_out
    );
    Ok(())
}

flood/src/tcpinfo.rs — 42 lines

//! TCP_INFO peek: how many segments this socket actually put on the wire.

use std::{io, mem, net::TcpStream, os::fd::AsRawFd};

use anyhow::{Result, ensure};
use libc::{IPPROTO_TCP, TCP_INFO, c_void, getsockopt, socklen_t, tcp_info};

/// Outgoing segment counters of one socket, as the kernel counted them.
pub struct SegmentCounts {
    /// Every segment sent, including handshake, pure ACKs and FIN.
    pub segs_out: u32,
    /// Only segments that carried payload bytes.
    pub data_segs_out: u32,
}

/// Read the socket's `struct tcp_info` via getsockopt and pick the counters.
pub fn segment_counts(stream: &TcpStream) -> Result<SegmentCounts> {
    let mut info: tcp_info = unsafe { mem::zeroed() };
    let mut len = mem::size_of::<tcp_info>() as socklen_t;
    let rc = unsafe {
        getsockopt(
            stream.as_raw_fd(),
            IPPROTO_TCP,
            TCP_INFO,
            (&raw mut info).cast::<c_void>(),
            &mut len,
        )
    };
    ensure!(
        rc == 0,
        "getsockopt(TCP_INFO): {}",
        io::Error::last_os_error()
    );
    ensure!(
        len as usize <= mem::size_of::<tcp_info>(),
        "kernel returned an oversized tcp_info: {len} bytes"
    );
    Ok(SegmentCounts {
        segs_out: info.tcpi_segs_out,
        data_segs_out: info.tcpi_data_segs_out,
    })
}
Newsletter

New playgrounds in your inbox

Get an email when a new playground drops, plus the occasional engineering deep-dive. No spam, unsubscribe anytime.