26.6 IPC im Detail
Als Nächstes wollen wir uns etwas näher mit der Interprozesskommunikation, der IPC, auseinandersetzen. In diesem Kapitel haben wir uns bereits ausführlich mit Signalen beschäftigt. Für Benutzer sind Signale – von der klassischen Ein-/Ausgabe einmal abgesehen – einer der wichtigsten Wege, mit den eigenen Prozessen zu interagieren.
26.6.1 Pipes und FIFOs
Pipes und FIFOs kennen Sie bereits aus der Shell als eine wichtige Möglichkeit, zwei Prozesse miteinander interagieren zu lassen. Die Anwendung sah dabei so aus, dass über das Pipe-Symbol »|« die Ausgabe eines Prozesses auf die Eingabe eines anderen umgeleitet wird. Bei einer Named Pipe/FIFO würde man dagegen eine entsprechende Datei erstellen, um per expliziter Eingabe-/Ausgabeumleitung schließlich die Daten auszutauschen. Die »Kommunikation zweier Prozesse« bezieht sich jedoch in jedem Fall auf die zu verarbeitenden Daten und weniger auf wechselseitig ausgetauschte (Status-)Informationen.
Da bei einer Pipe (wie generell bei jeder Art von IPC) Daten zwischen zwei Prozessen ausgetauscht werden müssen, die eigentlich durch den Kernel voreinander geschützt sind, ist natürlich ein Syscall notwendig. Der Syscall zum Erstellen einer Pipe lautet demzufolge auch pipe(). Als Argument für diesen Syscall wird ein Ganzzahl-Array der Größe 2 erwartet, in dem die zwei Dateideskriptoren gespeichert werden.
Über diese Dateideskriptoren kann schließlich auf die Pipe genau wie auf normale Dateien zugegriffen werden. Dabei ist der eine Deskriptor zum Schreiben da und der andere zum Lesen aus der Pipe. Wird also eine Pipe vor einem fork() erstellt, kann über die vererbten Deskriptoren eine Kommunikation vom Elternprozess zum Kindprozess aufgebaut werden.
Listing 26.28 Zugang zu einer Pipe vererben
int fds[2];
pipe(fds);
if( fork() == 0 )
{
// Kind-Prozess
...
read(fds[0], buffer, sizeof(buffer));
...
} else {
// Elternprozess
...
write(fds[1], buffer, sizeof(buffer));
...
}
Die Shell intern
Die Shell arbeitet mit dem Pipe-Symbol »|« ganz ähnlich. Denn die Shell nutzt ebenfalls fork() und exec(), um Kindprozesse zu erzeugen und anschließend in diesem neuen Prozess das gewünschte Programm zu starten. Um jedoch den Spezialfall erreichen zu können, dass die Ausgabe beziehungsweise die Eingabe eines Prozesses umgeleitet wird, müssen die Pipe-Deskriptoren auf die Standardeingabe beziehungsweise -ausgabe kopiert werden.
[zB]Dies erledigt man mit dem Syscall dup2, dem man als Argument den zu kopierenden sowie den Zieldeskriptor übergibt. Betrachten wir hierzu das folgende Beispiel, in dem der Aufruf ps | tail ausgeführt werden soll:
Listing 26.29 So arbeitet die Shell.
int fds[2];
pipe(fds);
if( fork() != 0 )
{
// ps starten
dup2( fd[1], 1 );
execvp( "ps", NULL );
}
if( fork() != 0 )
{
// tail starten
dup2( fd[0], 0 );
execvp( "tail", NULL );
}
Im ersten Abschnitt wird das beschreibbare Ende der Pipe auf die »1«, also auf die Standardausgabe, kopiert, und anschließend wird ps gestartet, das nun nicht auf den Bildschirm, sondern in die Pipe schreibt. Anschließend wird im zweiten Kindprozess das lesbare Ende der Pipe auf die Standardeingabe »0« kopiert. Im Folgenden wird also tail nicht von der Tastatur, sondern aus der Pipe lesen.
Und der Kernel?
Für den Kernel ist eine Pipe nur ein 4-kB-Puffer, bei dem er sich noch merken soll, wo zuletzt gelesen und wo zuletzt geschrieben wurde. Natürlich sind mit diesen Daten auch noch die Deskriptoren verknüpft – schließlich muss ja auch irgendwer lesen und schreiben können. Aber das war es dann auch.
26.6.2 Semaphore
Semaphore bieten im Gegensatz zu Pipes keine Möglichkeit, Daten zwischen unterschiedlichen Prozessen auszutauschen. Es handelt sich vielmehr um Datenobjekte, auf die zwei Operationen ausgeführt werden können: einen Zähler erhöhen beziehungsweise herabsetzen.
Mit diesen Operationen können Zugriffe auf exklusive Ressourcen synchronisiert werden. Schließlich ist beim Multitasking keine feste Reihenfolge der Prozess- und Thread-Ausführung garantiert, und eine Unterbrechung kann jederzeit eintreten. Sollen also komplexe Datenstrukturen verwaltet und Inkonsistenzen vermieden werden, könnte man zum Beispiel auf Semaphore zurückgreifen.
Semaphore sind dabei nichts weiter als Zähler: Ist der Zähler größer als Null, sind die Ressourcen noch verfügbar. Das Betriebssystem oder eine Thread-Bibliothek wird nun die Operation des Verkleinerns des Zählers atomar anbieten.
Eine atomare Ausführung kann nicht unterbrochen werden.
Was aber muss beim Verkleinern atomar ausgeführt werden? Nun ja, schließlich muss der Originalwert zuerst ausgelesen werden, dann muss er auf Eins getestet werden, und zum Schluss muss der neue Wert geschrieben werden. Würde der Prozess/Thread während dieser Ausführung zum Beispiel nach dem Lesen des Wertes unterbrochen, so könnte ein nun lauffähiger Prozess versuchen, auf dieselbe Ressource zuzugreifen.
Dieser zweite Prozess würde ebenfalls eine Eins auslesen, den Wert verringern und die Null zurückschreiben. Dann könnte er die Ressource nutzen und würde mittendrin wieder unterbrochen. Käme nun der erste Prozess wieder an die Reihe, würde er einfach weitermachen und eine Null in den Speicher schreiben. Natürlich glaubte er, dass er die Ressource jetzt allein nutzen könne, und dies auch tun. Das Ergebnis wäre eine potenziell zerstörte Datenstruktur, da zwei Prozesse, die nichts voneinander wissen, auf ihr arbeiten. Außerdem gäben beide Prozesse anschließend das Semaphor wieder frei und erhöhten dazu den gespeicherten Zähler jeweils um eins. Das Ergebnis wäre ein Semaphor, das plötzlich zwei Prozessen den Zugriff auf eine exklusive Ressource erlauben würde – double trouble!
Wie bereits erwähnt, gibt es viele verschiedene Implementierungen für Semaphore. Soll dieses Konzept zum Beispiel für Prozesse oder Kernel-Threads implementiert werden, muss das Betriebssystem über Syscalls entsprechende Schnittstellen anbieten. Sollen Userlevel-Threads mittels Semaphoren synchronisiert werden, muss die Thread-Bibliothek dagegen entsprechende Möglichkeiten anbieten. Zwar kann der Prozess mit den vielen Userlevel-Threads auch unterbrochen werden, wenn dort gerade ein Semaphor umschaltet, jedoch ist für das Scheduling der Threads immer noch die Bibliothek zuständig – und die wird sich schon nicht selbst sabotieren.
Die POSIX-Schnittstelle für Semaphore wollen wir im Folgenden erläutern. Nach Einbinden der Headerdatei semaphore.h können folgende Aufrufe genutzt werden:
- int sem_init(sem_t* sem, int pshared, unsigned int value)
Mit diesem Aufruf wird ein Semaphor vom Typ sem_t initialisiert. Als Argumente werden diesem Aufruf zwei Werte übergeben: Der erste legt fest, ob das Semaphor lokal für den erzeugenden Prozess (pshared = 0) ist – mithin also ein Semaphor zur Synchronisation von Threads – oder ob es über mehrere Prozesse geteilt werden soll (pshared > 0). Mit value wird das Semaphor schließlich initialisiert. - Zurzeit sind mit dieser API leider »nur« Per-Thread-Semaphore möglich – Werte über 0 für pshared führen zu Problemen. Na ja, vielleicht wird's ja irgendwann noch. ;-)
- int sem_wait(sem_t* sem)
Mit diesem Aufruf wird man versuchen, ein Semaphor »zu bekommen«. Dazu wird der Thread so lange blockiert, bis die Ressource verfügbar ist. - int sem_trywait(sem_t* sem)
Dieser Aufruf funktioniert wie sem_wait(), blockiert aber anders als dieser nicht. Stattdessen kehrt die Funktion mit einem entsprechenden Rückgabewert zurück. - int sem_post(sem_t* sem)
Mit diesem Aufruf wird das Semaphor erhöht und die Ressource somit wieder freigegeben. - int sem_getvalue(sem_t* sem, int* sval)
Mit diesem Aufruf kann man schließlich den Wert eines Semaphors auslesen. Dazu muss ein Zeiger auf eine Integer-Variable übergeben werden, in der dann der entsprechende Wert gespeichert werden kann. - int sem_destroy(sem_t* sem)
Mit diesem Wert wird das Objekt »zerstört«, was aber nichts weiter bewirkt, als dass alle noch wartenden Threads wieder lauffähig werden.
Natürlich kann man mit Semaphoren als Programmierer auch viel Mist bauen. Schließlich funktioniert die Synchronisierung von verschiedenen Prozessen oder Threads nur, wenn man sie auch richtig einsetzt. Da dies leider nicht selbstverständlich ist und es schon so manche Selbstblockade (Verklemmung, Deadlock) – gegeben hat, bieten manche Programmiersprachen eigene, einfachere Konzepte zur Synchronisierung an. So kann zum Beispiel in Java eine Methode als Monitor deklariert werden, was zur Folge hat, dass jeweils nur ein Thread in dieser Funktion laufen kann. Andere Threads, die den Monitor aufrufen, werden blockiert und erst wieder gestartet, wenn dieser wieder frei ist. [Fn. Allerdings soll es auch in dem einen oder anderen Monitor schon mal eine Endlosschleife gegeben haben ...]
26.6.3 Message Queues
Bei den sogenannten Message Queues handelt es sich ebenfalls um eine Variante der IPC. Message Queues stellen eine Warteschlange dar. Mit ihnen werden Nachrichten eines bestimmten Typs gesendet, die dann nacheinander vom Empfänger abgeholt werden. Dabei gelten allerdings einige Einschränkungen: MSGMAX gibt die maximale Anzahl an Bytes an, die gesendet werden können, MSGMNB hingegen gibt die maximale Anzahl an Bytes an, die eine Message Queue ausmachen darf. MSGMNI gibt die maximale Anzahl der Message Queues an, die verwendet werden dürfen, und MSGTQL die maximale Anzahl der Messages, die gesendet werden dürfen, bevor sie abgeholt werden müssen. [Fn. Oftmals sind MSGMAX und MSGMNB auf denselben Wert, etwa 2048, gesetzt. Auch MSGMNI und MSGTQL sind oft auf den gleichen Wert gesetzt. Dies kann z. B. 40 (OpenBSD) oder 50 (Linux) sein. Die für Ihr System definierten Werte finden Sie in sys/msg.h.]
Es stehen die folgenden Funktionen zur Verfügung:
- int msgget(key_t key, int msgflag)
msgget() gibt die ID einer Message Queue zurück, die mit dem Schlüssel key verbunden ist. Die Variable msgflag enthält die gesetzten Zugriffsrechte dieser Queue. - int msgsnd(int id, const void *msgp, size_t sz, int flg)
Die Funktion msgsnd() wird dazu verwendet, eine Message zu versenden. Dabei ist id die ID der Message Queue, an die diese Message geschickt werden soll; dieser Wert entspricht dem Rückgabewert von msgget(). msgp ist die Nachricht, die versandt werden soll, und sz ist deren Länge in Byte. Dabei gibt mtype den Message-Typ an und mtext den Inhalt des Texts, dessen Länge je nach Wunsch angepasst werden muss. - int msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int flg)
Um eine gesendete Message zu empfangen, wird die Funktion msgrcv verwendet. Ihr übergibt man zunächst die ID der Message Queue via msqid. In msgp wird die empfangene Message gespeichert. Dabei werden maximal msgsz Bytes empfangen. - int msgctl(int msqid, int cmd, struct msqid_ds *buf)
Der msgctl()-Syscall wird verwendet, um Manipulationen an der Message Queue mit der ID msqid durchzuführen. Die gewünschte Aktion, die msgctl() dabei durchführen soll, wird über cmd festgelegt, das folgende Werte annehmen kann: - IPC_STAT
Hierbei wird die Statusinformation einer Message Queue in buf geschrieben. - IPC_SET
Setzen von Eigentümer- und Zugriffsrechten. Diese Werte übergibt man via buf. - IPC_RMD
Löscht eine Message Queue.
Listing 26.30 Message Queue
struct my_msg {
long mtype;
char mtext[123];
}
Der letzte Parameter flg wird bei nicht-blockierender Kommunikation verwendet. Sollte beispielsweise die Queue voll sein, wird gewartet, bis Platz in der Queue ist, um die Message zu senden. Dies kann unter Umständen zu lange dauern, daher können Sie diese Funktion bei Bedarf durch das Flag IPC_NOWAIT unterdrücken.
Über msgtyp wird der Typ der Message festgelegt, die man empfangen möchte. Soll einfach die nächste vorliegende Message empfangen werden, so setzt man den Wert auf 0. Setzt man msgtyp auf einen Wert, der kleiner als 0 ist, wird die nächste Message empfangen, deren Typ maximal den Wert des absoluten Betrags von msgtyp hat.
Auch hier bestimmt der Parameter flg wieder über die nicht blockierenden Arbeitsweise. Normalerweise wird, wenn ein explizit gewünschter Typ (oder überhaupt eine Message) noch nicht vorliegt, so lange gewartet, bis solch eine Message vorliegt und aus der Message Queue abgeholt werden kann. Auch hier lässt sich diese Blockierung durch das Flag IPC_NOWAIT unterbinden.
Der letzte Parameter buf wird abhängig von den eben genannten Operationen verwendet. Die Struktur msqid_ds hat die folgenden Bestandteile:
Listing 26.31 msqid_ds
struct msqid_ds {
struct ipc_perm msg_perm; /* Zugriffsrechte */
u_long msg_cbytes; /* verwendete Bytes */
u_long msg_qnum; /* Anzahl der Messages */
u_long msg_qbytes; /* Max. Byte-Anzahl */
pid_t msg_lspid; /* PID der letzten msgsnd() */
pid_t msg_lrpid; /* PID der letzten msgrcv() */
time_t msg_stime; /* Zeitpunkt letzt. msgsnd() */
time_t msg_rtime; /* Zeitpunkt letzt. msgrcv() */
time_t msg_ctime; /* Zeitpunkt letzt. msgctl() */
};
[zB]Um Ihnen zumindest ein kurzes Anwendungsbeispiel zu geben, haben wir im Folgenden einige Zeilen des AstroCam-Quellcodes [Fn. Der vollständige Quellcode ist auf www.wendzel.de zu finden.] abgedruckt. Dieser Code erstellt eine Message Queue mit bestimmten Zugriffsrechten und empfängt an diese Message Queue versandte Messages in einer Schleife.
Listing 26.32 Message Queues in der Praxis
/* Zunächst wird eine Message Queue mit der ID von
* ipckey erstellt. */
if((srvid=msgget(globconf.ipckey,
S_IRWXU|S_IWGRP|S_IWOTH|IPC_CREAT))==-1){
perror("msgget");
sighndl(1000);
return –1;
}
...
/* Schleife zum Empfang der Messages */
while(msgrcv(srvid, &recvdata, 10, 0, 0)!=-1)
{
if(something happens){
/* Message Queue löschen */
if(msgctl(srvid, IPC_RMID, NULL)==-1)
logit("msgctl (rmid) problem!");
exit(1);
}
...
}
26.6.4 Shared Memory
In diesem Abschnitt müssen wir uns etwas näher mit dem schönen Wörtchen »eigentlich« befassen. Schließlich haben wir gesagt, dass die Adressräume unterschiedlicher Prozesse voneinander getrennt sind. Eigentlich. Eine Ausnahme von dieser Regel bildet das IPC-Konzept des sogenannten Shared Memory (SHM).
Wie Shared Memory funktioniert, soll am typischen Gebrauch der Syscalls erläutert werden:
- int shm_open(const char *name, int oflag, mode_t mode)
Zuerst öffnet man mit shm_open() ein durch einen Namen identifiziertes SHM-Objekt. Ein solches Objekt wird ähnlich wie ein absoluter Dateiname mit einem Slash »/« beginnen, aber weiter keine Sonderzeichen enthalten. Mit weiteren Flags kann dann – ähnlich wie beim normalen open()-Syscall – noch bestimmt werden, wie genau der SHM-Bereich geöffnet werden soll. Näheres dazu finden Sie auf der Manpage. - void* mmap(void* start, size_t len, int pro , int flags, int fd, off_t o)
Mittels mmap() kann nun ein File-Deskriptor fd in den Speicher eingebunden – gemappt – werden. Dazu wird diesem Syscall unter anderem der entsprechende Dateideskriptor übergeben. Der Syscall selbst liefert dann einen Pointer auf den Speicherbereich zurück, über den auf die »Datei« zugegriffen werden kann. - int shm_unlink(const char *name)
Mit diesem Kommando kann man schließlich einen mit shm_open() geöffneten Bereich wieder freigeben.
Interessant ist jedoch, dass der Aufruf im Erfolgsfall einen Dateideskriptor zurückgibt. Über diesen Deskriptor kann man dann auf den gemappten Bereich zugreifen.
Natürlich kann mmap() auch normale Dateien in den Hauptspeicher mappen, da aber Shared Memory nach einem Aufruf von shm_open() auch durch einen Dateideskriptor identifiziert wird, kann hier derselbe Mechanismus greifen.
Damit zwei oder mehr Prozesse auf einen solchen gemeinsamen Speicherbereich zugreifen können, müssen alle dieselbe ID angeben – sonst geht's schief. Auch intern ist das Ganze recht einfach realisiert: Es werden nämlich identische physische Speicherseiten des RAMs in die unterschiedlichen Adressräume der Prozesse eingebunden. Betrachten wir noch ein kurzes Beispiel:
Listing 26.33 Ein Beispiel
#define MODE (S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH)
fd = shm_open("/test", O_RDWR | O_CREAT, MODE);
ptr = mmap(NULL, 64, PROT_READ | PROT_WRITE, \
MAP_SHARED, fd, 0);
...
memcpy(ptr, "Hello World!", 13);
...
shm_unlink("/test");
Auf den gemappten Speicher kann also wirklich wie auf normale Variablen zugegriffen werden. In diesem Beispiel fehlt natürlich noch der Code eines weiteren Prozesses, der den Bereich dann beispielsweise auslesen könnte.
Es bleibt noch das Problem der Synchronisierung zwischen zwei kommunizierenden Prozessen bestehen, schließlich sollen keine Nachrichten durch überschreiben verloren gehen oder andere Phänomene auftreten – wie beispielsweise das Auslesen des Speicherbereichs, während dieser gerade geändert wird. Dafür bieten sich nun wiederum Mechanismen wie Signale oder Semaphore an.
26.6.5 Unix-Domain-Sockets
Sockets dienen der Herstellung und Identifizierung von Netzwerkkommunikation. In einem gewissen Sinne findet auch dort eine Kommunikation zwischen Prozessen statt, nur sind diese Prozesse eben durch ein Netzwerk voneinander getrennt.
Sockets sind im Unterschied zu Pipes eine bidirektionale Schnittstelle zur Interprozess- oder Netzwerkkommunikation.
Die gängigen TCP/IP-Sockets zur Netzwerkkommunikation werden nun unter Unix durch die Unix-Domain-Sockets für die Interprozess-Kommunikation ergänzt. Während bei TCP/IP die Verbindung über die beiden Rechneradressen sowie die jeweils benutzten Port-Nummern charakterisiert wird, geschieht dies bei Unix-Domain-Sockets über einen Dateinamen.
Bei der IPC über Unix-Domain-Sockets wird, wie im »richtigen« Netzwerk, das Client-Server-Modell angewandt. Auf dem Client sind für einen Verbindungsaufbau folgende Schritte durchzuführen:
- int socket(int domain, int type, int protocol)
Es muss ein Socket vom Typ AF_UNIX mit dem socket()-Syscall angelegt werden. - int connect(int fd, const struct sockaddr* serv, socklen_t len)
Der Socket wird über den connect()-Syscall mit der Serveradresse – dem Dateinamen des Unix-Domain-Sockets – verbunden. Befindet sich ein Unix-Domain-Socket im Dateisystem, so finden natürlich keine Zugriffe auf das Speichermedium statt. Es handelt sich lediglich um eine Repräsentation der Verbindung. - ssize_t read(int fd, void* buf, size_t count) ssize_t write(int fd, const void* buf,
size_t count)
Der Client kann nun mittels des write()-Syscalls Daten senden und mit dem read()-Syscall auch Daten empfangen. - int close(int fd)
Die Verbindung kann mittels des close()-Syscalls beendet werden.
Für den Server sehen diese Schritte etwas anders aus. Hier liegt der Schwerpunkt auf dem Bereitstellen einer Serveradresse:
- int socket(int domain, int type, int protocol)
Wie auch beim Client muss zuerst der Socket mit dem richtigen Typ über den socket()-Syscall angelegt werden. - int bind(int fd, const struct sockaddr* addr, socklen_t len)
Als Nächstes muss der Socket mittels des bind()-Syscalls an eine Adresse gebunden werden. - int listen(int fd, int backlog)
Schließlich wird mit listen() auf dem Socket nach ankommenden Verbindungen gelauscht. - int accept(int fd, struct sockaddr* addr, socklen_t* len)
Diese Verbindungen können schließlich mit dem accept()-Syscall akzeptiert werden. Ruft der Server diesen Syscall auf, so wird sein Prozess in der Regel blockiert, bis ein Client »angebissen« hat. - ssize_t read(int fd, void* buf, size_t count) ssize_t write(int fd, const void* buf,
size_t count)
Nach Aufbau der Verbindung können wiederum Daten gesendet und empfangen werden. - int close(int fd)
Auch der Server kann ein close() zum Schließen der Verbindung aufrufen.
Bei TCP/IP-Sockets sieht der Ablauf natürlich sehr ähnlich aus, allein die Adressstrukturen sind anders. Auch per TCP/IP ist über localhost (IP 127.0.0.1) eine Kommunikation mit anderen lokal laufenden Prozessen möglich.
In jedem Fall nutzt diese Art der IPC den Vorteil, dass der Server keine Kenntnisse von potenziellen Clients haben muss – bei einer Pipe ist dies bekanntlich anders. Dort müssen sogar die Deskriptoren vererbt werden, während bei Unix-Domain-Sockets lediglich der Dateiname bekannt sein muss. Dieser kann jedoch auch automatisch generiert oder vom Benutzer festgelegt werden.
Man könnte nun behaupten, dass Unix-Domain-Sockets aufgrund der möglichen lokalen TCP/IP-Kommunikation überflüssig wären. Sie sind es aber nicht. Der mit diesem Socket-Typ erreichbare Durchsatz liegt nämlich um Größenordnungen über einer TCP/IP-Verbindung, die lokal über das Loopback-Interface genutzt wird. Da sich beide Socket-Typen auch nur in der Adressierung voneinander unterscheiden, wird von Entwicklern auch häufig AF_UNIX als Alternative zu AF_INET angeboten, um so mehrere Gigabyte pro Sekunde von einem Prozess zu einem anderen schaufeln zu können und trotzdem netzwerktransparent [Fn. Wir erinnern uns an die Unix-Philosophie ...] zu bleiben.
Ihr Kommentar
Wie hat Ihnen das <openbook> gefallen? Wir freuen uns immer über Ihre freundlichen und kritischen Rückmeldungen.