IPC: Deliberate Holes in Process Isolation
Processes are isolated by design, so every way for two of them to communicate — pipes, shared memory, sockets, message queues, signals — is a hole the kernel punches on purpose, and each hole trades speed, isolation and reach differently.
The problem
Isolation is the point; IPC is the exception
A process cannot read another process’s memory, cannot see its descriptors and cannot call its functions. That is not a limitation the kernel apologises for — it is the property that lets a crashed renderer take down one browser tab instead of the browser, and lets a leaking worker be killed and restarted without touching its siblings (Program versus Process, Why Virtual Memory?). The cost is that any cooperation between processes has to go through the kernel, or through a region the kernel has explicitly agreed to map into both.
So every IPC mechanism is an answer to the same question with a different emphasis. How much isolation are you willing to give back? How many copies are you willing to pay for? Does the other side have to be on this machine? Do you need the kernel to preserve message boundaries and order, or is a byte stream enough? Do you need to *notify* the other side, or move *data* to it?
The five mechanisms below are what Unix-style systems give you; Windows has counterparts (anonymous and named pipes, file mappings, Winsock, mailslots, console control events) with different names and some different semantics. Where the lesson says “on Linux” it means it.
- Data mechanisms move bytes: pipes, shared memory, sockets, message queues.
- Notification mechanisms move an event with almost no payload: signals, and on Linux
eventfd. - A file is also IPC — two processes reading and writing the same path — but with no framing, no ordering and no wake-up; it is what people reach for when they have not read this lesson.
The five mechanisms, derived
Start with the simplest thing the kernel can do: keep a small buffer and let one process write into it while another reads from it. That is a pipe (Pipes: A Kernel Buffer Between Two Processes): one direction, byte stream, kernel-mediated, two copies (user → kernel → user), local only, and the kernel blocks whichever side gets ahead. It is the shell’s |.
Remove the copies: map the *same physical pages* into both address spaces. That is shared memory (Shared Memory: Zero Copies, Zero Protection): zero copies, the fastest IPC there is, and no help whatsoever from the kernel about who writes when — synchronisation is now your problem. Give the pipe framing and two directions and let the far end be on another machine: that is a socket (The Socket: A Descriptor With Two Kernel Buffers Behind It) — a Unix domain socket when both ends are local, TCP or UDP when they are not. Give the pipe *message* semantics — discrete messages, priorities, persistence across process exit — and you have a message queue (POSIX mq_open, or System V). Finally, drop the data entirely and just poke the other process: a signal (Signals: Asynchronous Notifications From the Kernel) — a number delivered asynchronously, interrupting whatever the target was doing.
Notice what varies: how many times the bytes are copied, whether the kernel arbitrates access, whether message boundaries survive, and whether the peer can be remote. Those are the columns of the comparison.
Comparison
Speed here means the cost of moving one message between two processes on the same machine, not throughput of a tuned system. Shared memory wins because there is nothing to move; a pipe or Unix socket costs two copies and two system calls per message (roughly 1–5 µs round trip on a modern Linux box, dominated by the context switches, not the copying); a TCP loopback connection adds the transport stack on top — still microseconds, but more of them. Isolation is the inverse: the mechanisms that copy through the kernel keep the two processes unable to corrupt each other; shared memory gives that up.
Framing is the property people forget until it bites: a pipe and a stream socket deliver a byte stream, so two write() calls of 100 bytes may arrive as one read() of 200 or three reads of 70/70/60. Message queues and datagram sockets preserve boundaries. Ordering is guaranteed by everything except UDP, which guarantees nothing — and by shared memory only if you build it.
| Mechanism | Speed | Isolation kept | Complexity | Local / remote | Framing & ordering |
|---|---|---|---|---|---|
| Pipe / FIFO | fast (2 copies, 2 syscalls) | high | low | local only | byte stream, ordered |
| Shared memory | fastest (0 copies) | low — you share pages | high (you synchronise) | local only | none unless you build it |
| Unix domain socket | fast (like a pipe, bidirectional) | high | medium | local only | stream or datagram, ordered; can pass descriptors |
| TCP socket | slower (transport stack) | high | medium | local or remote | byte stream, ordered, reliable |
| UDP socket | fast per packet | high | medium | local or remote | datagrams, unordered, lossy |
| Message queue | moderate (copies + metadata) | high | medium | local only | messages, ordered, priorities |
| Signal | n/a (no payload) | high | deceptively high | local only | a number; may coalesce |
Choosing, and what real software chose
Most application code never chooses: the runtime does. subprocess.run(capture_output=True) and child_process.spawn use pipes. Every database client library uses a socket — a Unix domain socket when the server is local (PostgreSQL’s /var/run/postgresql/.s.PGSQL.5432), TCP otherwise. Docker’s CLI talks to the daemon over /var/run/docker.sock, a Unix socket, which is why mounting that file into a container hands the container the host. Browsers use sockets or pipes for control messages and shared memory for pixels, because a 4K frame is 33 MB and copying it sixty times a second would be absurd.
When you do choose, the questions are the columns of the table. Need to send a 2 GB buffer to a sibling process every few milliseconds? Shared memory plus a small control channel. Need the peer to possibly be on another machine next year? A socket, today, so the protocol does not have to change. Need to tell a process to reload its config? A signal (SIGHUP by convention) — nginx, PostgreSQL and sshd all do this. Need work items to survive the consumer crashing? Not an OS message queue: a broker such as RabbitMQ or Kafka, which is a *socket* protocol to a *process* that owns durability — the Software Architecture idea of a message queue is built on top of the OS’s socket, not the OS’s mq_open.
- Descriptor passing over a Unix socket (
SCM_RIGHTS) lets one process hand an *open file or socket* to another — this is how a privileged parent can accept connections and give them to unprivileged workers. - On Linux,
eventfdandsignalfdturn “notify me” into a descriptor you canepollon, which is how event loops fold signals and cross-thread wake-ups into the same I/O Multiplexing: select, poll, epoll, kqueue, IOCP loop.
Key points
- Process isolation is the default; every IPC mechanism is an explicit exception the kernel grants.
- The axes are copies (speed), whether the kernel arbitrates (isolation), framing/ordering, and local-vs-remote reach.
- Pipes and stream sockets are byte streams: message boundaries are yours to add.
- Shared memory is the only zero-copy mechanism, and the only one where synchronisation is entirely your problem.
- Sockets are the one mechanism whose protocol survives moving the peer to another machine — which is why most application-level IPC is sockets.
- Signals carry no data and can coalesce; use them for notification, never for messaging.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why not just let processes read each other’s memory?
Because then a bug in one is a bug in all of them. Isolation is what makes a process a fault boundary; shared memory re-opens that boundary deliberately, for exactly the pages you choose.
▸Why are there five mechanisms instead of one good one?
Because the constraints conflict. Zero copies (shared memory) is incompatible with kernel arbitration (pipes). Reaching a remote machine (sockets) forces a protocol and a copy. A message with no payload (signal) cannot carry data. Each mechanism sits at one corner of that trade-off space.
▸Why do so many local tools use sockets even though a pipe would be faster?
Because a socket is bidirectional, can be connected to by a process that is not the parent, and has the same API whether the peer is local or remote. The microseconds lost are rarely the bottleneck.
Which IPC mechanism?
| Mechanism | Speed | Isolation | Simplicity | Reach | Framing | Latency (sim.) | Throughput (sim.) |
|---|---|---|---|---|---|---|---|
| ★ Pipe / FIFO | local | byte stream (you delimit) | ~5 µs | ~2 GB/s | |||
| Shared memory | local | none — you design the layout and the locking | ~0.1 µs (no syscall) | memory bandwidth | |||
| Unix domain socket | local | stream or datagram; can pass FDs | ~10 µs | ~1.5 GB/s | |||
| TCP socket | both | byte stream (protocol needed) | ~25 µs loopback · 0.5–150 ms remote | ~1 GB/s loopback · link-limited remote | |||
| Message queue (POSIX / broker) | both (broker) | discrete messages, priorities | ~10 µs local · ms via broker | ~0.5 GB/s local | |||
| Signal | local | no payload — one number | ~5 µs | n/a (not for data) |
How it fails
What the failure looks like from inside real software.
- Treating a pipe or stream socket as message-oriented: a 100-byte JSON write arrives as 60 + 40 bytes and the reader parses half a document.
- Sharing memory without a process-shared lock: two processes update a counter and the result is one — a cross-process Race Conditions with no stack trace to blame.
- Using a signal to carry state: two
SIGUSR1s sent while the target is busy are delivered as one; the second event is lost silently. - Choosing an OS message queue for durable work distribution: the queue lives in kernel memory and disappears at reboot.
- Mounting
/var/run/docker.sockinto a container for convenience — the socket is IPC to a process running as root on the host.