Ch. 21 · Operating Systems

IPC Compared: Pipes vs Unix Sockets vs TCP vs Shared Memory

Pipes, Unix sockets, TCP loopback and shared memory compared on a 64 MiB transfer, plus the synchronization and trust costs each one carries.

~7 min readadvancedupdated Oct 12, 2026

Processes are isolated on purpose: each has its own address space, so one can’t read or corrupt another’s memory. Interprocess communication (IPC) is the set of deliberate holes in that wall. The interview question usually sounds like “how would these two processes talk?”, and a strong answer picks a mechanism from the shape of the data and the trust between the two sides before worrying about speed. Speed still matters, though, and it’s easy to measure, so this note does both.

The menu

The common options on Unix-like systems:

  • Pipes. A one-way byte stream, usually between a parent and child (ls | grep). Named pipes (FIFOs) give one a path in the filesystem so unrelated processes can open it.
  • Unix domain sockets. Two-way, stream or datagram, addressed by a filesystem path, and usable by unrelated processes on the same host. They also carry extras that TCP can’t: the peer’s user and process IDs (SO_PEERCRED on Linux, getpeereid on macOS) and open file descriptors (SCM_RIGHTS). Docker’s API socket and PostgreSQL’s local connections use them.
  • TCP over loopback. The same API as a network connection, so the code works unchanged when the other side moves to another machine. More protocol overhead, and anything that can reach the port can connect.
  • Shared memory. Both processes map the same physical pages (shm_open plus mmap, or Python’s multiprocessing.shared_memory). No copies through the kernel, and no kernel help with coordination either.
  • Others. POSIX message queues (on Linux; macOS doesn’t implement them), signals for tiny notifications, plain files, and higher-level systems built on these: gRPC, D-Bus on Linux desktops, XPC on macOS.

Moving 64 MiB four ways

To compare costs, a parent forks a child, and the child sends 64 MiB of random bytes to the parent, which receives them into a pre-allocated buffer and checks they arrived intact. The child waits for a start signal so process creation isn’t timed. Shared memory gets the same treatment: the child writes straight into a shared segment, then sends one byte on a pipe to say the data is ready, and the parent reads the data in place.

import os, socket, time
from multiprocessing import shared_memory

TOTAL = 64 << 20           # move 64 MiB from a child process to its parent
CHUNK = 256 << 10
source = os.urandom(TOTAL)               # the child inherits this (copy-on-write)
dest = bytearray(TOTAL); dest[:] = bytes(TOTAL)   # pre-faulted destination in the parent

def stream(label, make_pair):
    send, recv_into = make_pair()
    src, dst = memoryview(source), memoryview(dest)
    go_r, go_w = os.pipe()
    pid = os.fork()
    if pid == 0:
        os.read(go_r, 1)                          # wait for the start signal
        for off in range(0, TOTAL, CHUNK): send(src[off:off + CHUNK])
        os._exit(0)
    time.sleep(0.2)                               # let the child finish starting up
    t0 = time.perf_counter(); os.write(go_w, b"g")
    got = 0
    while got < TOTAL:
        got += recv_into(dst[got:])
    dt = time.perf_counter() - t0
    os.waitpid(pid, 0)
    assert dest == source
    print(f"{label:24} {TOTAL / dt / 2**30:5.2f} GiB/s")

def unix_pair(bufsize=None):
    p, c = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
    if bufsize:
        for s in (p, c):
            s.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, bufsize)
            s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, bufsize)
    return c.sendall, lambda buf: p.recv_into(buf[:CHUNK])

# pipe_pair() and tcp_pair() return the same (send, recv_into) pair for a pipe
# and a loopback TCP connection; shm() times the shared-memory version.
python

One run on the M1, with Python 3.14:

pipe buffer before writer blocks: 65536 bytes
pipe                      0.72 GiB/s
Unix socket, default buf  0.68 GiB/s
Unix socket, 1 MiB buf    1.77 GiB/s
TCP over loopback         1.64 GiB/s
shared memory             4.82 GiB/s
Text

Across five more runs the ranges were: pipe 0.75 to 1.90 GiB/s, Unix socket with default buffers 0.69 to 0.82, with 1 MiB buffers 1.73 to 3.09, TCP 1.53 to 1.70, and shared memory 2.87 to 3.76. This laptop was under memory pressure at the time, so treat the numbers as a ranking, not a spec sheet.

Reading the results

Every stream mechanism copies twice. write() copies from the sender’s memory into a kernel buffer, and read() copies from the kernel buffer into the receiver’s. For bulk data, that double copy sets a ceiling.

Buffer size decides how often each side sleeps. A pipe’s writer blocks when the kernel buffer is full, which on this Mac happened at 65,536 bytes. A Unix domain socket on macOS defaults to 8,192-byte send and receive buffers (sysctl net.local.stream.sendspace and the socket’s SO_SNDBUF both report 8192). With buffers that small, the sender fills them, blocks, the receiver wakes, drains, blocks, and the two processes spend their time handing the CPU back and forth. Raising the buffers to 1 MiB with setsockopt made the same socket 2 to 4 times faster in every run. Linux uses larger defaults, so the gap there is smaller (I didn’t run the Linux comparison). TCP over loopback did better than the default Unix socket for the same reason: bigger buffers, despite running a whole protocol stack.

Shared memory did one copy and almost no system calls. The child wrote straight into pages the parent could see, and one byte on a pipe announced completion. If the producer generated the data in place, even that copy would disappear. That’s why video pipelines, browsers’ compositors and machine-learning data loaders move large buffers through shared memory and send only small control messages through sockets.

What shared memory makes you responsible for

The speed comes from the kernel stepping out of the way, which means everything the kernel was doing for you is now your job.

Synchronization. Nothing tells the reader when data is complete. The test used a pipe message; real systems use process-shared mutexes or semaphores placed in the segment, or a lock-free ring buffer with head and tail indices updated with atomic operations and the right memory ordering. Get it wrong and the reader sees half-written records.

Crash consistency. If a writer dies halfway through an update, the segment holds a torn record and possibly a lock nobody will release. Linux’s robust mutexes report EOWNERDEAD to the next locker so it can repair the state; with semaphores you get no such notice.

Validation. A process that maps memory writable by another process can’t trust anything in it. A classic bug is the double fetch: the reader checks a length field, then reads it again to use it, and a hostile writer changes it in between. Copy fields into private memory before validating them, or use sockets across trust boundaries.

Cleanup. A named segment outlives the processes that created it until someone unlinks it. On Linux, leaked segments sit in /dev/shm using RAM until reboot. Python’s multiprocessing runs a resource tracker that warns about segments left behind at shutdown.

Trust boundaries favour sockets

When the two processes have different privileges, say a root daemon and an unprivileged client, a socket is usually the right answer even if it’s slower. Every message arrives as a separate copy the receiver can validate before acting on it. The receiver can check who’s calling with peer credentials, and the socket file’s permissions decide who may connect at all. File descriptor passing even lets a privileged process open something on a client’s behalf and hand over just that descriptor, which is how privilege-separated designs give out narrow capabilities instead of broad access.

Interview practice: moving images to worker processes

A Python service runs eight worker processes for image inference. The parent receives 4 MB images and passes each one to a worker through a multiprocessing.Queue; results come back the same way. Profiling shows about 30% of total CPU in pickling and copying. How would you redesign the data path, and what new problems would you have to handle?

Answer

multiprocessing.Queue pickles each object and pushes the bytes through a pipe, so every image is serialised, copied into the kernel, copied out, and unpickled: several full copies of 4 MB per request, plus the small-buffer ping-pong measured above. The data is large and the processes trust each other, which is the case shared memory is for.

I’d allocate a shared memory pool of fixed-size slots at start-up, say 32 slots of 8 MB. The parent writes each incoming image into a free slot and sends only the slot number (a few bytes) through the queue. The worker reads the image in place, ideally viewing it as a NumPy array over the shared buffer so even the decode step avoids a copy, writes its result into the same slot or a result area, and sends the slot number back. The queue messages now double as the synchronization: whoever holds a slot number owns that slot, and ownership changes only when a message changes hands.

The new problems: oversized images need a fallback path or a bigger slot class; a worker that crashes while holding a slot must be detected (the parent watches worker exit codes) and its slot reclaimed; the pool must be unlinked on shutdown, including on crashes; and slot reuse must wait until the parent has finished with a result. I’d measure before and after, since in the runs above shared memory moved data between about 1.5 and 7 times faster than a pipe, but the real gain depends on how much of that 30% was copying versus pickling.

See also

More in Operating Systems

read ✓Operating Systems · hard

Operating Systems: CPU Cache Locality

Measured on an M1: column-order matrix walks up to 30x slower, shuffled lists at 97 ns a node, and false sharing slowing two counters 4x.

~6 min readread →
read ✓Operating Systems · easy

Context Switching and Scheduling Overhead

What a context switch saves and loses, voluntary vs involuntary switches, a 3.5 µs pipe ping-pong on an M1, and the real cost of 512 threads.

~6 min readread →
read ✓Operating Systems · easy

User Mode, Kernel Mode and Privileged Operations

How the CPU stops your code doing privileged work, what a system call costs, and why clock_gettime skips the kernel, with real M1 measurements.

~6 min readread →
esc