Shared Memory and Semaphores

On this page

Shared memory allows multiple executors to access the same underlying storage, reducing the need for large data blocks to round-trip through IPC buffers. However, it does not automatically define who can write, when data is readable, the data format, or how to recover after a crash. The truly difficult part of many shared memory protocols is synchronization and lifecycle management.

Choose the Creation Method for Shared Objects First

MethodHow another process gains accessLifecycle characteristics
MAP_SHARED anonymous mappingCreated before fork and inherited by child processesTypically suitable for processes with a common ancestor
POSIX shm_openOpens an object by a convention nameName management is separate from open references and mappings
Ordinary file MAP_SHAREDOpens the same file objectMust also consider file size and persistence semantics
memfd_createInherits fd or passed via SCM_RIGHTSNo public path name required; seals can be configured
SysV shmget/shmatAttached via IPC identifierRequires explicit management of "marked for deletion" and attachment relationships

mmap with MAP_SHARED returns MAP_FAILED on failure, not NULL. Mappings backed by files usually require the object to be extended to a sufficient size first; accessing beyond the actual file range may generate SIGBUS; concurrent truncation by the other party is also a protocol risk that needs handling.

The name for POSIX shm_open, such as /myregion, does not require the caller to directly open a /dev/shm path. Linux often implements these objects on tmpfs, but the API semantics should not be bound to that filesystem path. O_CREAT|O_EXCL can be used to identify the unique creator; the initializer and subsequent openers must coordinate so that neither side repeatedly initializes the synchronization object. shm_open(3)

Same Storage Does Not Equal Same Virtual Address

Two processes may map the same object to different addresses. Directly saving ordinary process pointers in shared data structures usually cannot be dereferenced by the other process; offsets relative to the object start, indices, or other protocol handles should be used instead.

Structure layout also involves integer width, alignment, byte order, versioning, and atomic operation support. If both programs are upgraded independently, binary layout compatibility cannot be assumed just because they use the same header file name.

MAP_SHARED causes writes to affect the shared object, but it does not replace synchronization. Cache coherence does not equal language-level happens-before. Publishing data should use correct inter-process locks, semaphores, or atomic protocols explicitly designed for this purpose; it cannot be cobbled together with volatile and busy-waiting for general synchronization.

Offsets also need validation, not just changing pointers to integers

Assume the shared region protocol allows a total access length of total, with a record providing offset and length. The check should be written as offset <= total and length <= total - offset; in fixed-width languages, directly calculating offset + length and then comparing may cause an overflow first. Below, all fields are constrained to unsigned 64-bit, and the range is verified.

U64_MAX = (1 << 64) - 1
def valid_span(total, offset, length):
    return (all(0 <= x <= U64_MAX for x in (total, offset, length))
            and offset <= total and length <= total - offset)
assert valid_span(4096, 128, 32)
assert valid_span(4096, 4096, 0)
assert not valid_span(4096, 4096, 1)
assert not valid_span(4096, U64_MAX - 3, 8)
assert not valid_span(4096, -1, 4)
base_a, base_b, offset = 0x10000000, 0x70000000, 128
assert base_a + offset != base_b + offset

offset=total and length=0 can represent a valid empty interval, but you cannot dereference that position based on this. After the range passes, you must also verify layout version, alignment, and object type; if another process can change the length or object size while reading the header, the validation result may also become invalid immediately. Validation and reading need to be guaranteed to be of the same version by the protocol, or changes need to be limited by immutable objects.

A shared offset or another process’s pointer?

The colored range belongs to the shared object; each process supplies its own coordinate origin. Moving the receiver base changes its pointer but not the object range. offset=256,length=0 is an empty boundary range, not an accessible byte.

Preparing the visual
A shared offset or another process’s pointer?

One object offset can produce different addresses in two processes. Validate offset≤total and length≤total−offset first; an empty range is not dereferenceable.

Semaphores: Counting Resources and Publishing Events

sem_wait obtains a count token upon success, and waits if the count is zero; sem_post increments the token and gives waiters a chance to continue. Semaphores do not have a fixed owner like a regular mutex; producers and consumers can post and wait from different threads or processes.

In sem_init(&sem, pshared, value), a non-zero pshared indicates cross-process sharing, but the sem_t must already be located in a shared region accessible by all processes. It does not turn a stack-based sem into shared memory, nor can it be re-initialized while there are still users. sem_init(3)

Shared mappings provide data location, synchronization operations define when it can be read Producer Writes shared.value = 42 Consumer sem_wait waits for ready sem_post: Publish a token Read value after successful wait sem_t itself is also located in the shared mapping; pshared=1 does not automatically move regular stack variables to shared memory.

Complete Example: Parent Process Publishes, Child Process Reads

Save as shared_demo.c, compile with cc -std=c11 -Wall -Wextra -pthread shared_demo.c -o shared_demo. The program checks the publish result using the child process exit status, without relying on stdio buffered output.

#define _GNU_SOURCE
#include <errno.h>
#include <semaphore.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>

struct Shared {
    sem_t ready;
    int value;
};

int main(void) {
    struct Shared *s = mmap(NULL, sizeof *s, PROT_READ | PROT_WRITE,
                           MAP_SHARED | MAP_ANONYMOUS, -1, 0);
    if (s == MAP_FAILED) return 1;
    if (sem_init(&s->ready, 1, 0) == -1) {
        munmap(s, sizeof *s);
        return 1;
    }
    pid_t child = fork();
    if (child == -1) {
        sem_destroy(&s->ready);
        munmap(s, sizeof *s);
        return 1;
    }
    if (child == 0) {
        while (sem_wait(&s->ready) == -1) {
            if (errno != EINTR) _exit(2);
        }
        int result = s->value == 42 ? 0 : 3;
        munmap(s, sizeof *s);
        _exit(result);
    }
    s->value = 42;
    if (sem_post(&s->ready) == -1) _exit(4);
    int status;
    while (waitpid(child, &status, 0) == -1) {
        if (errno != EINTR) _exit(5);
    }
    int failed = !WIFEXITED(status) || WEXITSTATUS(status) != 0;
    if (sem_destroy(&s->ready) == -1) failed = 1;
    if (munmap(s, sizeof *s) == -1) failed = 1;
    return failed;
}

Initialization is completed before fork, with an initial count of zero; the parent process writes data first and then posts, and the child process reads after a successful wait. The parent process waits for the child process to finish before destroying the semaphore, avoiding situations where there are still users. Retry on the EINTR path for sem_wait; other errors cannot be treated as having already obtained the token. sem_wait(3)

This is a one-time handover example, not a complete crash recovery protocol. If the producer dies before posting, the consumer may still wait forever; circular buffers also need empty slots, readable slots, sequence numbers, and shutdown states. Production systems should define timeouts, peer liveness detection, and strategies for discarding shared regions.

Reusing a shared slot requires handover in both production and consumption directions Producer wait(empty) Obtain slot write permission Write payload + length Post(full) after writing Consumer wait(full) Acquire this data version After reading, post(empty) permits the next overwrite Initialization: empty=1, full=0; semaphores are all in the shared region This only expresses the normal path for single producer/single consumer; shutdown, timeouts, and peer death still require additional protocols.

One-time ready cannot directly become a circular publishing protocol

If the parent process continuously writes value=1, posts, then writes value=2, posts, while the child process hasn't had time to read, the two tokens do not save two historical values. The child process might read 2 both times. Semaphore counts remember how many times a token can be obtained, not the copied payload.

Single-slot repeated handover can use two semaphores, empty and full, representing writable slots and readable slots respectively. The producer waits on empty, writes data, and posts full; the consumer waits on full, reads data, and posts empty. If the reader passes the shared region pointer to another asynchronous task, it cannot return empty until the task has finished reading, otherwise the next overwrite will race with the old task's read.

Protocol StateWho can access payloadCan it be released or overwritten?
Empty token not yet obtainedNo published content available for consumptionProducer must obtain the token first
Producer holds slotProducer writesConsumer cannot read early
Full publishedConsumer reads after obtaining tokenProducer cannot overwrite early
Consumer finishes and returns emptyNext round producerCan start a new round

A queue with N slots also requires correct index management. Multiple producers obtaining two empty tokens simultaneously does not automatically allocate them to different slots; index advancement still requires mutexes or atomic protocols. Abnormal exits may also lose tokens, which needs to be designed together with recovery strategies later.

Do two tokens preserve two payload versions?

In the broken protocol, publish v1 then v2 and consume twice: both reads return v2. Token counts survive, payload history does not. The safe handoff prevents republishing until consumption returns empty. A real implementation must establish this ordering with synchronization.

Preparing the visual
Do two tokens preserve two payload versions?

Semaphore tokens do not copy payload. Repeated single-slot handoff needs writable and readable ownership to prevent premature overwrite.

Named Semaphores and Deletion

sem_open creates or opens a named semaphore; sem_close releases the reference for this process, and sem_unlink deletes the name. Unlink should not be understood as immediately destroying objects still opened by other processes, similar to shared memory name management.

Name deletion and data accessibility are different levels. Safe cleanup is often the responsibility of the creator, but it must be clear how later discoverers find the object, when attachment is no longer allowed, and whether old version objects still have users. Writing resource deletion responsibilities into the protocol is more reliable than "cleaning up when the program exits."

memfd and Sealing: Limiting Changes to Objects

memfd_create returns an anonymous memory file fd, which can be ftruncate'd, mmap'd, and passed to another process via Unix socket. MFD_CLOEXEC controls exec inheritance; if seals need to be added later, MFD_ALLOW_SEALING should usually be set at creation time.

F_SEAL_SHRINK prevents shrinking, F_SEAL_GROW prevents growing; they do not prohibit rewriting existing bytes. F_SEAL_WRITE has constraints with writable shared mappings; the semantics of F_SEAL_FUTURE_WRITE are different, and existing writable mappings need special consideration. Seals must be selected based on the properties that need to be guaranteed; not all seals can be called "read-only". memfd_create(2)

For example, if a consumer needs to trust that the object size does not change after inspection, it can require the corresponding length seal; to trust that the content is immutable, write restrictions also need to be checked. memfd is a building block, implying that not all IPC in systemd or other programs is based on it.

Use the Same memfd to Check Size and Content Restrictions Separately

Save as seal-demo.c. After creation, write abc, first prohibit shrinking and growing; at this point, rewriting the first existing byte still succeeds; then add F_SEAL_WRITE, and rewriting again is rejected. This example does not establish a writable shared mmap, so it will not trigger the condition of "adding a write seal failing due to existing writable mappings."

#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <sys/mman.h>
#include <unistd.h>
int main(void) {
    int fd = memfd_create("seal-demo", MFD_CLOEXEC | MFD_ALLOW_SEALING);
    if (fd == -1) return 1;
    int failed = 1;
    if (write(fd, "abc", 3) != 3) goto done;
    int size_seals = F_SEAL_SHRINK | F_SEAL_GROW;
    if (fcntl(fd, F_ADD_SEALS, size_seals) == -1) goto done;
    if (pwrite(fd, "X", 1, 0) != 1) goto done;
    errno = 0;
    if (ftruncate(fd, 2) != -1 || errno != EPERM) goto done;
    if (fcntl(fd, F_ADD_SEALS, F_SEAL_WRITE) == -1) goto done;
    errno = 0;
    if (pwrite(fd, "Y", 1, 0) != -1 || errno != EPERM) goto done;
    char data[3];
    if (pread(fd, data, sizeof data, 0) != sizeof data) goto done;
    if (data[0] != 'X' || data[1] != 'b' || data[2] != 'c') goto done;
    int seals = fcntl(fd, F_GET_SEALS);
    if (seals == -1 || (seals & (size_seals | F_SEAL_WRITE)) !=
                      (size_seals | F_SEAL_WRITE)) goto done;
    puts("size fixed; overwrite first allowed, then denied; data=Xbc");
    failed = 0;
done:
    if (close(fd) == -1) failed = 1;
    return failed;
}

Compile and run with cc -Wall -Wextra seal-demo.c -o seal-demo. After the reader obtains the fd, it can check which restrictions the object currently has with F_GET_SEALS; these restrictions apply to the underlying object, not just some fd number of the sender. File sealing

F_SEAL_SEAL controls whether seals can be added later, and does not replace F_SEAL_WRITE. F_SEAL_FUTURE_WRITE allows existing writable shared mappings to continue modifying, so it cannot be used alone to prove that content is already immutable. When publishing a read-only snapshot, you should complete writing, handle existing mappings, and then establish and check the required content and length restrictions.

Mutex and Crash Recovery Cannot Rely Solely on Semaphore Counts

If a process dies after taking a semaphore token, POSIX semaphores do not automatically return it. If critical data structures require identifying that the lock holder has died, consider mutexes located in the shared region, simultaneously set with PTHREAD_PROCESS_SHARED and robust attributes, and design an EOWNERDEAD data repair path. See futex for mechanisms.

Repair may require operation logs, version states, or discarding the entire shared region. The lock becoming re-acquirable is only the first step of recovery, and does not mean that the length, indices, and data updated mid-way are already consistent.

SysV shared memory still has clear use cases and should not be uniformly excluded just because the interface is older. IPC_PRIVATE indicates creating a new object, not name-based access protection; permissions, attachment, and deletion behaviors should be handled separately.

File Locks: First Distinguish Ownership Models

Traditional fcntl record locks can apply cooperative locks to file byte ranges. F_SETLK does not wait, F_SETLKW can wait; errors must be checked against errno, and not all failures can be treated as "another process holds the lock."

Traditional record locks are associated with processes; closing other fds for the same file in this process may also release the lock on the file held by that process; OFD locks are associated with the open file description, and references after dup and fork will affect the final release. The two should not be collectively referred to as the same mechanism of "releasing when the current fd is closed". fcntl locks

File locks are usually advisory, and participants must jointly comply. Deleting the lock file and rebuilding may result in a new inode, causing old and new processes to lock different objects; daemon single-instance design needs to ensure everyone uses the same stable object.

Performance and Persistence

Shared memory reduces transmission copying, but cache line contention, false sharing, NUMA, and notification still have costs, and it is not guaranteed to be faster than pipes for small messages. It is suitable to first measure data scale, batching, read/write ratios, and backpressure requirements.

When mapped writes are correctly read by other processes is different from when they are persisted to media. File-backed mappings also need to consider msync, filesystems, and failure models; semaphore posts do not automatically make data power-fail safe.

Keywords: shared memory, MAP_SHARED, shm_open, semaphore, sem_init, memfd, sealing, process shared, robust mutex, file locking, lifecycle