POSIX Shared Memory with Semaphores
Shared Memory
When two processes need to exchange data efficiently, one of the best options on Linux/Unix systems is shared memory. Instead of sending data through pipes or sockets (which require copies between kernel and user space), shared memory allows processes to map the same region of memory and communicate directly.
Using POSIX Shared Memory with Semaphores in C
Linux systems provide two separate Apis for shared memory: the legacy system V Api and the more recent POSIX one. these Apis should never be mixed in a single application, however. A downside of the posix approach is that features are still in development and dependent upon the installed kernel version, which impacts code portability. For example, the POSIX API, by default, implements shared memory as a memory-mapped file: for a shared memory segment, the system maintains a backing file with corresponding contents. shared memory under posix can be configured without a backing file, but this may impact portability. my example uses the posix Api with a backing file, which combines the benefits of memory access (speed) and file storage (persistence).
- But there’s a catch: synchronization. If one process is writing while the other is reading, race conditions can occur. That’s where POSIX semaphores come in.
We’ll walk through a simple C program that demonstrates how to set up a shared memory segment with semaphores to synchronize read/write access.
The shared-memory example has two programs, named writer.c and reader.c, and uses a semaphore to coordinate their access to the shared memory. Whenever shared memory comes into the picture with a writer, whether in multi-processing or multi-threading, so does the risk of a memory-based race condition; hence, the semaphore is used to coordinate (synchronize) access to the shared memory.
The Header File: shm_sem.h
Before diving into the writer and reader code, let’s define a shared header file that contains all the constants, includes, and data structures needed for both processes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// shm_sem.h
#ifndef SHM_SEM_H
#define SHM_SEM_H
#include <fcntl.h>
#include <sys/mman.h>
#include <semaphore.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define SHM_NAME "/my_shm"
#define SEM_EMPTY_NAME "/sem_empty"
#define SEM_FULL_NAME "/sem_full"
#define SHM_SIZE 1024
typedef struct {
char buffer[SHM_SIZE];
} shm_data;
#endif
Key Points:
Header guards The
#ifndef ... #define ... #endifpattern ensures the file is included only once during compilation.System headers
<fcntl.h>: forO_CREAT,O_RDWR, etc.<sys/mman.h>: formmap/shm_open.<semaphore.h>: for POSIX semaphores.<unistd.h>,<stdio.h>,<stdlib.h>,<string.h>: standard C library headers for I/O, memory, and utility functions.
Definitions
SHM_NAME: the identifier for the shared memory object. It must start with a/and will typically live under/dev/shm/.SEM_EMPTY_NAMEandSEM_FULL_NAME: unique names for the two semaphores.SHM_SIZE: the size of the shared memory buffer (1 KB in this example).
Shared data structure The
shm_datastruct holds the buffer where messages are written. Both processes will map this structure into their address spaces and use it for communication.
Writer
To write some data to a shared memory space, basic steps are as follows:
Step 1: Shared Memory Setup
We start by creating (or opening) a shared memory object with shm_open:
1
shm_fd = shm_open(SHM_NAME, O_CREAT | O_RDWR, 0666);
SHM_NAMEis a string identifier (e.g.,"/my_shm").O_CREAT | O_RDWRmeans “create if it doesn’t exist, and open for read/write”.0666sets the permissions (read/write for everyone).
After creating it, we must set the size:
1
ftruncate(shm_fd, sizeof(shm_data));
Finally, we map it into our process’s address space:
1
data = mmap(0, sizeof(shm_data), PROT_WRITE | PROT_READ, MAP_SHARED, shm_fd, 0);
At this point, both processes can access the same memory region.
Step 2: Creating Semaphores
We use two semaphores to implement a producer-consumer model:
1
2
sem_t *sem_empty = sem_open(SEM_EMPTY_NAME, O_CREAT, 0666, 1);
sem_t *sem_full = sem_open(SEM_FULL_NAME, O_CREAT, 0666, 0);
sem_emptystarts at 1 → the buffer is empty, so the writer can write.sem_fullstarts at 0 → nothing is written yet, so the reader must wait.
This ensures:
- The writer waits if the buffer is full.
- The reader waits if the buffer is empty.
Step 3: Writing Data
Inside the main loop, the writer does:
1
2
3
4
sem_wait(sem_empty); // wait until buffer is empty
snprintf(data->buffer, SHM_SIZE, "Message %d from writer", i);
printf("Writer: wrote '%s'\n", data->buffer);
sem_post(sem_full); // signal that data is available
This means:
- Wait until the buffer is ready.
- Write a new message into shared memory.
- Signal the reader that data is available.
We also add a sleep(1) to simulate some delay.
What
emptyandfulldoIn the classic single-slot producer-consumer problem:
Semaphore Purpose emptyIndicates the buffer is empty / ready for writing. Initial value = 1. fullIndicates the buffer is full / ready for reading. Initial value = 0. How they work
Writer (Producer):
1 2 3 sem_wait(sem_empty); // wait until buffer is empty write_to_buffer(); sem_post(sem_full); // signal buffer now has dataReader (Consumer):
1 2 3 sem_wait(sem_full); // wait until buffer has data read_from_buffer(); sem_post(sem_empty); // signal buffer is now emptyWhy this works
emptyensures the writer does not overwrite unread data.If the buffer is already full (
empty = 0),sem_wait(empty)blocks the writer until the reader consumes it and callssem_post(empty).
fullensures the reader does not read garbage.If the buffer is empty (
full = 0),sem_wait(full)blocks the reader until the writer produces data and callssem_post(full).Together, they coordinate access to a shared single-slot buffer safely across processes.
Timing Diagram (Single-slot buffer)
1 2 3 4 5 6 7 Time → Writer: | write M0 | wait for empty | write M1 | wait for empty | write M2 ... Buffer: | M0 | blocked? | M1 | blocked? | M2 Reader: | wait full| read M0 | wait full| read M1 | wait full | read M2 Sem empty: 1 -------->0------>1-------->0-------->1 Sem full: 0 -------->1------>0-------->1-------->0Explanation of the arrows:
- Initially,
empty = 1,full = 0.- Writer does
sem_wait(empty)→empty-- = 0, writes data.- Writer calls
sem_post(full)→full++ = 1, signals reader.- Reader does
sem_wait(full)→full-- = 0, reads data.- Reader calls
sem_post(empty)→empty++ = 1, signals writer.
- This ping-pong continues for every data item.
Note
These semaphores don’t hold the data, only control access and order.
Single-slot model:
empty = 1→ ready to write,full = 1→ ready to read.Multi-slot (buffer with N slots):
empty = Ninitially,full = 0initially.- Writer consumes
empty, producesfull.- Reader consumes
full, producesempty.
Step 4: Cleanup
After finishing, we clean up resources:
1
2
3
4
munmap(data, sizeof(shm_data));
close(shm_fd);
sem_close(sem_empty);
sem_close(sem_full);
⚠️ Note: In production, you would also call
shm_unlink()andsem_unlink()to remove the named shared memory and semaphores once you are sure no process will use them anymore. This prevents resource leaks.
Full Example (Writer)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include "shm_sem.h"
int main() {
int shm_fd;
shm_data *data;
shm_fd = shm_open(SHM_NAME, O_CREAT | O_RDWR, 0666);
ftruncate(shm_fd, sizeof(shm_data));
data = mmap(0, sizeof(shm_data), PROT_WRITE | PROT_READ, MAP_SHARED, shm_fd, 0);
sem_t *sem_empty = sem_open(SEM_EMPTY_NAME, O_CREAT, 0666, 1);
sem_t *sem_full = sem_open(SEM_FULL_NAME, O_CREAT, 0666, 0);
for (int i = 0; i < 5; i++) {
sem_wait(sem_empty); // down
snprintf(data->buffer, SHM_SIZE, "Message %d from writer", i);
printf("Writer: wrote '%s'\n", data->buffer);
sem_post(sem_full); // post
sleep(1);
}
munmap(data, sizeof(shm_data));
close(shm_fd);
sem_close(sem_empty);
sem_close(sem_full);
// remove objects
shm_unlink(SHM_NAME);
sem_unlink(SEM_EMPTY_NAME);
sem_unlink(SEM_FULL_NAME);
return 0;
}
Conclusion
This small example demonstrates how POSIX shared memory can be combined with semaphores to build safe, efficient inter-process communication (IPC).
- Shared memory enables fast, direct data exchange.
- Semaphores ensure proper synchronization between producer and consumer.
With these building blocks, you can implement more complex IPC systems, such as message queues, logging systems, or real-time data streams.
Reader
Full Example (Reader)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include "shm_sem.h"
int main() {
int shm_fd;
shm_data *data;
// Open shared memory (writer already created it)
shm_fd = shm_open(SHM_NAME, O_RDWR, 0666);
if (shm_fd == -1) {
perror("shm_open");
exit(1);
}
// Map it
data = mmap(0, sizeof(shm_data), PROT_WRITE | PROT_READ, MAP_SHARED, shm_fd, 0);
if (data == MAP_FAILED) {
perror("mmap");
exit(1);
}
// Open semaphores
sem_t *sem_empty = sem_open(SEM_EMPTY_NAME, 0);
sem_t *sem_full = sem_open(SEM_FULL_NAME, 0);
if (sem_empty == SEM_FAILED || sem_full == SEM_FAILED) {
perror("sem_open");
exit(1);
}
// Read loop
for (int i = 0; i < 5; i++) {
sem_wait(sem_full); // wait until data is available
printf("Reader: read '%s'\n", data->buffer);
sem_post(sem_empty); // signal that buffer is now empty (writer can write)
}
// Cleanup
munmap(data, sizeof(shm_data));
close(shm_fd);
sem_close(sem_empty);
sem_close(sem_full);
return 0;
}
Step 1 — shm_open(...)
We call
shm_open(SHM_NAME, O_RDWR, 0666)withoutO_CREAT.- This means the reader expects the shared memory object to already exist (created by the writer).
- If the writer hasn’t created it yet,
shm_openwill fail and the program should handle that (here weperror+ exit).
O_RDWRis used because the reader maps the region with read and write permissions (PROT_READ|PROT_WRITE), matching the writer’s usage.
Practical note: start the writer first (or handle the failure and retry) so the reader can open the object.
Step 2 — mmap(...)
mmap(NULL, sizeof(shm_data), PROT_WRITE | PROT_READ, MAP_SHARED, shm_fd, 0)maps the same physical memory into the reader’s virtual address space.MAP_SHAREDensures writes from one process are visible to all other processes that mapped the same object.- The map size must match the size the writer set with
ftruncate()— otherwise the mapping may be smaller/invalid.
Step 3 — sem_open(...)
We open two named semaphores with
sem_open(name, 0)(noO_CREAT):sem_emptyandsem_full.- Opening with
0attempts to open existing semaphores created by the writer.
- Opening with
If
sem_openreturnsSEM_FAILED, it indicates the semaphore does not exist or some other error — the program logs and exits.
Semantic reminder:
sem_fullsignals “there is data to read” (initially 0).sem_emptysignals “buffer is empty, writer may write” (initially 1).
Step 4 — The read loop (sem_wait / sem_post)
1
2
3
sem_wait(sem_full); // block until writer has posted
printf("Reader: read '%s'\n", data->buffer);
sem_post(sem_empty); // allow writer to write again
sem_wait(sem_full)blocks the reader until the writer has written data and calledsem_post(sem_full).- After
sem_waitreturns, the reader can safely readdata->buffer. The semaphore-based synchronization guarantees visibility: thesem_poston the writer side andsem_waithere act as synchronization points (they provide the necessary memory ordering so you see the most recent write). - After reading, the reader calls
sem_post(sem_empty)to let the writer know the buffer is free.
Why this is safe: the semaphore operations provide the producer–consumer synchronization so you don’t need extra memory fences or msync() for correctness in this pattern.
Step 5 — Cleanup
munmap(data, ...)andclose(shm_fd)release this process’s mapping and file descriptor.sem_close(...)closes the semaphore handles in this process.
Important: the reader does not call shm_unlink() or sem_unlink() here because unlinking removes the named kernel object system-wide. Typically the creator (writer) or a dedicated cleanup process should unlink when the resource must be permanently removed. If readers start unlinking, other processes may fail to open the objects.
Run order:
- start the writer (it creates the shared memory and semaphores),
- then start the reader.
Example output (when paired with the writer from the tutorial):
1
2
3
Reader: read 'Message 0 from writer'
Reader: read 'Message 1 from writer'
...
Small improvements & robustness tips
- Avoid blocking forever: use
sem_timedwait()or handle signals (EINTR) if you don’t want the reader to hang indefinitely. - Check sizes: confirm
mmapsize matches the writer’sftruncate()size, or exchange a version/size field in the shared struct. - Handle startup race: if the reader may start before the writer, implement a retry loop with backoff for
shm_open/sem_openor create a small handshake protocol. - Multiple readers/writers: the two-semaphore single-slot pattern works for single producer + single consumer. For multiple producers/consumers you’ll need counting semaphores (or a ring buffer with head/tail indices plus atomic operations).
- Signal handling / cleanup: add signal handlers to close and unlink resources cleanly on Ctrl-C.