Session 6 · Operating Systems
Shared memory and semaphores
The life of a shared memory segment from shmget to IPC_RMID, why two processes reading and writing it race, System V semaphores as kernel counters with P and V, the difference between a semaphore that signals (0) and one that locks (1), cleanup with ipcs and ipcrm – and the solutions of the three pipe tasks.
Pipes and message queues copy data: the sender's bytes go into the kernel, and the receiver copies them out. Shared memory skips the copying. Two processes attach the same piece of memory, and what one writes, the other can read straight away – the fastest way processes can exchange data.
The price is that nothing in shared memory says when the data is ready. That is the job of a semaphore: a counter in the kernel that makes a process wait. This session builds both up from the examples of the course, shows the race they solve, and closes with the solutions of the three pipe tasks from the previous session.
Shared memory: one segment, many processes
Every process has its own address space: the same pointer value in two processes means two different places. A shared memory segment is memory owned by the kernel that can be mapped into several address spaces at once. Each process gets its own pointer to it, but the bytes behind the pointers are the same (Fig. 1).
key_t- A number that names the IPC object system-wide.
ftok(path, id)makes one from an existing file and a small project id: same file and same id give the same key in every process. - shmid
- The id
shmgetreturns for the segment. All further calls use it. - attach
shmatmaps the segment into the calling process and returns the address. A child created byforkinherits the attachment.- detach
shmdtremoves the mapping from this process. The segment itself stays.
The examples of the course create the key from the program's own file name, argv[0], with project id 1 for the memory and 2 for the semaphore, so the two keys never collide:
osztmem.c – create and attach
key_t kulcs = ftok(argv[0], 1); // same file + same id → same key
if (kulcs == -1) { perror("ftok"); exit(EXIT_FAILURE); }
int oszt_mem_id = shmget(kulcs, 500, IPC_CREAT | S_IRUSR | S_IWUSR);
if (oszt_mem_id == -1) { perror("shmget"); exit(EXIT_FAILURE); }
char *s = shmat(oszt_mem_id, NULL, 0); // NULL: the kernel picks the address
if (s == (void *)-1) { // the error value is (void *)-1, not NULL
perror("shmat");
shmctl(oszt_mem_id, IPC_RMID, NULL);
exit(EXIT_FAILURE);
}IPC_CREAT creates the segment if it doesn't exist yet and opens it if it does. S_IRUSR | S_IWUSR are the permissions (read and write for the owner). A newly created segment is filled with zero bytes, so a string read from it is empty until someone writes one.
The lifecycle of a segment
A segment is not tied to the program that created it. It exists in the kernel from shmget until someone removes it with shmctl(id, IPC_RMID, NULL) – and it survives the end of every process that used it (Fig. 2).
| Call | Affects | After it |
|---|---|---|
shmdt(s) | only this process | the pointer s is invalid here; the data is still in the segment |
shmctl(id, IPC_RMID, NULL) | the segment | marked for removal; the kernel frees it when the last process has detached |
The simplest correct order uses no synchronisation at all: write before fork. The child inherits the attachment and the text is already there when it starts.
osztmem_sec.c – write first, then fork
strcpy(s, "Valami iras!\n"); // 1. write ...
pid_t child_pid = fork(); // 2. ... then fork: the child sees the finished text
if (child_pid == 0) {
printf("A gyerek ezt olvasta: %s", s);
shmdt(s);
exit(EXIT_SUCCESS);
}osztmem_sec.c – the parent's cleanup
shmdt(s); // this process no longer uses it
waitpid(child_pid, &status, 0); // the child has detached and exited
shmctl(oszt_mem_id, IPC_RMID, NULL); // remove the segment itselfWhy access needs synchronisation
Writing before fork works once, in one direction. As soon as the parent writes after the fork, there are two processes and the scheduler decides which runs when. The first example of the course has the child sleep(1) and then read – usually the parent has written by then, but nothing guarantees it (Fig. 3).
sleep hides it, it doesn't fix it.A machine under load, a slow terminal or a debugger is enough to stretch the parent's delay beyond one second. A program that works "almost always" is the hardest kind to debug, so the rule is: whenever two processes touch shared memory and the order matters, something has to enforce the order. With shared memory that something is usually a semaphore.
Shared memory transports data; it does not synchronise. A pipe read blocks until there is data; a read from shared memory never waits – it returns whatever is there right now.
System V semaphores
A semaphore is a non-negative counter kept by the kernel, with two operations. P (down, wait) decreases it by one – but if the value is 0, the process blocks until it can. V (up, signal) increases it by one and wakes a process waiting in P (Fig. 4). Both are atomic: two processes can't both see the value 1 and both decrease it.
Creating and initialising
semget creates a set of semaphores – the second argument is how many. The new value is set with semctl(…, SETVAL, …), whose fourth argument is a union semun. POSIX leaves defining that union to the program, so you write it yourself:
szemafor.c – a set with one semaphore, value 0
union semun { // you define it – <sys/sem.h> does not
int val; // for SETVAL
struct semid_ds *buf; // for IPC_STAT, IPC_SET
unsigned short *array; // for GETALL, SETALL
};
int semid = semget(ftok(argv[0], 2), 1, IPC_CREAT | S_IRUSR | S_IWUSR);
union semun arg;
arg.val = 0; // initial value
if (semctl(semid, 0, SETVAL, arg) == -1) { perror("semctl"); exit(EXIT_FAILURE); }P and V with semop
semop takes an array of struct sembuf operations and performs them atomically. The course's helper wraps a single operation:
szemafor.c – P and V in one helper
void szemafor_muvelet(int semid, int op) // op = -1: P (down), +1: V (up)
{
struct sembuf muvelet;
muvelet.sem_num = 0; // index in the set – we have one semaphore
muvelet.sem_op = op;
muvelet.sem_flg = 0; // or SEM_UNDO, IPC_NOWAIT
if (semop(semid, &muvelet, 1) == -1) { // 1 = number of operations
perror("semop");
exit(EXIT_FAILURE);
}
}| sem_op | Name | What happens |
|---|---|---|
-1 | P, down, wait | value > 0: decrease and continue; value 0: block until it can |
+1 | V, up, signal | increase; a blocked process may continue |
0 | wait for zero | block until the value is 0 – "everyone has finished" |
A semaphore guarding shared memory
The initial value decides what the semaphore means. The two examples of the course show both uses.
Value 0: a signal – "the data is there"
The semaphore starts closed. The child calls P and blocks. The parent writes the segment and then calls V. Whichever process the scheduler runs first, the child reads only after the write (Fig. 5).
szemafor.c – write, V / P, read
// semaphore created with value 0 BEFORE fork()
if (child > 0) { // parent
strcpy(s, buffer);
szemafor_muvelet(semid, 1); // V: "the data is there"
} else { // child
szemafor_muvelet(semid, -1); // P: blocks until the parent's V
printf("Gyerek olvasta: %s", s);
}Create the semaphore before fork, so both processes have the same semid and the value is 0 before either can touch it.
Value 1: a lock – "one at a time"
In the second example the parent reads lines from the keyboard and writes them to a struct in the segment; the child reads the text and writes back its length. Both sides wrap their access in P … V, so neither can see the struct half-updated (Fig. 6).
szemafor_sec.c – the parent's critical section
struct sharedData { char text[MEMSIZE]; int textSize; };
szemafor_muvelet(semid, -1); // enter – value 1 → 0
strcpy(s->text, buffer); // parent → child
printf("previous length: %i\n", s->textSize); // child → parent
szemafor_muvelet(semid, 1); // leave – value 0 → 1The lock does not decide who goes first. The child in the example checks the segment every two seconds; if lines arrive faster – pasted or piped in – it sees only some of them. That is fine for the demonstration, but a real hand-over of every message needs two semaphores: one saying "a message is waiting", one saying "the slot is free".
Cleaning up
Shared memory segments, semaphore sets and message queues all outlive the program. Remove them in the process that created them, after the others have finished:
szemafor.c – the end of the parent
waitpid(child, &status, 0); // 1. nobody uses the objects any more
semctl(semid, 0, IPC_RMID); // 2. remove the semaphore set
shmctl(sh_mem_id, IPC_RMID, NULL); // 3. remove the segmentRemoving a semaphore set takes effect immediately: a process still blocked in semop on it wakes up with an error (EIDRM). That is why the parent waits for the child first.
If a program crashes or you stop it with Ctrl+C, the cleanup never runs. ipcs lists what you left behind, ipcrm removes it:
$ ipcs -m -s
------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x0105a2c1 32769 abc123 600 1028 0
------ Semaphore Arrays --------
key semid owner perms nsems
0x0205a2c1 3 abc123 600 1
$ ipcrm -m 32769 # remove a segment by its id
$ ipcrm -s 3 # remove a semaphore set by its idThe pipe tasks, solved
The previous session ended with three pipe tasks. Their solutions use three different shapes of pipes (Fig. 7); here is the structure of each and the few lines that matter.
1 · Counting together
Parent and child count to three, alternately, the parent first; the parent sends an enum value, not a character. One pipe carries "your turn" to the child. A sleep(1) in the parent's loop makes the output alternate in practice, but only a second pipe makes it certain: the child answers after printing, and the parent waits for that answer before it counts on.
kozos_szamlalas.c – the parent waits for the acknowledgement
// parent: io_pipes1 = parent → child, io_pipes = child → parent
while (mainszamlalo < 4) {
printf("Parent mondja: %i \n", mainszamlalo);
fflush(stdout);
write(io_pipes1[1], &puffer, sizeof(enum message));
if (read(io_pipes[0], &valasz, sizeof(enum message)) != sizeof(enum message))
break; // wait until the child has printed too
mainszamlalo++;
}The fflush(stdout) matters when the output goes to a file or a pipe: there printf is buffered, and two processes' buffers would come out in the wrong order.
2 · Child to child
The parent takes a number N, sends it to child 1, child 1 prints it and forwards it to child 2, followed by N random numbers; child 2 prints all of them. Two pipes, both created before the forks, so both children inherit them. Each child closes the ends it doesn't use and checks that every read returned a whole int:
child_communications.c – child 2
/* child 2 inherited the write end of the child1 → child2 pipe:
close it, or read never sees EOF if child 1 dies */
close(pipe_id_unused);
int arrived;
if (read(pipe_id_rec, &arrived, sizeof(int)) != sizeof(int)) {
perror("Child2 read"); // 0 = EOF, -1 = error: no number arrived
exit(EXIT_FAILURE);
}3 · Structs through a pipe
The parent hands out a job: its name (a fixed-size char array), the number of workers, then the workers themselves as an array of structs – in one write. The child reads in the same order and with the same sizes. That works because the struct has no pointers: its bytes mean the same in the child.
pipe_struct.c – one write, one read
/* parent: one write for all workers */
write(io_pipes_out[1], daily_datas, daily_num_ready * sizeof(struct worker));
/* child: allocate for the count it received, then one read */
struct worker *data = malloc(daily_num * sizeof(struct worker));
ssize_t expected = daily_num * (ssize_t)sizeof(struct worker);
if (read(pipe_id_rec, data, expected) != expected) { /* incomplete data */ }Practice
Task 1
Replace the sleep with a semaphore
Start from the first shared memory example: the parent writes after fork, the child sleep(1)s and reads. Remove the sleep and make the order certain with a semaphore. Then put a sleep(3) into the parent before its write – the child must still print the text, not an empty line.
Approach
Initial value 0, created before the fork. The parent calls V after strcpy, the child calls P before printf. Don't forget to remove the semaphore at the end, next to the segment.
Task 2
No line lost
Change the keyboard example so the child processes every line, also when the input is piped in (printf 'a\nbb\nexit\n' | ./a.out), and the parent prints the length of the line it has just sent, not the previous one.
Approach
Two signals instead of one lock: a set of two semaphores, both starting at 0. "Line ready" goes from parent to child, "length ready" from child to parent. The parent: write, V(line), P(length), print. The child: P(line), read, write the length, V(length). Address them with sem_num 0 and 1.
int semid = semget(kulcs, 2, IPC_CREAT | S_IRUSR | S_IWUSR); // a set of two
// sem 0 = "line ready", sem 1 = "length ready" – both SETVAL 0
struct sembuf v_line = { 0, +1, 0 }; // parent: a line is in the segment
struct sembuf p_length = { 1, -1, 0 }; // parent: wait for the child's answer
semop(semid, &v_line, 1);
semop(semid, &p_length, 1);Task 3
Find what a crash leaves behind
Run the keyboard example and stop it with Ctrl+C while it waits for input. List your IPC objects, find the segment and the semaphore set the program created, and remove both. Then explain why the next run would have worked anyway.
Approach
ipcs -m -s, then ipcrm -m and ipcrm -s with the ids. For the explanation: the same argv[0] gives the same key, IPC_CREAT reopens the old objects, and SETVAL resets the semaphore.
Summary
ftok→shmget→shmat→ use →shmdt→shmctl(IPC_RMID); checkshmatagainst(void *)-1.- Shared memory moves data without copying, but never waits – the order of reads and writes has to be enforced.
- A semaphore is a kernel counter: P (
-1) waits at 0, V (+1) lets a waiter through. Set its value withSETVALand aunion semun. - Initial value 0: a signal that data is ready. Initial value 1: a lock for one-at-a-time access – exclusion, not order.
- Create IPC objects before
fork, remove them afterwait; after a crash,ipcsandipcrm.