TogetherOS + Networkingsendrecvsocket buffersyscallTCP

Follow send() Through the OS to recv()

Between send() returning in one process and recv() returning in another there are two kernels, two NICs, three copies, a congestion gate, a routing decision and at least one context switch — and every one of them is a place where bytes wait.

ConceptualLinux

The problem

Your code calls send(fd, buf, 4096, 0) and it returns 4096 in a few microseconds. The bytes have not reached the other machine — they have not even left yours. What did the call actually do, what happens after it returns, and where can the bytes get stuck between here and the other process’s recv()?

Progressive depth

The same mechanism at different altitudes — start where you are.

A copy, a gate, a wire, a wake-up

Your program hands bytes to the kernel; the kernel transmits them when the receiver and the network allow; the other kernel collects them and wakes the other program, which copies them out. Every step can wait.

What send() promises, and what it does not

Conceptual

send() on a stream socket promises one thing: the bytes it reports as sent have been copied into the kernel’s send buffer for that socket. It does not promise they were transmitted, acknowledged, or read. That single fact explains most of the surprises in this lesson: the return value measures a copy into kernel memory, not delivery.

From there the bytes cross a fixed sequence of layers — transport, IP, the device queue, the NIC, the wire — and on the far side the same layers in reverse, ending in a copy out of the receiving kernel into the receiver’s user buffer. Each layer has its own state (sequence numbers, windows, routing cache, ring-buffer slots), its own queue, and its own way of saying "not now".

The picture below is the whole path at a glance. The rest of the lesson expands each side. The names in the ladders (sk_buff, qdisc, NAPI, softirq) are Linux; the structure — buffer, gate, queue, DMA, interrupt, wake-up — is what every mainstream kernel does, with different names.

The two halves of one send
send(): copyDMAframesframesDMA + interruptrecv(): copySender appSender kernelNICRoutersNICReceiver kernelReceiver app
UserLLMAgentToolDataDecisionHumanGuardrail

The send side, layer by layer

Linux

The application calls send() — via write(), a language runtime, or a library; it all ends at the same system call. The CPU switches to kernel mode (see System Calls), the kernel looks up the descriptor in the process’s table (see File Descriptors) and finds a socket. If the socket’s send buffer has room, the kernel copies the user bytes into kernel-owned sk_buff structures and returns. If it has no room and the socket is blocking, the calling thread goes to sleep here; if non-blocking, the call returns EAGAIN.

TCP then decides how much of the buffered stream it may transmit right now. Two windows gate it: the receiver’s advertised window rwnd (see Flow Control: The Receive Window) and the sender’s congestion window cwnd (see Congestion Control: Protecting the Network). Whatever fits is cut into segments no larger than the MSS, each stamped with a sequence number (see Sequence Numbers, ACKs and Reassembly), and handed to IP. With TSO/GSO the kernel hands one large segment down and lets the NIC (or the driver, late) do the cutting, saving per-packet CPU.

IP consults the routing table (see The Routing Table and Longest-Prefix Match), chooses the outgoing interface and next hop, resolves the next hop’s link address (see ARP and Neighbor Discovery: From an IP to a Local MAC), and prepends the IP header. The packet is enqueued on the interface’s qdisc — the queueing discipline, fq_codel by default on most modern distributions — which decides ordering and can drop under load. The driver then places a descriptor pointing at the packet in the NIC’s transmit ring; the NIC reads the bytes by DMA, computes the checksum if offload is enabled, and puts the frame on the wire.

Send side (Linux names; structure is general)
  1. Application: send(fd, buf, n)user mode; buf is in the process address space
  2. System call: mode switch~100–300 ns to enter; descriptor → socket lookup
  3. Socket send bufferCOPY #1 user → kernel sk_buff; blocks or EAGAIN when full
  4. TCP: gate and segmentmin(cwnd, rwnd) bytes allowed; seq numbers; retransmit timer
  5. IP: route and headerlongest-prefix match → interface + next hop; TTL
  6. qdisc: device queuefq_codel / pfifo_fast; can drop; TCP small queues limit depth
  7. NIC: TX ring + DMAdescriptor ring; NIC pulls bytes; TSO/checksum offload
  8. Wireframe on the medium; now the network’s problem

The receive side, layer by layer

Linux

The receiving NIC matches the frame’s destination MAC (see MAC Addresses: Identity for One Hop), writes the bytes by DMA into a pre-allocated buffer that the driver posted on the receive ring, and raises an interrupt. Linux uses NAPI: the first interrupt disables further interrupts for that queue and schedules a poll; the kernel then drains the ring in a batch (netdev_budget, 300 packets by default) before re-enabling interrupts. Under a high packet rate this turns thousands of interrupts per second into a polling loop, which is why a busy server shows CPU in softirq rather than in irq.

Each packet becomes an sk_buff and climbs the stack in software-interrupt context: link layer strips the Ethernet header, IP validates the checksum and decides "for me" or "forward", TCP matches the 4-tuple to a socket (see Ports: Addressing a Process, Not a Machine), checks the sequence number against what it expects, reassembles in-order data, and queues an ACK. In-order bytes are appended to the socket’s receive buffer; out-of-order segments wait in a separate queue until the gap is filled (see Head-of-Line Blocking). The receive window the kernel advertises back shrinks by exactly what it just queued.

Now the kernel looks for someone waiting. If a thread is blocked in recv() on that socket, it is marked runnable and the scheduler will run it within a time slice or on the next idle core (see Context Switching). If the socket is registered with epoll, the socket is added to the ready list and the thread blocked in epoll_wait() wakes instead (see I/O Multiplexing: select, poll, epoll, kqueue, IOCP). Either way the wake-up is a scheduler event, not a network one — this is where OS latency enters the path. When the thread runs, recv() copies the bytes from the receive buffer into the user buffer, frees the kernel memory, and the window can open again.

Receive side (Linux names; structure is general)
  1. Wire → NICMAC filter; DMA into RX ring buffer; interrupt
  2. NAPI poll (softirq)batch up to netdev_budget packets; GRO merges segments
  3. IP: for me?checksum; destination match or forward
  4. TCP: match socket, reassemble, ACK4-tuple lookup; seq check; out-of-order queue; rwnd shrinks
  5. Socket receive bufferkernel memory; bounded by SO_RCVBUF / autotuning
  6. Wake-upblocked recv() thread made runnable, or epoll ready list
  7. Scheduler runs the threadtime slice / idle core; 1–5 µs switch plus wait
  8. recv(): copy to userCOPY #2 kernel → user; buffer freed; window reopens

Where the copies are, and where the time goes

Conceptual

On a conventional path there are exactly two CPU copies of the payload — user → kernel on send, kernel → user on receive — plus two DMA transfers that the CPU does not perform. Everything else is header manipulation on a pointer. The copies matter at high throughput (a 10 Gbit/s stream is ~1.25 GB/s of memcpy per direction) and are what Zero-Copy: Serving a File Without Touching It removes for file-to-socket cases; for ordinary request/response traffic they are not the bottleneck.

Latency accumulates at the queues, not the copies. On the sender: waiting for send-buffer space, waiting for cwnd/rwnd to open, waiting in the qdisc behind other flows. In the network: serialization, propagation (~5 µs per km in fibre), router queueing. On the receiver: the interrupt-to-poll delay, the softirq run, and above all the scheduler: a woken thread runs only when a core is free. Under load the gap between "bytes in the receive buffer" and "application reads them" is frequently larger than the network RTT, and it shows up in the network’s tools as a shrinking receive window.

The rule for reading these layers in an incident: ss -ti on both ends tells you which queue holds the bytes. Send-Q non-zero on the sender and Recv-Q zero on the receiver means the network or the windows; Recv-Q growing on the receiver means the application is not reading — an OS problem wearing a networking symptom. See TCP Debugging: Reading the Handshake on the Wire and Combined Failure Simulator: Break a Layer, Watch It Propagate.

What waits where (typical, not measured — see the simulator’s scope tag)
StageCopy?Who waitsTypical costSymptom when it stalls
send() syscalluser → kernelcalling thread~1–5 µs for 4 kBsend() blocks / EAGAIN; Send-Q at SO_SNDBUF
TCP window gatenobytes in send buffer0 to one RTTSend-Q > 0, cwnd small or rwnd 0 in ss -ti
qdisc + NIC ringDMApackets~10–100 µs, more under bufferbloatdrops in tc -s qdisc; high softirq
Networknopackets0.1 ms LAN → 150 ms intercontinentalretransmits, RTT variance
RX ring + NAPIDMAframes~10–50 µsrx_dropped / rx_missed on the NIC
Receive buffer → wake-upnodata for the app0 to a scheduler tickRecv-Q grows; receiver advertises rwnd → 0
recv() copykernel → usercalling thread~1–5 µs for 4 kBrare — usually the app is simply not calling it

QUIC moves most of this into the process

Runtime-specific

With TCP, everything from segmentation to ACKs to retransmission happens in the kernel; the application sees a byte stream. With QUIC (the transport under HTTP/3 and QUIC) the kernel only sees UDP datagrams. Loss detection, congestion control, flow control per stream, encryption and reassembly run in the application’s library — Chrome’s, Cloudflare’s, quiche, msquic. The ladder above still exists, but the "TCP" rung becomes user-space code, and the kernel rungs shrink to sendmsg()/recvmsg() on a UDP socket.

That has two consequences worth remembering. Deployment is faster — a congestion-control change ships with the app instead of a kernel upgrade — and the per-packet cost is higher, because each datagram crosses the syscall boundary individually unless the stack uses sendmmsg/recvmmsg batching or UDP GSO. Server-side QUIC stacks spend real engineering effort recovering CPU efficiency that TCP gets from TSO, GRO and kernel-resident state.

Windows and the BSDs implement the same structure with different names (Winsock, AFD, kqueue instead of epoll, different offload knobs). Do not assume SO_SNDBUF semantics, autotuning behaviour or ring-buffer sizes carry across; the shape of the path does.

Key points

  • send() returning n means n bytes were copied into the kernel send buffer, nothing more; delivery is asynchronous and gated by cwnd and rwnd.
  • Two CPU copies (user → kernel, kernel → user) and two DMA transfers per payload; header work is pointer manipulation.
  • Sending: syscall → socket buffer → TCP gate/segment → IP route → qdisc → NIC ring → wire. Receiving: NIC ring → NAPI/softirq → IP → TCP reassemble/ACK → receive buffer → wake-up → scheduler → recv() copy.
  • Latency lives in queues and wake-ups, not in copies: the scheduler delay between data arriving and the application reading it is a first-class part of "network" latency.
  • ss -ti on both ends locates the stall: Send-Q on the sender means network or windows; Recv-Q on the receiver means the application.
  • QUIC keeps the same structure but runs the transport rung in user space over UDP; other kernels keep the structure with different names.

Why does this exist?

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

Why does send() copy instead of transmitting directly from my buffer?

Because the kernel must keep the bytes until they are acknowledged, which may be seconds later after retransmissions, and it cannot trust your buffer to still exist or be unchanged. The copy decouples your program’s lifetime from the transport’s. Zero-Copy: Serving a File Without Touching It APIs exist precisely for the cases where that copy is the bottleneck, and they impose ownership rules in exchange.

Why is there a queue on the device at all — why not hand packets straight to the NIC?

The NIC drains at link rate and the CPU produces in bursts; a queue absorbs the mismatch, and a queueing discipline decides fairness between flows when the link is saturated. Without it a single bulk transfer would starve interactive traffic.

Why does the receiving kernel poll instead of taking one interrupt per packet?

At 1 Gbit/s small packets arrive at up to ~1.5 million per second; an interrupt each would consume the CPU. NAPI takes one interrupt, then polls a batch, then re-arms — amortising the cost across packets.

Why can the receive window go to zero when the network is fine?

Because the receive buffer is drained by the application, not by the network. If the application’s thread is busy, blocked, or not scheduled, data sits in the buffer, the kernel advertises less room, and the sender stops. Networking tools then show a "network" stall whose cause is entirely inside the receiving process.

send() to recv()

send() to recv(): every layer between two programs
Follow 4 kB from a client’s user buffer to the server’s, counting copies, mode switches and simulated time.
Client
  1. Applicationsend()
  2. Socket APIlibc wrapper
  3. System callsendto → kernel
  4. Socket send buffersk_buff
  5. TCPseq · cwnd/rwnd
  6. IProute · header
  7. Device queueqdisc
  8. NICDMA · TSO
  9. Networklinks · routers
Server
  1. Server NICDMA → rx ring
  2. Kernelinterrupt · NAPI
  3. IPchecksum · demux
  4. TCPreassembly · ACK
  5. Socket receive bufferrwnd
  6. Application wakesepoll_wait
  7. recv()copy to user
Copies so far
0 / 4
Mode
user
Client send buffer
0 B
Server receive buffer
0 B
Elapsed (simulated)
0 µs
Transport
TCP
step 1
The program calls send(fd, buf, 4096, 0). buf is an ordinary user-space array; nothing has moved yet.
1/16Simulated

How it fails

What the failure looks like from inside real software.

  • Application treats a successful send() as delivery and discards its own copy; the connection dies and the data is gone. Symptom: "sent" records missing on the far side after a network blip.
  • A busy receiver stops calling recv(); its Recv-Q grows, it advertises rwnd 0, the sender’s Send-Q fills, the sender blocks. Symptom: both processes look "stuck on the network" while the cause is one thread’s CPU work.
  • Nagle plus delayed ACK on a request/response protocol adds ~40 ms per small write. Symptom: tiny messages take 40–200 ms on a 1 ms network until TCP_NODELAY is set.
  • RX ring overruns under a packet burst: the NIC has nowhere to DMA and drops. Symptom: rx_missed_errors climbing in ethtool -S while the application is idle.
  • All interrupts and softirq work pinned to one core: that core saturates at a fraction of line rate. Symptom: one CPU at 100% si in top, the rest idle, throughput capped.
  • Non-blocking socket without an EAGAIN handler: the program drops the unsent tail or spins. Symptom: truncated payloads under load, or 100% CPU with no progress.

Follow it through every layer

This lesson is one node of a longer journey. Zoom out, then zoom back in.