Axel Rogat
Betriebssysteme und betriebssystemnahes Programmieren
 
6.10: I/O-Umlenkung Kapitel 6 7: Scheduling 
 
  6.11 Pipes  
 

In der Shell haben wir Pipes und die entsprechenden Symbole "|" und "|&" schon kennengelernt. So wird aber immer nur die Standard-Ausgabe (plus evtl. der Fehlerkanal) mit der Standard-Eingabe eines anderen verbunden.

Eine allgemeine Pipe ist nicht auf diesen speziellen Fall beschränkt. Sie ist eine dateiähnliche Verbindung, meist (aber nicht notwendigerweise) zwischen zwei Prozessen. Irgendein schreibender Kanal des einen Prozesses wird mit irgendeinem lesenden Kanal des anderen verbunden. Der erste verhält sich dabei als "Producer", erzeugt also Daten, die der zweite als "Consumer" verbraucht.

Pipes sind sicherlich die einfachste Art, zwischen zwei Prozessen Daten auszutauschen. Diese Art von Prozeßkommunikation ist allerdings ziemlich eingeschränkt. Mit ausgefeilten Mechanismen des Datenaustauschs und der dafür ggf. notwendigen Synchronisation beschäftigen wir uns später in einem eigenen Kapitel.

6.11.1 pipe

Für das Anlegen der Kommunikationskanäle auf Systemebene ist der Systemaufruf pipe (deklariert in unistd.h) verantwortlich:

UNIX
int pipe(int fildes[2]);
  legt eine Pipe an und füllt das Array fildes mit zwei passenden File-Deskriptoren

Über die File-Deskriptoren kann die Pipe danach angesprochen werden. fildes[0] dient zum Lesen aus der Pipe, fildes[1] zum Schreiben in die Pipe (analog zu stdin=0, stdout=1). Wenn man bidirektionale Kommunikation braucht, muß man zwei Pipes öffnen.

Ein Analogon bei der Kommunikation zwischen verschiedenen Rechnern ist übrigens die BSD-Funktion socketpair.

Beachte: Die größte Einschränkung von Pipes ist, daß die kommunizierenden Prozesse sich die Filedeskriptoren teilen müssen. Dazu benötigen sie einen gemeinsamen Vorfahr, der die Pipe anlegt, und von dem sie die Deskriptoren erben. Pipes sind also nur zwischen Elter- und Kind-Prozeß, zwischen Bruder-Prozessen, etc. möglich. Server, die beliebigen anderen Prozessen Daten liefern, sind so nicht realisierbar!

Beispiel: Wir erzeugen mit pipe eine Pipe und verdoppeln danach mit fork unseren Prozeß. Genau wie offene Dateien werden auch Pipes geerbt, d.h Elter und Kind haben beide Zugriff auf die Pipe. Der jeweils nicht gebrauchte Kanal wird sofort geschlossen (Eingabe beim Kind, Ausgabe beim Elter).

Danach schreibt das Kind die Zahlen 1 bis 20 in die Pipe (jeweils als mit Linefeed abgeschlossene Zeile), und der Elter kopiert einfach zeichenweise aus der Pipe auf die Standard-Ausgabe.

Da das Kind nur schreibt und der Elter nur liest, schließen wir zu Beginn den jeweils nicht benötigten Kanal. Wenn das Kind fertig ist (return) wird seine Seite der Pipe geschlossen. Der Elter erhält dann irgendwann beim Lesen ein End-of-File, d.h. read liefert 0 als Anzahl gelesener Zeichen zurück. Dann beendet sich der Elter.

int main() { int fildes[2]; pipe(fildes);

if (fork()) { close(fildes[1]);

char c; while (read(fildes[0],&c,1)>0) write(1,&c,1);

int s; waitpid(-1,&s,0); } else { close(fildes[0]); for (int i=1;i<=20;++i) { char buf2[8]; sprintf(buf2,"%d\n",i); write(fildes[1],buf2,strlen(buf2)); } } return 0; }

Wenn man Pipes, wie man sie aus der Shell kennt, darstellt, ist es sinnvoll, daß der ursprüngliche Prozeß der letzte in der Kette ist und das zuletzt erzeugte Kind das erste. Normalerweise beendet sich ein Prozeß, wenn seine Standard-Eingabe zur Neige gegangen ist, d.h. wenn der Prozeß eins weiter links in der Kette sich beendet hat. Das Prozeß-Sterben schreitet also von links nach rechts fort.

Beispiel: Wir simulieren eine Pipe-Konstruktion wie beim `|' in der Shell. Wir wollen folgende Filter-Kombination mit einem Programm zusammenbauen:

grep "^\ *#"   |   sed -e "s/^\ *//" -e "s/^#\ */#/"
Wir nennen unser Programm praep. Ein Aufruf wie "cat *.c | praep" würde alle Präprozessorzeilen der C-Quelltexte im aktuellen Verzeichnis ausgeben. Spaces vor und nach dem `[[#]]' werden dabei eliminiert.

Der Elter-Prozeß ist der, der rechts vom `|' steht, der Kind-Prozeß der linke. Beide Prozesse überschreiben sich mit execlp durch ein anderes Programm, der Elter durch sed, das Kind durch grep.

Nun schreibt aber grep auf seine Standard-Ausgabe, und sed liest von seiner Standard-Eingabe. Wie müssen mittels dup2 diese Kanäle mit unseren Pipe-Kanälen verbinden!

int main() { int fildes[2]; pipe(fildes);

if (fork()==0) { close(fildes[0]); dup2(fildes[1],1); execlp( "grep" , "grep" , "^\\ *#" , 0 ); } else { close(fildes[1]); dup2(fildes[0],0); execlp( "sed" , "sed" , "-e" , "s/^\\ *//" , "-e" , "s/^#\\ */#/" , 0 ); } }

Jeder Prozeß schließt vor dem execlp zwei Kanäle: den nicht benötigten der Pipe und implizit den durch dup2 überschriebenen!

Bemerkung 1: Wir bauen hier Pipes nicht in unsere eigene Shell ein, da die Handhabung beliebig vieler Prozesse den Quelltext zu unübersichtlich macht. Das Prinzip ist aber genau das im vorangegangenen Beispiel beschriebene.

Die Aufrufe der Einzelprozesse könnten wieder mittels vector gespeichert werden. Beim Untersuchen der Aufrufzeile dürften wir aber beispielsweise nicht mehr strtok benutzen. Wir müßten ja auch bei `;' und `&' unterteilen, die Information über das jeweilige Trennzeichen geht bei strtok aber verloren.

Am besten definiert man die Syntax einer solchen Kommandozeile in Form einiger grammatischer Regeln und baut dann (per Hand oder mit einem Parser-Generator) zunächst einen vernünftigen Parser. Die technische Seite der Prozeß-Erzeugung sollte erst danach darauf aufgesetzt werden.

Bemerkung 2: In Linux werden Pipes durch das normale Dateisystem VFS (dazu später) dargestellt. Die Daten werden werden aber nicht auf einem Sekundärspeicher, sondern mit Hilfe von mmap im Hauptspeicher gelagert, in einem von beiden Prozessen zugreifbaren Segment.

6.11.2 popen

Es gibt in der C-Bibliothek (deklariert in stdio.h) eine weitere nützliche Funktion, die mit Pipes arbeitet:

UNIX
FILE *popen(const char *command, const char *type);
  die Shell bin/sh führt den String command, als Befehlszeile interpretiert, aus und koppelt seine Ein- oder Ausgabe an eine Pipe
int pclose(FILE *stream);
  schließt eine mit popen geöffnete Pipe

Der Aufruf ist also mit dem system-Aufruf verwandt. Es wird aber intern automatisch eine Pipe angelegt und der Prozeß mit fork dupliziert, bevor die Shell gestartet wird. Entweder die Eingabe oder die Ausgabe der Shell wird mit der Pipe gekoppelt. Dafür setzt man type="w" (zum Schreiben in die Eingabe des Programms) bzw. type="r" (zum Lesen aus seiner Ausgabe).

Über den zurückgelieferten File-Pointer kann man dann z.B. die Ausgabe des Programms zeichenweise lesen und bearbeiten. Am Schluß schließt man diese Art von Pipe mit pclose auf diesen Pointer ( nicht fclose verwenden)!

Beispiel: Wir führen in einem kurzen C-Programm ls aus, numerieren die angezeigten Dateien dabei aber durch.

#include <stdio.h>

int main() { int c,line=0,numout=1; FILE *pipe=popen("ls","r");

while (c=fgetc(pipe), c!=EOF) { if (numout) { printf("%d: ",++line); numout=0; } putchar(c); if (c=='\n') numout=1; } pclose(pipe); }

Die Konstruktion mit dem numout ist notwendig, damit wir am Ende nicht eine Zeilennummer zuviel ausgeben.

 
6.10: I/O-Umlenkung Startseite 7: Scheduling