24.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 vielleicht – mal von der klassischen Ein-/Ausgabe abgesehen – einer der wichtigsten Wege, mit den eigenen Prozessen zu interagieren.
24.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 Pipesymbol »|« 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 Ein-/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) zwischen zwei eigentlich durch den Kernel voreinander geschützten Prozessen Daten ausgetauscht werden müssen, 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 ein 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 Vater zum Kind aufgebaut werden.
int fds[2];
pipe(fds);
if( fork() == 0 )
{
// Kind-Prozess
...
read(fds[0], buffer, sizeof(buffer));
...
} else {
// Vater-Prozess
...
write(fsd[1], buffer, sizeof(buffer));
...
}
Listing 24.28 Zugang zu einer Pipe vererben
Die Shell intern
Die Shell arbeitet mit dem Pipe-Symbol »|« ganz ähnlich. Die Shell nutzt ja ebenfalls fork() und exec(), um Kind-Prozesse 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.
Das wird mit dem Syscall dup2 erledigt, dem man als Argument den zu kopierenden sowie den Zieldeskriptor übergibt. Betrachten wir zur Illustration das folgende Beispiel, in dem der Aufruf »ps | tail« ausgeführt werden soll:
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 ); }
Listing 24.29 So arbeitet die Shell.
Im ersten Abschnitt wird das beschreibare 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 Kind-Prozess 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 noch die Deskriptoren verknüpft – schließlich muss ja auch irgendwer lesen und schreiben können. Aber das war es dann auch.
24.6.2 Semaphore
Semaphore sind im Gegensatz zu Pipes keine Möglichkeit, Daten zwischen unterschiedlichen Prozessen auszutauschen.
Es handelt sich bei ihnen um Datenobjekte, auf die zwei Operationen ausgeführt werden können: einen Zähler erhöhen beziehungsweise ihn heruntersetzen.
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 ein 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.
Aber was muss beim Verkleinern atomar ausgeführt werden? Na 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 werden, könnte ein nun lauffähiger Prozess versuchen, auf dieselbe Ressource zuzugreifen.
Er 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 werden. Kommt nun der erste Prozess wieder an die Reihe, würde er einfach weitermachen und eine Null in den Speicher schreiben. Natürlich würde er glauben, dass er die Ressource jetzt allein nutzen kann, und dies natürlich auch tun. Das Ergebnis wäre eine potenziell zerstörte Datenstruktur, da zwei Prozesse, die nichts voneinander wissen, auf ihr arbeiten. Außerdem würden beide Prozesse anschließend das Semaphor wieder freigeben und dazu den gespeicherten Zähler jeweils um Eins erhöhen. 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 wurde, 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 der Einbindung 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(), mit der Ausnahme, das dieser Aufruf nicht blockiert. 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 also 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 das 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 das aber leider nicht selbstverständlich ist und es schon so manche Selbstblockade – einen 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. <Allerdings soll es auch in dem einen oder anderen Monitor schon mal eine Endlosschleife gegeben haben ...>
24.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 von 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 von Message Queues an, die verwendet werden dürfen, und MSGTQL die maximale Anzahl von Messages, die gesendet werden dürfen, bevor sie abgeholt werden müssen. <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. msgflag enthält die gesetzten Zugriffsrechte dieser Message 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.
struct my_msg { long mtype; char mtext[123]; }Listing 24.30 Message Queue
- Der letzte Parameter flg wird für eine nicht-blockierende Kommunikation verwendet. Sollte beispielsweise die Queue voll sein, wird gewartet, bis Platz in der Queue ist, um die Message zu senden. Dies kann natürlich extrem lange dauern und kann bei Bedarf durch das Flag IPC_NOWAIT unterdrückt werden.
- 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.
- Über msgtyp wird der Typ der Message festgelegt, die man empfangen möchte. Soll einfach die nächste vorliegende Message empfangen werden, 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 dient der Parameter flg wieder der 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.
- int msgctl(int msqid, int cmd, struct msqid_ds *buf)
- Der msgctl()-Syscall wird dazu 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.
Der letzte Parameter buf wird abhängig von den eben genannten Operationen verwendet. Die Struktur msqid_ds hat die folgenden Bestandteile:
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() */ };
Listing 24.31 msqid_ds
Um Ihnen zumindest ein kurzes Anwendungsbeispiel zu liefern, sind nun einige Zeilen des AstroCam-Quellcodes abgedruckt. Dieser Code erstellt eine Message Queue mit bestimmten Zugriffsrechten und empfängt an diese Message Queue versandte Messages in einer Schleife.
/* 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); } ... }
Listing 24.32 In der Anwendung
24.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 ist das IPC-Konzept des sogenannten Shared Memory (SHM).
Wie das Ganze funktioniert, soll am typischen Gebrauch der Syscalls erläutert werden:
- 1. 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 denn nun geöffnet werden soll – Näheres dazu finden Sie auf der Manpage.
- Interessant ist jedoch, dass der Aufruf im Erfolgsfall einen Dateideskriptor zurückgibt. Über diesen Deskriptor kann man dann auf den gemappten Bereich zugreifen.
- 2. void* mmap(void* start, size_t len, int pro , int flags, int fd, off_t o)
- Mittels mmap() kann nun ein Filedeskriptor 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.
- 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.
- 3. int shm_unlink(const char *name)
- Mit diesem Kommando kann man schließlich einen mit shm_open() geöffneten Bereich wieder freigeben.
Damit zwei oder mehr Prozesse auf einen solchen gemeinsamen Speicherbereich zugreifen können, müssen natürlich alle dieselbe ID angeben – sonst geht's schief. Intern ist das Ganze auch recht einfach realisiert: Es werden nämlich identische physikalische Speicherseiten des RAMs in die unterschiedlichen Adressräume der Prozesse eingebunden.
Betrachten wir noch ein kurzes Beispiel:
// Zugriffsrechte festlegen #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");
Listing 24.33 Ein Beispiel
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 z. B. auslesen könnte.
Es bleibt natürlich das Problem der Synchronisierung zwischen zwei kommunizierenden Prozessen bestehen, schließlich sollen keine Nachrichten durch Überschreiben verloren gehen oder andere Phänomene – wie beispielsweise das Auslesen des Speicherbereichs, während dieser gerade geändert wird – auftreten. Dafür bieten sich nun wiederum Mechanismen wie Signale oder Semaphore an.
24.6.5 Unix-Domain-Sockets
Sockets kennen Sie bereits aus Kapitel 10, wo diese zur Herstellung einer Netzwerkkommunikation benutzt wurden. In einem gewissen Sinne findet auch dort eine Kommunikation zwischen Prozessen statt, nur sind diese eben durch ein Netzwerk voneinander getrennt.
Sockets sind z. B. im Gegensatz 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 Interprozesskommunikation ergänzt. Während bei TCP/IP die Verbindung über die beiden Rechneradressen sowie die jeweils benutzten Portnummern 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 werden für einen Verbindungsaufbau folgende Schritte notwendig:
- 1. int socket(int domain, int type, int protocol)
- Es muss ein Socket vom Typ AF_UNIX mit dem socket()-Syscall angelegt werden.
- 2. 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. Aber auch wenn ein Unix-Domain-Socket im Dateisystem zu finden ist, finden natürlich keine Zugriffe auf das Speichermedium statt. Es handelt sich lediglich um eine Repräsentation der Verbindung.
- 3. 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.
- 4. 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«:
- 1. 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.
- 2. 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.
- 3. int listen(int fd, int backlog)
- Schließlich wird mit listen() auf dem Socket nach ankommenden Verbindungen gelauscht.
- 4. int accept(int fd, struct sockaddr* addr, socklen_t* len)
- Diese können schließlich mit dem accept()-Syscall akzeptiert werden. Ruft der Server diesen Syscall auf, wird dessen Prozess in der Regel blockiert, bis ein Client »angebissen« hat.
- 5. ssize_t read(int fd, void* buf, size_t count) ssize_t write(int fd, const void* buf, size_t count)
- Nachdem die Verbindung aufgebaut worden ist, können wiederum Daten gesendet und empfangen werden.
- 6. 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. Und auch per TCP/IP ist über »localhost« 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 schlicht 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 <Wir erinnern uns an die Unix-Philosophie …> zu bleiben.