Kapitel 8

Kommunikation

IN DIESEM KAPITEL

  • lernen Sie, wie Aktivitäten miteinander Informationen austauschen können,
  • erfahren Sie, dass Pipelines nicht nur für Öl und Gas da sind,
  • treffen Sie auf wichtige Begriffe wie Nachrichtenwarteschlangen, Signalhandler und Shared-Memory-Segmente.

Wenn Prozesse und Threads miteinander kooperieren, tauschen sie fast immer Daten aus. Für diesen Austausch stellen Betriebssysteme wiederum verschiedene Dienste zur Verfügung, deren Vor- und Nachteile Sie in diesem Kapitel kennenlernen. Natürlich geht es auch wieder praktisch zu: Sie sind eingeladen, die wichtigsten Mechanismen selbst praktisch zu erproben.

Wozu kommunizieren?

Im Kapitel 5 haben Sie erfahren, wie Aktivitäten in Systemen verwirklicht werden, und dank Kapitel 7 wissen Sie auch schon verhältnismäßig genau, wie Aktivitäten spezifisch aufeinander warten können. Dieses Warten geschieht in realen Systemen natürlich nicht zum Selbstzweck, sondern ist Ausdruck einer Kooperation zwischen den Aktivitäten.

Kooperation erfordert jedoch fast immer mehr als reine Synchronisation: Sie ist normalerweise mit dem Austausch von Daten verbunden.

Threads eines Adressraums haben gegenüber Prozessen diesbezüglich einen großen Vorteil: Da sie in ein und demselben Adressraum liegen, hat jeder Thread Zugriff auf alle globalen Daten. Selbst der Zugriff auf threadlokale Daten ist von anderen Threads möglich, jedoch im Regelfall unerwünscht.

Bei Prozessen sieht der Sachverhalt allerdings anders aus. Hier bildet der Adressraum eine zunächst unüberwindliche Grenze. Dies gilt genauso, wenn Threads verschiedener Adressräume miteinander kommunizieren wollen. Man benötigt gewissermaßen Mechanismen, die eine sichere Datenübertragung gewährleisten, ohne die separierenden Eigenschaften der Adressräume unnötig aufzuweichen. Im Laufe der Zeit wurde eine Menge solcher Mechanismen vorgeschlagen, eine weitaus kleinere, aber trotzdem nicht unbeträchtliche Menge steht in gegenwärtigen Betriebssystemen den Nutzern zur Verfügung. Wiederum würde eine erschöpfende Behandlung aller Realisierungsformen den Rahmen dieses Buches sprengen. Sie erlernen stattdessen wesentliche Prinzipien der wichtigsten Mechanismen.

In der Literatur hat sich übrigens für diese Mechanismen der Begriff Inter Process Communication (IPC) Mechanisms etabliert.

Im realen Leben wie in Computersystemen ist Kommunikation immer mit Synchronisation verbunden. Beispielsweise ist es in fast allen Regionen der Welt unmöglich, mehr als einen Teilnehmer gleichzeitig senden zu lassen, ohne dass es zu Datenverlust bei den Zuhörern kommt. Für einen fairen Austausch muss des Weiteren gewährleistet sein, dass jeder Kommunikationsteilnehmer seine Daten übertragen kann. Bestimmte Kommunikationsmechanismen kann man sogar zur Synchronisation einsetzen. Die beiden Kapitel 7 und sind inhaltlich daher sehr eng miteinander verbunden.

Begriffe

Wiederum müssen zunächst einige Begriffe definiert werden. Kommunikation bedeutet, dass Aktivitäten Daten über einen sogenannten Kanal austauschen. Der Kanal ist gewissermaßen das Medium oder die Ressource, über die Daten vom Sender zum Empfänger gelangen. Ist der Kanal nur für eine Richtung geeignet, so spricht man von unidirektionaler Kommunikation, können gleichzeitig Daten in beide Richtungen übertragen werden, so handelt es sich um bidirektionale Kommunikation.

Kommunikation kann man des Weiteren nach der Anzahl der Teilnehmer klassifizieren. Häufig kommuniziert genau ein Sender mit genau einem Empfänger, also gibt es ein 1:1-Verhältnis. Im Kontext von Rechnernetzen spricht man hierbei häufig von Unicast-Betrieb. Davon abzugrenzen ist eine Kommunikation von einem Sender und m Empfängern (1:m). Dies nennt man Multicast-Betrieb, wenn m nicht alle existierenden Prozesse umfasst, und Broadcast, wenn alle existierenden Prozesse die Nachricht empfangen können. Auch ein allgemeines m:n-Verhältnis ist möglich, hat aber keine extra Bezeichnung. Unicast entspricht einem Gespräch zweier Personen (zumindest wenn stets immer nur einer spricht und der andere zuhört), Multicast ist mit einem Vortrag zu vergleichen, und Broadcast würde bedeuten, dass dieser Vortrag per Videostreaming potenziell von allen Menschen auf der Welt empfangen werden kann (denken Sie an den Segen »Urbi et orbi« des Papstes zu Weihnachten).

Verbindungsorientierte Kommunikation liegt dann vor, wenn vor der eigentlichen Datenübertragung ein Aufbau der Verbindung oder der Infrastruktur nötig ist und danach der entsprechende Abbau. Man spricht davon, dass eine virtuelle, temporäre Verbindung zwischen Sender und Empfänger(n) existiert. Im Gegensatz dazu wird bei der verbindungslosen Kommunikation einfach gesendet und empfangen. Beide Kategorien entstammen wiederum der Welt der Kommunikationsnetze, werden aber auch gern auf Betriebssysteme angewendet. Beispielsweise ist die Kommunikation mittels unbenannter Pipes (diese finden Sie im Abschnitt »We are laying a pipeline«) verbindungsorientiert, während Signale (im Abschnitt »Prozesse, hört die Signale«) eine verbindungslose Form der Kommunikation darstellen.

Synchrone und asynchrone Operationen

Zu einer Datenübertragung gehört mindestens eine Sendeoperation sowie eine Empfangsoperation. Beide können synchron oder asynchron erfolgen. Eine synchrone Sendeoperation wartet, bis der Empfänger die Nachricht entgegengenommen oder gelesen hat, sie blockiert. Dies bringt den Vorteil einer impliziten Empfangsbestätigung: Die Instruktionen nach dem Senden können sich darauf verlassen, dass die Nachricht angekommen ist (anderenfalls würden sie nicht abgearbeitet, sondern der Sender wäre noch blockiert). Kein Licht ohne Schatten: Beim synchronen Senden kann der Empfänger den Sender prima blockieren, indem er sich einfach weigert, die Nachricht entgegenzunehmen. Häufig kombiniert man das synchrone Senden daher mit einer Timeout-Bedingung; nach Verstreichen einer definierten Zeitspanne ohne Erfolg der Operation kehrt der Dienst mit einer Fehlermeldung zurück, auch wenn der Empfänger die Nachricht nicht entgegengenommen hat.

Eine asynchrone Sendeoperation kehrt stattdessen sofort zurück; der Sender kann sofort weiterarbeiten, besitzt aber keine Information über Erfolg oder Misserfolg der Datenübertragung. Das Verschicken eines Briefes ist klassisch asynchron, zumindest wenn Sie den Brief in einen Briefkasten werfen. Wollen Sie diesen stattdessen direkt dem Postboten übergeben, so warten Sie auf dessen Ankunft und damit liegt synchrones Senden vor. Das asynchrone Senden wird manchmal auch »Fire-and-Forget« oder »No-Wait-Send« genannt.

Wie sieht es nun beim Empfänger aus? Eine synchrone Empfangsoperation wartet (blockiert), bis wirklich Daten eintreffen. Liegt bereits eine Nachricht vor, dann kehrt die Operation mit dieser zurück. Wurde keine Nachricht gesendet, dann blockiert der Empfänger. Dies kann wiederum mit einem Timeout kombiniert werden, für den Fall, dass der Sender nie eine Nachricht schickt, weil er beispielsweise vorzeitig beendet wurde.

Beim asynchronen Empfangen kehrt die Operation stets sofort zurück: entweder mit der entsprechenden Nachricht oder mit einer Fehlermeldung. Der tägliche Gang zum (eigenen) Briefkasten entspricht dieser Verfahrensweise.

Wenn Senden und Empfangen synchron erfolgen, nennt man das Rendezvous, weil die zuerst ausgeführte Operation stets wartet, bis die spätere eintrifft. Dies hat den weiteren Vorteil, dass die Daten direkt aus dem Adressraum des Senders in denjenigen des Empfängers übertragen werden können; ein Zwischenspeichern innerhalb des Betriebssystems, wie es beim asynchronen Kommunizieren der Fall ist, ist hierbei unnötig.

We are laying a pipeline

Zunächst lernen Sie einen ziemlich einfachen Mechanismus zum Austausch von Daten zwischen Prozessen kennen, die sogenannte Pipe oder Pipeline. Die deutsche Übersetzung »Röhre« ist weitgehend ungebräuchlich, kann aber zur gedanklichen Repräsentation herangezogen werden. Im Prinzip bietet eine Pipe eine unidirektionale Verbindung zwischen zwei Prozessen: Der Sender schreibt Daten mittels write() hinein, der Empfänger liest diese Daten mittels read() wieder heraus, ganz ähnlich, wie es bei normalen Dateien geschieht. Die Daten werden (ebenso wie bei Dateien) als unstrukturierter Bytestrom übertragen. Der Unterschied besteht darin, dass read() blockiert, wenn die Pipe keine Daten enthält, und write() blockiert, wenn die Pipe komplett gefüllt ist (diese hat also eine endliche Kapazität). Es handelt sich um eine Inkarnation des sogenannten Erzeuger-Verbraucher-Problems.

Pipes gibt es in vielen Betriebssystemen, die Idee stammt ursprünglich aus UNIX [4]. Doug McIlroy, der Schöpfer des Konzepts, wollte wirklich gleichzeitig ablaufende Prozesse mit einer Art »Gartenschlauch« verbinden, der aber nicht Wasser, sondern Daten transportiert.

Die Shell bietet den Mechanismus übrigens für den Nutzer an. Mit Hilfe des Symbols | wird stdout des davorstehenden Kommandos mit stdin des nachstehenden Kommandos über eine Pipe verbunden. So werden in der Kommandofolge

images

sämtliche Ausgaben des du-Kommandos mittels einer Pipe an das sort-Kommando übertragen. (Die Kommandofolge gibt eine sortierte Liste des Platzbedarfs sämtlicher Unterverzeichnisse des Homeverzeichnisses des jeweiligen Nutzers aus.)

Anonyme UNIX-Pipe

Bevor Daten übertragen werden können, muss die Pipe zunächst erzeugt werden. Dies erfolgt mit dem Systemruf pipe:

Das als Parameter übergebene Feld pipefd enthält nach der Ausführung zwei sogenannte Dateideskriptoren, wobei pipefd[0] das sogenannte Leseende und pipefd[1] das Schreibende repräsentiert, ganz analog zu den beiden »Öffnungen« eines Wasserrohrs.

Nachdem ein Prozess die Pipe erzeugt hat, kann er über die beiden Deskriptoren prima Daten übertragen: Jedes Bit, das er mit write() auf den Schreibdeskriptor in die Pipe schreibt, kann er anschließend mit read() über den Lesedeskriptor wieder herauslesen. Meist möchte er jedoch einem anderen Prozess diese Daten übermitteln oder von diesem Daten empfangen. Allerdings gibt es nun ein Henne-Ei-Problem: Um mit einem anderen Prozess Daten über die Pipe auszutauschen, müsste er zunächst diesem Prozess einen Deskriptor mitteilen. Dies kann aus naheliegenden Gründen nicht mit der Pipe erfolgen!

Der Prozess kann allerdings fork() aufrufen! (Falls Sie sich nicht mehr an den Mechanismus erinnern können, schlagen Sie bitte noch einmal im Abschnitt »Der Systemruf fork()« des Kapitels 5 nach). fork() kopiert den rufenden Prozess mit all seinen Daten (und somit auch dessen Pipe-Deskriptoren). Nun besitzen Vater- wie Sohnprozess jeweils einen Lese- und einen Schreibdeskriptor.

Man könnte nun auf die Idee kommen, die Pipe einfach bidirektional zu nutzen, indem Vater und Sohn beide Deskriptoren verwenden. Dies funktioniert leider nicht: Beide können zwar auf den Schreibdeskriptor schreiben, aber an den Leseenden bekommt man die Daten nicht mehr getrennt. Stellen Sie sich zur Veranschaulichung eine Röhre vor, in die Sie von der einen Seite Wein und von der anderen Bier hineingießen. Es wird Ihnen nicht gelingen, an der Wein-Eingießseite das Bier und an der anderen den Wein zu exfiltrieren. Man muss sich also konzeptionell für eine Übertragungsrichtung entscheiden. Benötigt man beide Richtungen, so sind zwei getrennte Pipes natürlich möglich.

Im Beispiel (Listing 8.1) sollen Daten vom Vater zum Sohn übertragen werden. Um nicht die Übersicht zu verlieren, ist es günstig, die jeweils unbenutzten Deskriptoren mittels close() zu schließen (der Vater schließt den Lesedeskriptor pipefd[0], der Sohn schließt pipefd[1]). Damit ist die Infrastruktur zur Datenübertragung etabliert, es kann losgehen.

Vater und Sohn können nun unabhängig voneinander Daten austauschen. Der Vater schreibt durch einen oder mehrere write()-Aufrufe die Daten in die Pipe, und der Sohn liest sie durch read(). Die Granularität der Daten (also wie viele Daten auf einen Schlag gelesen oder geschrieben werden) ist dabei freigestellt; diese muss auch nicht identisch für beide Prozesse sein.

Nachdem der Vater alle Daten übertragen hat, schließt er seinen verbliebenen Deskriptor. Ist die Pipe durch den Sohn »leer gelesen«, so liefert dessen read() den Resultatwert 0 (normalerweise die Anzahl der Bytes, die gelesen wurden). Daran erkennt der Sohn, dass der Vater seine Übertragung beendet hat, und er kann nun seinerseits close() aufrufen. Nach diesem letzten close() wird die Pipe durch das Betriebssystem zerstört. Abbildung 8.1 soll den »Lebenszyklus« einer Pipe von ihrer Erstellung über ihre Nutzung bis zur Vernichtung illustrieren.

images

Listing 8.1: Der Vater schickt dem Sohn eine Nachricht über eine Pipe

Beachten Sie im Beispiel die unterschiedliche Granularität des Zugriffs: Während der Sohn die Nachricht byteweise (Zeilen 23 und 30) aus der Pipe liest, schreibt der Vater die Nachricht in einem Rutsch (Zeile 38). In der Praxis würde man natürlich mit größeren Blöcken arbeiten, um möglichst wenige Systemrufe ausführen zu müssen.

Pathologische Situationen

Im Beispiel funktioniert die Datenübertragung zwischen beiden Prozessen einwandfrei, was allerdings damit zu tun hat, dass immer die gleiche Nachricht übertragen wird. In einem realen System ist das nicht der Fall, daher sollen Sie sich in diesem Abschnitt über einige »schwierige« Situationen bei der Nutzung von Pipes Gedanken machen.

Wie verhält sich der Code, wenn der Vater gar keine Daten schickt, sondern seine beiden Deskriptoren sofort schließt? (Probieren Sie es aus, indem Sie die Zeilen 38–41 aus dem Beispiel löschen.) Nun, dafür gibt es schon zwei Möglichkeiten: Entweder führt der Vater das close() zeitlich vor dem read() des Sohnes in Zeile 23 aus. Das Lesen von einem geschlossenen Leseende führt zu einem Ergebniswert von 0 beim read(). Somit wird die while-Schleife nicht betreten, und der Sohn gibt den leeren Puffer aus. Die andere Möglichkeit besteht darin, dass das close() des Vaters zeitlich nach dem read() des Sohnes erfolgt. Dann blockiert das read() des Sohnes so lange, bis das close() des Vaters erfolgt, dann geht es weiter, wie im Fall 1 beschrieben. In jedem Fall ist die close()-Operation unwiderruflich. Ein geschlossenes Pipeende kann nicht wieder eröffnet werden; stellen Sie sich vor, dass ein schöner massiver Deckel an die Rohröffnung geschweißt wird.

Nun muss auch noch der spiegelbildliche Fall diskutiert werden. Was tut ein schreibender Prozess, wenn das Leseende unerwarteterweise geschlossen wird? Diese Situation ist weitaus kritischer. Da das close() unwiderruflich ist, kann der Leser keine weiteren Daten aus der Pipe entnehmen. Die Pipe ist unidirektional, deshalb kann über sie keine Information vom Leser zum Schreiber über das vorzeitige Schließen des Deskriptors übertragen werden. Der Schreibprozess weiß also nichts über das geschlossene Leseende und muss nun irgendwie davon abgehalten werden, weiter Daten in die Pipe zu befördern, die sowieso keiner mehr liest. Daher gilt folgende Regel: Wenn in eine Pipe geschrieben wird, deren Leseende geschlossen ist, erhält der schreibende Prozess das Signal SIGPIPE zugestellt. Im Abschnitt »Prozesse, hört die Signale« erlernen Sie genauer, was sich dahinter verbirgt. An dieser Stelle soll es zunächst genügen, festzustellen, dass der schreibende Prozess ohne besondere Vorkehrungen seinerseits abgebrochen wird.

Abbildung 8.1: Der Lebenszyklus einer unbenannten UNIX-Pipe

Diese Maßnahme erscheint auf den ersten Blick rabiat, jedoch kann sich der Prozess dagegen schützen. Die write()-Operation liefert dann einen Fehler, daher ist es wichtig, ihren Resultatwert zu prüfen.

Eine gute Eigenschaft der Pipe ist deren implizite Synchronisation. Die beteiligten Prozesse müssen sich nicht abstimmen, wer wann auf die Pipe zugreift. Die Leseoperation erfolgt im Normalfall synchron, und geschriebene Daten werden im Kernel zwischengespeichert, sodass der Schreibprozess sofort weiterarbeiten kann. Man kann eine Pipe sogar als weiteren Synchronisationsmechanismus auffassen, der Schreiber kann den Leser durch gezielte Aufrufe von write() gewissermaßen »fernsteuern«.

Pipes oder besser gesagt anonyme Pipes sind nur zwischen verwandten Prozessen möglich, da das fork() die einzige Möglichkeit ist, die Deskriptoren der Pipeenden weiterzugeben. Beispielsweise können Brüder miteinander per Pipe kommunizieren, indem der Vater die Pipe anlegt und danach entsprechend mehrere Kindprozesse erzeugt. Was macht man aber, wenn Prozesse Pipes nutzen wollen, die keine Verwandtschaftshierarchie bilden?

Benannte Pipes

Um eine Pipe für alle Prozesse nutzbar zu machen, muss sie durch alle Prozesse identifiziert werden können. Dies ist für anonyme Pipes gerade unmöglich, denn Deskriptoren sind prozesslokal, genauso wie geöffnete Dateien.

Ein Namenssystem, das prinzipiell allen Prozessen zur Verfügung steht, ist das Dateisystem. Aus diesem Grunde hat man benannte Pipes oder named pipes geschaffen, die wie anonyme Pipes funktionieren, aber im Dateisystem auftauchen und somit einen Namen besitzen. Sie werden auch manchmal FIFOs genannt, was für First In First Out steht und darauf hinweist, dass die Daten in der Reihenfolge des Schreibens wieder gelesen werden. Für ihre Erzeugung benötigt man einen anderen Systemruf: mkfifo().

Der Parameter pathname gibt den intendierten Namen der Pipe an (innerhalb des Dateisystems), und mode repräsentiert die Rechte. Nach der Erzeugung der benannten Pipe können beliebige Prozesse diese mittels open() öffnen, sofern deren Rechte dies erlauben, und erhalten auf diese Weise Deskriptoren zum Lesen oder Schreiben. Danach erfolgen Lese- und Schreiboperationen wie bei der anonymen Pipe. Zuletzt müssen alle Deskriptoren wieder geschlossen werden. Im Gegensatz zur anonymen Pipe verschwindet die benannte Pipe nach dem letzten close() nicht, sondern der Name muss explizit wie bei einer Datei gelöscht werden. Sie überdauert somit gegebenenfalls ihren Erzeuger, daher nennt man diesen Mechanismus persistent.

Man kann eine benannte Pipe auch direkt an der Kommandozeile anlegen, dafür gibt es das Kommando mkfifo, das seinerseits intern den mkfifo()-Systemruf benutzt.

Damit der Nutzer eine benannte Pipe nicht versehentlich mit einer »richtigen« Datei verwechselt, erscheint sie beim Kommando ls -l mit einem »p« an der allerersten Stelle. Zusätzlich wird der Name meist farblich hervorgehoben:

images

Prozesse, hört die Signale

Ein zweiter wichtiger Mechanismus, der in kaum einem Betriebssystem fehlt, sind die sogenannten Signale. Diese dienen im Allgemeinen nicht der Datenübertragung, sondern der Information, dass etwas Bestimmtes geschehen ist, das eine Reaktion durch den Prozess erfordert. Man darf den Mechanismus nicht mit dem gleichlautenden Begriff aus anderen Wissensgebieten, wie der Elektrotechnik oder der Verkehrsleittechnik, verwechseln: Im Kontext der Betriebssysteme handelt es sich um einen rein software-basierten Dienst zur Übertragung bestimmter elementarer Informationen. Manchmal wird anstelle von Signalen von Events gesprochen; diese funktionieren ganz ähnlich.

Sie erlernen den Mechanismus wiederum anhand der UNIX-Betriebssystemfamilie, bei der er im Übrigen erstmals verwirklicht wurde.

Grundprinzip

Jedes Signal besitzt eine ID (eine Nummer) und einen symbolischen Namen, der auf seinen Zweck hinweist. Wenn ein Prozess beispielsweise eine Adresse referenziert, die für ihn verboten ist, dann erhält er das Signal SIGSEGV zugestellt, das ausgeschrieben »Segmentation Violation« bedeutet. Wenn man einen anderen Prozess außerplanmäßig beenden möchte, dann schickt man ihm das Signal SIGTERMTermination«).

Jedem Signal ist eine bestimmte voreingestellte Aktion zugeordnet, die der Empfängerprozess »erleidet«, sofern er keine speziellen Vorkehrungen in Form eines Signalhandlers (dieser Begriff wird gleich erläutert) getroffen hat. Diese Aktion ist meist der Abbruch des Prozesses, dessen Anhalten (mit der Option, ihn später fortzusetzen) oder (in jeweils einem Fall) das Ignorieren des Signals sowie die Fortsetzung des Prozesses.

Manche Signale werden durch das Betriebssystem ausgelöst: Wenn der Prozess versehentlich durch null dividiert, wird eine sogenannte Gleitkomma-Ausnahme generiert (SIGFPE; Floating Point Exception), die darauf hinweist, dass infolge einer ungültigen Rechenoperation die Operandenregister undefinierte Werte enthalten können.

Andere Signale kann der Nutzer an der Tastatur generieren. Drückt er  +  in einem Terminal, dann erhält der assoziierte Prozess im Vordergrund das Signal SIGINT zugestellt, was zu seiner sofortigen Beendigung führt.

Zwei Signale, SIGUSR1 und SIGUSR2, haben keine voreingestellte Semantik, sondern stehen dem Nutzer frei zur Verfügung. Tabelle 8.1 führt einige der wichtigsten Signale auf. Viele Betriebssysteme unterscheiden sich aber subtil in bestimmten Aspekten des Signalmechanismus, daher ist es ratsam, stets die relevanten man-Pages zu konsultieren.

Name

Bedeutung

Defaultaktion

SIGABRT

Abbruch des Prozesses

Abbruch

SIGCHLD

Kindprozess beendet

Ignorieren

SIGILL

Illegale Instruktion

Abbruch

SIGINT

+ auf Tastatur gedrückt

Abbruch

SIGKILL

Kill-Signal

Abbruch

SIGSTOP

Anhalten des Prozesses

Stopp

SIGCONT

Fortsetzen des Prozesses

Fortsetzen

SIGPIPE

Schreiben in eine geschlossene Pipe

Abbruch

SIGFPE

Ungültige Rechenoperation

Abbruch

Tabelle 8.1: Die wichtigsten Signale unter UNIX

Der Systemruf kill()

Wie versendet man nun ein Signal an einen Prozess? Da es sich um einen Mechanismus des Betriebssystems handelt, kommt nur ein Systemruf in Frage. Er ist etwas irreführend kill() benannt und benötigt genau zwei Informationen:

  • Wer soll das Signal erhalten?
  • Welches Signal soll gesendet werden?

Diese beiden Informationen erhält der Systemruf als Parameter.

Der erste Parameter enthält die PID des Zielprozesses und der zweite Parameter die Nummer des zu sendenden Signals.

Damit sich verschiedene Nutzer nicht gegenseitig Prozesse mit Signalen »wegschießen«, muss der ausführende Prozess entweder privilegiert sein, oder Sender und Empfänger müssen den gleichen Nutzer als Eigentümer haben. Im Normalfall kann ein Nutzer also nur eigenen Prozessen Signale zustellen.

Wie ein Handler handelt

Was hat es aber nun mit dem bereits erwähnten Begriff des Signalhandlers auf sich? Dieser halb deutsche, halb englische Begriff hat sich leider etabliert; ins Deutsche übertragen könnte man ihn am ehesten mit »Signalbehandler« bezeichnen, aber das tut niemand.

Der Signalhandler ist einfach eine beliebige Funktion des Prozesses. Mit Hilfe des Systemrufs signal() legt der Prozess fest, dass die Funktion immer dann aufgerufen werden soll, wenn ein bestimmtes Signal zugestellt wird. Dieser Aufruf geschieht nicht aktiv, sondern der Prozess landet einfach in dem Moment in der Handlerfunktion, wenn er das passende Signal erhält. (Voraussetzung ist, dass er ausgeführt wird, also nicht im Bereit- oder Wartezustand ist.) Zuvor merkt er sich aber, an welcher Stelle der Abarbeitung er unterbrochen wurde, um nach Verlassen des Handlers dort fortzusetzen. Wichtig ist zu verstehen, dass dieser Aufruf der Handlerfunktion jederzeit geschehen kann. Der Prozess kann sich dagegen nicht zur Wehr setzen (dies wäre auch unsinnig, schließlich hat er ja zuvor bestimmt, dass er im Signalzustellungsfalle im Handler landen möchte). Der Handleraufruf erfolgt asynchron zum normalen Programmablauf.

Die Vereinbarung eines Signalhandlers geschieht mittels des Systemrufes signal(), der ein bisschen kompliziert aussieht.

Der erste Parameter ist die Nummer oder ID des Signals, für das der Handler angelegt werden soll. Der zweite Parameter ist die Adresse einer Funktion, nämlich des Handlers selbst. Diese Funktion wiederum hat einen Parameter vom Typ int und liefert kein Ergebnis zurück (void). Innerhalb des Handlers kann man mit Hilfe des Integer-Parameters herausfinden, welches Signal den Aufruf verursachte. Dies ist nützlich, wenn ein und dieselbe Funktion als Handler für verschiedene Signale fungieren soll.

Anstelle der Handleradresse kann man als zweiten Parameter auch die symbolische Konstante SIG_IGN an signal übergeben. In diesem Falle wird das eintreffende Signal ignoriert, also nichts passiert. Des Weiteren kann man SIG_DFL übergeben, dann wird die zum Signal gehörende voreingestellte Aktion aktiviert.

Der Resultatwert von signal() ist ebenfalls ein Funktionszeiger, nämlich auf diejenige Funktion, die bisher als Handler für das Signal fungierte, oder das Symbol SIG_ERR, falls ein Fehler geschah.

SIGKILL und SIGSTOP können nicht mit einem Handler abgefangen oder ignoriert werden; sie werden stets zugestellt.

Mit Signalen kann man sehr viel Unsinn treiben. Ein beliebter »Scherz« besteht darin, einem Nutzer das Shellkommando kill -9 -1 (oder den Systemruf kill(-1,9);) unterzujubeln. Dies schickt SIGKILL an alle Prozesse des Nutzers, sodass er sich augenblicklich am Anmeldeprompt wiederfindet, ohne dass die laufenden Applikationen eine Chance zum Abspeichern des gegenwärtigen Zustandes haben.

Ein kleines Programmbeispiel

Zur Illustration von kill() und signal() sehen Sie sich nun bitte Listing 8.2 an:

images

Listing 8.2: Ein Prozess schickt sich selbst Signale

Bevor Sie es übersetzen und ausprobieren, sollten Sie den Programmablauf gedanklich nachvollziehen. Nach dem Start ermittelt der Prozess in Zeile 28 seine PID mittels getpid(). Dies ist übrigens einer der wenigen Systemrufe, die nicht fehlschlagen und somit keine Resultatprüfung erfordern. Danach wird die Funktion sigusr1_handler() als Signalhandler für SIGUSR1 vereinbart (Zeile 29). Ab sofort wird bei Zustellung von SIGUSR1 diese Funktion aufgerufen.

Danach schickt der Prozess SIGUSR1 an sich selbst (Zeile 34). Als Folge davon unterbricht er seine Arbeit in main() und arbeitet nun ein Mal sigusr1_handler() ab. Er gibt eine kurze Nachricht aus, welches Signal empfangen wurde, und verändert danach (Zeile 14) das Verhalten bei zukünftiger Zustellung von SIGUSR1, indem er das Standardverhalten wieder einstellt.

Er verlässt danach den Handler und setzt in main() fort, wo er unterbrochen wurde. In Zeile 38 schickt er sich erneut das Signal SIGUSR1. Nun wird nicht mehr der Handler, sondern die Standardaktion ausgeführt: Der Prozess bricht ab. Das printf()-Statement in Zeile 42 wird somit nicht mehr ausgeführt, was sein Inhalt verdeutlichen soll.

Das Beispiel ist einigermaßen praxisfremd gewählt, aber es birgt einige Erkenntnisse:

  • Es gibt die Funktion strsignal(), um aus der Signalnummer eine textuelle Beschreibung des Signals zu generieren,
  • Prozesse können sich selbst Signale schicken,
  • Ein Prozess kann während seiner Laufzeit sein Verhalten bezüglich zugestellter Signale verändern.

Was noch zu sagen wäre

Damit haben Sie den grundlegenden Mechanismus der Signale verstanden. Der Teufel steckt allerdings im Detail. Schon verschiedene UNIX-Varianten implementieren bestimmte Aspekte von Signalen unterschiedlich. Dies bedeutet, dass man bei der Nutzung von Signalen sehr schnell die Portabilität aufgibt. Beispielsweise empfiehlt die entsprechende man-Page von Linux, auf die Nutzung von signal() zu verzichten, obwohl es in den relevanten Standards (POSIX-2008, C99) beschrieben ist, und stattdessen sigaction() zu nutzen. Des Weiteren gibt es in modernen UNIX-Betriebssystemen wie Linux sogenannte Echtzeitsignale (Real-Time Signals), die eine eigene API mitbringen und es sogar gestatten, in beschränktem Maße Daten zu übertragen. All diese Aspekte liegen außerhalb dieses Buches; ihre Diskussion bleibt Spezialliteratur vorbehalten: [1111 , Kapitel 20–22] oder [1919 , Abschnitt 6.6].

Vom Senden und Empfangen: Nachrichtenaustausch

Ein weiterer wichtiger Mechanismus zur Übertragung von Daten ist der Nachrichtenaustausch (Message Passing). Im Gegensatz zum unstrukturierten Bytestrom der Pipes werden hierbei Nachrichten ausgetauscht, die jede beliebige Struktur aufweisen können. Nachrichtenaustausch kann uni- und bidirektional mit einer beliebigen Anzahl von Teilnehmern erfolgen. Das Prinzip ist also recht universell.

Bemerkenswert ist, dass Nachrichtenaustausch in sehr verschiedenen Kontexten genutzt wird. Auf der einen Seite existieren sogenannte Mikrokerne, sehr effiziente Betriebssystemarchitekturen, die häufig nur wenige Kilobyte Code umfassen. Sie sind Ihnen im Kapitel 4 »Grundlegende Begriffe und Abstraktionen« schon einmal begegnet. Mikrokerne nutzen zur Kommunikation sowohl zwischen Systemkomponenten als auch zwischen Nutzerprozessen typischerweise Nachrichten. Das andere Extrem verkörpern Softwaresysteme wie das Message Passing Interface (MPI), eine Middleware zur Kommunikation auf parallelen Systemen, die häufig Tausende von individuellen Rechnern umfassen, die ebenfalls Nachrichtenaustausch verwirklicht. Nachrichtenaustausch ist sowohl innerhalb eines Systems auf gemeinsamem Speicher möglich als auch in verteilten Systemen, bei denen keine gemeinsame Speicherresource existiert.

Viele konventionelle Betriebssysteme verwirklichen den Mechanismus ebenso. UNIX bietet sogenannte Nachrichtenwarteschlangen (Message Queues) der System-V-IPC sowie solche nach POSIX. FreeRTOS besitzt Message Buffers, die weitaus einfacher strukturiert sind, aber das gleiche Grundprinzip besitzen. Windows nutzt Message Passing ebenfalls extensiv.

Basics

Wenn man von der Etablierung (und späteren Eliminierung) der Infrastruktur absieht, gibt es beim Message Passing eigentlich nur zwei wichtige Funktionen: eine zum Senden einer Nachricht, gemeinhin send() genannt, und eine zum Empfang einer Nachricht, receive().

Beide Funktionen können im Allgemeinen sowohl synchron als auch asynchron genutzt werden. (Falls Sie nicht mehr wissen, was man darunter versteht, schlagen Sie bitte noch einmal im Abschnitt »Synchrone und asynchrone Operationen« nach.) Meist reserviert der Sender einen Speicherblock auf dem Heap, schreibt seine Nachricht hinein und übergibt die Adresse des Blocks an send(). (Falls Sie nicht mehr wissen, was man unter dem Heap versteht, schauen Sie noch einmal kurz in den Abschnitt »Dynamische Speicherverwaltung« von Kapitel 3. Im Kapitel 9 lernen Sie diesen Teil des Speichers übrigens noch bedeutend besser kennen.) Der Empfänger reserviert ebenfalls einen Speicherblock und übergibt diesen zum Zwecke des Nachrichtenempfangs an receive(). Arbeiten beide synchron, dann kann das Betriebssystem die Nachricht aus dem Adressbereich des Senders direkt in den des Empfängers kopieren (Abbildung 8.2).

Abbildung 8.2: Synchrones Senden beim Message Passing

Erfolgt das Senden hingegen asynchron, dann muss die Nachricht zunächst innerhalb des Betriebssystems zwischengespeichert werden, da man nicht weiß, wann der Empfänger receive() aufruft, der Sender aber sofort weiterarbeiten soll und damit der Speicher der Nachricht wieder nutzbar sein soll. Aus dem Zwischenspeicher des Betriebssystems wird die Nachricht in den Adressraum des Empfängers kopiert, sobald dieser receive() aufruft. Im asynchronen Fall muss somit doppelt kopiert werden (Abbildung 8.3)!

Abbildung 8.3: Asynchrones Senden beim Message Passing

Wesentlich ist des Weiteren die Adressierung der Kommunikationsteilnehmer. Es ist zum einen möglich, die Teilnehmer über IDs direkt anzusprechen. Flexibler, aber auch aufwendiger ist es, wenn stattdessen eine Datenstruktur ähnlich einem Briefkasten durch Sender und Empfänger angesprochen wird. Eine solche Struktur bietet naturgemäß viel mehr Flexibilität, da beispielsweise auch nach dem Senden einer Nachricht noch der verantwortliche Empfangsprozess geändert werden kann.

POSIX Message Queues in UNIX

Als Beispiel für eine API, die Message Passing anbietet, sollen die Nachrichtenwarteschlangen gemäß POSIX fungieren. Der Code für einen sehr einfach strukturierten Empfänger befindet sich in Listing 8.3, der Code eines dazugehörigen Senders ist in Listing 8.4 aufgeführt.

images

Listing 8.3: Ein Server, der an einer POSIX-Nachrichtenwarteschlange lauscht

Beim Übersetzen vergessen Sie bitte nicht, beide Beispiele gegen den Echtzeitteil der Pthreads-Bibliothek zu linken (gcc-Switch -lrt). Betrachten Sie zunächst den Code des Empfängers in Listing 8.3. Die Nachrichtenwarteschlange wird eingangs mittels mq_open() (Zeile 21) eröffnet. Zur Identifikation besitzt sie einen Namen; dieser Name taucht sogar im Dateisystem (im Pfad /dev/mqueue) auf. Zusätzlich muss man dem System mitteilen, welche Größe die Nachrichten besitzen und wie viele Nachrichten maximal gespeichert werden sollen. Somit kann das Betriebssystem den benötigten Speicher planen. Die weiteren Parameter des Systemrufs sind identisch mit open(); das Resultat ist ein sogenannter Message Queue Descriptor, der eine geöffnete Nachrichtenwarteschlange repräsentiert, ganz analog zum Dateideskriptor.

Die Empfangsoperation mq_receive() (Zeile 28) übernimmt den Puffer msg sowie den durch mq_open() zurückgelieferten Deskriptor und blockiert den Prozess, solange keine Nachrichten eintreffen. Bei Eintreffen einer Nachricht kehrt der Ruf zurück, und in msg befindet sich die empfangene Nachricht. Diese wird im Beispiel nur nach stdout ausgegeben (Zeile 33), und solange sie nicht »quit« lautet, wird sofort das nächste mq_receive() ausgeführt.

Beim Empfang von »quit« wird die Schleife verlassen und die Nachrichtenwarteschlange geschlossen (mq_close(), Zeile 36). Da die Nachrichtenwarteschlange ein persistenter IPC-Mechanismus ist, sie also dauerhaft im System verbleibt, muss sie explizit gelöscht werden, was mit mq_unlink() geschieht.

images

Listing 8.4: Ein Client, der in eine POSIX-Nachrichtenwarteschlange schreibt

Der zugehörige Client ist genauso einfach strukturiert (Listing 8.4). Er nutzt ebenfalls mq_open(), jedoch ohne das Flag O_CREATE (Zeile 16). Daher wird nicht versucht, eine neue Nachrichtenwarteschlange einzurichten (was ohnehin fehlschlüge, da eine solche unter diesem Namen bereits existiert), sondern es wird Zugriff auf diese beantragt. Zu diesem Zweck benötigt er den Namen (Symbol QNAME), unter dem sie durch den Server registriert wurde. Dieser würde normalerweise in einer gemeinsam genutzten Headerdatei abgelegt. Der Client benötigt ebenfalls einen Speicher buf für Nachrichten. Falls ein Kommandozeilenargument (argv[1]) übergeben wurde, wird es in den Puffer übertragen (Zeile 21), sonst wird die bei der Initialisierung (Zeile 7) abgelegte »Standardmeldung« benutzt.

imagesSie wundern sich vielleicht, dass dieses Kopieren nicht einfach mittels

images

erfolgt. Der Grund dafür ist ein möglicher Pufferüberlauf (Buffer Overflow). Ein böswilliger Nutzer könnte ein Kommandozeilenargument übergeben, das länger als 1024 Zeichen ist. Die Funktion strcpy() kopiert die Quellzeichenkette, bis sie das terminierende Nullbyte erreicht. Somit würde der Puffer buf »überlaufen« und benachbarte Daten überschreiben, die es im Beispiel jedoch nicht gibt. Das hier eingesetzte strncpy() limitiert den Kopiervorgang auf eine maximale Puffergröße (hier: 1024). Es bringt seinerseits den Nachteil mit, dass die kopierte Zeichenkette nicht nullterminiert wird, falls das angegebene Limit erreicht wird. Daher wird sicherheitshalber das letzte Byte des Puffers extra auf null gesetzt. Ganz schön umständlich, nicht wahr? Im Kapitel 11 werden Sie erlernen, warum man diese Pufferüberläufe unbedingt vermeiden muss.

Das eigentliche Senden der Nachricht erfolgt mittels mq_send() in Zeile 24. Da die Nachrichtenwarteschlange durch den Server als synchron vereinbart wurde, blockiert das Senden, bis die Gegenstelle mq_receive() ausführt. Es kommt somit zum Rendezvous. Nach dem Sendevorgang schließt der Client die Warteschlange und endet. Abbildung 8.4 verdeutlicht den Datenfluss.

Abbildung 8.4: Datenübertragung via POSIX Message Queues

Offenbar wird bei dieser Form des Message Passing die Datenstruktur adressiert, nicht der Kommunikationspartner. Beachten Sie auch, dass die miteinander kommunizierenden Prozesse nicht miteinander verwandt sind und dass es gleichzeitig auch mehrere Clients geben könnte.

Nun sollten Sie das Ganze einmal ausprobieren.

images

Scheint zu funktionieren, und auch der Mechanismus zum Beenden des Servers arbeitet wie gewünscht!

Das Beispiel unterscheidet sich auf den ersten Blick gar nicht sehr von dem der Pipe. Konzeptuell sind Nachrichtenwarteschlangen aber viel mächtiger, weil:

  • sie eine Priorisierung der Nachrichten erlauben,
  • für das synchrone Senden und Empfangen maximale Wartezeiten vereinbart werden können (sogenannte Timeout-Bedingungen),
  • durch die Strukturierung der Daten eine getrennte Behandlung von Nachrichten durch verschiedene Empfänger möglich ist,
  • allgemein mehrere Sende- und mehrere Empfangsprozesse miteinander kommunizieren können.

imagesUNIX bietet noch eine zweite API zum Nachrichtenaustausch, die sogenannten System-V-Funktionen msgget(), msgsnd(), msgrcv() und msgctl(). Diese sind historisch älter, in der einschlägigen Literatur ausführlich erklärt, aber deutlich schwieriger zu benutzen. Aus diesem Grunde wird an dieser Stelle auf eine genauere Darstellung verzichtet. Bitte verwechseln Sie diese Funktionen nicht mit den gerade genutzten POSIX-Warteschlangen.

Teilen macht froh: Shared Memory

Ein weiteres wichtiges Konzept zur Kommunikation zwischen Prozessen ist der sogenannte gemeinsam genutzte Speicher, meist kürzer Shared Memory genannt.

Die Grundidee besteht darin, dass ein bestimmter, wohldefinierter Bereich innerhalb des Adressraums der miteinander kommunizierenden Prozesse auf ein und demselben physischen Speicherbereich abgebildet wird. Präziser ausgedrückt wird ein physischer Speicherbereich definierter Größe in allen virtuellen Adressräumen der teilnehmenden Prozesse eingeblendet (Abbildung 8.5).

Abbildung 8.5: Prinzip des Shared Memory

Wenn also ein bestimmter Prozess innerhalb dieses Shared-Memory-Segmentes eine Schreiboperation vornimmt, dann erscheinen die geschriebenen Daten sofort bei allen Prozessen in den jeweiligen Abbildungen (Mappings) des Shared-Memory-Segments.

Diese Form der Kommunikation bietet die größte Freiheit, wirft aber gleichzeitig das Problem der Synchronisation auf. Die Prozesse müssen sich untereinander einigen, wer wann auf das Shared-Memory-Segment zugreift. Dies war bei den bisher erklärten IPC-Mechanismen Pipe und Nachrichtenaustausch unnötig; diese verfügten über eine implizite Synchronisation!

Genau zu diesem Zweck bietet das Betriebssystem die im voranstehenden Kapitel beschriebenen Synchronisationsdienste wie Semaphore an.

Wie bei anderen Kommunikationsmechanismen auch muss vor der eigentlichen Datenübertragung die Infrastruktur aufgebaut werden: Genau ein Prozess muss das Shared-Memory-Segment kreieren, und alle anderen potenziellen Teilnehmer müssen sich danach Zugriff auf das neu angelegte Segment verschaffen. Ein wesentlicher Vorteil gegenüber Pipe & Co. besteht darin, dass für die eigentliche Datenübertragung, das Schreiben in das oder das Lesen aus dem Segment keine Systemrufe benötigt werden. Beim Einblenden des Segments in den Adressraum liefert das System einen Zeiger auf den Segmentbeginn, über den der Zugriff später erfolgt. Nach getaner Arbeit muss das Segment aus allen Adressräumen entfernt und final gelöscht werden, da es sich wiederum um einen »persistenten« IPC-Mechanismus handelt.

Auch dieses Konzept findet sich in den meisten modernen Betriebssystemen, so in allen UNIX-Varianten und in Windows. Voraussetzung ist die Existenz virtuellen Speichers. Gibt es keine MMU, dann nutzen alle Aktivitäten ohnehin ein und denselben (physischen) Adressraum, sodass das Konzept unsinnig ist.

Im UNIX gibt es analog zu den Nachrichtenwarteschlangen zwei verschiedene APIs, zum einen die sogenannten System-V-Funktionen shmget(), shmat() und shmctl(), zum anderen die moderneren POSIX-Funktionen auf der Grundlage von mmap().

images

images

Listing 8.5: Ein Server, der die Systemzeit in ein Shared-Memory-Segment schreibt

Listing 8.5 zeigt ein Beispiel für einen (sehr) einfachen Server, der mit den POSIX-Funktionen realisiert ist. Er installiert zuallererst (Zeile 35) einen Signalhandler für SIGUSR1. Dabei wird nicht das im Abschnitt »Prozesse, hört die Signale« behandelte signal(), sondern die modernere Variante sigaction() genutzt.

Danach wird mit Hilfe der Funktion shm_open() (Zeile 40) ein Shared-Memory-Objekt angelegt. Dazu benötigt man einen Namen, der im Symbol SHMNAME abgelegt ist, denn das Objekt erscheint wiederum auch im Namensraum des Dateisystems. Über diesen Namen können die Clients später auf das Objekt zugreifen. Nun wird das Objekt auf die richtige Größe gebracht (ftruncate(), Zeile 44) und im Hauptspeicher des Servers mittels mmap() eingeblendet (Zeile 47). Der zurückgelieferte Zeiger shmpt enthält die Anfangsadresse des Segments. Mit einer kleinen Statusmeldung endet der Aufbau-Teil.

Nun beginnt die eigentliche Arbeit des Servers. Er liest mittels time() die Systemzeit aus. Diese ist jedoch für Menschen ziemlich unhandlich: Sie besteht im UNIX aus der Anzahl vergangener Sekunden seit dem 1. Januar 1970, der sogenannten Epoche. Die Funktion strftime() wird genutzt, um daraus eine für Menschen nutzbare Zeitdarstellung in Form von Stunden, Minuten und Sekunden zu generieren. Die resultierende Zeichenkette schreibt der Server an den Anfang des Shared-Memory-Segmentes, das im Zeiger shmpt abgelegt ist. Danach beginnt der Ablauf von Neuem.

Erhält der Server SIGUSR1, dann wird über die globale Variable quit die Schleife verlassen. Das Segment wird ausgeblendet (Zeile 62) und das zugehörige Shared-Memory-Objekt mittels shm_unlink (Zeile 65) gelöscht. Der Ausgangszustand ist wiederhergestellt, der Server endet damit.

images

Listing 8.6: Ein Client, der die Zeit mittels des Shared-Memory-Segments ermittelt

Ein sehr einfacher Client, der die Uhrzeit entsprechend ausliest, ist im Listing 8.6 abgebildet. Wiederum muss mittels shm_open() das Objekt geöffnet und mit mmap() im Speicher eingeblendet werden. Der lesende Zugriff erfolgt mit Hilfe des Zeigers shmpt. Beachten Sie, dass weder im Server noch im Client für den Zugriff auf das Segment ein Systemruf nötig ist! Der Client blendet anschließend das Segment wieder aus und endet.

Beide Beispiele müssen wieder mit dem zusätzlichen gcc-Switch -lrt übersetzt werden. Der Inhalt des Shared-Memory-Segments kann übrigens wie der einer normalen Datei angezeigt werden. Ein Protokoll eines Probelaufs könnte ungefähr so aussehen:

images

Die Symbole SHMNAME und SHMSIZE gehören natürlich in ein extra Headerfile, das von beiden Quelltexten per #include eingelesen werden muss.

Was es sonst noch zum Kommunizieren gibt

Damit sind Sie am Ende des Kapitels zum Thema »Kommunikationsmechanismen in Betriebssystemen« angekommen. Mitnichten ist das Thema jedoch erschöpfend behandelt.

Eine ganz primitive Möglichkeit zur Kommunikation ist die Nutzung regulärer Dateien. Im gerade behandelten Abschnitt »Teilen macht froh: Shared Memory« wurde diese Möglichkeit zumindest skizziert, denn die Shared-Memory-Objekte erscheinen ja als Dateien im Dateisystem (unter /dev/shm). Falls solche sophistischen Lösungen nicht zur Verfügung stehen, kann man immer noch auf normale Dateien zurückgreifen, vorausgesetzt, es gibt ein Dateisystem. Nachteilig ist dabei natürlich die geringe Geschwindigkeit des Massenspeichers. Jedoch gibt es auch Dateisysteme, die nur im Hauptspeicher existieren und dann entsprechend effizienter sind!

Eine weiterer häufig genutzter Mechanismus stammt ursprünglich aus der Netzwerkkommunikation. Hier hat sich seit Langem als wichtigste Abstraktion der sogenannte Socket etabliert (die deutsche Übersetzung »Steckdose« oder »Buchse« ist ungebräuchlich). Wenn Sie mit einem anderen Rechner über die Netzwerkprotokolle UDP oder TCP kommunizieren wollen, müssen Sie solche Sockets verwenden. Sockets kann man ebenso verwenden, um Daten zwischen lokalen Prozessen zu transportieren, sie werden dann UNIX Domain sockets genannt. Da ihre Nutzung jedoch verhältnismäßig komplex ist und die Kenntnis mehrerer weiterer Systemrufe erfordert, verzichten wir an dieser Stelle auf eine genauere Diskussion und verweisen stattdessen auf die entsprechende Spezialliteratur, beispielsweise [2626 , S. 501 ff. ] oder [1111 , Kapitel 57].

Lesevorschläge

Wenn Sie tief in alle Aspekte der IPC unter UNIX einsteigen wollen, sind die beiden folgenden Lehrbücher das Richtige für Sie:

  • W. Richard Stevens. Advanced Programming in the UNIX Environment. Professional Computing Series. Addison-Wesley, 1993.
  • Michael Kerrisk. The Linux Programming Interface. No Starch Press, 2010.

Übungsaufgaben

  1. Implementieren Sie folgendes Prozesssystem: Der Vater soll zwei Söhne erzeugen. Ein Sohn soll von stdin eine Pfadangabe einlesen. Diese bezeichnet eine beliebige Datei, die dieser Sohn dem anderen mittels einer unbenannten Pipe übermitteln soll. Der zweite Sohn soll auf stdout die Anzahl der über die Pipe empfangenen Bytes ausgeben. Danach sollen sich alle Prozesse ordnungsgemäß beenden.
  2. Übermitteln Sie von einem Vaterprozess an eine variable Anzahl Sohnprozesse Nachrichten. Legen Sie dazu im Vaterprozess mittels mmap() ein anonymes Mapping (Flag MAP_ANONYMOUS) an, und rufen Sie danach fork() für jeden anzulegenden Sohn. Die Söhne erben die Mappings und können daher ihrerseits auf das Rufen von mmap() verzichten. Nun können der Vater schreibend und die Söhne lesend auf den Speicherbereich zugreifen. Sie müssen sich jedoch eine einfache Form der Synchronisation überlegen, damit die Söhne nicht vor dem Abschluss des Schreibvorgangs lesen.
    1. Implementieren Sie einen Serverprozess, der aus einer Nachrichtenwarteschlange synchron Nachrichten entnimmt. Bei Empfang einer Nachricht soll der Server per fork() ein Kind erzeugen, das die Nachricht bearbeitet. Die Nachricht soll jeweils den Namen einer Datei enthalten, die der Server in ein bestimmtes Verzeichnis kopieren soll.

      Hinweise:

      • Anstatt die Datei programmtechnisch zu kopieren, ist es günstig, das cp-Kommando der Shell zu nutzen. Sie müssen den entsprechenden cp-Aufruf zunächst in einem Puffer konstruieren und diesen Puffer dann an system() übergeben.
      • Denken Sie an eine ordnungsgemäße Beendigung des Servers. Es bietet sich die Nutzung eines Signals an. Im zugehörigen Handler sollte die Nachrichtenwarteschlange gelöscht werden.
    2. Implementieren Sie einen Clientprozess, der die Nachrichtenwarteschlange mit Nachrichten füllen soll. Die Dateinamen sollen interaktiv eingelesen werden. Starten Sie dann drei Clientprozesse, leiten Sie stdin auf Dateien mit Listen von Namen um, und überzeugen Sie sich, dass alle Dateien durch den Server kopiert werden.

      Hinweise:

      • Wenn Sie mittels fgets() den Namen der zu kopierenden Datei einlesen, sollten Sie daran denken, dass diese Funktion den Zeilenumbruch »\n« an die eingelesene Zeichenkette anhängt.
      • Wenn Sie eine Zeichenkette (also beispielsweise den Dateinamen) per Nachricht übertragen, dann sollten Sie das terminierende Nullbyte mit übertragen, das bedeutet, dass die Länge der Nachricht um ein Byte größer ist, als strlen() für die Zeichenkette liefert.