Skip to main content

Inter-Process Communication (IPC)

In an operating system, processes are isolated by the Virtual Memory Manager to prevent one process from corrupting another. However, complex systems (like Chrome, Nginx, or a Database) require these isolated units to cooperate. IPC is the set of mechanisms provided by the kernel to bridge this isolation.
Caveat 1: Pipes are unidirectional — this trips up almost every newcomer. A pipe() call gives you two file descriptors: fd[0] for reading and fd[1] for writing. The data flows in one direction only. If you want a parent and child to talk back and forth, you need TWO pipes (one parent-to-child, one child-to-parent) — or you can use socketpair(AF_UNIX, SOCK_STREAM, 0, fds) which gives you a single bidirectional channel. This is why most modern code uses socketpair for parent-child IPC: half the file descriptors, no risk of crossing the streams. The pitfall: closing the write end of a pipe causes the reader to get EOF; closing the read end causes the writer to get SIGPIPE, which by default kills the process. Senior developers always install a SIGPIPE handler or set MSG_NOSIGNAL on every send.
Pattern: prefer socketpair for new bidirectional IPC. It avoids the four-FD bookkeeping of dual pipes, supports recvmsg/sendmsg (so you can pass file descriptors via SCM_RIGHTS), and gives you MSG_PEEK and MSG_NOSIGNAL. Reserve raw pipe() for the unidirectional cases where it is the obvious primitive: shell pipelines, popen-style streaming, log forwarding.
Caveat 2: Shared memory races are the most subtle bug class in systems programming. The kernel does ZERO synchronization for shared memory — it just maps the same physical pages into two virtual address spaces. If both processes write to the same byte without coordination, the result is undefined (literally: torn writes, lost updates, silent corruption). Worse, on weakly-ordered architectures (ARM, POWER), even reads of correctly-aligned 64-bit values can return garbage if the writer and reader did not insert appropriate barriers. “It works on x86” is not a correctness proof; x86’s TSO model accidentally papers over many missing barriers.
Pattern: pair shared memory with explicit synchronization. The standard recipe is shm_open + mmap + a POSIX semaphore (sem_open) or a process-shared mutex (pthread_mutexattr_setpshared(PTHREAD_PROCESS_SHARED)). For producer/consumer, use a shared-memory ring buffer with atomic head and tail indices using memory_order_release/memory_order_acquire. Test on ARM (a Raspberry Pi works) — if your code passes there, it almost certainly passes everywhere. Crash safety: if a process dies holding a shared mutex, use PTHREAD_MUTEX_ROBUST so the next acquirer gets EOWNERDEAD and can recover instead of deadlocking forever.
Caveat 3: Unix domain sockets vs. TCP loopback — UDS is roughly 2x faster but local-only. Engineers often default to 127.0.0.1:PORT because it is portable. On the same host, this routes through the full TCP stack: socket buffers, sequence numbers, checksums (skipped on loopback in Linux but still memory-copied twice), congestion control state. A Unix domain socket bypasses all of that — the kernel just hands the bytes from one process’s socket buffer to another’s. Benchmarks routinely show UDS at 2x-4x the throughput of TCP loopback for the same workload, with substantially lower CPU. The tradeoff: UDS only works for processes on the same kernel; you cannot transparently move them to different hosts.
Pattern: use UDS for sidecar / colocated IPC. When you have a service mesh sidecar (Envoy, Linkerd) on the same host as your application, talk to it over UDS, not loopback TCP. Same for daemon-style architectures (docker.sock, systemd socket activation, redis-cli to a local Redis). For cross-host fallback, abstract the connection behind an interface that picks UDS for unix:// URIs and TCP for tcp:// URIs — changing one config flag should be the only difference.
Caveat 4: POSIX message queues have hard size limits and can block silently. The kernel default mq_msgsize is 8192 bytes; mq_maxmsg defaults to 10. A mq_send with a full queue blocks the sender by default — and if the receiver crashes, the sender hangs forever. The system-wide limits in /proc/sys/fs/mqueue/ are also low (256 queues, ~819200 total bytes by default), so you cannot scale message queues like you would scale a Kafka topic.
Pattern: open mqueues O_NONBLOCK and handle EAGAIN explicitly. mq_send and mq_receive return EAGAIN when full/empty in non-blocking mode — treat this as backpressure, not as an error. Pair with mq_notify or a signalfd so you can wait for queue events in your event loop. If you genuinely need durable, large-volume messaging across processes on one host, prefer a real broker (Redis Streams, NATS, Kafka) over POSIX mqueues — mqueues are best for low-volume control plane messages, not data plane.
Mastery Level: Senior Systems Engineer
Key Internals: Kernel Ring Buffers, Page Table Aliasing, Signal Frames, rt_sigreturn
Prerequisites: Virtual Memory, Process Internals

0. The Big Picture: Why So Many IPC Mechanisms?

Before diving into each mechanism, understand how they compare:

Decision Flowchart

  1. Need to pass file descriptors? → Unix Domain Socket (SCM_RIGHTS).
  2. Need zero-copy bulk transfer? → Shared Memory + your own synchronization.
  3. Need structured messages with priority? → POSIX Message Queue.
  4. Parent–child streaming? → Pipe.
  5. Unrelated processes, streaming? → Named Pipe or Unix Socket.
  6. Cross-machine? → TCP/UDP Socket.
  7. Async notification only? → Signal.

Understanding Process Isolation

Before diving into IPC mechanisms, let’s understand why processes need isolation and how the kernel enforces it.

Virtual Memory Isolation

Each process operates in its own virtual address space:
Isolation Benefits:
  • Memory safety: Process A cannot corrupt Process B
  • Security: Privileged data stays protected
  • Stability: Crash in one process doesn’t affect others
  • Predictability: Each process sees a consistent address space
The Problem: This isolation prevents direct communication. The kernel must provide controlled mechanisms for processes to exchange data.

1. Pipes: The Kernel Ring Buffer

Pipes are the oldest and most fundamental IPC mechanism in Unix. While they appear as simple file descriptors to user space, their internal implementation reveals sophisticated kernel buffer management.

1.1 Pipe Fundamentals

A pipe is a unidirectional communication channel with:
  • Write end: One process writes data
  • Read end: Another process reads data
  • FIFO ordering: First In, First Out
  • Byte stream: No message boundaries
Critical Design Pattern: Always close unused pipe ends. If the parent keeps pipefd[1] open, the read() call will never return 0 (EOF) because the kernel sees there’s still a potential writer.

1.2 Kernel Implementation Deep Dive

The Pipe Buffer Structure

In the Linux kernel, a pipe is implemented using a circular buffer structure (struct pipe_inode_info):
Memory Layout:

Write Operation Flow

When a process calls write(pipefd[1], data, size):
Key Kernel Functions:

1.3 Atomicity and PIPE_BUF

Critical Guarantee: Writes of size ≤ PIPE_BUF (4096 bytes on Linux) are atomic. What does atomic mean?
What happens with writes > PIPE_BUF?
Kernel Implementation: The atomicity guarantee is enforced by holding the pipe mutex for the entire write operation when size <= PIPE_BUF:

1.4 Named Pipes (FIFOs)

Regular pipes only work between related processes (parent-child via fork()). Named pipes (FIFOs) allow unrelated processes to communicate.
Kernel Implementation: A FIFO is represented by an inode with type S_IFIFO. The inode’s i_pipe field points to the same struct pipe_inode_info as regular pipes.

1.5 Pipe Performance Characteristics

Typical Results: 2-5 GB/s on modern hardware (limited by memory copy speed)

1.6 The Self-Pipe Trick (Advanced Pattern)

Problem: Signal handlers are asynchronous and severely limited in what they can do (async-signal-safe functions only). How do you integrate signals with an event loop? Solution: The self-pipe trick.
Why it works:
  1. Signal handler executes in async context (can’t safely do much)
  2. Handler writes 1 byte to pipe (write is async-signal-safe)
  3. Main event loop wakes up from poll()
  4. Main loop reads signal number and handles it safely
  5. Signal handling is now integrated with other I/O events
Used in: Redis, Nginx, Node.js event loops

2. Shared Memory: The Zero-Copy Holy Grail

Shared Memory is the fastest IPC mechanism because it completely eliminates kernel involvement in data transfer. Once set up, processes communicate at memory speed.

2.1 The Fundamental Concept

Traditional IPC (pipe/socket) data flow:
Shared Memory data flow:

2.2 POSIX Shared Memory Implementation

2.3 The MMU Magic: Page Table Aliasing

How the kernel makes shared memory work:
Kernel Implementation: When Process A calls mmap(MAP_SHARED):
When Process B calls mmap(MAP_SHARED) on the same shared memory object:
Result: Two different virtual addresses (0x7000 and 0x9000) both resolve to the same physical memory (0x5000). This is page table aliasing.

2.4 System V Shared Memory (Legacy API)

Persistence: System V shared memory persists until explicitly deleted with IPC_RMID or system reboot. Use ipcs -m to list and ipcrm -m <shmid> to delete orphaned segments.

2.5 Synchronization: The Critical Challenge

Problem: Shared memory provides NO synchronization. Multiple processes accessing the same memory simultaneously will corrupt data.

Race Condition Example

Correct Solution

Producer-Consumer with Shared Memory:

2.6 Huge Pages for Shared Memory

For large shared memory regions (GB+), using huge pages (2MB or 1GB instead of 4KB) reduces TLB pressure and improves performance.
Performance Impact:
  • Regular 4KB pages: 1 GB = 262,144 page table entries
  • 2MB huge pages: 1 GB = 512 page table entries
  • TLB misses: Reduced by ~99%

3. Message Queues: Structured Communication

Message Queues provide message-oriented communication with built-in synchronization and priority handling.

3.1 POSIX Message Queues

Output:

3.2 Kernel Implementation

POSIX message queues are implemented in the kernel as a priority-sorted list:
Priority Queue Implementation: Messages are stored in a sorted array or priority queue. When receiving, the kernel returns the highest priority message in O(1) or O(log N) time.

3.3 Asynchronous Notification

Message queues support asynchronous notification via signals:

3.4 Performance Comparison


4. Signals: Asynchronous Interrupts

Signals are “software interrupts” that allow asynchronous notification of events.

4.1 Signal Fundamentals

Standard Signals:

4.2 Signal Handler Installation

4.3 The Signal Delivery Mechanism (Deep Dive)

What happens when Process B sends a signal to Process A?
Kernel Code (simplified from kernel/signal.c):

4.4 Async-Signal-Safety: The Critical Constraint

Problem: A signal can interrupt a process anywhere, including inside non-reentrant functions.
Why this deadlocks:
Async-Signal-Safe Functions (partial list from POSIX):
Correct Pattern 1: Use only write()
Correct Pattern 2: Set a flag, check in main loop

4.5 Realtime Signals (SIGRTMIN - SIGRTMAX)

Realtime signals (32-64 on Linux) have additional features:
Realtime Signal Features:
  1. Queued: Multiple instances of the same signal are queued (standard signals are not)
  2. Ordered: Delivered in priority order (lower signal numbers first)
  3. Data: Can send an integer or pointer with the signal

4.6 Signal Masking and Blocking

Test:

5. Unix Domain Sockets: High-Speed Local IPC

Unix Domain Sockets (UDS) provide socket API semantics for local IPC with superior performance compared to network sockets.

5.1 Stream Sockets (SOCK_STREAM)

5.2 Datagram Sockets (SOCK_DGRAM)

5.3 Passing File Descriptors (SCM_RIGHTS)

This is the “superpower” of Unix Domain Sockets - the ability to pass open file descriptors between processes.
Kernel Magic: When sending a FD via SCM_RIGHTS:
Use Cases:
  • Privilege separation: Privileged broker passes FDs to sandboxed workers (Chrome, systemd)
  • Zero-copy: Pass a socket FD to another process for load balancing
  • Capability-based security: Grant access to specific resources without filesystem permissions

5.4 Credentials Passing (SO_PEERCRED)

Security: The kernel provides these credentials, so they cannot be forged (unlike network protocols where client can claim any identity).

5.5 Abstract Namespace Sockets

Linux supports “abstract” Unix sockets that don’t exist in the filesystem:
Advantages:
  • No filesystem clutter
  • No permission/ownership issues
  • Automatically cleaned up on close
  • No race conditions with unlink()
Disadvantages:
  • Linux-specific (not portable)
  • Can’t use filesystem permissions for access control

6. Performance Comparison & Selection Guide

6.1 Throughput Benchmark

6.2 Selection Decision Tree


7. Advanced Topics

7.1 splice() and vmsplice() - Zero-Copy Pipes

Linux provides splice() and vmsplice() for zero-copy operations:
How it works:

7.2 memfd and File Sealing

memfd_create() creates anonymous file descriptors that can be shared and sealed:
Use case: Wayland compositor sharing pixmaps with clients. Sealing prevents malicious client from modifying the buffer after sharing.

8. Real-World Architecture Patterns

8.1 Chrome Multi-Process Architecture

8.2 systemd Socket Activation

Benefits:
  • Zero-downtime restarts (systemd holds socket)
  • On-demand activation
  • Privilege separation (systemd binds privileged port, passes FD to unprivileged service)

9. Interview Questions & Answers

Problem: Signal handlers can interrupt a process anywhere, including inside non-reentrant functions like malloc(). This severely limits what you can do in a signal handler.Solution: The self-pipe trick:
  1. Create a pipe: pipe(signal_pipe)
  2. In signal handler: write(signal_pipe[1], &sig, 1) (write is async-signal-safe)
  3. Add signal_pipe[0] to your event loop (epoll, select, poll)
  4. When pipe becomes readable, main loop reads signal number and handles it safely
Why it works: The signal handler only does minimal work (one async-signal-safe write). The actual signal handling happens in the main event loop where all functions are safe to call.Used in: Redis, Nginx, Node.js, any event-driven server.
Concept: Two different processes map the same physical memory pages into their virtual address spaces.Mechanism:
  1. Process A calls mmap(MAP_SHARED) on shared memory object
  2. Kernel creates VMA (Virtual Memory Area) in Process A’s address space
  3. Kernel allocates physical pages (or uses existing ones for the shared memory object)
  4. Kernel updates Process A’s page tables: Virtual Page → Physical Frame
  5. Process B calls mmap(MAP_SHARED) on the SAME shared memory object
  6. Kernel creates VMA in Process B’s address space (different virtual address)
  7. Kernel updates Process B’s page tables to point to the SAME physical frames
Result: Two different virtual addresses resolve to the same physical memory. This is page table aliasing.Example:
Key insight: The kernel doesn’t copy data. It just manipulates page table entries to create multiple mappings to the same physical pages.
SCM_RIGHTS: A Unix Domain Socket control message type that allows passing open file descriptors between processes.Kernel Operation:
  1. Sender has FD 3 → struct file* (kernel object)
  2. Sender calls sendmsg() with SCM_RIGHTS control message containing FD 3
  3. Kernel increments reference count of the struct file object
  4. Kernel finds free FD slot in receiver’s FD table (e.g., FD 5)
  5. Kernel installs same struct file* pointer in receiver’s FD table at slot 5
  6. Receiver now has FD 5 pointing to the same kernel file object
Privilege Separation Pattern:
Example: Chrome browser process (privileged) opens files and passes FDs to renderer processes (sandboxed, no filesystem access).Security benefit: Capability-based security. Worker gets access to specific resource instances, not broad permissions.
PIPE_BUF: Linux defines it as 4096 bytes (one page).Atomicity Guarantee: If two processes write ≤ 4096 bytes simultaneously, the kernel guarantees the data won’t interleave.Implementation:
For writes > PIPE_BUF:
Example:
Solution: If you need atomicity for large messages:
  1. Use message queues (message-oriented)
  2. Use Unix sockets with framing protocol
  3. Use shared memory with proper locking
  4. Break into ≤4096 byte messages with sequence numbers
System V IPC (shmget, semget, msgget):Pros:
  • Kernel-persistent (survives process death until reboot or manual deletion)
  • Well-established, available on all Unix systems
  • Atomic operations (semop with multiple sem ops)
Cons:
  • Awkward API (ftok for key generation, numeric IDs)
  • No integration with file descriptors (can’t poll/select)
  • Requires manual cleanup (orphaned segments persist)
  • Global namespace (key collisions possible)
POSIX IPC (shm_open, sem_open, mq_open):Pros:
  • Clean API (name-based, like files)
  • FD-based (can use poll/select on message queues)
  • Better integration with modern APIs
  • Filesystem-like semantics (/dev/shm)
Cons:
  • Not truly kernel-persistent (typically backed by tmpfs)
  • Less widely available on old Unix systems
  • Platform differences (macOS limits)
Decision Matrix:Recommendation: Use POSIX IPC for new projects unless you specifically need System V features.
Risks:
  1. Race Conditions:
    • Problem: Multiple processes accessing shared memory without synchronization
    • Result: Data corruption, security-critical state corruption
    • Example: Authentication flag flipped by race condition
  2. Information Leakage:
    • Problem: Uninitialized memory in shared region
    • Result: Process A’s secrets leaked to Process B
    • Example: Crypto keys left in shared buffer
  3. Unauthorized Access:
    • Problem: Permissive shm permissions (0666)
    • Result: Any process can attach and read/write
  4. Memory Exhaustion:
    • Problem: Process allocates huge shared memory segments
    • Result: Denial of service (no memory for other processes)
  5. Persistence Issues (System V):
    • Problem: Orphaned shared memory segments
    • Result: Memory leak, potential reuse by attacker
Mitigations:
Best Practices:
  • Treat shared memory as untrusted input (even from “trusted” processes)
  • Use capabilities/SELinux to limit which processes can access shared memory
  • Monitor for orphaned segments (ipcs -m, /dev/shm)
  • Consider using Unix sockets instead (better isolation, kernel-mediated)
Signal Delivery Process:
Code:
Key Insight: The signal frame is a “snapshot” of the process state that allows the kernel to resume execution exactly where it was interrupted.
Benchmark Results (100 MB data transfer):Why Shared Memory is Fastest:
When NOT to use shared memory:
  • Small messages (less than 4KB): Synchronization overhead dominates
  • Infrequent communication: Setup cost not amortized
  • Simple protocols: Complexity not worth it
  • Need message boundaries: Pipes/sockets handle this
Optimization Tips:
  1. For Pipes/Sockets:
  2. For Shared Memory:
  3. Use splice() for zero-copy:

10. Debugging IPC

Listing Active IPC Objects

Cleaning Orphaned IPC

Tracing IPC with strace


Multi-Mechanism Lab: Producer–Consumer Three Ways

Implement the same producer–consumer pattern using three different IPC mechanisms to feel the differences firsthand.

Variant A: Pipe

Variant B: Shared Memory + Semaphore

Key difference: Zero copy, but you must manage synchronization yourself.

Variant C: Unix Domain Socket

Key difference: FD passing is possible, and you get stream/datagram semantics.

What to Observe

  • Latency: Shared memory is fastest (no kernel copy).
  • Complexity: Pipe is simplest; shared memory requires explicit sync.
  • Flexibility: Unix sockets support FD passing and can be converted to network sockets easily.

Summary

IPC Mechanisms: Key Takeaways:
  1. Pipes: Simple but powerful. Remember PIPE_BUF atomicity and closing unused ends.
  2. Shared Memory: Fastest IPC via page table aliasing. Requires manual synchronization. Security-critical applications must validate all shared data.
  3. Message Queues: Built-in priority and message boundaries. Overhead makes them slower than pipes for high throughput.
  4. Signals: Async-signal-safety is critical. Use self-pipe trick for event loops. Modern apps prefer signalfd (Linux).
  5. Unix Sockets: Versatile. SCM_RIGHTS enables capability-based security. SO_PEERCRED provides kernel-verified credentials.
  6. Performance: Shared memory > Unix sockets > Pipes > TCP. But complexity also increases in that order.
Real-World Patterns:
  • Chrome: Unix sockets + SCM_RIGHTS for sandboxing
  • X11/Wayland: Shared memory for pixmap transfer
  • systemd: Socket activation with FD passing
  • Databases: Shared memory for buffer pools

Next: Synchronization & Locks

Interview Deep-Dive

Strong Answer Framework:
  1. Define the workload first. Throughput target (msgs/sec, bytes/sec), message size distribution, latency budget (p99), durability requirement, number of producers/consumers, crash semantics.
  2. Eliminate clearly-wrong choices. If you need cross-host: UDS is out. If you need durability: shared memory and pipes are out (data is gone if either side crashes). If you need backpressure: signals are out (no flow control).
  3. Compare the survivors on the metric that dominates:
    • Latency-sensitive, small messages, single producer/consumer: shared memory ring buffer with futex notification. ~50-100 nanoseconds per message. Used by HFT systems, LMAX Disruptor.
    • High throughput, multiple producers, small/medium messages: Unix domain sockets, SOCK_DGRAM. Each sendmsg is one atomic message. Kernel manages buffering and backpressure. ~1-3 microseconds per message. Used by journald, rsyslog.
    • Bulk transfer, large payloads, infrequent: shared memory (mmap) with semaphore. Zero copy beats everything for big payloads. Used by databases (PostgreSQL shared_buffers), Wayland (pixel buffers).
  4. Address the specific question — a high-throughput producer/consumer on one machine usually wants Unix domain sockets unless you are above ~1M msgs/sec, at which point shared memory + ring buffer becomes worth the complexity.
  5. Sketch the chosen design: socket(AF_UNIX, SOCK_DGRAM, 0), bind to an abstract namespace path (\0prodcons — no filesystem cleanup needed), producers connect with connect(), consumer uses epoll to multiplex, set SO_RCVBUF to 4MB or higher to avoid drops under burst.
  6. Mention the failure modes you have to handle: consumer slow → EAGAIN on producer with non-blocking sockets → application-level backpressure. Producer crashes → kernel auto-closes the socket → consumer sees EOF on that connection, no orphan resources.
Real-World Example: Facebook’s LogDevice writes its inter-thread queue using a shared-memory ring buffer (their MPMCQueue) and then ships data to remote nodes over TCP — they pick the right primitive at each layer. journald uses Unix domain sockets for log collection, with kernel buffering as the backpressure mechanism; in 2018 they added the journal-remote feature precisely because UDS does not cross hosts. The Aeron messaging framework (real-time finance) goes all-in on shared memory + busy-spin readers and hits ~10 million messages/second on a single core — the cost is dedicating that core to nothing else.
Senior Follow-up 1: When does shared memory beat UDS by enough to justify the complexity? Above ~500K msgs/sec or below 1us p99 latency. Below that, UDS is “fast enough” and the synchronization complexity of shared memory (correct memory ordering, robust mutex handling, head/tail wraparound) is not worth it. Always benchmark before choosing — measured numbers, not vibes.
Senior Follow-up 2: Why use SOCK_DGRAM over SOCK_STREAM for log shipping? SOCK_DGRAM gives message boundaries — one sendmsg = one recvmsg for the consumer, no framing protocol needed. For SOCK_STREAM you would have to add a length prefix or delimiter, which adds CPU on both sides. The cost: on Linux, SOCK_DGRAM on UDS is reliable (unlike UDP) but messages above wmem_max are dropped. Tune net.core.wmem_max if you have large messages.
Senior Follow-up 3: How does io_uring change this picture? io_uring (Linux 5.1+) lets you batch syscalls and avoid the syscall overhead per message. For UDS, the win is moderate (~20-30%) because the syscall itself is cheap. For TCP loopback or shared-memory + futex_wake patterns, io_uring + IORING_OP_SEND with multi-shot recv can dramatically reduce CPU. As of 2024, frameworks like ScyllaDB’s Seastar use io_uring extensively to cut IPC CPU overhead.
Common Wrong Answers:
  • “Always use shared memory because it is fastest” — ignores synchronization complexity and crash semantics.
  • “Use Kafka / Redis / RabbitMQ” — adds a network hop and a separate process to manage; usually overkill for same-host IPC.
  • “Pipes are the standard Unix way” — pipes do not work for many-to-one without per-producer FIFOs and have PIPE_BUF atomicity limits.
Further Reading:
  • LMAX Disruptor whitepaper — the canonical shared-memory ring buffer design.
  • Aeron architecture docs — how a high-performance messaging system layers shared memory and UDP.
  • Brendan Gregg, “Linux Performance” page — has benchmarks of UDS vs. loopback TCP.
Strong Answer Framework:
  1. Pick stream vs. datagram. SOCK_STREAM if requests/responses can exceed a single datagram or need ordering across messages. SOCK_DGRAM if every message is small (under ~64KB) and self-contained — you get message boundaries for free. For most RPC use cases, SOCK_STREAM with explicit framing is the standard.
  2. Server side setup:
  3. Client side:
  4. Framing — the part candidates botch. Streams have no message boundaries, so you MUST frame. Two standard options:
    • Length prefix: 4-byte big-endian length, then length bytes of payload. Read exactly 4 bytes, then exactly N bytes. Simple, standard. Used by gRPC over UDS, Redis RESP.
    • Delimiter: e.g., newline-terminated JSON. Easy to debug with socat, slow due to per-byte scanning. Used by HTTP/1.1.
  5. Request-response pairing. For single-threaded clients, the next response on the wire matches the next request — order is implicit. For pipelined clients (multiple in-flight requests), assign an integer request ID; server echoes it in the response.
  6. Timeouts and cancellation. Set SO_RCVTIMEO so reads do not hang forever. For clean cancellation across both endpoints, signal via a separate “cancel” message or close the socket (the peer gets EPIPE/EOF).
  7. FD passing if needed. Use sendmsg with SCM_RIGHTS cmsg to ship file descriptors — this is what makes UDS uniquely powerful for local IPC. The receiving process gets a brand new FD pointing to the same kernel object.
Real-World Example: The Docker daemon serves its REST API on /var/run/docker.sock (UDS, SOCK_STREAM) using HTTP/1.1 framing. docker ps is just an HTTP GET /containers/json over UDS. systemd’s D-Bus broker also uses UDS with length-prefixed messages, and uses SCM_CREDENTIALS to authenticate the calling process by UID. Visual Studio Code’s language server protocol (LSP) uses UDS or pipes with JSON-RPC framing (Content-Length: N\r\n\r\n{...}).
Senior Follow-up 1: How do you handle partial reads on SOCK_STREAM? read(fd, buf, n) can return any value from 1 to n. You must loop: while (got < n) got += read(fd, buf+got, n-got);. Wrappers like recv_all() or read_exact() (Rust) encode this. On non-blocking sockets, EAGAIN means “no more data available right now; come back later” — combine with epoll edge-triggered mode for efficient event loops.
Senior Follow-up 2: How do you authenticate the peer on a Unix socket? getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &len) returns struct ucred with the peer’s PID, UID, GID. The kernel populated this at connect time, so it is unforgeable — the peer cannot lie about who they are. This is how Polkit, D-Bus, and systemd authenticate clients without passwords. Linux-specific; BSD has LOCAL_PEERCRED or getpeereid.
Senior Follow-up 3: Why do some systems use abstract namespace sockets (path starts with \0)? Abstract sockets (sun_path[0] = '\0', then a name) live in a kernel namespace, not the filesystem. Advantages: no unlink needed at startup or shutdown (no stale socket files), no filesystem permission issues, automatically cleaned up when all FDs close. Disadvantage: Linux-only, and visible only within a network namespace (so containers each see their own).
Common Wrong Answers:
  • “Just read() once and process the buffer” — ignores partial reads on streams; will randomly fail under load.
  • “Use port 0 and let the kernel pick” — that is a TCP concept; UDS uses paths.
  • “TLS over Unix sockets for security” — normally unnecessary; UDS is local-only and SO_PEERCRED is more useful than TLS for local auth. TLS adds CPU for no benefit on UDS.
Further Reading:
  • Beej’s Guide to Unix IPC — the practical reference for socket programming with UDS.
  • unix(7) man page on Linux — definitive reference for UDS semantics.
  • gRPC source code — production-quality length-prefixed framing implementation.
Strong Answer Framework:
  1. State the core difference. Shared memory is “raw bytes you both can see, you handle synchronization.” Message queues are “kernel-managed mailbox: structured messages, built-in priority, kernel handles synchronization.”
  2. Shared memory wins on:
    • Throughput: zero copy. The kernel only does the page-table aliasing once at setup; all subsequent reads/writes are in-process.
    • Bulk transfers: a 1GB shared region costs the same as a 1KB region.
    • Tight latency: spin or futex_wait on a flag, ~50ns wakeup.
  3. Message queues win on:
    • Simplicity: kernel handles queueing, blocking, priority. No memory ordering bugs.
    • Discrete messages: mq_send is atomic at the message level, no framing protocol needed.
    • Priority delivery: messages are delivered in priority order (high priority first), useful for control-plane messages.
    • Crash safety: if a sender dies, queued messages survive until consumed or until queue is unlinked.
  4. The honest tradeoff matrix:
  5. Pick by workload: small structured messages, low frequency, need priority -> message queue. High-volume bulk data, single-digit microsecond latency required -> shared memory.
Real-World Example: PostgreSQL uses shared memory for its buffer pool (shared_buffers) — every backend process maps the same region, and access is coordinated by spinlocks and lwlocks in shared memory. This is why PostgreSQL can serve thousands of concurrent reads from cache without copying pages. Conversely, the Linux kernel’s audit subsystem uses a netlink socket (similar to a message queue) to send audit events to userspace — audit events are small, structured, and the kernel needs reliable delivery semantics that a shared ring buffer would not provide without complex coordination.
Senior Follow-up 1: Can you implement a “message queue” on top of shared memory? Yes — a shared-memory ring buffer with head/tail indices and a fixed message size is exactly that. Aeron and the LMAX Disruptor are essentially user-space message queues built on shared memory. They get higher throughput than POSIX mqueues because they avoid the per-message syscall, but require careful memory ordering and offer no priority semantics.
Senior Follow-up 2: What is mq_notify and when is it useful? mq_notify registers a one-shot notification: when a message arrives on a previously-empty queue, the kernel either sends a signal or spawns a thread (caller’s choice). Useful for waking an idle reader without polling. The catch: it is one-shot per registration, so you must re-arm after every wakeup. Modern code prefers mq_getattr + select/epoll on the queue’s FD (Linux only — mqueues are FDs in Linux).
Senior Follow-up 3: Why do most modern systems use neither, and prefer io_uring or eventfd-based protocols? POSIX mqueues have unfriendly limits, no batching, and no zero-copy. Shared memory has no kernel semantics for delivery. io_uring (Linux 5.1+) gives you submission queues + completion queues that ARE shared memory rings, but with kernel cooperation — syscalls only when you choose to enter the kernel. eventfd is a simpler primitive when you just need a counter to wake a waiter. The 2020s default for high-performance Linux IPC is “shared memory ring + eventfd or io_uring for notification.”
Common Wrong Answers:
  • “Shared memory is always better because it is faster” — ignores complexity cost and that mqueues handle priority and crash safety automatically.
  • “Message queues are deprecated” — they are legacy but still useful for simple structured IPC; many embedded and POSIX-compliant systems still rely on them.
  • “Use a database for IPC” — adds disk I/O, transactions, and a separate process; vastly slower for short-lived inter-process signaling.
Further Reading:
  • mq_overview(7) man page — definitive reference for POSIX message queues.
  • PostgreSQL src/backend/storage/lmgr/ — production-grade shared-memory locking.
  • Martin Thompson, “Mechanical Sympathy” blog — shared-memory design lessons from LMAX.
Strong Answer:
  • For a logging pipeline with multiple producers and one consumer, Unix domain sockets are the right choice. Here is why.
  • Pipes are limited: a regular pipe only works between related processes (parent-child), and named pipes (FIFOs) are unidirectional. With multiple producers writing to a single FIFO, you get interleaving problems — writes larger than PIPE_BUF (4096 bytes on Linux) are NOT atomic, so log lines can get mixed together. You would need one FIFO per producer, which complicates the aggregator.
  • Shared memory is the fastest (zero-copy), but you have to build your own synchronization. You need a ring buffer or bounded queue in shared memory, protected by futexes or semaphores. You also need to handle producer crashes gracefully (what if a producer dies while holding a lock on the shared buffer?). For a logging pipeline, this is over-engineering unless you need extreme throughput (millions of log lines per second).
  • Unix domain sockets (SOCK_STREAM or SOCK_DGRAM) are the sweet spot. Multiple producers can connect to a single socket path. SOCK_DGRAM gives you message boundaries (each sendmsg is one complete log line, no framing needed) and is atomic for messages up to the socket buffer size. The aggregator uses epoll to multiplex all producer connections. You get kernel-managed buffering, backpressure (slow consumer causes producers to block on send), and clean handling of producer crashes (the kernel closes the socket on process exit).
  • In production, this is exactly what rsyslog and journald use — Unix domain sockets for local log collection. If throughput demands exceed what Unix sockets can handle, I would move to shared memory with a ring buffer (like the LMAX Disruptor pattern), but that is only justified at millions of messages per second.
Follow-up: What is SCM_RIGHTS, and how does Chrome use it for sandboxing?SCM_RIGHTS is a mechanism for passing file descriptors between processes over a Unix domain socket using ancillary messages (sendmsg/recvmsg with cmsg). The sending process puts an fd number in the control message, and the kernel creates a new fd in the receiving process’s fd table pointing to the same underlying file/socket/device. Chrome uses this to implement its sandbox: the renderer process (which has no filesystem access via seccomp) cannot open files directly. Instead, the browser process opens the file and passes the fd to the renderer over a Unix socket. The renderer can read/write through the fd without ever having the capability to open arbitrary files. This is capability-based security at the OS level.
Strong Answer:
  • When a signal is sent to a process (via kill() or the kernel generating SIGSEGV), the kernel sets a bit in the target process’s pending signal mask. The signal is not delivered immediately — it is delivered when the process returns to user space (e.g., returning from a syscall, returning from an interrupt, or when the scheduler runs the process).
  • At the point of delivery, the kernel examines the process’s signal disposition. If the handler is SIG_DFL, the kernel performs the default action (terminate, ignore, stop). If a custom handler is installed, the kernel builds a “signal frame” on the process’s user-space stack: it saves the current registers (including the instruction pointer), pushes the signal number and siginfo, and modifies the process’s instruction pointer to point to the signal handler. When the handler returns (via sigreturn() syscall), the kernel restores the saved registers and the process resumes where it was interrupted.
  • The gotcha: signal handlers interrupt the process at arbitrary points. If the main code is in the middle of updating a data structure, the signal handler runs with that structure in an inconsistent state. This is why only “async-signal-safe” functions (a small subset — write, _exit, signal, etc.) can be safely called in a signal handler. Calling malloc, printf, or acquiring a mutex in a signal handler is undefined behavior.
  • The self-pipe trick: to integrate signal handling with an event loop (epoll/select), you create a pipe, and the signal handler writes a single byte to the pipe. The event loop monitors the pipe’s read end alongside other file descriptors. When a signal arrives, the pipe becomes readable, and the event loop handles it in its normal, non-interrupted context where it is safe to call any function. Modern Linux provides signalfd() which provides the same functionality without needing a pipe — the kernel delivers signal information as readable data on a file descriptor.
Follow-up: What happens if a signal arrives while the process is blocked in a syscall like read()?It depends on the SA_RESTART flag. By default (without SA_RESTART), the syscall is interrupted and returns -1 with errno set to EINTR. The application must check for EINTR and retry the syscall. With SA_RESTART set on the signal handler, the kernel automatically restarts the interrupted syscall after the signal handler returns. Not all syscalls are restartable — some (like select, poll, nanosleep) are never automatically restarted because their timeout semantics make restart ambiguous. This EINTR handling is one of the most common sources of bugs in systems programming.
Strong Answer:
  • When Process A creates a shared memory segment (via shmget + shmat, or mmap with MAP_SHARED on a file or memfd), the kernel allocates physical frames and creates a VMA (virtual memory area) in A’s address space. The page table entries for that VMA point to those physical frames.
  • When Process B attaches to the same shared memory segment, the kernel creates a VMA in B’s address space and sets up B’s page table entries to point to the SAME physical frames. This is “page table aliasing” — two different virtual addresses (in different processes) map to the same physical pages. The MMU translates both to the same location in DRAM.
  • Writes by A are immediately visible to B (and vice versa) because they are reading/writing the same physical memory. However, “immediately visible” has caveats: on x86 (TSO), stores by one core are visible to other cores in order after the store buffer drains (which happens relatively quickly). On ARM (weak model), you need explicit memory barriers (or atomic operations) to ensure visibility.
  • The kernel does NOT provide any synchronization for shared memory. If A and B both write to the same location without coordination, you get a data race. The processes must use their own synchronization: POSIX semaphores (sem_init with pshared=1), futexes (the kernel-assisted mutex primitive that underlies pthread_mutex when initialized with PTHREAD_PROCESS_SHARED), or atomic operations on shared variables.
  • Performance: shared memory is the fastest IPC because there is zero data copying — both processes access the same physical memory. The only overhead is the synchronization mechanism. This is why databases (PostgreSQL’s shared_buffers), display servers (Wayland’s buffer sharing), and high-frequency trading systems use shared memory for their hot paths.
Follow-up: What is memfd_create, and why was it added when we already had shm_open?memfd_create() creates an anonymous file in RAM (backed by tmpfs) that is not linked to any filesystem path. It returns a file descriptor that can be passed to other processes via SCM_RIGHTS over a Unix socket or inherited via fork. The advantage over shm_open is that it has no namespace collision risk (no path to conflict with), it can be sealed (using F_SEAL flags to prevent resizing or writing, providing security guarantees for zero-copy data sharing), and it works naturally with mmap and fd passing. Wayland compositors use memfd_create + fd passing + sealing to share pixel buffers between the client and compositor without any risk of the client resizing the buffer while the compositor reads it.