Partial reads and write coalescing
Preserve byte offsets across asynchronous retries, reconstruct protocol frames, and trade fewer writes against bounded buffering delay.
On this page
The question for today
What you’ll understand
- Distinguish a short read, end of stream, and a would-block result.
- Maintain receive and transmit offsets without dropping or duplicating bytes.
- Explain when coalescing writes improves efficiency and when it delays useful work.
A socket delivers a sequence of bytes, while your application usually thinks in records: one inference request, one database command, or one image description. These boundaries do not automatically coincide. A single record can arrive through several reads, and several records can become available in one read. Correct asynchronous I/O starts by tracking exactly which bytes have moved. Efficiency improvements such as batching belong on top of that accounting; they must not silently change framing or the meaning of successful delivery.
Separate transport progress from message completion
For a positive-length read on a stream socket, a positive return value reports the bytes obtained, possibly fewer than requested. A zero result means orderly end of the incoming byte stream, after already buffered bytes have been consumed; a zero-length request is a separate case and cannot establish EOF. With nonblocking I/O, EAGAIN or EWOULDBLOCK means no progress is currently possible. Preserve state and resume after readiness instead of treating this temporary condition as a disconnected peer.
Define a framing rule independently: perhaps a fixed-width length header followed by that many payload bytes. Keep an accumulator, parse only complete headers, validate the advertised size before allocating, and dispatch only complete frames. Retain surplus bytes for the next record. EOF between frames can be normal; EOF halfway through a declared frame is truncation. Test every possible split of a small sample frame, including a split inside its header, rather than testing only convenient network packet boundaries.
Keep a durable transmit cursor
A successful low-level write can also transfer fewer bytes than requested. Retain the unsent suffix and advance its cursor only by the returned count. Restarting from the beginning duplicates the accepted prefix; discarding the whole buffer loses the suffix. A library may instead copy the complete input into its own queue and expose flow control through another API. Read that contract before applying syscall-level retry logic to it. Local acceptance still does not prove that the remote application processed the record.
Serialize writers or maintain an explicitly ordered output queue so concurrent tasks cannot interleave fragments of application frames. Keep each buffer alive until the relevant API releases it. A readiness notification is permission to try, not a guarantee that the whole suffix will fit. Handle permanent failures through the connection state machine and resolve pending operations consistently. If application retries can repeat a completed command after a disconnect, use a protocol-level identifier and documented deduplication policy rather than assuming byte offsets solve distributed uncertainty.
Coalesce with a size limit and a deadline
Combining small queued writes can reduce per-call overhead. Scatter/gather I/O can present several buffers without first concatenating them, but a partial completion still needs a cursor spanning buffer boundaries. Choose both a maximum batch size and a maximum waiting time: a size-only policy can strand a small final message indefinitely. Flush promptly when a request depends on its response, when the timer expires, or when closing according to the protocol. Batch memory must remain bounded under a slow reader.
Use the queue lab to examine the pressure behind that bound. Press Reset, set arrival rate to 4 and service rate to 3, predict growth from four initial items, then press Play. For a separate draining comparison, press Reset again, explicitly set arrival rate to 2 and service rate to 3, predict the slope, then press Play. Slider edits pause but retain elapsed time, so they are not a live change applied to an earlier backlog. The model uses equal abstract work units; it does not simulate TCP segments, partial writes, batching timers, or kernel buffers. Measure syscall count, queued bytes, and end-to-end latency in your implementation before claiming that a larger batch is better.
Work it through
Reconstruct and transmit one frame
A frame has a 4-byte header declaring a 20-byte payload. Reads return 6, 9, and 9 bytes. Its 24-byte response is accepted by low-level writes of 10 bytes, then EAGAIN, then 14 bytes. No other frames are mixed into this trace.
After the first read, consume the four header bytes and retain two payload bytes. After the second read, the accumulator contains eleven of the required twenty payload bytes; dispatch nothing yet.
The third read supplies the remaining nine payload bytes. Now the complete frame occupies 4 + 20 = 24 bytes, and the parser can dispatch it exactly once.
For transmission, advance the response cursor from zero to ten after the first write. EAGAIN leaves it at ten, so retry the fourteen-byte suffix after readiness. The final positive result advances the cursor to twenty-four.
Three reads produce one complete request. The response requires two successful transfers, with a pause between them; neither the pause nor a short transfer changes its framing.
The living notebook
Explore the model
Interactive lab · queue
Backpressure behind an output queue
Press Reset, set arrival rate to 4 and service rate to 3, predict the slope, then press Play. For draining, press Reset again, explicitly set both rates to 2 and 3, predict, then press Play. Each independent run begins with four equal-sized abstract chunks; slider edits are not live changes to an old backlog.
- Fluid backlog
- 4
- Net rate · λ − μ
- −1 /s
- M/M/1 mean residence
- 1 s
Fluid backlog starts at 4 and follows max(0, 4 + (λ − μ)t). The separate M/M/1 residence-time formula assumes Poisson arrivals, exponential service and λ < μ. Parameter changes pause playback and recompute the model at the current elapsed time. Reset restores both parameters and returns time to zero. Playback starts paused. Adjust either parameter, then choose Play or Step.
Look forThe fluid backlog shows rate imbalance only. It cannot validate framing, write coalescing, packetization, or remote acknowledgment; the separately labeled M/M/1 readout requires its own stochastic assumptions.
Pause & reason
Check your understanding
Your turn
Try it yourself
A protocol uses a 2-byte header declaring a 10-byte payload. Two reads return 5 and 4 bytes. How many payload bytes remain? Separately, a 12-byte response has a successful 7-byte write followed by EAGAIN. State its next cursor and suffix length.
Take a moment to make a prediction or work it out on paper. Open a hint when you need one.
Give me a hint
Count the header once in the receive path. On the transmit path, distinguish a successful byte count from an error indication.
Compare with the solution
The reads have supplied nine total bytes: two header bytes and seven payload bytes. Three payload bytes remain, so the frame is incomplete and must not be dispatched. The response cursor is seven and the remaining suffix is five bytes; EAGAIN changes neither. Add a third read of three bytes and a final write of five bytes to finish this particular trace, while retaining the same logic for arbitrary future split sizes.
Keep the idea
Take it with you
- Transport transfer boundaries do not define application message boundaries.
- Advance cursors only by positive byte counts, and preserve them across temporary would-block results.
- Bound coalescing by both memory and time, then measure its throughput and latency tradeoff.
Read further
Original sources and deeper explanations to follow at your own pace.
Optional progress, saved in this browser when available. Revisit any lesson at any time.
Lesson not marked complete.