Shared Memory: Zero Copies, Zero Protection
Map the same physical pages into two address spaces and data moves between processes at the speed of a load and a store — but the kernel is no longer between them, so every rule about who may write when has to be rebuilt in user space with process-shared locks and atomics.
The problem
Same frames, two page tables
Virtual memory (The Virtual Address Space, Page Tables) already maps virtual pages in each process to physical frames. Nothing stops the kernel from mapping one physical frame into *two* page tables. Once it does, a store by process A at its virtual address 0x7f3a… lands in a frame that process B sees at its own virtual address 0x7e11…. No copy happens because there was never a second copy; the two processes are literally looking at the same RAM.
On Unix-style systems the modern API is POSIX shared memory: shm_open("/frames", O_CREAT | O_RDWR, 0600) creates a named object (on Linux it is a file in the /dev/shm tmpfs), ftruncate() sets its size, and mmap(…, MAP_SHARED, fd, 0) maps it. Any other process that opens the same name and maps it sees the same bytes. Linux also offers memfd_create() for anonymous shared objects passed by descriptor, and the older System V shmget/shmat family that ipcs still lists. Windows does the same thing with CreateFileMapping and MapViewOfFile; the mechanism is identical because the hardware is.
Shared memory is also what MAP_SHARED file mappings give you: two processes that mmap the same file share the page-cache pages, which is how a writer’s changes are visible to a reader without a write(). See Memory Mapping — a shared mapping of /dev/shm and a shared mapping of a regular file are the same kernel path with different backing.
1int fd = shm_open("/atlas-frames", O_CREAT | O_RDWR, 0600);2ftruncate(fd, 64 << 20); // 64 MiB3auto* base = static_cast<uint8_t*>(4 mmap(nullptr, 64 << 20, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0));5close(fd); // mapping outlives the descriptor6// base[0..64 MiB) is now visible to every process that maps "/atlas-frames".7// Nothing here says who may write. That is the problem of the next section.Why it is the fastest IPC, and what that costs
A pipe transfer of a 4 MB frame is a write() that copies 4 MB into the kernel, a read() that copies it back out, and at least two context switches (Context Switching). Shared memory is a store by A and a load by B. On the same core the data may not even leave L2. A copy runs at perhaps 10 GB/s, so 4 MB is ~400 µs per copy — meaningful at 60 frames per second — while a mapped write-and-notify costs whatever the notification costs.
And there is the catch: notify. Shared memory moves data but carries no event. B does not know that A has finished writing a frame unless something tells it. So real shared-memory designs pair the segment with a control channel — a pipe, a Unix socket, an eventfd, a semaphore — that carries “frame 17 is ready” while the frame itself never moves. The control channel is small and slow; the data channel is huge and free.
The deeper cost is that the kernel has stepped out. With a pipe, A cannot write while B is reading — the kernel serialises them. With shared memory, A and B run truly in parallel on separate cores and nothing stops A from overwriting the frame B is halfway through reading. Every Race Conditions lesson from the concurrency module applies again, except now the two parties are processes that may be written in different languages by different teams, and a crash in one can leave the shared state half-updated with the lock held.
- The segment outlives its creator:
shm_openobjects persist untilshm_unlinkor reboot. A crashed producer leaves/dev/shm/atlas-framesbehind, full of stale data. - Page faults still happen: the first touch of each page maps it (Page Faults); pre-fault with
MAP_POPULATEormlockif latency matters. - Under memory pressure
/dev/shmpages can be swapped, unlike a pipe buffer; a 2 GB segment counts against the same RAM.
Synchronising across process boundaries
A normal Mutexes lives in one process’s memory and is meaningless to another. For shared memory you need locks whose state *is in the shared segment* and whose wait/wake mechanism the kernel understands across processes. POSIX gives pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED) for mutexes and condition variables placed inside the mapping, and named semaphores (sem_open("/frames-ready", …)) that any process can open by name (Semaphores and Condition Variables). On Linux both are built on futex, which takes the *physical* address into account, so two processes contending on the same shared word block and wake correctly.
For the common producer/consumer case the best-known structure is a ring buffer in the segment: a fixed array of slots plus a head index owned by the producer and a tail index owned by the consumer. With a single producer and a single consumer, each index is written by exactly one side, so Atomic Operations with acquire/release ordering are enough — no lock at all. The producer writes the slot, then publishes the new head with a release store; the consumer reads the head with an acquire load, then reads the slot. That is the design behind high-frequency trading feeds, DPDK and every serious logging shim.
What a lock cannot fix is a peer dying while holding it. A process-shared mutex left locked by a crashed process hangs everyone else forever unless it was created with PTHREAD_MUTEX_ROBUST, in which case the next locker gets EOWNERDEAD and must repair the state. Designs that want to survive peer crashes usually avoid locks in the data path and use the single-writer ring above, where a dead producer simply stops publishing.
1struct Ring { atomic<u32> head; atomic<u32> tail; Slot slots[N]; } // N is a power of two2 3producer(ring, item):4 h = ring.head.load(relaxed)5 if h - ring.tail.load(acquire) == N: return FULL // consumer lags by N6 ring.slots[h & (N-1)] = item // write data first…7 ring.head.store(h + 1, release) // …then publish8 9consumer(ring):10 t = ring.tail.load(relaxed)11 if t == ring.head.load(acquire): return EMPTY // acquire pairs with producer's release12 item = ring.slots[t & (N-1)]13 ring.tail.store(t + 1, release)14 return itemWho uses it
PostgreSQL is a process-per-connection server (Creating Processes: fork, exec, wait): the postmaster forks a backend for each client. Every backend must see the same cached pages, the same lock table and the same WAL buffers, so all of that lives in one shared memory segment created at startup — shared_buffers (128 MB by default, often gigabytes in production) plus the lock manager, the commit log and more. Postgres uses anonymous mmap(MAP_SHARED|MAP_ANONYMOUS) mappings inherited across fork() for the bulk and a small System V segment for locking, and its LWLocks and spinlocks are exactly the process-shared primitives above, one per buffer, one per hash partition.
Browsers are the other canonical example. Chromium runs the renderer, GPU and browser as separate processes for isolation (VM vs Container: Where the Boundary Is explains why they went further and sandbox them). A rendered tile or a decoded video frame is far too large to send over the Mojo IPC channel, so the renderer draws into a shared-memory buffer and sends the GPU process a *handle* to it. The pixels never cross the channel. The same pattern — small control messages over a socket, bulk data in shared pages — appears in Wayland compositors, Android’s Binder and audio servers such as PipeWire.
ipcs -m(System V) andls -l /dev/shm(POSIX) show the segments that exist right now on a Linux host; a Postgres or Chrome instance will be there.- Docker’s default
/dev/shmis 64 MB; Chrome in a container crashes tabs until--shm-sizeis raised — a shared-memory failure that looks like a browser bug.
Key points
- Shared memory maps one physical frame into two page tables; the data is never copied because it never moves.
shm_open+ftruncate+mmap(MAP_SHARED)on Unix-style systems;/dev/shmon Linux;CreateFileMappingon Windows.- It moves data but not events: pair it with a small control channel (pipe, socket, eventfd, semaphore).
- The kernel no longer serialises access; process-shared mutexes, named semaphores or a single-writer atomic ring buffer must.
- A peer that dies holding a lock hangs everyone; robust mutexes or lock-free single-writer designs survive it.
- PostgreSQL’s shared buffers and a browser’s frame buffers are shared memory; the control traffic goes over sockets.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why does shared memory exist if the whole point of processes is isolation?
Because copying is sometimes the bottleneck and the two parties trust each other for one region. Sharing exactly the pages that must be shared keeps the rest of both address spaces isolated.
▸Why can’t I use an ordinary mutex in the shared segment?
An ordinary mutex assumes one address space: its wait queue and its wake-up path are per-process. A process-shared mutex tells the kernel to key the wait on the physical page so two processes contend on the same object.
▸Why do most designs use a ring buffer instead of a lock?
With one producer and one consumer, each index has exactly one writer, so release/acquire atomics give correctness with no blocking and no lock to be left held by a crashed peer.
Shared memory ring buffer
fd = shm_open("/orders", O_CREAT|O_RDWR, 0600);
ftruncate(fd, 4096);
ring = mmap(NULL, 4096, PROT_READ|PROT_WRITE,
MAP_SHARED, fd, 0);
// mutex inside the segment:
pthread_mutexattr_setpshared(&a, PTHREAD_PROCESS_SHARED);How it fails
What the failure looks like from inside real software.
- Two processes update a shared counter without a process-shared lock: lost updates, no crash, wrong totals that vary with load.
- A producer crashes while holding a process-shared mutex: every consumer blocks forever;
topshows them sleeping, not spinning. - The segment is never
shm_unlinked:/dev/shmfills over restarts and a laterftruncatefails withENOSPC. - Chrome inside a container with the default 64 MB
/dev/shmcrashes renderers on large pages — fixed by--shm-size, not by any browser flag. - Reading a struct from shared memory that another process compiled with different alignment or a different version: silent field misinterpretation.