Shared Memory Between Processes
See how two processes communicate through named shared memory, with explicit layouts, permissions, synchronization, and cleanup.
Cheatsheet for Lesson 9.8
A one-page summary of “Shared Memory Between Processes” you can keep beside your notes. Plain Markdown.
Lesson 9.8 — Shared Memory Between Processes: cheatsheet
- Remember: The handle names the shared mapping; the view is the address where this process can access its bytes.
- Also useful: Sharing storage is like sharing a notebook: agree on the page layout and on when a complete entry may be read.
Key ideas
- agree on an explicit length format at both ends, and validate the header before trusting it.
Review
- Q: Why does shared memory still need a synchronization or publication rule? A: A reader could see a new length beside the old payload bytes
- Q: Two processes both use the address 0x00400000. What does that tell you about the storage they refer to? A: Nothing, since each process has its own virtual address space
Processes normally have separate address spaces
Section titled “Processes normally have separate address spaces”The proxy copied messages between sockets. A supported game add-on and a separate visualizer could instead agree on a local shared byte region. The message still needs a layout and a lifetime; only the transport changes.
Two running programs can both use the address 0x00400000, yet those numbers can refer to completely different physical storage. Each process has its own virtual address space.
That separation is a safety feature. A map helper should not need to patch a game process merely to receive a supported event from a mod. Windows provides interprocess communication, or IPC, for programs that intentionally cooperate.
Named shared memory is one IPC option:
The two views do not need the same virtual address. Windows connects both views to the same file-mapping object.
The programs share bytes through one kernel-managed object, not pointers to each other’s private address spaces:
The views may have different addresses, but they expose the same mapped bytes. The small protocol is what turns those bytes back into a trustworthy value.
A mapping object is not the view
Section titled “A mapping object is not the view”These terms describe different resources:
- A file-mapping object tells Windows what storage is shared, how large it is, and which protections are allowed.
- A view maps some or all of that object into one process’s virtual address space.
- A handle names the mapping object.
- A pointer from
MapViewOfFilenames the beginning of one process’s view.
Closing the handle and unmapping the view are separate cleanup operations. The lab uses OwnedHandle for the first and a small MappedView type with Drop for the second.
Paging-file-backed means no ordinary file is required
Section titled “Paging-file-backed means no ordinary file is required”CreateFileMappingW normally accepts a file handle. Passing INVALID_HANDLE_VALUE asks Windows for storage backed by the system paging file instead. We must provide a nonzero maximum size—in this lab, 4096 bytes.
The name starts with Local\, so it lives in the current logon session’s local namespace. We do not use Global\, which can require extra privilege and makes the scope wider than this lab needs.
Bytes need a tiny protocol
Section titled “Bytes need a tiny protocol”Memory has no built-in String type. Both programs must agree on a layout:
HEADER_BYTES is 4; everything from 4 + len onward is left as zero padding.The four-byte length makes embedded zero bytes unambiguous and lets the reader reject impossible sizes before creating a string slice.
This is the beginning of serialization: turning structured information into an agreed byte representation.
The original reader treats the first zero byte as the end of UTF-8 text. A legitimate embedded zero therefore shortens the message. The improved format uses the four-byte length header shown above; the writer must use that same format too.
fn read_text(bytes: &[u8]) -> anyhow::Result<&str> { let end = bytes.iter().position(|byte| *byte == 0) .unwrap_or(bytes.len()); // A zero inside the text looks like its end. Ok(std::str::from_utf8(&bytes[..end])?)}Validate that a complete header exists before slicing, then check its declared length against the remaining mapping. The length describes payload bytes, not the mapping’s zero padding.
Improved code
fn read_text(bytes: &[u8]) -> anyhow::Result<&str> { anyhow::ensure!(bytes.len() >= HEADER_BYTES, "shared-memory header is truncated"); let length = u32::from_le_bytes(bytes[..HEADER_BYTES].try_into()?) as usize; anyhow::ensure!(length <= bytes.len() - HEADER_BYTES, "shared-memory length is larger than its mapping"); let payload = &bytes[HEADER_BYTES..HEADER_BYTES + length]; Ok(std::str::from_utf8(payload)?)}Show the diff
fn read_text(bytes: &[u8]) -> anyhow::Result<&str> { let end = bytes.iter().position(|byte| *byte == 0) .unwrap_or(bytes.len()); // A zero inside the text looks like its end. Ok(std::str::from_utf8(&bytes[..end])?) anyhow::ensure!(bytes.len() >= HEADER_BYTES, "shared-memory header is truncated"); let length = u32::from_le_bytes(bytes[..HEADER_BYTES].try_into()?) as usize; anyhow::ensure!(length <= bytes.len() - HEADER_BYTES, "shared-memory length is larger than its mapping"); let payload = &bytes[HEADER_BYTES..HEADER_BYTES + length]; Ok(std::str::from_utf8(payload)?)}Takeaway: agree on an explicit length format at both ends, and validate the header before trusting it.
One hazard is worth naming here, even though this one-shot lab sidesteps it by writing only once. Shared memory gives two processes access to the same bytes. It gives them no agreement about when. Nothing prevents a reader from arriving in the middle of a write:
The reader’s length check passes, the UTF-8 decode very likely succeeds, and the text returned is simply wrong — or is half of the previous message. Writing the payload before the length narrows that window without closing it, since the CPU and the compiler may reorder the two stores anyway. Closing it takes a sequence number the reader checks before and after copying, or a real synchronization object.
The figure shows the smallest case: old payload READY (five bytes), new
payload RUNNING (seven bytes), and a reader that copies in the gap between
publishing the length and finishing the text. Both checks in read_text
succeed, yet the message is not one the writer ever meant to publish. Seeing
the same bytes does not give either program an agreed moment to read them.
Read every step of the lesson’s example
Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.
coordination = mutex.
Step 2, line 1. Begin with an intact old record.
coordination = mutex; copied_health = not copied; copied_gold = not copied.
Memory: health = 100; gold = 80.
Step 3, line 2. The reader holds the mutex while copying both fields; the writer waits.
coordination = mutex; copied_health = 100; copied_gold = not copied.
Memory: health = 100; gold = 80.
Step 4, line 4. Copy gold before releasing the same lock.
coordination = mutex; copied_health = 100; copied_gold = 80.
Memory: health = 100; gold = 80.
Step 5, line 5. The reader releases the mutex; the writer can now proceed.
coordination = mutex; copied_health = 100; copied_gold = 80.
Memory: health = 100; gold = 80.
Step 6, line 3. The waiting writer publishes its complete newer record.
coordination = mutex; copied_health = 100; copied_gold = 80.
Memory: health = 80; gold = 60.
Step 7, line 6. Result: copied health 100 and gold 80 belong to the same protected record.
coordination = mutex; copied_health = 100; copied_gold = 80; consistent = true.
Memory: health = 80; gold = 60.
Build the writer and reader
Section titled “Build the writer and reader”Complete lab source: shared_memory_lab.rs
shared_memory_lab.rs
Show the codeHide the code
#[cfg(windows)]
mod app {
use std::{io, slice};
use anyhow::{Context, ensure};
use gha_windows_labs::OwnedHandle;
use windows::{
Win32::{
Foundation::INVALID_HANDLE_VALUE,
System::Memory::{
CreateFileMappingW, FILE_MAP, FILE_MAP_READ, FILE_MAP_WRITE,
MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, OpenFileMappingW, PAGE_READWRITE,
UnmapViewOfFile,
},
},
core::w,
};
const MAPPING_BYTES: usize = 4_096;
const HEADER_BYTES: usize = 4;
struct MappedView {
address: MEMORY_MAPPED_VIEW_ADDRESS,
length: usize,
}
impl MappedView {
fn new(handle: &OwnedHandle, access: FILE_MAP) -> windows::core::Result<Self> {
// SAFETY: handle refers to a file-mapping object. A null address is
// checked before any slice is created, and the view is unmapped in Drop.
let address = unsafe { MapViewOfFile(handle.raw(), access, 0, 0, MAPPING_BYTES) };
if address.Value.is_null() {
return Err(windows::core::Error::from_thread());
}
Ok(Self {
address,
length: MAPPING_BYTES,
})
}
fn bytes(&self) -> &[u8] {
// SAFETY: MapViewOfFile returned a non-null view of length MAPPING_BYTES,
// and self keeps the view mapped for the slice's entire lifetime.
unsafe { slice::from_raw_parts(self.address.Value.cast(), self.length) }
}
fn bytes_mut(&mut self) -> &mut [u8] {
// SAFETY: the writer requested FILE_MAP_WRITE, owns this mutable borrow,
// and the mapped range remains valid until Drop.
unsafe { slice::from_raw_parts_mut(self.address.Value.cast(), self.length) }
}
}
impl Drop for MappedView {
fn drop(&mut self) {
// SAFETY: this object owns one live mapped view and unmaps it once.
let _ = unsafe { UnmapViewOfFile(self.address) };
}
}
fn writer(message: &str) -> anyhow::Result<()> {
ensure!(
message.len() <= MAPPING_BYTES - HEADER_BYTES,
"message is longer than {} UTF-8 bytes",
MAPPING_BYTES - HEADER_BYTES
);
// SAFETY: INVALID_HANDLE_VALUE requests paging-file-backed memory. The
// mapping name is static and the size is nonzero.
let mapping = unsafe {
CreateFileMappingW(
INVALID_HANDLE_VALUE,
None,
PAGE_READWRITE,
0,
MAPPING_BYTES as u32,
w!("Local\\GhaSharedMemoryV1"),
)
}?;
let mapping = OwnedHandle::from_raw(mapping)?;
let mut view = MappedView::new(&mapping, FILE_MAP_WRITE)?;
let bytes = view.bytes_mut();
bytes.fill(0);
bytes[..HEADER_BYTES].copy_from_slice(&(message.len() as u32).to_le_bytes());
bytes[HEADER_BYTES..HEADER_BYTES + message.len()].copy_from_slice(message.as_bytes());
println!(
"Published {} UTF-8 bytes to a Local\\ mapping.",
message.len()
);
println!("Run `shared_memory_lab read` in another window.");
println!("Press Enter here after the reader finishes.");
let mut line = String::new();
io::stdin().read_line(&mut line)?;
Ok(())
}
fn reader() -> anyhow::Result<()> {
// SAFETY: the name is static, inheritance is disabled, and the reader
// requests read-only access.
let mapping = unsafe {
OpenFileMappingW(
FILE_MAP_READ.0,
false,
w!("Local\\GhaSharedMemoryV1"),
)
}
.context("open the writer first so the named mapping exists")?;
let mapping = OwnedHandle::from_raw(mapping)?;
let view = MappedView::new(&mapping, FILE_MAP_READ)?;
let bytes = view.bytes();
let length = u32::from_le_bytes(bytes[..HEADER_BYTES].try_into()?) as usize;
ensure!(
length <= MAPPING_BYTES - HEADER_BYTES,
"shared-memory length header is out of range"
);
let message = std::str::from_utf8(&bytes[HEADER_BYTES..HEADER_BYTES + length])
.context("shared message was not valid UTF-8")?;
println!("Read {length} bytes: {message}");
println!("The reader requested FILE_MAP_READ and changed nothing.");
Ok(())
}
pub fn run() -> anyhow::Result<()> {
let mut arguments = std::env::args().skip(1);
match arguments.next().as_deref() {
Some("write") => {
let message = arguments.collect::<Vec<_>>().join(" ");
let message = if message.is_empty() {
"Wesnoth turn 12: local lab ready"
} else {
&message
};
writer(message)
}
Some("read") => reader(),
_ => anyhow::bail!("usage: shared_memory_lab write <message> | shared_memory_lab read"),
}
}
}
#[cfg(windows)]
fn main() -> anyhow::Result<()> {
app::run()
}
#[cfg(not(windows))]
fn main() {
eprintln!("This named shared-memory lab must run on Windows.");
}#[cfg(windows)]mod app { use std::{io, slice};
use anyhow::{Context, ensure}; use gha_windows_labs::OwnedHandle; use windows::{ Win32::{ Foundation::INVALID_HANDLE_VALUE, System::Memory::{ CreateFileMappingW, FILE_MAP, FILE_MAP_READ, FILE_MAP_WRITE, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, OpenFileMappingW, PAGE_READWRITE, UnmapViewOfFile, }, }, core::w, };
const MAPPING_BYTES: usize = 4_096; const HEADER_BYTES: usize = 4;
struct MappedView { address: MEMORY_MAPPED_VIEW_ADDRESS, length: usize, }
impl MappedView { fn new(handle: &OwnedHandle, access: FILE_MAP) -> windows::core::Result<Self> { // 🛡️ SAFETY: handle refers to a file-mapping object. A null address is // checked before any slice is created, and the view is unmapped in Drop. let address = unsafe { MapViewOfFile(handle.raw(), access, 0, 0, MAPPING_BYTES) }; if address.Value.is_null() { return Err(windows::core::Error::from_thread()); } Ok(Self { address, length: MAPPING_BYTES }) }
fn bytes(&self) -> &[u8] { // ✅ SAFETY: MapViewOfFile returned a non-null view of length MAPPING_BYTES, // and self keeps the view mapped for the slice's entire lifetime. unsafe { slice::from_raw_parts(self.address.Value.cast(), self.length) } }
fn bytes_mut(&mut self) -> &mut [u8] { // 🔒 SAFETY: the writer requested FILE_MAP_WRITE, owns this mutable borrow, // and the mapped range remains valid until Drop. unsafe { slice::from_raw_parts_mut(self.address.Value.cast(), self.length) } } }
impl Drop for MappedView { fn drop(&mut self) { // 🧹 SAFETY: this object owns one live mapped view and unmaps it once. let _ = unsafe { UnmapViewOfFile(self.address) }; } }
fn writer(message: &str) -> anyhow::Result<()> { ensure!( message.len() <= MAPPING_BYTES - HEADER_BYTES, "message is longer than {} UTF-8 bytes", MAPPING_BYTES - HEADER_BYTES ); // 🛡️ SAFETY: INVALID_HANDLE_VALUE requests paging-file-backed memory. The // mapping name is static and the size is nonzero. let mapping = unsafe { CreateFileMappingW( INVALID_HANDLE_VALUE, None, PAGE_READWRITE, 0, MAPPING_BYTES as u32, w!("Local\\GhaSharedMemoryV1"), ) }?; let mapping = OwnedHandle::from_raw(mapping)?; let mut view = MappedView::new(&mapping, FILE_MAP_WRITE)?; let bytes = view.bytes_mut(); // 📦 Zeroing removes bytes left by an older, longer message. The first // four bytes are the only trusted boundary for the current payload. bytes.fill(0); bytes[..HEADER_BYTES] .copy_from_slice(&(message.len() as u32).to_le_bytes()); bytes[HEADER_BYTES..HEADER_BYTES + message.len()] .copy_from_slice(message.as_bytes());
println!("Published {} UTF-8 bytes to a Local\\ mapping.", message.len()); println!("Run `shared_memory_lab read` in another window."); println!("Press Enter here after the reader finishes."); // 🧹 Keep both the mapping handle and its view alive until the reader has // opened the named object; dropping the last handle destroys its name. let mut line = String::new(); io::stdin().read_line(&mut line)?; Ok(()) }
fn reader() -> anyhow::Result<()> { // 🔍 SAFETY: the name is static, inheritance is disabled, and the reader // requests read-only access. let mapping = unsafe { OpenFileMappingW( FILE_MAP_READ.0, false, w!("Local\\GhaSharedMemoryV1"), ) } .context("open the writer first so the named mapping exists")?; let mapping = OwnedHandle::from_raw(mapping)?; let view = MappedView::new(&mapping, FILE_MAP_READ)?; let bytes = view.bytes(); // 📏 The writer stores a little-endian byte length, not a character count. let length = u32::from_le_bytes(bytes[..HEADER_BYTES].try_into()?) as usize; ensure!( length <= MAPPING_BYTES - HEADER_BYTES, "shared-memory length header is out of range" ); // 🛡️ Validate the length before slicing, then validate UTF-8 before // treating arbitrary shared bytes as text. let message = std::str::from_utf8( &bytes[HEADER_BYTES..HEADER_BYTES + length] ).context("shared message was not valid UTF-8")?; println!("Read {length} bytes: {message}"); println!("The reader requested FILE_MAP_READ and changed nothing."); Ok(()) }
pub fn run() -> anyhow::Result<()> { let mut arguments = std::env::args().skip(1); match arguments.next().as_deref() { Some("write") => { let message = arguments.collect::<Vec<_>>().join(" "); let message = if message.is_empty() { "Wesnoth turn 12: local lab ready" } else { &message }; writer(message) } Some("read") => reader(), _ => anyhow::bail!( "usage: shared_memory_lab write <message> | shared_memory_lab read" ), } }}
#[cfg(windows)]fn main() -> anyhow::Result<()> { app::run()}
#[cfg(not(windows))]fn main() { eprintln!("This named shared-memory lab must run on Windows.");}Why is this unsafe code contained?
Section titled “Why is this unsafe code contained?”MapViewOfFile returns a raw pointer. The compiler cannot prove how many bytes it covers, whether the view is writable, or when Windows unmaps it. MappedView establishes those facts once, then ordinary methods return bounded slices.
The writer receives &mut [u8]; the reader receives &[u8]. That is semantic highlighting at the type-system level: mutability shows which side is allowed to change data.
Run two copies
Section titled “Run two copies”In PowerShell window one:
cd windows-labscargo run --release --target i686-pc-windows-msvc ` --bin shared_memory_lab -- write "AssaultCube local observer ready"Leave it waiting. In window two:
cd windows-labscargo run --release --target i686-pc-windows-msvc ` --bin shared_memory_lab -- readReturn to window one and press Enter. The mapping object disappears after the last handle closes, so another reader should then receive a clear “open the writer first” error.
Where shared memory fits
Section titled “Where shared memory fits”A Wesnoth add-on or game build could publish a tiny turn/event record. A separate visualizer could read it. The producer defines the shared-memory contract, so both sides agree on its layout and lifetime.
The one-shot lab avoids concurrent writes. A concurrent design could hold the same named mutex while the writer updates a record and while the reader copies it; the reader would then avoid mixing old and new fields. Both sides also need a shared format version and an agreed sequence-number meaning. A notification event alone announces readiness; it does not by itself protect the copy. Never put passwords or secret tokens in a mapping protected only by a convenient name.
The buildable source is shared_memory_lab.rs.
References: sharing files and memory, creating named shared memory, CreateFileMappingW, and MapViewOfFile.
Lesson 9.9 uses a named pipe when the producer and consumer would rather exchange one request and one reply than coordinate reads of a shared region.
Coordinate shared state
Why does shared memory still need a synchronization or publication rule?
Shared storage does not make a multi-field update atomic. A mutex, event, sequence counter, or carefully designed atomic protocol defines when data is complete.
Finished Lesson 9.8?
Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.
Plain Markdown, saved in this browser.
This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.