Shared Memory Pitfalls and Practical Best Practices
· Updated: · Go Komura · Shared Memory, IPC, Concurrency, C++, C#, Windows Development
Revision history (1 updates, last updated Sep 1, 2026)
A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.
- Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614535)
- First published
Cite this article(DOI: 10.5281/zenodo.21614534)
This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.
Go Komura (2026). Shared Memory Pitfalls and Practical Best Practices. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614534 https://comcomponent.com/en/blog/2026/03/18/000-shared-memory-pitfalls-best-practices/
- DOI (latest version)
- 10.5281/zenodo.21614534
- DOI (this version)
- 10.5281/zenodo.22217172
Image frames, inspection results, time-series logs, market depth data, huge buffers. When you want to exchange large data at low latency within the same machine, shared memory looks very attractive.
The slightly dangerous part, though, is that shared memory arrives wearing the face of “fast IPC.” In reality, shared memory is IPC that reduces copies in exchange for pushing the responsibility for consistency back onto your application.
- Fast
- Flexible
- But the protocol is yours to build
- And when it fails, the symptoms are spectacular
That is roughly the four-piece set.
flowchart TB
accTitle: The two faces of shared memory
accDescr: Diagram showing that shared memory arrives wearing the face of fast IPC, but is in reality an IPC that reduces copies in exchange for pushing the responsibility for consistency back onto the application.
f1["The face of fast IPC"] --> f2["What it really is"]
f2 --> f3["Fewer copies"]
f2 --> f4["Consistency is the application's job"]
f4 -.-> f5["You build the protocol; failures are spectacular"]
Figure 1: Shared memory arrives wearing the face of fast IPC and pushes the responsibility for consistency back onto the application.
In this article, with Windows file mappings and POSIX shm_open / mmap in mind, we sort out where shared memory trips you up in practice, and how to design so that less goes wrong.
Whether you use C/C++ or C#’s MemoryMappedFile, the essentials are almost identical.1
Intended Readers and Assumptions
This is written for developers who are about to decide how to pass large data between processes on the same machine. The main audience is people who touch Windows file mappings or POSIX shm_open directly from C / C++, but anyone coming in through C#’s MemoryMappedFile hits the same pitfalls. The chapters on pitfalls and design guidance (chapters 5 and 6) are language-independent.
A working sample sits in 6.9, in both C (Windows / MSVC) and C#. For the POSIX side, chapter 7 gives only the correspondence between API names, in a table.
Terms to Know Up Front
A handful of terms recur throughout the article. Here they are up front, so the first occurrence does not trip you up.
| Term | Meaning |
|---|---|
| IPC (Inter-Process Communication) | Communication between processes: any mechanism for exchanging data or signals with another process. Pipes, sockets, named pipes, and shared memory all count |
| coherent | Multiple views onto the same underlying object show the same content at the same point in time. It does not mean that a reader always reads a consistent, fully-updated record |
| ABI (Application Binary Interface) | The binary-level contract that executables keep with each other, rather than a source-code one. It covers type sizes, alignment, padding, and the order of struct fields |
| SPSC / MPSC / SPMC / MPMC | Shorthand for the number of producers and consumers. S is single, M is multi, P is producer, C is consumer. SPSC means 1 writer and 1 reader. Expanded in 4.2 |
| lock-free | A design that makes progress with atomic operations alone, without taking a lock. The term names a progress guarantee, that at least one thread always moves forward, which is a different property from being fast |
| sentinel | A special value reserved to mean invalid or end-of-data. For an offset, you might decide that UINT64_MAX is invalid |
| NUMA (Non-Uniform Memory Access) | A layout where memory is not equally distant from every CPU. Touching memory on a far node makes the same code visibly slower |
1. The Conclusion First (In One Breath)
Stated rather bluntly, but in a way that is useful in practice:
- Shared memory is a mechanism that shows the same byte sequence to multiple processes; it is not synchronization itself23
- What it is fast at is moving large data within the same machine. If all you have is small control messages, pipe / socket / named pipe / queue is very often easier
- With shared memory, being visible and being safe to read are separate problems
- Do not build your design on
volatile. Atomicity, ordering, and waiting need to be considered separately45 - Putting raw pointers,
HANDLEs, file descriptors,std::string,std::vector,std::mutexin directly will, almost always, make you cry later - Data placed in shared memory is safer when pushed toward fixed-width integers + explicit layout + a versioned header
- Just putting magic / version / size / state / generation / heartbeat in a leading header dramatically changes how easy incidents are to investigate
- The hard parts of shared memory are not speed but initialization, lifetime, recovery, permissions, and ABI
- On Windows the skeleton is
CreateFileMapping/OpenFileMapping/MapViewOfFile; on POSIX it isshm_open/ftruncate/mmap63 - The starting point least likely to break is an SPSC (single-producer single-consumer) ring buffer or a double buffer
In short: shared memory is fast, but use it carelessly and you catch the it-feels-like-it-synchronizes-itself disease. Avoiding that is the first battle.
flowchart TB
accTitle: The difference between being visible and being safe to read
accDescr: Diagram showing that shared memory is a mechanism for showing the same byte sequence to multiple processes rather than synchronization itself, so being visible and being safe to read are separate problems.
k1["Mechanism that shows the same byte sequence"] --> k2["Not synchronization itself"]
k2 --> k3["Being visible"]
k2 --> k4["Being safe to read"]
k3 --> k5["Design them as separate problems"]
k4 --> k5
Figure 2: With shared memory, being visible and being safe to read are separate problems.
Knowledge map for this article
Shared memory uses CreateFileMapping/MapViewOfFile on Windows and shm_open/mmap on POSIX to make the same physical pages visible from several processes, but it is not synchronization in itself, so reading and writing several fields without synchronization can lead to an accident where a reader picks up a state the writer is still in the middle of writing. That accident is prevented with a commit protocol based on an SPSC ring buffer or double buffering, process-local resources such as raw pointers and HANDLEs cannot be placed there as they are and have to be replaced with offset-based references, and separating the control plane from the data plane so that notifications go out over a separate channel is recommended. In addition, fixing the ABI, avoiding initialization races by having only the creator initialize, designing crash recovery around abandoned mutexes on Windows or robust mutexes on POSIX, arranging the namespace and permissions, and fixing the size for each generation are the practical points that lower the accident rate.
flowchart LR
accTitle: Shared memory pitfalls and best practices
accDescr: Diagram showing that shared memory only shares a range of bytes and is not synchronization in itself, the pitfalls of reading a half-written state, initialization races, crash recovery, ABI mismatches, and false sharing, and how they relate to countermeasures such as separating the control plane from the data plane and using an SPSC ring buffer, double buffering, and offset-based references
shared_memory["Shared Memory"]
windows_createfilemapping["CreateFileMapping / MapViewOfFile"]
posix_shm_open["shm_open/ftruncate/mmap"]
memory_mapped_file["File Mapping (Memory-Mapped File)"]
torn_read["Torn Read"]
spsc_ring_buffer["SPSC Ring Buffer"]
double_buffering_commit["Double-Buffered Commit Protocol"]
control_plane_data_plane_separation["Control plane / data plane separation"]
shared_memory_header["Fixed Shared Memory Header"]
offset_reference["Offset-Based References"]
process_local_resource["Process-Local Resources"]
shared_memory_abi["Shared Memory ABI Design"]
initialization_race["Initialization Race"]
creator_joiner_initialization["Creator-only initialization"]
crash_recovery_design["Crash recovery design"]
abandoned_mutex["Abandoned Mutex (Windows)"]
robust_mutex["Robust Mutex (POSIX)"]
false_sharing["False Sharing and Cache-Line Contention"]
shared_memory_namespace_permission["Namespace and Permissions (Global/Local)"]
fixed_generation_sizing["Fixed Size per Generation"]
remote_machine_sharing["Shared Memory Across Machines"]
notification_channel_separation["Separate Waitable Notification Channel"]
busy_loop_polling["Busy-Loop Polling"]
named_pipe["Named Pipe"]
shared_memory -.->|"uses"| windows_createfilemapping
shared_memory -.->|"uses"| posix_shm_open
windows_createfilemapping -->|"uses"| memory_mapped_file
shared_memory -.->|"may cause"| torn_read
spsc_ring_buffer -.->|"prevents"| torn_read
double_buffering_commit -.->|"prevents"| torn_read
control_plane_data_plane_separation -->|"recommended for"| shared_memory
shared_memory_header -->|"recommended for"| shared_memory
offset_reference -->|"recommended for"| shared_memory
process_local_resource -->|"incompatible with"| shared_memory
shared_memory -->|"requires"| shared_memory_abi
shared_memory -.->|"may cause"| initialization_race
creator_joiner_initialization -->|"prevents"| initialization_race
crash_recovery_design -->|"recommended for"| shared_memory
crash_recovery_design -.->|"uses"| abandoned_mutex
crash_recovery_design -.->|"uses"| robust_mutex
shared_memory -.->|"may cause"| false_sharing
shared_memory -.->|"configured by"| shared_memory_namespace_permission
fixed_generation_sizing -->|"recommended for"| shared_memory
shared_memory -->|"incompatible with"| remote_machine_sharing
windows_createfilemapping -->|"incompatible with"| remote_machine_sharing
notification_channel_separation -->|"recommended for"| shared_memory
busy_loop_polling -->|"not recommended for"| shared_memory
notification_channel_separation -.->|"uses"| named_pipe
In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (24 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle
2. What Shared Memory Shares — and What It Does Not
Roughly speaking, shared memory is a mechanism that maps the same physical pages into the virtual address spaces of multiple processes.
Windows uses a file mapping object and views; POSIX mmaps a shared memory object.273
Two points matter here.
- What is shared is the byte content, not the virtual addresses themselves
- Being coherent and being synchronized are different things
flowchart TB
accTitle: Seeing the same physical pages through separate views
accDescr: Diagram showing that shared memory maps the same physical pages into the virtual address spaces of multiple processes, and that what is shared is the byte content rather than the virtual addresses themselves.
pa["View in process A"] --> pp["Same physical pages"]
pb["View in process B"] --> pp
pp -.-> pn["What is shared is the bytes, not the virtual addresses"]
Figure 3: What is shared is the bytes of the same physical pages, not the virtual addresses themselves.
The Windows documentation also says that views created from the same file mapping object are coherent at a given point in time. But that does not mean a reader can always read a consistent, fully-updated record.8
For example, even if the writer intends to write
length- then
payload - then the
ready flag
in that order, a reader that reads with no synchronization at all may see the new length combined with the old payload.
Shared memory does not fix this for you automatically.
So what shared memory shares is bytes. What it does not share is meaning, ordering, completion notification, and recovery policy. All of that you have to design yourself.
flowchart TB
accTitle: What shared memory shares and what it does not
accDescr: Diagram showing that shared memory shares only bytes, while meaning, ordering, completion notification, and recovery policy are not shared and have to be designed by the application.
s1["Shared memory"] --> s2["Shares bytes"]
s1 --> s3["Does not share"]
s3 --> s4["Meaning and ordering"]
s3 --> s5["Completion notification and recovery policy"]
s4 --> s6["You design these"]
s5 --> s6
Figure 4: Bytes are shared, but meaning, ordering, completion notification, and recovery policy are yours to design.
3. Where Shared Memory Fits — and Where It Does Not
| Situation | Fit | Reason |
|---|---|---|
| Passing large frames or buffers within the same machine | Good fit | Easy to reduce the number of copies |
| High-frequency sensor values, images, audio, market depth, and the like | Good fit | Easy to aim for low latency and high throughput |
| Exchanging only small commands and responses | Poor fit | The synchronization cost of control is relatively heavy |
| Communicating with other machines | Not a fit | Shared memory fundamentally assumes a single host |
| Long-term coexistence of different languages and different versions | Hard | Requires ABI and versioning design |
| Persistence is also required | Depends on the goal | File-backed mappings are viable, but persistence and IPC responsibilities mix easily |
In practice, the separation control over messaging, data payload over shared memory is very strong. For example:
- The UI process notifies the worker process to use the next frame via an event / pipe / socket
- The actual frame payload lives in shared memory
That configuration tends to be peaceful.
flowchart TB
accTitle: Control over messaging, data payload over shared memory
accDescr: Diagram showing a split where the UI process notifies the worker process to use the next frame over an event, pipe, or socket, while the actual frame payload is passed through shared memory.
ui["UI process"] -->|"notification (event / pipe / socket)"| wk["Worker process"]
ui -.->|"writes the frame payload"| shm["Shared memory"]
wk -.->|"reads the frame payload"| shm
Figure 5: Notifications travel over messaging, and only the frame payload sits in shared memory.
4. The Four Things to Decide First
When designing shared memory, the first four things to decide are these.
flowchart TB
accTitle: The four things to decide first
accDescr: Diagram showing the four items to decide first when designing shared memory: separating the control plane from the data plane, the concurrency model, ownership and lifetime, and the ABI and versioning.
d0["Shared memory design"] --> d1["Plane separation"]
d0 --> d2["Concurrency model"]
d0 --> d3["Ownership and lifetime"]
d0 --> d4["ABI and version"]
Figure 6: At the start of the design, settle separation, concurrency model, ownership and lifetime, and ABI.
4.1 Separate the Control Plane from the Data Plane
Decide up front what goes into shared memory.
- data plane: images, audio, record sequences, bulk data
- control plane: start, stop, errors, reconnection, reinitialization, notifications
Just separating these two makes the shared-memory side of the design considerably simpler.
4.2 Narrow the Concurrency Model
- SPSC: 1 producer / 1 consumer
- MPSC: many writers / 1 consumer
- SPMC: 1 writer / many readers
- MPMC: many writers / many readers
The difficulty rises in roughly that order. Going straight to MPMC is not something we would recommend. You end up facing mutual exclusion between writers and memory ordering at the same time, and the bugs that surface later are hard to reproduce in tests.
flowchart TB
accTitle: Difficulty of the concurrency models
accDescr: Diagram showing that difficulty rises roughly in the order SPSC, MPSC, SPMC, MPMC, and that going straight to MPMC means facing mutual exclusion and memory ordering at the same time.
m1["SPSC (1 writer, 1 reader)"] --> m2["MPSC (many writers, 1 reader)"]
m2 --> m3["SPMC (1 writer, many readers)"]
m3 --> m4["MPMC (many writers, many readers)"]
m4 -.-> m5["Mutual exclusion and memory ordering at the same time"]
Figure 7: Concurrency-model difficulty rises roughly in this order, from SPSC to MPMC.
4.3 Decide Ownership and Lifetime
- Who creates it
- Who initializes it
- Who deletes it
- Who recovers it when a participant dies midway
If this is vague, behavior changes with every startup order and every restart, and isolating the cause gets hard.
4.4 Decide the ABI and Versioning
- Layout
- Type sizes
- Alignment
- Reserved areas
- Version / feature flags
- Compatibility guarantees
Shared memory is not an API; it is an ABI (binary interface) problem. Get this wrong and you end up with the nasty kind of failure where source compatibility exists but things break only at runtime.
flowchart TB
accTitle: Shared memory is an ABI matter
accDescr: Diagram showing that shared memory is not an API but a binary-level contract, an ABI, so being careless here produces failures where source compatibility holds but the program breaks only at runtime.
a1["shared memory"] --> a2["An ABI contract, not an API"]
a2 --> a3["Be careless here"]
a3 --> a4["Source compatible, broken at runtime"]
Figure 8: Shared memory is an ABI contract; careless design breaks at runtime even when the source still compiles.
5. Common Pitfalls
5.1 Not Synchronizing
This is the most common one.
“We are looking at the same memory, so if I write it, they can read it.”
Sometimes they can. But that does not mean they can read it at the right time, in the right units, in the right order.
On both Windows and POSIX, access to shared memory assumes a separate synchronization mechanism alongside it. The Windows documentation says that access to shared views should be coordinated with mutexes / semaphores / events.2 The POSIX material likewise says that access to shared memory requires synchronization.9
flowchart TB
accTitle: The if-I-write-it-they-can-read-it pitfall
accDescr: Diagram showing that even when a read succeeds because both sides look at the same memory, reading at the right time, in the right units, and in the right order is a separate guarantee, so access to shared memory assumes a synchronization mechanism alongside it.
g1["If I write it, they can read it"] --> g2["Sometimes they can"]
g2 --> g3["Right time, units, and order are separate"]
g3 --> g4["Assumes a synchronization mechanism alongside"]
g4 -.-> g5["mutex / semaphore / event and the like"]
Figure 9: Being able to read is not the same as reading in the right units and order, and synchronization is the prerequisite.
5.2 Trying to Fix It with volatile
volatile is not a magic spell that rescues your shared-memory design.
At minimum, atomicity and mutual exclusion are separate problems.45
For example, a design that places volatile bool ready; and busy-loops on it
- wastes CPU
- leaves the ordering guarantee between payload and ready vague
- is not portable
- easily picks up intermediate states
and pretty much nothing good comes of it.
On top of that, Windows’s WaitOnAddress is for threads within the same process.
It is safer not to think of it as a cross-process waiting mechanism.10
flowchart TB
accTitle: Problems with watching a volatile flag
accDescr: Diagram showing that busy-looping on a volatile bool wastes CPU, leaves ordering guarantees vague, and easily picks up intermediate states, so it should not be the foundation of a design.
v1["Busy-loop on a volatile bool"] --> v2["Wastes CPU"]
v1 --> v3["Vague ordering guarantees"]
v1 --> v4["Easily picks up intermediate states"]
v2 --> v5["Do not build the design on this"]
v3 --> v5
v4 --> v5
v5 -.-> v6["WaitOnAddress is also for threads in one process"]
Figure 10: A volatile busy loop loses on all three counts: CPU, ordering, and intermediate states.
5.3 Letting Readers See Intermediate States
When shared memory fails, the symptoms look quite mundane.
- Only the header is new
- Only the payload is old
- Only the length has been updated
- A pair of two fields is inconsistent
Drawn out, the way the failure happens is simple. The reader just slips into the gap before the writer has finished writing length and payload.
sequenceDiagram
participant W as writer process
participant M as shared memory
participant R as reader process
W->>M: write 1024 into length
Note over M: length is new<br/>payload is still old
R->>M: read length
M-->>R: 1024
R->>M: read 1024 bytes of payload
M-->>R: contents of the previous generation
Note over R: grabs a half-finished state<br/>where only the header is new
W->>M: write payload
W->>M: raise the ready flag
Figure 11: The reader slips into the gap before the writer finishes the payload and grabs an intermediate state.
That gap necessarily exists as long as writing length and writing payload are not a single indivisible operation. Atomically updating a single scalar is relatively simple, but publishing a record made of multiple fields requires a commit procedure.
Typically one of the following:
- Protect the whole thing with a mutex
- Use a double buffer and flip the currently valid buffer index at the end
- Use a ring buffer with per-slot state / sequence
- For 1 writer / many readers, take snapshots with a sequence counter
Even setting the ready flag last is still a half-finished design unless you decide with what memory ordering that flag is written and read. In shared memory, the moment of publication is itself the protocol.
5.4 Placing Pointers or Complex Objects As Is
This is another frequent pattern.
- Raw pointers
HANDLE- File descriptors
std::stringstd::vectorstd::unordered_mapstd::mutexCRITICAL_SECTION
The pattern is placing these straight into shared memory and trying to use them from another process. In the reading process, the result is almost certainly an access violation or a meaningless value.
The reason is simple: virtual addresses and process-local resources only have meaning inside that process’s context. For Windows views too, mapping the same mapping in another process does not guarantee that the virtual addresses match.711
So if you need a reference, the basic approach is to hold it as an offset from the base address.
typedef struct ShmRef {
uint64_t offset; /* position relative to the start of the segment */
uint32_t length;
uint32_t kind;
} ShmRef;
This way, each process can resolve base + offset into its own address.
flowchart TB
accTitle: Reference by offset rather than by pointer
accDescr: Diagram showing that virtual addresses and process-local resources only have meaning inside their own process, so references are held as offsets from the base address and each process resolves base plus offset.
p1["Place a raw pointer or HANDLE"] --> p2["Meaningless value in another process"]
p2 -.->|"instead"| p3["Hold an offset"]
p3 --> p4["Each process resolves base + offset"]
Figure 12: Hold references as offsets from the base address, not as raw pointers.
5.5 Breaking the ABI
Shared memory is a binary contract, not source code. Which means every one of the following differences matters.
- Sizes of
int/long - Representation of
bool - The underlying type of
enums - Size of
wchar_t - 32-bit / 64-bit differences
#pragma pack- Compiler / language differences
- Alignment / padding
- Little-endian / big-endian
Within a single host, endianness is usually consistent, but just adding ARM64 support or a mixed toolchain produces perfectly ordinary mismatches.
So for structures placed in shared memory, we strongly recommend:
- Fixed-width integers such as
uint32_t/uint64_t - Explicit padding / reserved fields
- A header with
version,header_size,record_size,total_size static_assert(sizeof(...))where needed- No non-trivial objects
flowchart TB
accTitle: What breaks the ABI and how to prevent it
accDescr: Diagram showing that differences in type sizes, alignment, pack and padding, and 32-bit versus 64-bit break the binary contract, and that fixed-width integers, explicit layout, and a versioned header prevent it.
b1["Differences in type sizes"] --> b4["The binary contract breaks"]
b2["Differences in pack and padding"] --> b4
b3["32-bit / 64-bit differences"] --> b4
b4 --> b5["Fixed-width integers + explicit layout"]
b5 --> b6["Put version and size in the header"]
Figure 13: Differences in type sizes and padding break the ABI, so lean on fixed-width integers and explicit layout.
5.6 Initialization Races
Shared memory breaks easily on the assumption that whoever created it must have initialized it.
On Windows, when CreateFileMapping hits an existing name it returns the existing object, and GetLastError() reports ERROR_ALREADY_EXISTS.
The initial pages of a pagefile-backed mapping start out zeroed.8
On POSIX, a new shared memory object starts with length 0 and gets its size from ftruncate. Newly allocated bytes are zero-initialized. Creation with O_CREAT | O_EXCL is atomic.3
If you do not know these differences and you
- use it immediately after opening
- have no initialization-complete flag
- let participants initialize concurrently
- never look for a version mismatch
then it breaks depending on startup order.
At minimum, put these states in the leading header.
INITIALIZINGREADYBROKEN
And only the creator initializes; joiners wait for READY.
This etiquette alone makes the world considerably quieter.
flowchart TB
accTitle: How to avoid an initialization race
accDescr: Diagram showing that putting INITIALIZING, READY, and BROKEN states in the leading header, having only the creator initialize and raise READY, and having joiners wait for READY avoids initialization races.
c1["The creator creates it"] --> c2["Only the creator initializes it"]
c2 --> c3["Set state to READY"]
j1["A joiner opens it but does not use it yet"] --> j2["Wait for READY"]
c3 -.-> j2
j2 --> j3["Start using it"]
Figure 14: Only the creator initializes, and joiners wait for READY in the header before using the block.
5.7 Not Thinking About Crash Recovery
What happens when the writer dies in the middle of updating shared data? Ship to production with that undefined and the look on everyone’s face during an outage suddenly gets serious.
A Windows mutex becomes abandoned when the owning thread exits without releasing it, and waiters receive WAIT_ABANDONED. That means the shared resource may be in an indeterminate state.12
With POSIX robust mutexes as well, when the owner dies you get EOWNERDEAD back, and after repairing the state you call pthread_mutex_consistent().1314
What matters here is not to just keep going. Recovery requires at least one of the following:
- A generation number
- The last committed sequence
- A heartbeat
- A dirty / clean flag
- Journal-style two-phase commit
- A full reinitialization procedure for corruption
flowchart TB
accTitle: When the writer dies mid-update
accDescr: Diagram showing that when an owner exits without releasing, Windows returns WAIT_ABANDONED and a POSIX robust mutex returns EOWNERDEAD, the shared resource may be in an indeterminate state, and the right move is to go to a recovery procedure rather than keep going.
w1["Writer dies mid-update"] --> w2["WAIT_ABANDONED (Windows)"]
w1 --> w3["EOWNERDEAD (POSIX robust)"]
w2 --> w4["The shared resource may be indeterminate"]
w3 --> w4
w4 --> w5["Do not just keep going"]
w5 -.-> w6["generation / heartbeat / two-phase commit / reinitialize"]
Figure 15: When the owner dies, suspect an indeterminate state and move to the recovery procedure instead of continuing.
5.8 False Sharing and Cache-Line Contention
Shared memory is often said to be fast. But if hot counters are packed into the same cache line, the line bounces between CPUs and things slow down by an amount you cannot ignore.
The classic example is
- the producer updates
write_index - the consumer updates
read_index - both sit on the same cache line
In that case,
- split hot fields onto separate cache lines
- separate frequently updated fields from rarely updated ones
- aim for one writer per cache line
and that alone changes things considerably. You often hear about aligning to 64 bytes; treat that as 64 bytes being a common value on many CPUs rather than an absolute law.
flowchart TB
accTitle: The classic false sharing case and its fix
accDescr: Diagram showing that when the write_index updated by the producer and the read_index updated by the consumer sit on the same cache line, the line bounces between CPUs and slows things down, so hot fields are split onto separate cache lines.
fp["Producer updates write_index"] --> fl["Same cache line"]
fc["Consumer updates read_index"] --> fl
fl --> fs["The line bounces between CPUs and slows down"]
fs --> fx["Split hot fields onto separate cache lines"]
Figure 16: Hot counters on the same cache line slow things down, so move them to separate lines.
5.9 Taking Names, Permissions, and Security Lightly
Named shared memory is convenient, but careless names and permissions will come back to bite you.
On Windows,
- there are
Global\andLocal\namespaces - creating a
Global\file mapping from outside session 0 requiresSeCreateGlobalPrivilege - object names share a namespace with events / semaphores / mutexes / waitable timers / jobs
— those are the quirks you get.1582
In other words:
- you name it
"Global\\MyApp"and figure the service and the desktop app can share it - but it fails on permissions
- and on top of that, a mutex with the same name was created first, so you get
ERROR_INVALID_HANDLE
which is exactly the very Windows-flavored mess you would expect.
flowchart TB
accTitle: The mess around the Global namespace
accDescr: Diagram showing that trying to share between a service and a desktop app through a Global name can fail on permissions for lack of SeCreateGlobalPrivilege, or collide with a same-named mutex that already exists because the namespace is shared.
n1["Want to share through a Global name"] --> n2["Fails on permissions"]
n1 --> n3["Collides with a same-named mutex"]
n2 -.-> n4["Creation requires SeCreateGlobalPrivilege"]
n3 -.-> n5["Ends in ERROR_INVALID_HANDLE"]
Figure 17: The Global namespace is where you step in two kinds of mess: permissions and name collisions.
On the POSIX side too, treating shm_open’s mode or umask lightly makes the object unnecessarily visible, or conversely impossible to open.3
Shared memory is not safe just because it is only memory. From any process with read permission, it is quite plainly visible. If you put confidential information in it, you need to think about paging / swap / dumps / permissions, exactly as you would for ordinary memory.
5.10 Resizing and Upgrading Carelessly
“I would like to grow the shared memory a bit later” is a fairly dangerous request.
- A Windows mapping object has a size fixed at creation8
- On POSIX too, unless you keep
ftruncateandmmapconsistent, the participants’ mapped lengths stop matching316
In practice, it is safer to make the size immutable within a generation. If you need to grow it,
- create a segment with a new version / name / generation
- switch the participants over
- close the old segment
— that route breaks far less often.
flowchart TB
accTitle: Grow the size by switching generations
accDescr: Diagram showing that the size of a shared memory segment stays immutable within a generation, and that growing it by creating a new-generation segment, switching participants over, and closing the old segment leaves far less room for things to go wrong.
z0["Want to grow it later"] --> z1["Create a new-generation segment"]
z1 --> z2["Switch the participants over"]
z2 --> z3["Close the old segment"]
z0 -.-> z4["Resize in place is dangerous"]
Figure 18: Fix the size within a generation and grow by switching to a new-generation segment.
5.11 Cramming Even Notifications into Shared Memory
A common pattern is
- write
ready = 1into shared memory - the peer does
while (!ready) Sleep(1);
This works at first. But later it comes back as
- wasted CPU
- latency jitter from
Sleep(1) - missed updates that are hard to notice
- timeouts and shutdown notifications that are hard to write cleanly
Push shared memory toward the data side, and move notification out to primitives you can wait on.
- Windows: event / semaphore / mutex / named pipe and the like217
- POSIX: semaphores / process-shared mutex + condvar and the like1819
flowchart TB
accTitle: Move notification out to a primitive you can wait on
accDescr: Diagram showing that watching a shared memory flag with Sleep wastes CPU, adds latency jitter, and hides missed updates, so shared memory stays on the data side and notification moves to primitives you can wait on such as events and semaphores.
t1["Watch a ready flag with Sleep"] --> t2["Wasted CPU and latency jitter"]
t1 -.-> t6["Missed updates are hard to notice"]
t2 --> t3["Notification goes to a waitable primitive"]
t3 --> t4["Windows: event / semaphore and the like"]
t3 --> t5["POSIX: semaphore / condvar and the like"]
Figure 19: Receive notifications through a primitive you can wait on, not by watching a flag.
5.12 Thinking “This Lets Me Share Across Machines Too”
There is a moment when you are tempted to think: if I use a file-backed mapping and map a file on a network share, maybe I can do shared memory across machines too.
That is dangerous.
The documentation for Windows CreateFileMapping also says that coherence is not guaranteed for remote files.
If two machines map the same page as writable, each sees only its own writes, and nothing is merged when the disk is updated.8
Shared memory is fundamentally a single-host mechanism. If you need to cross machines, choosing socket / RPC / message broker outright is far better for your sanity.
flowchart TB
accTitle: Do not share across machines
accDescr: Diagram showing that mapping a file on a network share does not guarantee coherence for remote files, that shared memory is a single-host mechanism, and that crossing machines calls for sockets, RPC, or a message broker.
r1["Want to share by mapping a remote file"] --> r2["Coherence is not guaranteed"]
r2 --> r3["Shared memory is a single-host mechanism"]
r3 --> r4["Go to socket / RPC / message broker"]
Figure 20: Shared memory is a single-host mechanism, so pick messaging when you cross machines.
6. Best Practices
6.1 Separate the Control Plane from the Data Plane
Taking the separation decided in 4.1 down to implementation-level assignments gives you this (for how it goes wrong, see 5.11).
- Shared memory: frame, sample, batch, snapshot
- Event / semaphore / pipe / socket: ready, consumed, stop, error, reconnect
This separation improves design clarity even before it improves performance.
6.2 Put a Fixed Header at the Front
At minimum, we strongly recommend a leading header like this.
typedef struct SharedHeader {
uint32_t magic;
uint16_t abi_version;
uint16_t header_size;
uint32_t state; /* 0=initializing, 1=ready, 2=broken */
uint32_t flags;
uint64_t total_size;
uint64_t generation;
uint64_t heartbeat_ns;
uint64_t payload_offset;
uint64_t payload_size;
uint64_t write_seq;
uint64_t read_seq;
uint8_t reserved[64];
} SharedHeader;
The points are
magicrejects a foreign or uninitialized segmentabi_versionandheader_sizereject layout differencesstaterejects a segment that is still initializinggenerationdetects re-creationheartbeatshows livenessreservedleaves an escape hatch for future extension
What is painful about shared memory is that it is hard to see what is happening. That is exactly why you give it observability metadata from the start.
flowchart TB
accTitle: What each field of the fixed header does
accDescr: Diagram showing the division of labor in the leading header, where magic rejects foreign or uninitialized segments, abi_version and header_size reject layout differences, state rejects segments still initializing, generation detects re-creation, and heartbeat shows liveness.
h0["Leading fixed header"] --> h1["magic rejects foreign segments"]
h0 --> h2["version rejects differences"]
h0 --> h3["state rejects mid-initialization"]
h1 --> h4["Becomes observability metadata"]
h2 --> h4
h3 --> h4
h0 -.-> h5["generation detects re-creation"]
h5 -.-> h6["heartbeat shows liveness"]
Figure 21: Each field of the fixed header rejects something: a foreign segment, a layout difference, or an unfinished initialization.
6.3 Use Offset References
Hold references as offsets, not pointers.
- Resolve them as
base + offset - Add range checks on
offset + length - Define a sentinel for invalid values
This alone substantially reduces address-mismatch failures.
6.4 Narrow the Concurrency Model
Of the four models in 4.2, the first choice should be one of these two.
- SPSC ring buffer
- 1-writer / many-reader snapshot
An SPSC ring buffer is just an array of fixed-length slots where the producer writes at the write_seq position and the consumer reads from the read_seq position. Because there is exactly one writer and one reader, both indices move in one direction only.
flowchart LR
subgraph ring["Ring buffer with 8 slots"]
direction LR
s0["slot 0<br/>read"]
s1["slot 1<br/>read"]
s2["slot 2<br/>unread"]
s3["slot 3<br/>unread"]
s4["slot 4<br/>being written"]
s5["slot 5<br/>free"]
s6["slot 6<br/>free"]
s7["slot 7<br/>free"]
end
C["consumer<br/>read_seq = 2<br/>advances after reading"] --> s2
P["producer<br/>write_seq = 4<br/>advances after writing"] --> s4
s7 -.->|"wraps to slot 0 at the end"| s0
Figure 22: The structure of an SPSC ring buffer. Producer and consumer advance separate indices in one direction.
The key is never to break the order of write first, then advance the index and read first, then advance the index. And, as 5.8 says, put write_seq and read_seq on separate cache lines.
If you need multiple writers, things usually go better when you reduce the number of places responsible for consistency, for example:
- only the enqueue is lock-free / atomic
- actual data updates are funneled to a single consumer
6.5 Make the Commit Protocol Explicit
A design where you cannot explain in words from which moment it is safe to read is dangerous.
For a double buffer, for example, you settle on a publication ritual:
- write to the unpublished buffer
- finalize the checksum and length
- switch the active buffer index with release semantics
- the reader reads the active index with acquire semantics
- after reading, check that the index has not changed
Drawn out, it becomes clear that there is exactly one moment where the switch happens.
sequenceDiagram
participant W as writer
participant BA as buffer A
participant IX as active index
participant BB as buffer B
participant R as reader
Note over IX: active index is A
R->>IX: read with acquire
IX-->>R: A
R->>BA: read buffer A
W->>BB: write to the unpublished B
W->>BB: finalize length and checksum
W->>IX: switch to B with release
Note over IX: active index is B
R->>IX: recheck the index after reading
IX-->>R: it had changed to B
Note over R: discard what was read<br/>and read again from B
Figure 23: The publication ritual of a double buffer. The reader rechecks the active index after it finishes reading.
Skip that step of rechecking the index after reading, and the writer will reuse the same buffer for its next write while the reader is still in it, producing the same half-finished state as 5.3. Note that with only two buffers the index can flip again during the re-read, so if updates are fast, add more buffers or move to the sequence-counter approach listed in 5.3.
6.6 Fix the Size per Generation
Rather than resizing in place, cutting generations like
name = MyShm.v3abi_version = 3generation = 42
is easier to maintain.
Shared memory does not type-check at call time the way an API does. That is why not breaking an ABI once it is decided matters so much.
6.7 Build In Observability
At minimum, having these around helps a lot.
- Last update time
- Last successful sequence
- Drop count / overwrite count
- Version mismatch count
- Attach / detach count
- Last error code
- Heartbeat
When shared memory breaks, the logs are usually thin. Adding your own counters makes incident response considerably easier.
6.8 Write the Failure-Path Tests First
The happy path alone is not enough. At the very least, look at these.
- Force-kill the writer mid-update
- The reader lags and the ring overflows
- Connecting with a version mismatch
- Mixed 32-bit / 64-bit
- Opening across sessions
- Insufficient permissions
- A predecessor process restarts while still holding an old generation
- Cache misses / NUMA effects under continuous huge-data transfer
With shared memory, tests that break things are worth more than happy-path tests.
6.9 A Minimal Round-Trip Sample
Here is everything above boiled down to a minimal working configuration. It puts one fixed-layout block into a pagefile-backed Windows file mapping and exchanges notifications with two auto-reset events. That is all it does.
The four shared rules are these.
- The block holds only fixed-width integers and fixed-length arrays. No pointers, no
HANDLEs magic/abi_version/block_size/statesit at the front- Finish writing the payload, then finalize the length, and only then raise the event
- The event names are different from the file mapping name (on Windows, events, semaphores, mutexes, waitable timers, jobs, and file mappings share one namespace)815
sequenceDiagram
accTitle: The round trip of the minimal sample
accDescr: Diagram showing the round trip in which the sender finishes initialization, writes the payload, finalizes the length, and raises the event, while the receiver waits for the event, checks the ABI and state, range-checks the length before reading, and replies in the same order.
participant W as sender
participant M as shared block
participant R as receiver
W->>M: initialize and set state to READY
W->>M: finish writing the payload, then finalize the length
W->>R: raise the request event
R->>M: check magic / version / state
R->>M: range-check the length and read
R->>M: write the reply payload, then finalize the length
R->>W: raise the reply event
Figure 24: The round trip of the minimal sample. Finish the payload, finalize the length, and only then notify.
The sender comes first.
/* shm_writer.c : the sender. Start this one first.
* cl /W4 /nologo shm_writer.c (kernel32.lib is linked by default) */
#include <windows.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#define SHM_NAME L"Local\\KsShmDemo.v1.Block"
#define EVT_REQ L"Local\\KsShmDemo.v1.Request"
#define EVT_REP L"Local\\KsShmDemo.v1.Reply"
#define SHM_MAGIC 0x314D4853u /* 'S','H','M','1' laid out little-endian */
#define SHM_ABI 1u
#define STATE_INITIALIZING 0u
#define STATE_READY 1u
#pragma pack(push, 8)
typedef struct DemoBlock {
uint32_t magic;
uint32_t abi_version;
uint32_t block_size;
uint32_t state;
uint32_t request_len;
uint32_t reply_len;
char request[256];
char reply[256];
} DemoBlock; /* 24 + 256 + 256 = 536 bytes */
#pragma pack(pop)
int main(void)
{
HANDLE hMap = NULL, hReq = NULL, hRep = NULL;
DemoBlock *blk = NULL;
uint32_t len = 0;
DWORD waited = 0;
int rc = 1;
/* 1. Create a pagefile-backed mapping. Its initial pages start out zeroed.
* If the same name already exists, CreateFileMappingW "succeeds and returns
* the existing object", so a NULL check alone cannot stop a second sender.
* Fall through to the memset below and it wipes the shared block of a peer
* that is still running. GetLastError() is set on success too, so read it
* immediately. */
hMap = CreateFileMappingW(INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE,
0, (DWORD)sizeof(DemoBlock), SHM_NAME);
if (hMap == NULL) {
printf("CreateFileMapping failed: %lu\n", GetLastError());
goto cleanup;
}
if (GetLastError() == ERROR_ALREADY_EXISTS) {
printf("%ls is already in use. Only one sender at a time\n", SHM_NAME);
goto cleanup;
}
blk = (DemoBlock *)MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(DemoBlock));
if (blk == NULL) {
printf("MapViewOfFile failed: %lu\n", GetLastError());
goto cleanup;
}
/* 2. Notification events. Give them names different from the mapping */
hReq = CreateEventW(NULL, FALSE, FALSE, EVT_REQ); /* auto-reset / non-signaled */
hRep = CreateEventW(NULL, FALSE, FALSE, EVT_REP);
if (hReq == NULL || hRep == NULL) {
printf("CreateEvent failed: %lu\n", GetLastError());
goto cleanup;
}
/* 3. Only the creator initializes. state is raised last */
memset(blk, 0, sizeof(*blk));
blk->magic = SHM_MAGIC;
blk->abi_version = SHM_ABI;
blk->block_size = (uint32_t)sizeof(DemoBlock);
blk->state = STATE_INITIALIZING;
MemoryBarrier();
blk->state = STATE_READY;
/* 4. Never break the order: payload -> barrier -> length -> notification */
strcpy_s(blk->request, sizeof(blk->request), "ping from writer");
MemoryBarrier();
blk->request_len = (uint32_t)strlen(blk->request);
if (!SetEvent(hReq)) {
printf("SetEvent failed: %lu\n", GetLastError());
goto cleanup;
}
/* 5. Wait for the reply. Never wait forever */
waited = WaitForSingleObject(hRep, 5000);
if (waited == WAIT_TIMEOUT) {
printf("no response from the reader\n");
goto cleanup;
}
if (waited != WAIT_OBJECT_0) {
printf("WaitForSingleObject failed: %lu\n", GetLastError());
goto cleanup;
}
/* 6. Range-check the length before reading */
len = blk->reply_len;
if (len > sizeof(blk->reply)) {
printf("reply_len is out of range: %u\n", len);
goto cleanup;
}
printf("reply: %.*s\n", (int)len, blk->reply);
rc = 0;
cleanup:
/* Close every view and handle and the name disappears with them.
Do not exit before the reader */
if (hRep != NULL) CloseHandle(hRep);
if (hReq != NULL) CloseHandle(hReq);
if (blk != NULL) UnmapViewOfFile(blk);
if (hMap != NULL) CloseHandle(hMap);
return rc;
}
The receiver keeps everything from SHM_NAME through DemoBlock exactly identical to the sender and only swaps out main. In production you would factor that shared part into a header file.
/* shm_reader.c : the receiver. The constants and the DemoBlock definition are
* identical to shm_writer.c.
* cl /W4 /nologo shm_reader.c */
int main(void)
{
HANDLE hMap = NULL, hReq = NULL, hRep = NULL;
DemoBlock *blk = NULL;
uint32_t len = 0;
int rc = 1;
hMap = OpenFileMappingW(FILE_MAP_ALL_ACCESS, FALSE, SHM_NAME);
if (hMap == NULL) {
printf("OpenFileMapping failed: %lu / is the writer running?\n", GetLastError());
goto cleanup;
}
blk = (DemoBlock *)MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(DemoBlock));
if (blk == NULL) {
printf("MapViewOfFile failed: %lu\n", GetLastError());
goto cleanup;
}
hReq = CreateEventW(NULL, FALSE, FALSE, EVT_REQ);
hRep = CreateEventW(NULL, FALSE, FALSE, EVT_REP);
if (hReq == NULL || hRep == NULL) {
printf("CreateEvent failed: %lu\n", GetLastError());
goto cleanup;
}
/* 1. Wait for the notification first. The writer only raises it
* after it has finished initializing */
if (WaitForSingleObject(hReq, 5000) != WAIT_OBJECT_0) {
printf("no request arrived\n");
goto cleanup;
}
/* 2. Check the ABI and the state before touching anything */
if (blk->magic != SHM_MAGIC || blk->abi_version != SHM_ABI ||
blk->block_size != (uint32_t)sizeof(DemoBlock)) {
printf("ABI mismatch: magic=%08X abi=%u size=%u\n",
blk->magic, blk->abi_version, blk->block_size);
goto cleanup;
}
if (blk->state != STATE_READY) {
printf("initialization is not finished yet: state=%u\n", blk->state);
goto cleanup;
}
/* 3. Range-check the length, then read */
len = blk->request_len;
if (len > sizeof(blk->request)) {
printf("request_len is out of range: %u\n", len);
goto cleanup;
}
printf("request: %.*s\n", (int)len, blk->request);
/* 4. payload -> barrier -> length -> notification, the same order as the writer */
strcpy_s(blk->reply, sizeof(blk->reply), "pong from reader");
MemoryBarrier();
blk->reply_len = (uint32_t)strlen(blk->reply);
if (!SetEvent(hRep)) {
printf("SetEvent failed: %lu\n", GetLastError());
goto cleanup;
}
rc = 0;
cleanup:
if (hRep != NULL) CloseHandle(hRep);
if (hReq != NULL) CloseHandle(hReq);
if (blk != NULL) UnmapViewOfFile(blk);
if (hMap != NULL) CloseHandle(hMap);
return rc;
}
MemoryBarrier is there to prevent the reordering that would let the length or a flag become visible before the payload has been fully written.5
If you want layout drift caught at build time, add /std:c11 to MSVC and pin sizeof(DemoBlock) with static_assert from <assert.h>.
Handling the same block from C# looks like this. The key point is that the offsets are spelled out as constants, written so that not a single byte drifts from the C struct. Named MemoryMappedFile and EventWaitHandle are Windows-only.1
// .NET 8 / Windows. Sender: dotnet run -- write, receiver: dotnet run -- read
using System.IO.MemoryMappedFiles;
using System.Text;
const string MapName = "Local\\KsShmDemo.v1.Block";
const string ReqName = "Local\\KsShmDemo.v1.Request";
const string RepName = "Local\\KsShmDemo.v1.Reply";
const uint Magic = 0x314D4853; // 'S','H','M','1'
const uint Abi = 1;
const int BlockSize = 536;
const int MaxBody = 256;
// The same layout as the C DemoBlock, pinned down with offset constants
const int OffMagic = 0, OffAbi = 4, OffBlockSize = 8, OffState = 12;
const int OffRequestLen = 16, OffReplyLen = 20, OffRequest = 24, OffReply = 280;
bool isWriter = args.Length > 0 && args[0] == "write";
// The sender uses CreateNew. With CreateOrOpen, a second sender would simply
// open the block that is already in use and wreck the peer's exchange in the
// initialization below. CreateNew throws IOException when the name already
// exists, so you notice right there
// (same intent as the ERROR_ALREADY_EXISTS check on the C side)
using var mmf = isWriter
? MemoryMappedFile.CreateNew(MapName, BlockSize)
: MemoryMappedFile.OpenExisting(MapName);
using var view = mmf.CreateViewAccessor(0, BlockSize);
using var reqEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ReqName);
using var repEvent = new EventWaitHandle(false, EventResetMode.AutoReset, RepName);
if (isWriter)
{
view.Write(OffMagic, Magic);
view.Write(OffAbi, Abi);
view.Write(OffBlockSize, (uint)BlockSize);
Thread.MemoryBarrier();
view.Write(OffState, 1u); // READY
byte[] request = Encoding.UTF8.GetBytes("ping from C#");
view.WriteArray(OffRequest, request, 0, request.Length);
Thread.MemoryBarrier();
view.Write(OffRequestLen, (uint)request.Length);
reqEvent.Set();
if (!repEvent.WaitOne(TimeSpan.FromSeconds(5)))
{
Console.WriteLine("no response from the reader");
return 1;
}
return PrintBody(OffReplyLen, OffReply, "reply");
}
if (!reqEvent.WaitOne(TimeSpan.FromSeconds(5)))
{
Console.WriteLine("no request arrived");
return 1;
}
if (view.ReadUInt32(OffMagic) != Magic || view.ReadUInt32(OffAbi) != Abi
|| view.ReadUInt32(OffBlockSize) != BlockSize || view.ReadUInt32(OffState) != 1u)
{
Console.WriteLine("ABI mismatch, or initialization is not finished yet");
return 1;
}
if (PrintBody(OffRequestLen, OffRequest, "request") != 0)
{
return 1;
}
byte[] reply = Encoding.UTF8.GetBytes("pong from C#");
view.WriteArray(OffReply, reply, 0, reply.Length);
Thread.MemoryBarrier();
view.Write(OffReplyLen, (uint)reply.Length);
repEvent.Set();
return 0;
int PrintBody(int lenOffset, int bodyOffset, string label)
{
uint length = view.ReadUInt32(lenOffset);
if (length > MaxBody)
{
Console.WriteLine($"{label} length is out of range: {length}");
return 1;
}
byte[] body = new byte[length];
view.ReadArray(bodyOffset, body, 0, body.Length);
Console.WriteLine($"{label}: {Encoding.UTF8.GetString(body)}");
return 0;
}
The C version and the C# version use the same names and the same layout, so you can make either one the sender and the other the receiver and the round trip still works. That is what pinning an ABI means.
This sample deliberately does exactly one round trip. For continuous transfer, move on to the ring buffer in 6.4; to survive an abnormal writer exit, move on to the generation and heartbeat in 5.7.
7. What to Check on Windows vs. POSIX
| Aspect | Windows | POSIX |
|---|---|---|
| Create / open | CreateFileMapping / OpenFileMapping / MapViewOfFile6 |
shm_open / ftruncate / mmap3 |
| Sharing without a disk file | Pagefile-backed mapping created with INVALID_HANDLE_VALUE68 |
POSIX shared memory object + mmap3 |
| Initial values | Pagefile-backed pages are zero-initialized8 | A new object has length 0. Newly allocated bytes are zero-initialized3 |
| Synchronization | mutex / semaphore / event / interlocked and the like25 | Process-shared mutex / condvar / semaphore2018 |
| Must not be used cross-process | CRITICAL_SECTION, WaitOnAddress2110 |
A mutex / condvar left as PTHREAD_PROCESS_PRIVATE2019 |
| Owner death | WAIT_ABANDONED12 |
Robust mutex + EOWNERDEAD / pthread_mutex_consistent()1314 |
| Deleting the name | Disappears when the last handle / view is released28 | shm_unlink removes the name. The object itself survives as long as references remain2223 |
| Namespace / permissions | Global\ / Local\, ACLs, SeCreateGlobalPrivilege1524 |
mode, umask, namespace, O_CREAT|O_EXCL3 |
C#’s MemoryMappedFile is essentially a wrapper over the Windows file mapping too.
So:
- open by the same name
- use a separate mutex / event
- read the view with an explicit layout
- never place object references directly
— these basics remain exactly the same.1
flowchart TB
accTitle: MemoryMappedFile follows the same basics
accDescr: Diagram showing that C# MemoryMappedFile is essentially a wrapper over the Windows file mapping, so the basics of opening by the same name, using a separate mutex or event, reading with an explicit layout, and not placing object references still hold.
cs["C# MemoryMappedFile"] --> fm["A wrapper over file mapping"]
fm --> q1["Open by the same name"]
fm --> q2["Synchronize with a separate mutex / event"]
fm --> q3["Read with an explicit layout"]
q3 -.-> q4["Do not place object references directly"]
Figure 25: With C#’s MemoryMappedFile, the same basics as file mapping carry over unchanged.
8. The Checklist to Run First
- Do you really need shared memory? Is it large data on the same host?
- Have you separated the control plane from the data plane?
- Can the concurrency model be reduced to SPSC / 1 writer, many readers?
- Does the leading header have magic / version / size / state / generation / heartbeat?
- Are you placing any pointer /
HANDLE/ fd / STL object /std::mutex? - Is there a commit protocol so readers never see intermediate states?
- Is exactly one initializer designated?
- Is there a recovery procedure for abnormal termination?
- Are names and permissions spelled out?
- Is
Global\really necessary? - Are you assuming resize in place?
- Have you tried writer kill / reader stall / version mismatch / insufficient permissions?
9. Summary
Used well, shared memory is genuinely powerful. Especially for
- images
- audio
- sensor streams
- large batches
- high-frequency snapshots
and other large data within a single machine, it really pays off.
But the essence of shared memory is less speed than a transfer of responsibility. In exchange for fewer copies and less kernel-mediated messaging, you take on
- synchronization
- visibility
- initialization
- ABI
- recovery
- permissions
- observability
yourself.
flowchart TB
accTitle: The essence of shared memory is a transfer of responsibility
accDescr: Diagram showing the transfer of responsibility in which reducing copies and kernel-mediated messaging means the application takes on synchronization, visibility, initialization, ABI, recovery, permissions, and observability.
e1["Fewer copies and less messaging"] --> e2["What you take on instead"]
e2 --> e3["Synchronization, visibility, initialization"]
e2 --> e4["ABI, recovery, permissions"]
e2 --> e5["Observability"]
Figure 26: The essence of shared memory is less speed than moving the responsibility for consistency onto you.
So for your first implementation, the safe shape is this.
- An SPSC ring buffer or a double buffer
- A fixed leading header
- Offset references
- Notification over a separate channel
- With version / generation / heartbeat
- With failure-path tests
Start from this shape and shared memory becomes a fairly well-behaved tool. Treat it from day one as fast common memory where anything goes, and over time it stops being an application and turns into archaeology.
10. References
- Windows: file mapping and named shared memory basics682
- Windows: namespace / security / synchronization1524512
- POSIX:
shm_open,shm_unlink,mmap, process-shared / robust synchronization322162013 - .NET:
MemoryMappedFileoverview1
-
Microsoft Learn, “Memory-Mapped Files” / Microsoft Learn, “MemoryMappedFile Class” ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, “/volatile (volatile Keyword Interpretation)” / Microsoft Learn, “volatile (C++)” ↩ ↩2
-
Microsoft Learn, “Interlocked Variable Access” / Microsoft Learn, “MemoryBarrier function” ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, “Creating Named Shared Memory” ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, “Scope of Allocated Memory” ↩ ↩2
-
Microsoft Learn, “CreateFileMappingA function” ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10
-
man7.org, “POSIX Shared Memory” training slides ↩
-
Microsoft Learn, “WaitOnAddress function” ↩ ↩2
-
Microsoft Learn, “MapViewOfFileEx function” / Microsoft Learn, “MapViewOfFile function” ↩
-
Microsoft Learn, “Mutex Objects” ↩ ↩2 ↩3
-
man7.org, “pthread_mutex_lock(3p)” / man7.org, “pthread_mutexattr_setrobust(3)” ↩ ↩2 ↩3
-
man7.org, “pthread_mutex_consistent(3)” / man7.org, “pthread_mutex_consistent(3p)” ↩ ↩2
-
Microsoft Learn, “Kernel object namespaces” ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, “Using Mutex Objects” ↩
-
man7.org, “sem_init(3)” / man7.org, “sem_init(3p)” ↩ ↩2
-
Microsoft Learn, “Critical Section Objects” ↩
-
man7.org, “shm_unlink(3p)” ↩ ↩2
-
man7.org, “shm_open(3)” (shm_unlink semantics) ↩
-
Microsoft Learn, “File Mapping Security and Access Rights” ↩ ↩2
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Named Pipes in Practice — Windows' Standard IPC from Design to Security
A practical guide to named pipes, Windows' standard inter-process communication. This article organises, from primary sources, the choice...
A Checklist for Safely Handling Child Processes in Windows Apps
Handling child processes safely in a Windows app depends less on the launch API than on who owns the process tree and how shutdown is des...
Calling a C# Native AOT DLL from C/C++
Publish a C# class library as a native DLL with Native AOT and call its UnmanagedCallersOnly entry points from C/C++ - where the setup fi...
The Win32 Thread Pool API — Concurrency Without Creating Threads, via CreateThreadpoolWork
Are you spawning CreateThread calls all over your native code? This article explains the Win32 thread pool API redesigned in Vista — the ...
DllMain and the Loader Lock — The Real Reason You're Told to "Do Nothing in DLL Initialization"
Why you must not call LoadLibrary or synchronize with other threads from DllMain. Drawing on primary sources, this article explains how t...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
Large-volume data exchange and process-isolation designs using shared memory, file mappings, and MemoryMappedFile are directly connected to Windows application development.
Technical Consulting & Design Review
Design work that cuts down how much can go wrong — synchronization strategy, ABI design, recovery strategy, and separating the control plane from the data plane — is a good fit for technical consulting and design review.
Frequently Asked Questions
Common questions about the topic of this article.
- Can another process read a value written to shared memory correctly, right away?
- Being visible and being safe to read are separate problems. Shared memory is a mechanism that shows the same byte sequence to multiple processes; it is not synchronization itself. Even when the writer intends to write length, then payload, then the ready flag in that order, a reader that reads with no synchronization at all can end up seeing the new length combined with the old payload. On both Windows and POSIX, access to shared memory assumes you combine it with a synchronization mechanism such as a mutex, a semaphore, or an event.
- Can I place pointers, std::string, or HANDLEs in shared memory?
- Better not to. Virtual addresses and process-local resources only have meaning inside that process's context, and mapping the same mapping in another process does not guarantee that the virtual addresses match. The same goes for std::vector, std::mutex, and CRITICAL_SECTION. If you need a reference, hold it as an offset from the base address, and keep the data you place in shared memory to fixed-width integers plus an explicit layout plus a versioned header.
- Does volatile remove the need to synchronize shared memory?
- No. volatile is not a magic spell that rescues a shared-memory design, and at minimum atomicity and mutual exclusion are separate problems. A design that busy-loops on a volatile bool wastes CPU, leaves the ordering guarantee between payload and ready flag vague, and easily picks up intermediate states. Windows's WaitOnAddress is also meant for threads within a single process, so it is safer not to treat it as a cross-process waiting mechanism. Move notification out to a primitive you can wait on, such as an event or a semaphore.
- What should I decide first when designing shared memory?
- Four things. Separating the control plane from the data plane, so control (start, stop, notification) goes over messaging while the data payload lives in shared memory; narrowing the concurrency model, where starting with an SPSC ring buffer or a double buffer is the hardest to get wrong; ownership and lifetime, meaning who creates it, who initializes it, who deletes it, and who recovers it; and the ABI design, including layout and versioning. Just putting magic, version, size, state, generation, and heartbeat in the leading header dramatically changes how easy incidents are to investigate.