File Descriptor / I/O · Linux userspace / kernel ABI

Completion loops for read/write

Explains why one read/write is not guaranteed to process the full request even on a blocking fd, and separates EOF, EAGAIN, and EINTR.

Series
12 / 38
Build
cc -std=c17 -Wall -Wextra -O2 copy_loop.c -o copy_loop
Run
./copy_loop < input.bin > output.bin
Kernel
Linux 6.18.37 LTS

If write(fd, buf, len) succeeds, were all len bytes written?

A successful return value is the number of bytes actually processed, not the requested length. Regular files usually make substantial progress, but a short return is normal for pipes, terminals, sockets, resource limits, and signal conditions.

A read result of 0 is EOF; -1/EAGAIN on a nonblocking fd means that no data is available right now. Treating both as 'no data' confuses connection termination with temporary backpressure.

Structure diagram

Figure 1. Ownership ranges in a user buffer after a short write
request: 16 bytes
0123456789101112131415
return n=6
processedprocessedprocessedprocessedprocessedprocessedremainingremainingremainingremainingremainingremainingremainingremainingremainingremaining
next call
buffer + 6length - 6

If write returns 6, the kernel consumed only the first 6 bytes. The next call must pass the remaining 10 bytes from buffer+6.

Call path

Figure 2. From userspace code to observable results
buffer base + total
read/write remaining length
return n classify progress
retry/wait EINTR/EAGAIN policy
complete total == requested

The loop is not merely code that repeats a syscall; it updates byte ownership. Advance the pointer and remaining length only by the actual return value.

Figure 3. Major points along the kernel-internal path
vfs_write rw_verify_area
file op write_iter/read_iter
iov_iter remaining bytes
device/fs actual progress
return ssize_t count

This diagram is not for memorizing function names. Follow which return value, file descriptor, memory mapping, or wait queue is passed to the next stage.

Linux 6.18.37 LTS source locations

Go beyond the glibc function to the files where the syscall implementation meets kernel objects. Each link points to the original file at the same tag.

FileFunction / structureWhat to inspect
fs/read_write.c ksys_read(), ksys_write() Handle fd lookup, file position, and returned bytes
lib/iov_iter.c copy_to_iter(), copy_from_iter() Track progress across scatter/gather buffers
fs/pipe.c pipe_read(), pipe_write() A clear example of capacity, EOF, and EAGAIN

Complete runnable example

The code below is not pseudocode with explanatory lines omitted. It is a minimal example that can be built and run as a file.

Buildcc -std=c17 -Wall -Wextra -O2 copy_loop.c -o copy_loop
01#include <errno.h>
02#include <stdio.h>
03#include <unistd.h>
04
05static int write_all(int fd, const unsigned char *buffer, size_t length)
06{
07    size_t total = 0;
08    while (total < length) {
09        ssize_t n = write(fd, buffer + total, length - total);
10        if (n > 0) {
11            total += (size_t)n;
12            continue;
13        }
14        if (n < 0 && errno == EINTR)
15            continue;
16        return -1;
17    }
18    return 0;
19}
20
21int main(void)
22{
23    unsigned char buffer[16384];
24    for (;;) {
25        ssize_t n = read(STDIN_FILENO, buffer, sizeof(buffer));
26        if (n > 0) {
27            if (write_all(STDOUT_FILENO, buffer, (size_t)n) < 0)
28                return 1;
29        } else if (n == 0) {
30            return 0;
31        } else if (errno != EINTR) {
32            perror("read");
33            return 1;
34        }
35    }
36}

Code notes

Source line 7size_t total = 0

Tracks the beginning of the unwritten range. It verifies that the ssize_t return value is positive before adding it to size_t.

Source line 9buffer + total

A retry starts at the unprocessed bytes instead of sending from the beginning again. This directly prevents duplicate data.

Source line 9length - total

Derives pointer movement and length reduction from the same total so the code cannot pass the end of the buffer.

Source line 29else if (n == 0)

This is EOF because every writer of stdin has closed. Unlike EAGAIN, waiting with poll will not make new bytes arrive on the same stream.

Source line 31errno != EINTR

Retries only when read made no progress before the signal. Supporting nonblocking input requires a separate branch that returns to poll/epoll on EAGAIN.

Detailed behavior

01

Blocking does not mean complete

The blocking flag means an operation may sleep when it cannot make immediate progress; it does not promise to atomically complete the full requested length. Signals, target-specific maximum transfers, and current capacity reduce the returned length.

Even a regular-file write can complete some bytes and then hit RLIMIT_FSIZE or a storage error.

02

Do not create a busy loop on EAGAIN

Immediately repeating the same syscall when an O_NONBLOCK fd is not ready consumes CPU. Register the event of interest with epoll/poll and retry the remaining range when readiness arrives.

Readiness is the currently observed state, not a permanent guarantee that the next syscall can never block.

03

Durability is separate from successful write

A write return means the kernel accepted the data; it does not mean the data is permanently stored on the medium. Place fsync/fdatasync, directory fsync, and the rename protocol according to durability requirements.

A successful network send is likewise not an acknowledgement that the peer application read the data.

Objects and lifetimes

ObjectCreation and releaseValues to inspect
user bufferAllocated by the caller and must remain valid until the syscall returnsbase, length, processed
iov_iterTracks the current segment and remaining length during kernel I/Ocount, iov offset
file positionUnless positioned I/O is used, it remains in struct file and is shared with later callsf_pos, append mode

Failure conditions and common misconceptions

Observed symptomLikely causesHow to verify
Duplicate outputThe buffer is resent from the beginning after a short writepointer/remaining log
CPU reaches 100% at EOFA read result of 0 is retriedCheck the read return value and poll HUP
CPU reaches 100% in nonblocking modeEAGAIN busy loopCheck strace call frequency and epoll use

Verify it yourself

  1. Copy a large file through a pipe and compare requested and returned lengths in strace -e read,write.
  2. Connect stdout to a slow reader and observe pipe backpressure and write blocking.
  3. Set stdout to O_NONBLOCK and implement a write_all variant that uses poll on EAGAIN.
Run./copy_loop < input.bin > output.bin
Tracestrace -e trace=read,write ./copy_loop < input.bin > output.bin

Primary sources