IPC – Lernzettel für den Praxistest

Was du können solltest

C-Programme mit mehreren Prozessen schreiben: Prozesse erzeugen, Daten austauschen, Signale behandeln und Ressourcen sauber freigeben.
Die sechs ausführlich behandelten Bereiche sind fork/Warten, Pipes, FIFOs, Message Queues, Shared Memory und Signale. Sockets werden in der allgemeinen IPC-Liste genannt, aber in den verlinkten Materialien nicht ausführlich behandelt. Die genaue Testabgrenzung ist dort nicht angegeben.

Übersicht

BereichZweckWichtigste Funktionen
ProzesseProzesse erzeugen und auf Kinder wartenfork, wait, waitpid
PipeBytestrom zwischen typischerweise verwandten Prozessenpipe, read, write, close
FIFOBenannter Bytestrom, auch für unabhängig gestartete Prozessemkfifo, open, read, write, unlink
Message QueueEinzelne Nachrichten puffern und nach Typ empfangenmsgget, msgsnd, msgrcv, msgctl
Shared MemoryGemeinsamer Speicher für mehrere Prozesseshmget, shmat, shmdt, shmctl
SignaleEreignisse melden und Prozesse steuernkill, raise, signal, alarm, setitimer

Zu den Codebeispielen

Die kurzen Sammlungen sind Merkhilfen und keine vollständigen Programme. Rückgabewerte und Fehlerbehandlung musst du im eigenen Programm ergänzen. Die Funktionen gehören zur Linux-/Unix-Programmierung in C.

1. Prozesse: fork, wait und waitpid

Das musst du verstehen

  • fork() erzeugt einen Kindprozess. Beide Prozesse laufen ab der nächsten Anweisung weiter.
  • Eltern- und Kindprozess haben getrennte Adressräume. Normale Variablen werden kopiert; Änderungen sind nicht automatisch gemeinsam sichtbar.
  • Der Elternprozess muss beendete Kinder mit wait() oder waitpid() einsammeln, damit keine Zombieprozesse zurückbleiben.
  • Für Parent → Child → Child muss das erste Kind das zweite Kind erzeugen.

Befehlssammlung

#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
 
pid_t pid = fork();
// -1: Fehler
//  0: Dieser Code läuft im Kind
// >0: Dieser Code läuft im Elternprozess; Wert = Kind-PID
 
getpid();                   // Eigene PID
getppid();                  // PID des Elternprozesses
exit(42);                   // Prozess mit Exit-Code 42 beenden
 
int status;
wait(&status);              // Auf irgendein Kind warten
waitpid(pid, &status, 0);    // Auf bestimmtes Kind warten
 
if (WIFEXITED(status)) {
    int code = WEXITSTATUS(status);
}

Nicht blockierend prüfen:

pid_t r = waitpid(pid, &status, WNOHANG);
// r == 0: Noch kein passender Kindstatus verfügbar
// r >  0: Status eines Kindes erhalten
// r == -1: Fehler

Grundmuster

pid_t pid = fork();
if (pid == -1) {
    perror("fork");
    return 1;
} else if (pid == 0) {
    // Arbeit des Kindes
    exit(0);
} else {
    // Arbeit des Elternprozesses
    waitpid(pid, NULL, 0);
}

Typischer Fehler

Ein Kind läuft versehentlich in einen weiteren fork() hinein. Dadurch entstehen mehr Prozesse als geplant. Prozesszweige und deren Ende bewusst festlegen.

2. Anonyme Pipes

Eine Pipe überträgt einen Bytestrom in eine Richtung. Typisch: Elternprozess und Kindprozess.

Das musst du können

  • Pipe vor fork() erstellen, damit die Deskriptoren vererbt werden.
  • fd[0] zum Lesen und fd[1] zum Schreiben verwenden.
  • In jedem Prozess alle unbenutzten Pipe-Enden schließen.
  • Für Hin- und Rückweg zwei Pipes verwenden.

Befehlssammlung

#include <unistd.h>
#include <string.h>
 
int fd[2];
pipe(fd);                           // Vor fork()!
 
// Schreiber:
close(fd[0]);
write(fd[1], "Hallo", strlen("Hallo") + 1);
close(fd[1]);
 
// Leser in einem anderen Prozess:
close(fd[1]);
char buf[100];
ssize_t n = read(fd[0], buf, sizeof(buf) - 1);
if (n > 0) {
    buf[n] = '\0';
}
close(fd[0]);

Unbedingt merken

Ergebnis von read()Bedeutung
> 0Anzahl gelesener Bytes
0EOF: keine Daten mehr und alle Schreibenden geschlossen
-1Fehler
  • Eine leere Pipe mit noch geöffnetem Schreibende lässt read() normalerweise warten.
  • EOF kommt erst, wenn alle Schreibenden geschlossen sind, auch geerbte Deskriptoren in anderen Prozessen.
  • Schreiben ohne Leser kann SIGPIPE auslösen; wird es ignoriert, meldet write() normalerweise EPIPE.
  • Ein read() entspricht nicht automatisch einem vollständigen write().
  • Nachrichten durch feste Größen, Längenfelder oder Trennzeichen abgrenzen. Auch Teilübertragungen berücksichtigen.
  • Bei gegenseitigem Warten kann ein Deadlock entstehen.

Merksatz

0 liest, 1 schreibt. Unbenutzte Enden schließen!

3. FIFOs – benannte Pipes

Eine FIFO hat einen Pfad im Dateisystem. Damit können auch unabhängig gestartete Programme kommunizieren. Die Daten sind weiterhin ein Bytestrom und werden nicht dauerhaft in der FIFO-Datei gespeichert.

Befehlssammlung

#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
 
mkfifo("/tmp/meine_fifo", 0666);
 
// Im Sender:
int fd = open("/tmp/meine_fifo", O_WRONLY);
 
// Alternativ im Empfänger:
int fd = open("/tmp/meine_fifo", O_RDONLY);
 
write(fd, daten, anzahl);
read(fd, puffer, groesse);
close(fd);
 
unlink("/tmp/meine_fifo");

Die beiden open()-Zeilen sind Alternativen für getrennte Prozesse.

Unbedingt merken

  • Beide Programme verwenden denselben Pfad.
  • Ohne besondere Flags wartet open() normalerweise auf die Gegenseite.
  • close() schließt den Zugriff; unlink() entfernt den Pfad.
  • Existiert die FIFO bereits, liefert mkfifo() einen Fehler mit errno == EEXIST. Diesen Fall beim Neustart berücksichtigen.
  • 0666 bedeutet Lese-/Schreibrechte für alle; die tatsächlichen Rechte werden durch die umask eingeschränkt.

Terminal

mkfifo /tmp/meine_fifo
 
# Sender und Empfänger in getrennten Terminals:
echo "Hallo" > /tmp/meine_fifo
cat /tmp/meine_fifo
 
rm /tmp/meine_fifo

4. Message Queues – Nachrichtenwarteschlangen

Eure Beispiele verwenden System V Message Queues. Der Kernel puffert einzelne Nachrichten, bis sie empfangen werden. Nachrichtengrenzen bleiben erhalten. Sender und Empfänger müssen nicht gleichzeitig laufen.

Ablauf merken

Schlüssel → Queue öffnen → Nachricht senden/empfangen → Queue entfernen

Befehlssammlung

#include <sys/ipc.h>
#include <sys/msg.h>
#include <string.h>
 
struct message {
    long mtype;                     // Erstes Feld! Wert > 0
    char mtext[100];
};
 
key_t key = ftok("/tmp/ipc_key", 'A');
int id = msgget(key, IPC_CREAT | 0666);
 
struct message msg = {0};
msg.mtype = 1;
strcpy(msg.mtext, "Hallo");
 
msgsnd(id, &msg, strlen(msg.mtext) + 1, 0);
msgrcv(id, &msg, sizeof(msg.mtext), 1, 0);
 
msgctl(id, IPC_RMID, NULL);

Parameter und Flags

AusdruckBedeutung
ftok(pfad, projekt_id)Schlüssel aus existierendem Dateipfad und Projekt-ID ableiten
IPC_CREATRessource erstellen, falls sie noch nicht existiert
IPC_CREAT \| IPC_EXCLFehler, falls Ressource schon existiert
Empfangstyp 0Erste Nachricht unabhängig vom Typ
Empfangstyp > 0Erste Nachricht mit genau diesem Typ
IPC_NOWAITNicht auf verfügbare Nachricht bzw. freien Platz warten
MSG_NOERRORZu große empfangene Nachricht abschneiden
IPC_RMIDQueue entfernen

Unbedingt merken

  • Die Datei für ftok() muss existieren. ftok() erzeugt sie nicht und garantiert keine absolut eindeutigen Schlüssel.
  • Beide Programme brauchen denselben Schlüssel und eine übereinstimmende Nachrichtenstruktur.
  • Die Größenangabe enthält nur die Nutzdaten, ohne long mtype!
  • msgrcv() entfernt die empfangene Nachricht aus der Queue.
  • Ohne IPC_NOWAIT blockiert der Empfang, wenn keine passende Nachricht vorhanden ist.
  • msgsnd() kann bei voller Queue ebenfalls blockieren.
  • Empfangene Bytes sind nicht automatisch ein nullterminierter C-String. Bei Text das Übertragungsformat und Puffergrenzen beachten.
  • Die Queue bleibt nach Prozessende bestehen, bis sie entfernt wird oder das System neu startet.

Terminal

touch /tmp/ipc_key
ipcs -q                 # Queues anzeigen
ipcrm -q 123             # Queue mit ID 123 entfernen

5. Shared Memory – gemeinsamer Speicher

Mehrere Prozesse greifen auf denselben Speicherbereich zu. Nach dem Anhängen erfolgt der Datenzugriff direkt über einen Zeiger.

Ablauf auswendig können

ftok → shmget → shmat → Datenzugriff → shmdt → shmctl

Befehlssammlung

#include <sys/ipc.h>
#include <sys/shm.h>
 
struct SharedData {
    int messwert;
    int status;
};
 
key_t key = ftok("/tmp/ipc_key", 'B');
int id = shmget(key, sizeof(struct SharedData),
                IPC_CREAT | 0666);
 
struct SharedData *p = shmat(id, NULL, 0);
if (p == (void *) -1) {
    // Fehler behandeln
}
 
// Zugriff nur nach erfolgreichem shmat():
p->messwert = 42;
int wert = p->messwert;
 
shmdt(p);
shmctl(id, IPC_RMID, NULL);

Unbedingt merken

  • shmget() liefert eine ID; shmat() liefert einen Zeiger.
  • Fehlerwert von shmat(): (void *) -1, nicht NULL.
  • shmdt() trennt nur den aktuellen Prozess vom Segment.
  • shmctl(..., IPC_RMID, ...) markiert das Segment zum Entfernen. Endgültig verschwindet es nach dem letzten Abtrennen.
  • Gemeinsamer Speicher synchronisiert Zugriffe nicht automatisch: Race Conditions sind möglich.
  • Das Statusmodell aus euren Beispielen ist 0 = leer, 1 = Daten vorhanden, 2 = beenden.
  • Ein normales Statusflag und sleep() ersetzen keine zuverlässige Synchronisation mit Semaphoren oder prozessübergreifend eingerichteten Mutexes. Auch volatile allein löst das nicht.

Terminal

ipcs -m                 # Segmente anzeigen
ipcrm -m 123             # Segment mit ID 123 entfernen

POSIX-Alternative

Eure Unterlagen erwähnen zusätzlich shm_open(), ftruncate(), mmap(), munmap() und shm_unlink(). Die ausführlichen Beispiele verwenden aber System V.

6. Signale und Timer

Signale melden asynchrone Ereignisse und steuern Prozesse. Für umfangreiche Daten nutzt du eine andere IPC-Methode.

Wichtige Signale

SignalBedeutung
SIGINTUnterbrechung, normalerweise Strg+C
SIGTERMAufforderung zum Beenden; behandelbar
SIGKILLSofort beenden; nicht abfangbar oder ignorierbar
SIGUSR1, SIGUSR2Eigene Bedeutung im Programm
SIGCHLDStatusänderung eines Kindes
SIGPIPESchreiben in Pipe ohne Leser
SIGALRMAblauf eines Alarms
SIGSTOPStoppen; nicht abfangbar oder ignorierbar
SIGTSTPStoppsignal des Terminals, normalerweise Strg+Z
SIGCONTGestoppten Prozess fortsetzen
SIGPROFAblauf des Profiling-Timers

Befehlssammlung

#include <signal.h>
#include <unistd.h>
 
kill(pid, SIGUSR1);           // Signal an anderen Prozess
raise(SIGUSR1);               // Signal an sich selbst
 
signal(SIGUSR1, handler);     // Eigenen Handler registrieren
signal(SIGINT, SIG_IGN);      // Ignorieren
signal(SIGINT, SIG_DFL);      // Standardverhalten herstellen
 
alarm(5);                    // SIGALRM nach 5 Sekunden
alarm(0);                    // Alarm abbrechen

Einfaches Handler-Muster

volatile sig_atomic_t stop = 0;
 
void handler(int sig) {
    stop = 1;
}
 
// Im Hauptprogramm:
if (signal(SIGTERM, handler) == SIG_ERR) {
    // Fehler behandeln
}
 
while (!stop) {
    // Arbeit erledigen
}
 
// Hier aufräumen und Kinder einsammeln

Unbedingt merken

  • Im Handler möglichst nur ein Flag setzen. Ausgabe und Aufräumen im normalen Programmablauf erledigen.
  • printf() ist nicht async-signal-safe. write() ist geeignet.
  • SIGKILL und SIGSTOP können weder abgefangen noch ignoriert werden.
  • Normale Signale sind keine zuverlässigen Nachrichtenzähler: Mehrere gleiche anhängige Signale können zusammenfallen.
  • SIGUSR1 und SIGUSR2 beenden ohne eigenen Handler standardmäßig den Prozess.
  • Blockierende Systemaufrufe können durch Signale unterbrochen werden; gegebenenfalls errno == EINTR behandeln.
  • Für robustes Registrieren von Handlern ist sigaction() üblich; eure Beispiele verwenden signal().

Korrektur zu den Unterrichtsunterlagen

Strg+Z sendet normalerweise SIGTSTP, nicht SIGSTOP. Die Forderung der Übung, alle anderen Signale zu ignorieren, ist wegen SIGKILL und SIGSTOP nur eingeschränkt umsetzbar.

Intervalltimer

#include <sys/time.h>
 
struct itimerval t = {0};
t.it_value.tv_usec = 10000;       // Erster Ablauf: 10 ms
t.it_interval.tv_usec = 10000;    // Wiederholung: 10 ms
 
setitimer(ITIMER_PROF, &t, NULL);
  • it_value: Zeit bis zum ersten Ablauf.
  • it_interval: Wiederholungsintervall; null bedeutet einmalig.
  • ITIMER_PROF: verbrauchte Benutzer- und System-CPU-Zeit, Signal SIGPROF.
  • CPU-Zeit ist kein fester Takt nach tatsächlicher Uhrzeit.
  • Vorher passenden Handler installieren, wenn das Signal den Prozess nicht beenden soll.

Terminal

kill -l
kill -USR1 1234
kill -USR2 1234
kill -TERM 1234
kill -KILL 1234

7. Praktische Aufgaben aus euren Materialien

Produktionsstraße

Struktur: Produkt-Generator → Qualitätskontrolle → Endmontage/Versand

  • Prozesskette Parent → Child → Child korrekt erzeugen.
  • Produkt-IDs mit einer geeigneten IPC-Methode weiterleiten.
  • Beispielsweise zwei Pipes für die zwei Übertragungsstrecken verwenden.
  • Alle unbenutzten Pipe-Enden in jedem Prozess schließen.
  • Aktionen und Verarbeitung nachvollziehbar ausgeben.
  • Das Ende der Übertragung festlegen, etwa durch EOF oder Endnachricht.
  • Beendigung wie gefordert signalisieren und Signalwahl begründen.
  • Jeder Elternprozess wartet auf sein direktes Kind.
  • Wahl der IPC-Methode kurz im Quellcode begründen.

Sensorüberwachung

  • Hauptprozess initialisiert Shared Memory.
  • Mehrere Sensorprozesse erzeugen oder getrennt starten.
  • Sensoren und Anzeige hängen das Segment an.
  • Messwerte und Status in einer gemeinsamen Struktur speichern.
  • Kritische Werte mit kill(anzeige_pid, SIGUSR1) oder SIGUSR2 melden.
  • Anzeige setzt im Handler ein Flag und verarbeitet die Warnung außerhalb des Handlers.
  • Zugriffe synchronisieren und am Ende Speicher freigeben.

Alive-Kommunikation

  • Eltern- und Kindprozess senden abwechselnd alle zwei Sekunden eine Alive-Nachricht.
  • Zwei Pipes für Kommunikation in beide Richtungen verwenden; alternativ FIFO/Message Queue.
  • SIGUSR1 beendet den Elternprozess kontrolliert.
  • SIGUSR2 beendet den Kindprozess kontrolliert.
  • Ausfall der Gegenseite erkennen, etwa durch EOF oder SIGPIPE/EPIPE.
  • Verbleibenden Prozess ebenfalls sauber beenden.

8. Terminal und Fehlersuche

# Kompilieren und starten
gcc -Wall -Wextra -g programm.c -o programm
./programm
 
# Prozesse ansehen
ps -f
pstree -p
 
# IPC-Ressourcen ansehen
ipcs -q
ipcs -m
 
# Dokumentation
man 2 fork
man 2 waitpid
man 2 pipe
man 3 mkfifo
man 2 msgsnd
man 2 shmat
man 2 sigaction
man 7 signal
man 7 signal-safety

Fehlerbehandlung

#include <stdio.h>
#include <errno.h>
 
if (/* Systemaufruf */ == -1) {
    perror("Funktionsname");
    // Fehler behandeln, gegebenenfalls Ressourcen freigeben
}
FunktionFehlerwert
fork, pipe, open, read, write-1
wait, waitpid-1
ftok(key_t)-1
msgget, msgsnd, msgrcv, msgctl-1
shmget, shmdt, shmctl-1
shmat(void *)-1
signalSIG_ERR
kill, setitimer-1

9. Letzte Lerncheckliste

  • Ich kann die drei Rückgabefälle von fork() erklären.
  • Ich kann einen vorgegebenen Prozessbaum zeichnen und programmieren.
  • Ich kann mit waitpid() auf Kinder warten und Exit-Codes auswerten.
  • Ich weiß: fd[0] liest, fd[1] schreibt.
  • Ich weiß, warum unbenutzte Pipe-Enden geschlossen werden müssen.
  • Ich kann EOF und Kommunikationsfehler erkennen.
  • Ich kann Pipe und FIFO unterscheiden.
  • Ich kenne den vollständigen Ablauf einer Message Queue.
  • Ich weiß: Nachrichtengröße ohne mtype angeben.
  • Ich kenne den vollständigen Ablauf von Shared Memory.
  • Ich kann shmdt() und IPC_RMID unterscheiden.
  • Ich kann Signale senden und einen einfachen Handler schreiben.
  • Ich weiß, welche Signale nicht abgefangen werden können.
  • Ich kann alle Ressourcen am Ende sauber freigeben.

Wichtigste Reihenfolge bei jeder Aufgabe

Rückgabewerte prüfen → Prozesse richtig verzweigen → Daten übertragen → Ende erkennen → Ressourcen schließen/entfernen → Kinder einsammeln.

Quellen