Pipes and socketpair

On this page

A pipe provides a unidirectional byte stream, while socketpair creates a pair of already-connected local sockets, suitable for bidirectional communication. Both express references and lifecycles through file descriptors, and both require protocols to distinguish normal termination, partial transfers, and errors.

Pipe capacity and atomic writes are two different concepts

pipefd[0] is the read end, and pipefd[1] is the write end. Linux's pipe2 can atomically set O_CLOEXEC and O_NONBLOCK at creation, avoiding the race window of creating first and then setting flags.

PIPE_BUF is the boundary guaranteeing that writes from multiple writers are not interleaved; it is not the total pipe capacity. A single write of length not exceeding this boundary gets corresponding atomicity; exceeding it may interleave with other writers. In non-blocking mode, if there is insufficient space for a write of size less than or equal to PIPE_BUF, it returns EAGAIN; do not interpret this as guaranteed partial success. pipe(7)

Reads have no message boundaries: one write does not guarantee a corresponding one read, even if the write itself is not interleaved with other writers. Applications can adopt fixed-length, length-prefixed, or escape-delimiter protocols.

Capacity affects how much data can be buffered. Common Linux configurations use multi-page capacities, but page size, user quotas, and kernel limits alter the result; query with F_GETPIPE_SZ, request adjustment with F_SETPIPE_SZ, and check the actual return value. Increasing buffering can smooth bursts but may also increase queuing latency; it cannot eliminate imbalance where production consistently outpaces consumption.

Read batch boundaries and protocol message boundaries are independent Protocol messages Length 3 + abc Message 1 Length 5 + hello Message 2 Possible reads First 2 bytes of header Cannot decode length yet Header tail + ab Message 1 is incomplete c + entire Message 2 Two messages can be completed in one read The receiver retains incomplete prefixes, validates length limits, and only passes complete messages to the business logic; check for residuals at EOF.

Length prefixes require parsing state across read batches

The convention below assumes each message starts with a 4-byte big-endian unsigned length, followed by the corresponding payload; empty messages are allowed, with a maximum single message size of 1024 bytes. The parser receives byte chunks as read; chunk boundaries do not enter the protocol. The example artificially splits headers and payloads to verify that it still yields two complete messages.

class Decoder:
    def __init__(self):
        self.pending = bytearray()
        self.expected = None

    def feed(self, chunk):
        self.pending.extend(chunk)
        messages = []
        while True:
            if self.expected is None:
                if len(self.pending) < 4:
                    break
                self.expected = int.from_bytes(self.pending[:4], "big")
                del self.pending[:4]
                if self.expected > 1024:
                    raise ValueError("message too large")
            if len(self.pending) < self.expected:
                break
            messages.append(bytes(self.pending[:self.expected]))
            del self.pending[:self.expected]
            self.expected = None
        return messages

    def finish(self):
        if self.expected is not None or self.pending:
            raise ValueError("truncated message at EOF")

wire = (3).to_bytes(4, "big") + b"abc" + (5).to_bytes(4, "big") + b"hello"
decoder = Decoder()
messages = []
for chunk in [wire[:2], wire[2:6], wire[6:]]:
    messages.extend(decoder.feed(chunk))
assert messages == [b"abc", b"hello"]
decoder.finish()
assert Decoder().feed(bytes(4)) == [b""]
for bad in [(1025).to_bytes(4, "big"), b"\0\0", (3).to_bytes(4, "big") + b"ab"]:
    try:
        decoder = Decoder()
        decoder.feed(bad)
        decoder.finish()
    except ValueError:
        pass
    else:
        raise AssertionError("malformed stream accepted")

Upon parsing error, this example abandons the stream and does not attempt to guess the next message header; if the application requires resynchronization, the protocol must define explicit recovery rules. feed stores input chunks and pending complete messages in memory, so when integrating, you must still limit single read sizes and pending message queues; assuming "single message 1024 bytes" limits the entire process's memory is incorrect.

Receiving EOF only indicates the end of the byte stream. If two payload bytes are still missing, you should report truncation rather than treating a half-message as success; whether to roll back previously delivered complete messages is an issue for the upper-layer transaction protocol.

EOF and SIGPIPE stem from reference states

EOF depends on all write-end references: after fork, both sides must close extra ends Parent process Keep write end, close read end Child process Keep read end, close write end Byte stream Parent finishes writing and closes Last write-end reference disappears Child finishes reading buffer read returns 0 (EOF) If the child process still holds its own write end, the read end may never see EOF even if the parent has closed it.

read only returns 0 when all write ends are closed and the buffer is drained. If you write while all read ends are closed, it typically generates SIGPIPE; if that signal is ignored or handled appropriately, write fails with EPIPE. An empty buffer with still-active writers is a different state from true EOF.

fork, dup, and inheritance all increase references. O_CLOEXEC closes the corresponding descriptor upon successful exec; it does not close unused ends for you immediately after fork. For sockets, shutdown(SHUT_WR) can explicitly half-close one direction, which is different from simply decrementing a specific fd reference.

Why is closing one write end not yet EOF?

First copy the write end, then close the original reference; an empty read is still EAGAIN. Subsequently, write and close the last write end; the first read should still return data from the buffer, and only the next one will be EOF. You close references, but you read bytes; these two states must be judged together.

Preparing the visual
Why is closing one write end not yet EOF?

EOF requires both buffer exhaustion and closure of all write ends. When write references remain, an empty non-blocking read returns EAGAIN.

A parent-child process example verifying EOF

The following example sends only a fixed five-byte payload, handles short writes and EINTR, and has the child process verify the total length after reading EOF.

#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stddef.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
    int fd[2];
    if (pipe2(fd, O_CLOEXEC) == -1) return 1;
    pid_t child = fork();
    if (child == -1) { close(fd[0]); close(fd[1]); return 1; }
    if (child == 0) {
        close(fd[1]);
        size_t total = 0;
        char buf[32];
        for (;;) {
            ssize_t n = read(fd[0], buf, sizeof buf);
            if (n > 0) total += (size_t)n;
            else if (n == 0) break;
            else if (errno != EINTR) _exit(2);
        }
        close(fd[0]);
        _exit(total == 5 ? 0 : 3);
    }
    close(fd[0]);
    const char payload[] = "hello";
    size_t done = 0;
    int failed = 0;
    while (done < 5) {
        ssize_t n = write(fd[1], payload + done, 5 - done);
        if (n > 0) done += (size_t)n;
        else if (n < 0 && errno == EINTR) continue;
        else { failed = 1; break; }
    }
    close(fd[1]);
    int status;
    while (waitpid(child, &status, 0) == -1) {
        if (errno != EINTR) return 1;
    }
    return failed || !WIFEXITED(status) || WEXITSTATUS(status) != 0;
}

Compile with cc -Wall -Wextra pipe_demo.c -o pipe_demo. Real protocols must also handle peer premature exit, timeouts, SIGPIPE policies, and input size limits; the length check here is only to illustrate lifecycle, not a general message integrity check.

You can verify that extra write ends prevent EOF without waiting for timeouts

The following single-process example duplicates a write end, closes the original write end, and performs a non-blocking read on an empty pipe. Because the duplicated write end still exists, the result is EAGAIN; only after closing the last write end does the same read return 0.

#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int main(void) {
    int fd[2];
    if (pipe2(fd, O_NONBLOCK | O_CLOEXEC) == -1) return 1;
    int extra = fcntl(fd[1], F_DUPFD_CLOEXEC, 0);
    if (extra == -1) { close(fd[0]); close(fd[1]); return 1; }
    if (close(fd[1]) == -1) { close(extra); close(fd[0]); return 1; }
    char byte;
    errno = 0;
    ssize_t before = read(fd[0], &byte, 1);
    int saved = errno;
    int failed = !(before == -1 && (saved == EAGAIN || saved == EWOULDBLOCK));
    if (close(extra) == -1) failed = 1;
    if (read(fd[0], &byte, 1) != 0) failed = 1;
    if (close(fd[0]) == -1) failed = 1;
    if (!failed) puts("writer remains: EAGAIN; last writer closed: EOF");
    return failed;
}

Save as pipe-refs.c and compile/run with cc -Wall -Wextra pipe-refs.c -o pipe-refs. This example does not write data, so EOF is visible immediately after the last close; if data already exists, it must be drained first. F_DUPFD_CLOEXEC also sets the close-on-exec flag for the new descriptor, avoiding the misconception that all duplication interfaces automatically inherit FD_CLOEXEC. F_DUPFD_CLOEXEC

socketpair: type determines boundaries

int sv[2];
int rc = socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sv);
/* Check rc; both sv[0] and sv[1] are readable/writable; close each at end. */

This is an API snippet requiring <sys/socket.h>. The two ends of a socketpair are connected to each other upon creation, eliminating the need for bind, listen, and accept. socketpair(2)

AF_UNIX TypeData ModelConsiderations on Receive
SOCK_STREAMReliable, ordered byte streamFrame yourself, handle short reads/writes
SOCK_DGRAMLocal reliable, ordered datagramPreserve boundaries; buffer too small may truncate messages
SOCK_SEQPACKETConnection-oriented, reliable, ordered recordsPreserve boundaries; check for truncation and record size

Linux AF_UNIX datagrams do not equal UDP's delivery semantics and should not be written as "unreliable." SOCK_SEQPACKET did not appear only in Linux 5.3. Reliability indicates transfer semantics within the interface scope and does not guarantee that the receiving process has completed business processing after reading; crash recovery still requires upper-layer protocols. unix(7)

AF_UNIX bypasses the IP/TCP path but still has its own protocol implementation, buffering, and backpressure; "no protocol stack, no flow control" is inaccurate. Latency and throughput should be measured by message size, concurrency, copy paths, and scheduling.

Credentials and file descriptor passing

SCM_RIGHTS passes a reference to an open file description; the receiving process obtains its own fd number, rather than the sender's integer becoming a usable handle directly. Shared file offsets and status may also be associated with that open file description, so understanding ownership and closure responsibility is necessary.

SO_PEERCRED can be used to query the credentials of the connected peer, while SCM_CREDENTIALS is a message auxiliary data mechanism; reception policies and kernel validation depend on the interface specifications. Identity information provided by the kernel should be combined with application authorization rules; seeing a UID does not justify unconditional trust for any operation.

recvmsg must check MSG_CTRUNC, control buffer size, and the number of received fds. When rejecting a message, you must also close any received fds that are no longer used; Linux's MSG_CMSG_CLOEXEC can prevent received descriptors from accidentally leaking across exec. unix(7) Auxiliary Data

splice: reduces user buffer round-trips, does not guarantee zero copies

splice requires at least one end to be a pipe and can transfer data between supported fds and pipes. Some paths can move page references, avoiding copying to user buffers and back to the kernel first, but whether actual copying occurs depends on the endpoints and kernel implementation; you cannot uniformly guarantee that physical data is never copied. splice(2)

A single splice may also result in short transfers, blocking, or returning EAGAIN. For loops from file to pipe to socket, record bytes that have entered the pipe but not yet been sent; handle backpressure first to avoid repeated reads or dropping the tail. It shares optimization purposes with sendfile, but you cannot fix all sendfile implementation details into two specific splice calls.

eventfd and selection

eventfd is a kernel-maintained 64-bit counter interface: normal reads return the cumulative value and clear it, while EFD_SEMAPHORE mode decrements by one on each read. It is suitable for notifications or work counting, does not carry arbitrary byte messages, and is not a pipe that can only transmit along a specific unidirectional endpoint. eventfd(2)

Choose pipe for unidirectional streams; choose socketpair STREAM for bidirectional byte protocols; consider SEQPACKET if record boundaries are needed; use AF_UNIX auxiliary data for passing fds or identity information; consider eventfd if only notification is needed. For lifecycle and synchronization of large shared data, see Shared Memory and Semaphores.

Keywords: pipe, PIPE_BUF, EOF, SIGPIPE, socketpair, AF_UNIX, SOCK_SEQPACKET, SCM_RIGHTS, credentials, splice, eventfd, backpressure