Interprocess Communication (IPC)
How isolated processes talk to each other — pipes, sockets, shared memory, and the trade-offs between them
Everything you have learned so far worked hard to keep processes apart. Each process gets its own private virtual address space, its own file descriptors, its own view of the machine, precisely so that one buggy program cannot reach into another and corrupt it. That isolation is the reason a crashing browser tab does not take down your editor. But real systems are not built from lonely, self-contained programs — they are built from processes that cooperate: a shell piping one command into the next, a web server handing a request to a worker, a database backend talking to a hundred client connections at once.
So we have a tension. The OS spends enormous effort walling processes off from each other, and yet those same processes constantly need to exchange data and coordinate. The resolution is that the kernel — the one component allowed to see across the walls — offers a controlled set of channels for processes to communicate through. Collectively these mechanisms are called interprocess communication, or IPC, and this chapter is a tour of the whole toolbox.
We will see that the toolbox splits into two big families. In message passing, processes send discrete chunks of data through a kernel-managed channel — pipes, FIFOs, message queues, sockets — and the kernel copies the bytes across the boundary for you. In shared memory, the kernel maps one region of physical memory into two address spaces so the processes can read and write it directly, with no copy at all, but with a new obligation to synchronize their access. Every IPC mechanism you meet in the wild is a point on the spectrum between these two ideas, and knowing where each one sits is what lets you pick the right tool under pressure.
Why processes need to talk
Recall the central promise of virtual memory: every process believes it owns a huge, private address space starting at address zero. Process A’s pointer 0x1000 and process B’s pointer 0x1000 refer to completely different physical memory. This is deliberate and it is enforced by hardware — A simply cannot dereference a pointer into B’s space, because the page tables that map A’s virtual addresses do not include B’s pages at all. Isolation is not a convention the processes politely honour; it is a wall the MMU refuses to let them climb.
That wall is exactly why they cannot just share a variable. If you have two separate programs and you want one to send data to the other, there is no global they can both name, no address that means the same thing to both. The only entity that can see into both address spaces — the only one that lives on both sides of the wall — is the kernel. So every form of IPC is ultimately the kernel acting as an intermediary: either it copies bytes from one process to the other, or it arranges for a single patch of physical memory to appear in both address maps.
Those two arrangements are the two families that organise this entire chapter. It is worth fixing them in your mind before we look at any specific API, because the API is just packaging over one of these two ideas.
- Message passing
- Processes exchange discrete messages through a kernel channel; the kernel copies the data across the boundary. Pipes, FIFOs, message queues, and sockets all live here.
- Shared memory
- The kernel maps one region of physical memory into two (or more) address spaces so the processes read and write it directly — no copy, but they must synchronize themselves.
- The intermediary
- In every case the kernel is the trusted party that bridges two otherwise isolated address spaces.
The one trade-off to hold: Message passing is simple and safe but pays a copy through the kernel on every exchange. Shared memory is blazingly fast because there is no copy — but the moment two processes touch the same bytes, you inherit every concurrency hazard we study in Part C. Speed versus simplicity: that tension runs through the whole chapter.
Pipes — the shell’s workhorse
A pipe is the oldest and most beloved IPC mechanism in Unix, and you use it every day at the shell without thinking about it. A pipe is a unidirectional byte stream living inside the kernel: it has a write end and a read end, bytes go in one side and come out the other in order, and that is all. It carries no message boundaries — it is a raw stream of bytes, exactly like a file you can only read forward.
The classic shell pipeline is the pipe made visible. When you type ls | grep, the shell creates a pipe, then forks two processes: it wires ls’s standard output into the pipe’s write end and grep’s standard input into the read end. Neither command knows the other exists — ls just writes to fd 1 as always, grep just reads from fd 0 as always — but the kernel has quietly connected them.
# list files, keep only the .txt ones, count them
$ ls | grep ".txt" | wc -l
3
# ls has no idea grep exists; the kernel's pipe connects fd 1 -> fd 0Under the shell’s convenience is a system call. pipe(fd) asks the kernel for a fresh pipe and fills a two-element array: fd[0] is the read end, fd[1] is the write end. On its own that just gives one process two connected descriptors, which is not very useful. The power comes when you fork afterwards: the child inherits copies of both descriptors, and now parent and child share one pipe. Each side closes the end it does not use, and data flows across the process boundary.
#include <unistd.h> /* pipe, fork, read, write, close */
#include <stdio.h>
int main(void) {
int fd[2];
pipe(fd); /* fd[0] = read end, fd[1] = write end */
if (fork() == 0) { /* --- child: reader --- */
close(fd[1]); /* child never writes */
char buf[64];
ssize_t n = read(fd[0], buf, sizeof buf);
write(1, buf, n); /* echo what we received */
close(fd[0]);
} else { /* --- parent: writer --- */
close(fd[0]); /* parent never reads */
write(fd[1], "hi child\n", 9);
close(fd[1]); /* EOF for the reader */
}
return 0;
}Two details in that code are the whole discipline of pipes. First, each side closes the end it will not use — the parent closes the read end, the child closes the write end — because the reader only gets end-of-file once every write end is closed. Forget to close, and the reader blocks forever waiting for data that will never come. Second, the direction is fixed: this pipe carries data parent-to-child only. If you need to talk back, you create a second pipe going the other way.
The key limitation: Anonymous pipes only connect related processes — a parent and the children it forks — because the connection is shared by inheriting descriptors across fork. Two unrelated programs, started independently, have no descriptor to inherit, so they cannot use an anonymous pipe. That gap is exactly what the next mechanism fills.
Named pipes (FIFOs) — pipes with a name
A named pipe, or FIFO, removes the one big restriction of anonymous pipes. It behaves like an ordinary pipe — unidirectional, a byte stream, first in first out, hence the name — but instead of existing only as a pair of inherited descriptors, it appears in the filesystem as a special file with a path. Any process that can open that path can join, whether or not it is related to the other end.
You create one with the mkfifo command (or the mkfifo() system call). After that it sits in the directory listing like a file, marked with a p in ls -l, but it stores no data on disk — it is still just a kernel buffer. The filesystem entry is nothing more than a rendezvous point: a well-known name that two unrelated programs can both refer to.
# create the named pipe once; it now shows up in ls
$ mkfifo /tmp/mypipe
$ ls -l /tmp/mypipe
prw-r--r-- 1 you staff 0 Jul 9 10:00 /tmp/mypipe
# terminal 1 — a reader; it blocks, waiting for a writer
$ cat /tmp/mypipe
# terminal 2 — an unrelated writer; its line pops out in terminal 1
$ echo "hello across processes" > /tmp/mypipeRun that and something quietly remarkable happens: two shells you started separately, with no parent-child relationship at all, exchange a line of text. The reader’s cat blocks on open until a writer shows up — a FIFO open normally waits until both ends are present — and then the byte stream flows just like an anonymous pipe. The leading p in prw-r--r-- is the kernel telling you this is a pipe, not a regular file.
- mkfifo
- The command and system call that create a named pipe as a special file at a chosen path.
- Persistent name
- The FIFO’s path lives in the filesystem until removed, so processes can find it by name across time — but no data is stored on disk.
- Connects strangers
- Unlike anonymous pipes, unrelated processes can meet at a FIFO because both open the same well-known path.
Where you meet it: FIFOs are a lightweight way to bolt two independent programs together without writing socket code — a long-running script that reads commands from /tmp/control, or a quick logging hookup between tools. They are still one-directional byte streams, so for anything richer you reach for message queues or sockets.
Message queues — discrete messages, kept in order
Pipes and FIFOs give you a byte stream: if the sender writes 10 bytes then 20 bytes, the reader might receive all 30 in one read, or 5 then 25 — the boundaries are gone, and it is on you to frame the data. A message queue removes that burden. It is a kernel-managed list of discrete messages: each send deposits one whole message, each receive lifts out one whole message, and the boundaries are preserved exactly. You never have to reassemble a message from a stream.
There are two lineages of them on Unix. The older System V message queues (msgget, msgsnd, msgrcv) are ubiquitous but clunky, identified by numeric keys. The newer POSIX message queues (mq_open, mq_send, mq_receive) are cleaner, name-based like a file, and — importantly — support message priorities: a higher-priority message jumps ahead of lower-priority ones already waiting, so urgent work is received first. The queue also persists in the kernel independently of any one process, so a sender can enqueue messages before the receiver has even started.
#include <mqueue.h> /* mq_open, mq_send */
#include <fcntl.h>
int main(void) {
/* open (or create) a named queue */
mqd_t q = mq_open("/jobs", O_CREAT | O_WRONLY, 0600, NULL);
const char *msg = "render frame 42";
unsigned int priority = 5; /* higher = delivered sooner */
mq_send(q, msg, 15, priority); /* one whole message, kept intact */
mq_close(q);
return 0;
}The receiver calls mq_receive on the same "/jobs" name and gets back that message as a single unit, together with its priority — never a half message, never two messages fused together. That framing, plus priorities, plus kernel persistence, is what you are paying for.
When you would choose them: Reach for a message queue when the natural unit of work is a discrete task or event, when you want the kernel to preserve message boundaries for you, and when priority ordering matters. That said, in modern distributed systems most teams reach past OS-level queues for a networked broker — RabbitMQ, Kafka, an SQS — because those cross machines and add durability. OS message queues remain the right, low-overhead choice for structured messaging strictly between processes on one host.
Signals — asynchronous notifications
Signals are the odd member of the IPC family. They are not a channel for data — they are asynchronous notifications, tiny interrupts the kernel delivers to a process to say "something happened". A signal carries essentially no payload; it is just a number with a well-known meaning. When one arrives, the process is yanked out of whatever it was doing, runs a short handler if it has installed one, and then resumes. It is the software equivalent of a hardware interrupt.
You have sent signals by hand many times. Pressing Ctrl-C in a terminal sends SIGINT to the foreground process. The kill command, despite its name, is a general signal sender — kill -TERM 1234 asks process 1234 to shut down gracefully. A few signals you meet constantly:
- SIGINT
- Interrupt from the keyboard (Ctrl-C). The default action is to terminate, but a program can catch it to clean up first.
- SIGTERM
- A polite request to terminate. Catchable, so a process can finish work, flush data, and exit cleanly. The default "please stop" signal.
- SIGKILL
- Terminate immediately and unconditionally. Cannot be caught, blocked, or ignored — the kernel does it for you. The last resort.
- SIGCHLD
- Sent to a parent when a child stops or exits, so the parent can wait() on it and reap the zombie.
A process decides how to respond by installing a signal handler. The example below catches SIGTERM so that, instead of dying on the spot, the program gets a chance to run cleanup code first — the foundation of graceful shutdown.
#include <signal.h>
#include <unistd.h>
#include <stdio.h>
static volatile sig_atomic_t stop = 0;
void on_term(int sig) { stop = 1; } /* just flip a flag; keep handlers tiny */
int main(void) {
signal(SIGTERM, on_term); /* install our handler */
while (!stop) {
/* ... do real work ... */
sleep(1);
}
printf("caught SIGTERM, cleaning up\n"); /* graceful exit path */
return 0;
}The limit that defines them: A signal tells a process that something happened, but not what data goes with it — there is no message body, and if two of the same signal arrive close together they may be coalesced into one. Signals are perfect for control and lifecycle events ("stop", "reload your config", "a child exited") and wrong for moving data. For that, use one of the channels in this chapter.
Sockets — the general-purpose channel
Sockets are the most general and most widely used IPC mechanism, because a single API covers two situations at once: communication between processes on the same machine, and communication between processes on different machines across a network. You learn one set of calls — socket, bind, listen, accept, connect, send, recv — and it works whether the peer is next door or across the planet. Unlike a pipe, a socket is naturally bidirectional: both ends can send and receive.
When both processes are on the same host, you use a Unix-domain socket. It has an address that is a filesystem path — something like /tmp/app.sock — and the kernel keeps all the data inside the machine, never touching the network stack. This makes it fast and private, and it is the standard way local services expose an endpoint: your Docker daemon listens on /var/run/docker.sock, and many databases offer a Unix-domain socket for local clients as a quicker alternative to a TCP connection.
- Unix-domain socket
- A socket addressed by a filesystem path, for processes on the same host. Bidirectional, fast, and never leaves the machine — the local-IPC option.
- Network socket
- A socket addressed by IP and port, for processes on different machines, riding TCP or UDP. Same API, now spanning the network.
- One interface, two reaches
- The socket API abstracts "where the peer is" so the same code serves local and remote communication.
When the peer is on another machine, you use a network socket addressed by IP and port, carried over TCP or UDP. The code barely changes — you swap the address family and the address, and the same send and recv move your bytes. That is the quiet genius of the design: the difference between "talk to the process next door" and "talk to a server in another datacentre" is a few lines of setup, not a different mental model.
Bridge to the Computer Networking course: Network sockets are where operating systems hand off to networking. How those bytes actually cross the wire — TCP’s reliable stream, the three-way handshake, IP routing, ports and addresses — is the whole subject of the Computer Networking notes, so we will not duplicate it here. For this chapter, the takeaway is that a socket is the process’s doorway onto that machinery, exposed through the same IPC API you use locally.
IPC in the real world & why engineers care
None of this is academic — the systems you run every day are held together by exactly these mechanisms, and being able to name them changes how you debug. Consider where each one shows up.
- Pipes are the soul of the Unix shell: every command1 | command2 you type wires two processes together through a kernel pipe, which is why small, single-purpose tools compose into powerful one-liners.
- Sockets are everywhere — every web request, database connection, and microservice call is a socket. Local tools use Unix-domain sockets: the Docker CLI talks to its daemon over /var/run/docker.sock, and browsers reach local dev servers over TCP.
- Shared memory powers high-performance systems where copy cost is unacceptable. PostgreSQL keeps its shared buffer pool — the cache of disk pages — in a shared-memory segment that every backend process maps in, so a page read by one connection is instantly available to all.
- Signals drive lifecycle and graceful shutdown. When Kubernetes or Docker stops a container, it sends SIGTERM first and waits; a well-behaved server catches it, stops accepting new work, drains in-flight requests, and exits. Ignore SIGTERM and the orchestrator eventually follows with an uncatchable SIGKILL and cuts you off mid-request.
That last point is worth dwelling on because it bites teams constantly. A container that does not handle SIGTERM will appear to "lose" requests during every deploy, because it is being hard-killed rather than draining. The fix is pure IPC: install a SIGTERM handler, flip a shutdown flag, and finish gracefully — precisely the pattern from the signals section. Knowing IPC turns a mysterious deploy-time error into a five-line fix.
Step back and the chapter forms one picture. Processes are isolated by design, so the kernel offers channels to bridge them, and those channels sort into two families: message passing, where the kernel copies discrete data across the wall (pipes, FIFOs, message queues, sockets), and shared memory, where one region is mapped into two address spaces for zero-copy speed at the price of synchronization. Signals sit apart as lightweight control notifications. Pick the mechanism by matching its trade-offs to your problem: reach, speed, message framing, and how much synchronization you are willing to own.
What is next: Shared memory left us with a dangling promise: the moment two processes touch the same bytes, we inherit race conditions, and the fixes — mutexes, semaphores, condition variables — are their own deep subject. That is exactly where Part C on concurrency and synchronization picks up.