Skip to content
Lesson 9.8Network Messages, Authority, and IPC

Shared Memory Between Processes

See how two processes communicate through named shared memory, with explicit layouts, permissions, synchronization, and cleanup.

Lesson 9.8
Read or listenPrint chapter or book
About 34 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 9.8Key ideas, reference and questions

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
Download cheatsheet (.md)

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:

writer's view
(its own address)
one file-mapping object
reader's view
(its own address)
the same shared bytes

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:

Writer value
Encode length and UTF-8
Writer view
Shared mapping object
Reader view
Validate length and UTF-8
Reader value

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.

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 MapViewOfFile names 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.

Memory has no built-in String type. Both programs must agree on a layout:

0..4little-endian `u32` text length
4..4+lenUTF-8 text
4+len..zeroed
The shared-memory wire format. 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.

Before: stop text at the first zero byte
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 new length arrives before the new textThe mapping holds READY (5 bytes). The writer is replacing it with RUNNING (7 bytes) and stores the new length first.
  1. 1 READY is published

    Shared mapping

    Bytes 0 to 3: length
    05= 5
    00
    00
    00
    Bytes 4 to 10: text
    R52
    E45
    A41
    D44
    Y59
    00zero
    00zero

    A little-endian u32 length of 5, then the five text bytes. The rest of the mapping is zero.

  2. 2 Length 7 stored, text not yet

    Shared mapping

    Bytes 0 to 3: length
    07= 7
    00
    00
    00
    Bytes 4 to 10: text
    R52
    E45
    A41
    D44
    Y59
    00zero
    00zero

    What the reader returns

    read_text
    READY + two zero bytesneither the old nor the new message

    If the reader copies now, 7 fits in the mapping and zero bytes are valid UTF-8, so both checks in read_text pass.

  3. 3 Both sides take one named mutex

    Shared mapping

    Bytes 0 to 3: length
    07= 7
    00
    00
    00
    Bytes 4 to 10: text
    R52
    U55
    N4E
    N4E
    I49
    N4E
    G47

    Order

    Writer
    lock
    length and text
    unlock
    Reader
    lock: waits
    copy 11 bytes
    unlock

    The reader waits while the writer holds the mutex, so it copies all of READY or all of RUNNING, never a mix.

The mapping holds READY (5 bytes). The writer is replacing it with RUNNING (7 bytes) and stores the new length first.

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.

A wake-up does not protect a shared record. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

record = {health: 100, gold: 80}
reader attempts to copy health
writer updates health and gold together
reader attempts to copy gold
release the shared mutex if used
publish only the copied observation

Result: copied health 100 and gold 80 belong to the same protected record.

Final values: coordination = mutex; copied_health = 100; copied_gold = 80; consistent = true.

Read every step of the lesson’s example
  1. Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.

    coordination = mutex.

  2. 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.

  3. 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.

  4. Step 4, line 4. Copy gold before releasing the same lock.

    coordination = mutex; copied_health = 100; copied_gold = 80.

    Memory: health = 100; gold = 80.

  5. 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.

  6. 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.

  7. 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.

Complete lab source: shared_memory_lab.rs

shared_memory_lab.rs

windows-labs/src/bin/ · Rust, 149 lines

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.");
}

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.

Windows VM

In PowerShell window one:

Terminal window
cd windows-labs
cargo run --release --target i686-pc-windows-msvc `
--bin shared_memory_lab -- write "AssaultCube local observer ready"

Leave it waiting. In window two:

Terminal window
cd windows-labs
cargo run --release --target i686-pc-windows-msvc `
--bin shared_memory_lab -- read

Return 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.

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.

Knowledge check

Coordinate shared state

Why does shared memory still need a synchronization or publication rule?

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.

Chapter 9: 10 lessons

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.