TogetherOS + NetworkingSO_SNDBUFSO_RCVBUFtcp_rmemtcp_wmemautotuning

The Buffer Chain

Application buffer → socket send buffer → device queue → wire → NIC ring → socket receive buffer → application: a chain of bounded queues in which every full buffer pushes back on the one above, sized by bandwidth × delay and dangerous when oversized.

ConceptualLinux
▶ InteractiveInterview question
Progress

The problem

Data crosses at least five queues between your write() and the other side’s read(). Each has a size, each can be full, and each being full changes what the layer above experiences. How big should they be, who decides, and what goes wrong when they are too small — or too large?

The chain, both directions

Conceptual

On the sender: the application often has its own buffer (a BufferedWriter, a framework’s response buffer, Node’s writable stream) that batches small writes into fewer syscalls. write() copies into the socket send buffer, bounded by SO_SNDBUF; TCP keeps bytes there until acknowledged. Segments the kernel decides to transmit go to the interface’s device queue (qdisc), then to the NIC’s transmit ring, then to the wire. Each of these is a queue with a limit.

On the receiver, in reverse: the NIC DMA-writes frames into the receive ring (a fixed number of descriptors, typically 256–4096, set by ethtool -g); the kernel drains it into sk_buffs and, after TCP processing, appends payload to the socket receive buffer, bounded by SO_RCVBUF; the application’s read() copies out of it into its own buffer. The receive buffer’s free space, minus some accounting, is the window the receiver advertises back to the sender.

The whole chain is what Follow send() Through the OS to recv() walked through; this lesson looks only at the queues and their coupling. One rule governs all of them: a full queue stops the producer above it. The producer’s stop is itself a full queue for the layer above that, and so on up to the application, which experiences it as a blocked write(), an EAGAIN, or a false return from a stream. That is backpressure, and it is the same mechanism whether the queue is a NIC ring or a socket buffer.

The buffer chain, sender (top) to receiver (bottom)
  1. Application buffer (sender)library/runtime; batches writes; unbounded unless you bound it
  2. Socket send bufferSO_SNDBUF / tcp_wmem; holds until ACKed; full → write blocks or EAGAIN
  3. Device queue (qdisc)fq_codel etc.; full → drop or TSQ throttle; bufferbloat lives here
  4. NIC TX ring256–4096 descriptors; full → qdisc stops; BQL bounds bytes in flight
  5. Wire and routersrouter queues: the other bufferbloat
  6. NIC RX ringfull → frames dropped: rx_missed
  7. Socket receive bufferSO_RCVBUF / tcp_rmem; free space = advertised rwnd
  8. Application readdrains the receive buffer; if it stops, rwnd → 0

How big: the bandwidth-delay product

Conceptual

TCP can have at most one send buffer’s worth of unacknowledged data in flight, and it takes one RTT for an acknowledgment to return. So a connection’s throughput is bounded by buffer / RTT. To fill a 1 Gbit/s link with 100 ms RTT you need 1 Gbit/s × 0.1 s = 12.5 MB in flight; a 64 kB buffer on that path caps you at 64 kB / 0.1 s = 5 Mbit/s no matter how fast the link is. That product — bandwidth × delay — is the minimum buffer for full throughput, and it grows with distance: the same buffer that saturates a data centre link at 0.5 ms RTT is 200× too small across an ocean. See Bandwidth vs Latency and Throughput: Requests, Packets and Bytes per Second.

The receive side must match, because the receiver’s buffer bounds the window it advertises: a sender with a 12 MB send buffer talking to a receiver with a 64 kB receive buffer is limited by the receiver. The historical 64 kB TCP window limit is why the window-scale option exists; without it no TCP connection could exceed 64 kB per RTT.

Large buffers are not free. Each byte in a send buffer is memory held until ACKed; on a 100,000-connection server 12 MB each is impossible, which is why the kernel sizes buffers per connection dynamically rather than at the maximum.

Buffer needed for full throughput
1needed_bytes = bandwidth_bits_per_s / 8 * rtt_seconds
21 Gbit/s, 0.5 ms RTT (same rack): 62.5 kB
31 Gbit/s, 20 ms RTT (same country): 2.5 MB
41 Gbit/s, 100 ms RTT (transatlantic): 12.5 MB
510 Gbit/s, 100 ms RTT: 125 MB # per connection
6throughput_cap = buffer_bytes / rtt_seconds # if buffer < needed

Autotuning: the kernel decides per connection

Linux

Linux does not allocate SO_SNDBUF and SO_RCVBUF at their maximum. net.ipv4.tcp_wmem and net.ipv4.tcp_rmem are triples — min default max, e.g. 4096 16384 4194304 and 4096 131072 6291456 — and each connection starts at default and grows toward `max` as the connection demonstrates it needs the space: the receive buffer grows when the application drains it and the window would otherwise limit the sender; the send buffer grows with the congestion window. Idle or slow connections stay small. This is what makes 100,000 connections and a 12 MB long-haul transfer coexist on one box.

Setting SO_RCVBUF or SO_SNDBUF explicitly on a socket disables autotuning for that socket and fixes the size (the kernel doubles the value you pass, for bookkeeping overhead, and caps it at net.core.rmem_max/wmem_max). This is the single most common self-inflicted throughput bug: an application sets a 64 kB buffer "to be safe" and caps itself at a few Mbit/s on any long path. Leave the socket options alone unless you have measured; tune the sysctl maxima if long paths need more than the defaults allow.

On the device side, Byte Queue Limits (BQL) bound how many bytes sit in the NIC ring so that the qdisc, not the hardware, holds the queue and can apply a policy; TCP Small Queues (TSQ) bound how many bytes one socket may have in the qdisc, so a bulk flow cannot monopolise the device queue. Both exist to keep the queue where a scheduler can see it. Other operating systems have equivalents with different names and defaults; do not carry the sysctl names across.

Autotuning in action: buffers grow only where needed (Linux)
$ sysctl net.ipv4.tcp_rmem net.ipv4.tcp_wmem
net.ipv4.tcp_rmem = 4096	131072	6291456       # min default max (bytes)
net.ipv4.tcp_wmem = 4096	16384	4194304

$ ss -tmi dst 203.0.113.10
ESTAB 0 0 10.0.0.5:44712 203.0.113.10:443
   skmem:(r0,rb3145728,t0,tb2359296,...)      # rb/tb grew to 3 MB / 2.3 MB on this 90 ms path
   cubic wscale:7,7 rto:296 rtt:92.5/1.2 cwnd:1893 bytes_acked:1922411520 ...

$ ss -tmi dst 10.0.0.9
ESTAB 0 0 10.0.0.5:51120 10.0.0.9:5432
   skmem:(r0,rb131072,t0,tb87040,...)         # local connection: stayed near default

Too much buffer: bufferbloat

Conceptual

Buffers absorb bursts. A buffer that is *larger than the burst it needs to absorb* does something worse than waste memory: it adds latency without adding throughput. A 1 Gbit/s link with 100 MB of queue in front of it delivers 1 Gbit/s and 800 ms of queueing delay; every packet waits behind the queue before it is sent. Loss-based congestion control (see Congestion Control: Protecting the Network) makes it worse, because the sender keeps increasing cwnd until a packet is dropped, and a huge buffer drops nothing until it is full — so the sender fills it, and everyone sharing the link waits.

This is bufferbloat, and it lives in every queue on the chain: over-deep qdiscs and NIC rings (why BQL exists), home routers and cable modems with megabytes of FIFO, and oversized socket buffers that let one application pump a whole RTT’s worth of data ahead of a keystroke. The fix is not smaller buffers everywhere but smarter ones: fq_codel and cake measure queueing delay and drop or mark early; BBR estimates the bottleneck bandwidth and RTT directly instead of filling buffers to find loss; ECN lets routers signal congestion without dropping.

The practical rule: at the socket, let autotuning size the buffer; at the device, keep the default fq_codel; on the path, measure latency under load, not just idle ping. A link that shows 5 ms idle and 300 ms with one download running has bufferbloat somewhere, and the throughput graph will never show it.

  • Buffer size for throughput: ≥ bandwidth × RTT. Buffer size for latency: as small as absorbs the burst. These conflict; AQM resolves it.
  • Symptom of too small: throughput capped at buffer/RTT, no loss. Symptom of too large: RTT under load balloons, throughput fine.
  • The receive buffer, uniquely, is also a flow-control signal: its free space is the window. Draining it is the application’s job.

Key points

  • Five to seven bounded queues sit between write() and read(); a full one stops the producer above it, up to the application. That is backpressure.
  • Throughput ≤ buffer / RTT. Buffers must hold bandwidth × delay to fill a link, and the requirement grows with distance.
  • Linux autotunes each socket’s buffers between tcp_rmem/tcp_wmem min and max according to demand; setting SO_SNDBUF/SO_RCVBUF explicitly disables that and usually caps throughput.
  • The receive buffer’s free space is the advertised window; a slow reader shrinks it to zero.
  • Oversized buffers cause bufferbloat: latency under load without extra throughput. Active queue management (fq_codel, BBR, ECN) fixes what smaller FIFOs cannot.
  • BQL and TSQ keep the queue in the qdisc, where policy can be applied, rather than in the NIC or one socket.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why buffer at all between the application and the NIC?

Because producers and consumers run at different rates and in bursts: the application writes 64 kB at once; the NIC drains at line rate; the far end acknowledges an RTT later. Without buffers every rate mismatch would be a stall or a drop. With buffers, the mismatch becomes queueing delay — bounded if the buffer is bounded.

Why does the kernel size buffers dynamically instead of letting me set them?

Because the right size depends on the path (RTT, bandwidth) and the application’s behaviour, neither of which the programmer knows at socket() time, and because a static maximum per connection times a hundred thousand connections does not fit in memory. Autotuning gives each connection what it uses.

Why is a bigger buffer not always better?

Because a buffer only helps up to the burst it must absorb; beyond that it just holds packets longer. Loss-based TCP fills whatever buffer it is given, so an oversized queue becomes permanent latency for every flow sharing it. Bufferbloat is buffers doing their job too well.

The buffer chain

The buffer chain
Seven buffers between write() and read(). When the consumer is slow, each buffer fills and the one before it fills next — backpressure walks upstream.
Delivered
0 kB
Producer
running
Bandwidth × delay: at 30 kB/tick and a 40 ms RTT, 120 kB are in flight. A receive buffer smaller than 120 kB caps throughput below the link — the sender must stop and wait for window updates.
App send buffer40 kB
Kernel send buffer (SO_SNDBUF)0 kB
NIC queue0 kB
Network (in flight) (BDP 120 kB)0 kB
NIC ring0 kB
Kernel receive buffer (SO_RCVBUF)0 kB
Data flows; the slowest stage sets the throughput. Watch which buffer fills first when you slow the consumer.
1/40 · t = 0 msSimulated

How it fails

What the failure looks like from inside real software.

  • Application sets SO_RCVBUF to 64 kB "for safety": every long-haul transfer caps at ~5 Mbit/s on a 100 ms path with zero packet loss; ss -ti shows rwnd as the limiter.
  • A home router with 4 MB of FIFO: one upload makes video calls unusable; ping jumps from 15 ms to 600 ms under load. Throughput graphs show nothing wrong.
  • NIC ring left at 256 descriptors on a 10 Gbit/s interface with interrupt coalescing: bursts overrun it; ethtool -S shows rx_missed_errors while the CPU is idle.
  • A reverse proxy buffers whole responses from upstream in memory with no limit: a client that reads slowly holds a 200 MB response in the proxy; 50 such clients exhaust it.
  • net.core.rmem_max left low while tcp_rmem max is raised: autotuning silently caps at the lower value; the tuning "does nothing".