| Axel Rogat |
| Betriebssysteme und betriebssystemnahes Programmieren |
|   |
9.6: Dateisperren
| Kapitel 9 |
10: Deadlocks 
|
|   |
|   | 9.7 Sockets |   |
|   |
Unter UNIX kommunizieren Prozesse auf verschiedenen Rechnern standardmäßig über sogenannte Sockets ("Steckdosen"). Ein Socket ist dabei einfach das Ende eines Kommunikationswegs -- die Verbindungsdetails werden vom Kernel und Treibern übernommen. Sockets wurden zuerst in BSD eingeführt, aber in die meisten anderen UNIX-Geschmacksrichtungen (auch Linux) übernommen.
In diesem Kapitel interessieren uns natürlich nicht die technischen Grundlagen dieser Verbindung, sondern die Handhabung der Sockets in Benutzerprogrammen. Die unterliegenden Mechanismen wollen wir hier nur kurz betrachten.
Die Kommunikation findet auf drei unterschiedlichen Ebenen statt (man kann
noch feinere Unterteilungen treffen):
Sehr häufig wird als ein solches Protokoll TCP/IP (zwei
Ebenen in sich) eingesetzt. TCP (Transfer Control Protocol) auf der
"Transport-Ebene" beschäftigt sich mit der Verwaltung eingehender
Datenpakete (Aufteilung in kleine Pakete, Sortieren von Paketen,
Checksummen-Kontrolle und ggf. mehrfaches Senden). IP ( Internet
Protocol) auf der "Netzwerk-Ebene" beschäftigt sich mit dem
Routing (Wegfindung) und dem tatsächlichen Verschicken.
Wenn der Benutzer die Verwaltung der Pakete übernimmt,
erhält man die Kombination UDP/IP
(User Datagram Protocol). TCP ist die sicherere Wahl, da es selbst
die Verantwortung für verlorengegangene
Pakete etc. übernimmt. UDP ist simpler und kann im Ernstfall schneller
sein. Außerdem können mit UDP größere Pakete (bis 8K)
verschickt werden, während TCP Daten vergleichsweise klein zerteilt.
Auf dieser Ebene sind weitere High-Level-Protokolle definiert, wie etwa
HTTP ( HyperText Transfer Protocol) zum Austausch von
Dokumenten im WWW, FTP ( File Transfer Protocol),
Mail-Protokolle, etc.
Die Kommunikation über Sockets spiegelt den Austausch von kleinen
Paketen (Datagrammen) auf den unteren Ebenen wieder. Sie
ähnelt daher einem Message Passing mit Send- und Receive-Aufrufen.
Wenn eine FIFO-ähnliche Verbindung gewünscht ist, müssen die
beteiligten Prozesse das selbst verwalten.
Ein Socket wird meist angelegt durch folgenden Systemaufruf:
Die drei zu übergebenden Attribute bedeuten dabei folgendes:
Geschlossen wird ein Socket mit
close, ein vorzeitiges
einseitiges Einstellen der Kommunikation kann mit
shutdown erfolgen.
Ein Server-Prozeß (z.B. Webserver-Programm) definiert seinen eigenen Port
und "hört" ihn dann ab, d.h. er wartet auf
Daten, die unter Angabe dieser Nummer an seinen Rechner geschickt werden.
Ein Client-Prozeß (z.B. Webbrowser) muß die Port-Nummer des Servers
kennen, den er ansprechen will.
Zur Darstellung von Adressen für Sockets dient die Struktur
sockaddr, bzw. speziellere Strukturen wie
sockaddr_in für Internet-Sockets, etc.:
Für das Abschicken von Daten ist die Funktion sendto, für das
Empfangen recvfrom zuständig.
Wenn man mit Stream-Sockets
arbeitet, besteht eine permanente Verbindung.
In diesem Fall verwendet man send und recv, es funktionieren
aber genausogut write und read (wodurch
z.B. Standard-Ein-/Ausgabe auf Sockets umgelenkt werden können).
send und recv bieten lediglich zusätzliche
Steuermöglichkeiten (Protokolle umgehen, etc.):
Die Funktionen liefern die Anzahl der tatsächlich geschriebenen/empfangenen
Bytes zurück (-1 bei Fehler).
Der recv-Aufruf blockiert normalerweise, wenn keine Daten
vorliegen, es sei denn, man hat das mit einem fcntl-Aufruf
anders festgelegt:
Es werden drei Deskriptor-Mengen readfds, writefds und
exceptfds angegeben. Die Funktion blockiert so lange, bis
über einen der Deskriptoren Daten gelesen bzw.
geschrieben werden können, bzw. bis eine Datei ihren Status
ändert. n ist der numerisch größte File-Deskriptor +1.
Zurückgegeben wird die Anzahl der Deskriptoren, die das Ende von
select ausgelöst haben. In timeout kann man eine maximale
Wartezeit angeben. Bei timeout=0 blockiert select unbegrenzt
lange.
Um die Deskriptor-Mengen zu manipulieren, sollte man nur die
Makros FD_ZERO (leeren), FD_SET (einen
Deskriptor eintragen) und FD_CLR (austragen) verwenden.
Nach einem select kann man mit dem Makro FD_ISSET fragen,
ob ein bestimmter Selektor Auslöser war:
Client-Sockets:
Hier dient ein Socket hauptsächlich dazu, einen speziellen Host
anzusprechen (einen Server), ihm Kommandos zu schicken und Daten als Antwort
von ihm entgegenzunehmen. Auf dem entfernten Rechner muß bereits ein
Socket existieren, mit dem der neue verbunden wird.
Folgende Schritte sind erforderlich, um so eine Verbindung herzustellen:
Server-Sockets:
Ein "Listen-Socket" ist
eine Anlaufstelle für Client-Anfragen. Er wird wie folgt erzeugt:
Über den Accept-Socket kann nun die Kommunikation mit dem Client erfolgen,
während über den Listen-Socket weiterhin Anfragen anderer Clients
entgegengenommen werden können. Wenn die Bearbeitung des Client-Auftrags
länger dauert, kann man sie am besten mit fork abspalten und
direkt wieder mit accept auf weitere Clients horchen.
Das folgende Bild faßt die einzelnen Abläufe (für
Stream-Sockets mit ständiger Verbindung) noch einmal zusammen:
Im folgenden sind die benötigten Funktionen aufgeführt:
Wenn nicht anders angegeben, liefern die Funktionen 0 bei okay
zurück, -1 bei Fehlern.
Beispiel: Als Client-/Server-Beispiel implementieren wir wieder
die lange Multiplikation. Wir verwenden der Einfachheit halber Stream-Sockets.
Der Client sclient erwartet als Aufrufparameter die Adresse des
Server-Rechners, die Port-Nummer und die beiden Faktoren
(um den Quelltext abzukürzen, werden einige Sicherheitsabfragen
ausgelassen). Ein Aufruf wäre also:
"sclient vulcan 4711 1000000 12345678".
if (argc!=5) { cerr << "Usage: sclient host port num1 num2"; exit(1); }
struct hostent *host=gethostbyname(argv[1]);
if (host==0) sorry("host");
int host_sock=socket(AF_INET,SOCK_STREAM,0);
if (host_sock<0) sorry("socket");
static struct sockaddr_in host_in;
host_in.sin_family=AF_INET;
host_in.sin_addr.s_addr=((struct in_addr*)(host->h_addr))->s_addr;
host_in.sin_port=htons(atoi(argv[2]));
if (connect(host_sock,(struct sockaddr*)&host_in,sizeof(host_in))<0)
sorry("connect");
sprintf(buffer,"%s %s",argv[3],argv[4]);
len=strlen(buffer)+1;
if (send(host_sock,buffer,len,0)!=len) sorry("send");
if (recv(host_sock,buffer,1024,0)>0)
cout << argv[3] << '*' << argv[4] << '=' << buffer << endl;
else sorry("recv");
close(host_sock);
}
Die Bibliotheksfunktion htons (Host-To-Network/Short) dient hier dazu,
die Byte-Reihenfolge der Port-Nummer vom Rechner-Format ins
Netzwerk-Format umzuwandeln (Intel-Prozessoren speichern längere
Integer-Zahlen in umgekehrter Reihenfolge ab als der Rest der Welt).
Auf Rechner, auf denen die Reihenfolgen übereinstimmen, tut die Funktion
also nichts.
Das Server-Gegenstück sserver erhält als Parameter nur die
Port-Nummer, die er verwenden soll. Er empfängt von beliebigen Clients
einmal zwei Zahlen als Nachricht und schickt das Produkt zurück.
Damit er sofort für andere Clients empfangsbereit ist, spaltet er
die Berechnung und Beantwortung in einen Kind-Prozeß ab:
void sorry(const char *s) { perror(s); exit(1); }
ostream & operator << (ostream &o, const struct in_addr &adr)
{
unsigned char c[4];
memcpy(c,&adr,4);
return o << (int)c[0]<<'.'<<(int)c[1]<<'.'<<(int)c[2]<<'.'<<(int)c[3];
}
int main(int argc, char *argv[])
{
if (argc!=2) { cerr << "Usage: sserver port\n"; exit(1); }
int lsocket=socket(AF_INET,SOCK_STREAM,0);
if (lsocket<0) { perror("socket"); exit(1); }
static struct sockaddr_in s_in;
s_in.sin_family=AF_INET;
s_in.sin_addr.s_addr=INADDR_ANY;
s_in.sin_port=htons(atoi(argv[1]));
if (bind(lsocket,(struct sockaddr*)&s_in,sizeof(s_in))<0) sorry("bind");
if (listen(lsocket,10)<0) sorry("listen");
for (;;)
{
static struct sockaddr_in incoming;
int addrlen=sizeof(incoming);
int asocket=accept(lsocket,(struct sockaddr*)&incoming,&addrlen);
if (asocket<0) sorry("accept");
if (fork()==0)
{
cout << "request from " << incoming.sin_addr << endl;
char buffer[1024];
int bytes;
bytes=recv(asocket,buffer,1024,0);
if (bytes>=3)
{
char *s1=strtok(buffer," "), *s2=strtok(0," ");
char *s3=long_mul(s1,s2);
int len=strlen(s3)+1;
if (send(asocket,s3,len,0)!=len) sorry("send");
free(s3);
}
close(asocket);
_exit(0);
}
else close(asocket);
}
}
Die Mandelbrotmenge ist die Menge Zahlen c in C, für
die die Folge mc in CN
mit mc,0:=c und
mc,i+1=mc,i2+c
(für alle i>=0) nicht divergiert.
Als Divergenzkriterium verwenden wir, daß
|mc,i|>=2 wird.
Wir stellen einen rechteckigen Ausschnitt der komplexen Ebene dar. Für
jeden Pixel berechnen wir die ersten Glieder der zugehörigen Folge.
Wenn wir Divergenz feststellen, färben wir den Pixel entsprechend der
Nummer des erreichten Folgenglieds unterschiedlich ein. Wenn wir nach
einer Maximalzahl von Folgengliedern noch keine Divergenz festgestellt
haben, färben wir den Pixel schwarz.
Für die Darstellung in einem X-Fenster verwenden wir eine einfache
C++-Klasse SimpleWindow, deren Interna hier
nicht relevant sind. Wir benötigen hauptsächlich
Konstruktor/Destruktor und eine Funktion zum Zeichnen eines Pixels.
Die Port-Nummern und die Strukturen für die beiden Nachrichtentypen sind in
der gemeinsamen Header-Datei multimandel.h definiert:
struct cmd_msg // vom Master zum Servant
{
unsigned short num; // Nummer der Servants
char command,dummy; // Kommando: q(uit), s(tart), c(ompute)
unsigned short line; // Nummer der Zeile
double y,x1,x2; // Imaginärteil der Zeile, Realteil-Begrenzungen
unsigned short width; // Bildbreite
unsigned short depth; // maximale Iterationstiefe
};
struct data_msg // vom Servant zum Master
{
unsigned short num; // Nummer des Servants
unsigned short line; // Nummer der Zeile
unsigned char it[0]; // Farbdaten, eigentlich variabel it[width]
};
Es folgt der Code des Servants (servant.cpp. Die Funktionen
sorry, x_recv und der Ausgabe-Operator << für
Adressen sind aus den vorhergehenden Programmen übernommen.
for (int i=0;i<width;++i)
*P++=modtab[one_point(*xP++,msgbuf.y,*x2P++,y2)];
}
void init_mandel() // Initialisierungen
{
width=msgbuf.width; numcolors=msgbuf.line; maxit=msgbuf.depth;
x_1=msgbuf.x1; x_2=msgbuf.x2; dx=(x_2-x_1)/width;
xval=new double[width]; xsq=new double[width];
for (int i=0;i<width;++i) { xval[i]=x_1+i*dx; xsq[i]=xval[i]*xval[i]; }
modtab=new int[maxit+1];
for (int i=0,j=1;i<maxit;++i)
{
modtab[i]=j;
if (++j>=numcolors) j=1;
}
}
int main()
{
signal(SIGPIPE,sigpipe_handler);
in_sock=socket(AF_INET,SOCK_STREAM,0);
if (in_sock<0) sorry("socket");
static struct sockaddr_in s_in;
s_in.sin_family=AF_INET;
s_in.sin_addr.s_addr=INADDR_ANY;
s_in.sin_port=htons(SERVANTPORT);
if (bind(in_sock,(struct sockaddr*)&s_in,sizeof(s_in))<0) sorry("bind");
if (listen(in_sock,10)<0) sorry("listen");
struct sockaddr_in incoming;
int addrlen=sizeof(incoming);
recv_sock=accept(in_sock,(struct sockaddr*)&incoming,&addrlen);
if (recv_sock<0) sorry("accept");
cout << "accepted from " << incoming.sin_addr << endl;
send_sock=socket(AF_INET,SOCK_STREAM,0);
if (send_sock<0) sorry("socket");
incoming.sin_port=htons(MASTERPORT);
if (connect(send_sock,(struct sockaddr *)&incoming,sizeof(incoming))<0)
sorry("connect");
for (;;)
{
if (x_recv(recv_sock,&msgbuf,sizeof(msgbuf))==0) sorry("recv");
switch (msgbuf.command)
{
case 'q': // quit: Stop-Signal vom Master erhalten, STOP
exit(0);
case 's': // start: Initialisierung, "hello" zurückschicken
init_mandel();
data_size=sizeof(data_msg)+sizeof(unsigned char[width]);
data=(data_msg*)new char[data_size];
data->num=msgbuf.num;
if (send(send_sock,data,data_size,0)<0) sorry("send");
break;
case 'c': // compute: eine Zeile berechnen & zurückschicken
one_line();
data->line=msgbuf.line;
if (send(send_sock,data,data_size,0)<0) sorry("send");
break;
default:
cerr << "unknown command " << msgbuf.command << endl;
}
}
}
Zweiteres erzwingen wir mit der Option `+' an unser Master-Programm.
Es erzeugt dann für jeden Servant einen Kind-Prozeß, der sich mit dem
rsh-Kommando (Remote-Shell)
überlädt. Diese Shell startet auf
dem entfernten Rechner das Kommando, das ihr als Parameter übergeben wird.
Hier geben wir den absoluten Pfad des Servant-Codes an (in unserer Version
muß er auf allen Rechnern an derselben Stelle liegen).
Achtung: Damit die Remote-Shell nicht nach unserem Paßwort fragt,
müssen wir auf den entfernten Rechnern in der Datei
.rhosts den Master-Rechner eintragen.
pwin->handle_events(true);
fini(0);
}
Hier liegen die Quelltexte:
Bei acht Test-Rechnern ergab sich beispielsweise folgende Ausgabe:
Ein gleichwertiges Programm auf einem Rechner alleine ohne jegliches
Verschicken benötigt 13.9 Sekunden, mit Verschicken an sich selbst
dagegen 30.4 Sekunden! Bei den vergleichsweise kurzen Berechnungszeiten
ist der Verwaltungsaufwand des Datentransports dominant. Erst bei drei
Rechnern sind wir wieder schneller als die 13.9 Sekunden.
9.7.1 Anlegen von Sockets
Die Include-Dateien, die bei Benutzung von Sockets wichtig sind, sind
sys/socket.h (Socket-Definitionen),
netdb.h (allgemeine Netzkommunikation), netinet/in.h
(Internet-Definitionen).
UNIX
int socket(int domain, int type, int protocol);
legt einen Socket an; gibt einen passenden File-Deskriptor zurück
9.7.2 Socket-Adressen
Ein Socket muß noch an eine bestimmte Adresse gebunden werden
(s.u.). Eine solche Adresse besteht aus der Definition des Rechners
(z.B. Internet-Adresse) und eines "Ports". Ein Port ist einfach
eine ganze Zahl, die den Adressaten innerhalb des Rechners identifiziert.
Die Zahlen 0 bis 1023 sind für Systemdienste reserviert (z.B. Port
80 für den HTTP-Dämon, 25 für Mail-Dienste, etc.).
9.7.3 Datenaustausch über Sockets
Viele Datei-Operationen mit File-Deskriptoren sind auch mit den
Deskriptoren für Sockets möglich; beispielsweise schließt man ja
mit close eine Verbindung und gibt den Socket frei.
UNIX
int send(int s, const void *msg, int len, unsigned int flags);
schickt Daten der Länge len Bytes ab der Adresse
msg über den Socket s
int recv(int s, void *buf, int len, unsigned int flags);
empfängt max. len Bytes Daten über den Socket s
und schreibt sie nach buf
Es ist aber dennoch nicht gewährleistet, daß ein recv-Aufruf soviele
Bytes liefert wie gewünscht. Folgende Funktion ruft so lange recv
auf, bis die geforderte Menge angekommen ist (funktioniert natürlich
auch für normale Files):
Wenn man bei Kommunikation mit mehreren anderen Rechnern auf mehrere
Sockets gleichzeitig warten möchte, sollte man den Systemaufruf
select verwenden. Er ist für File-Deskriptoren ausgelegt und
funktioniert auch mit normalen Dateien (einzubinden ist sys/time.h):
UNIX
int select(int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
wartet auf Status-Änderungen bei beliebig vielen File-Deskriptoren
Das ausführliche Beispiel am Ende des Kapitels verwendet diesen
Mechanismus.
9.7.4 Server- und Client-Sockets
Es gibt zwei (bzw. drei) Arten, einen Socket einzusetzen:
UNIX
struct hostent *gethostbyname(const char *name);
liefert eine (statisch angelegte) Struktur hostent mit
Informationen über den Host mit dem Namen name (alphanumerisch
oder numerisch mit 4 oder 6 Feldern);
der Eintrag h_addr der Struktur liefert die
numerische Adresse im internen Format
int connect(int fd, struct sockaddr *serv_addr, int addrlen);
öffnet eine Verbindung vom Socket fd zu einem passenden
Socket auf dem Server mit der Adresse serv_addr;
es können unterschiedliche Strukturen bei serv_addr angegeben
werden (beispielsweise sockaddr_in für Internet, daher muß
die Größe der Struktur in addrlen übergeben werden
int bind(int fd, struct sockaddr *my_addr, int addrlen);
bindet den Socket fd an die lokale Adresse my_addr
(Länge addrlen)
int listen(int s, int backlog);
definiert für den Server-Socket s die Länge der
Warteschlange für eingehende Verbindungen
int accept(int s, struct sockaddr *addr, int *addrlen);
akzeptiert eine Verbindung über den Server-Socket s, erzeugt
einen passenden Accept-Socket und gibt dessen Deskriptor zurück;
*addrlen muß die Länge der Adreßstruktur
enthalten;
füllt außerdem addr mit Informationen
über den anfragenden Client (und addrlen mit der echten
Adreßlänge)
9.7.5 Ausführliches Beispiel
Als komplexere Anwendung schreiben wir eine
Client-/Server-Kombination zur verteilten Berechnung von Ausschnitten
der Mandelbrotmenge.
Die zweite Struktur ist "variabel groß" (abhängig von der
Bildbreite). Ihre tatsächliche Größe in Bytes wird per
sizeof im Programm berechnet.
Es folgt der Code des Masters, zu dem zunächst einige
Bemerkungen vorangestellt werden.
multimandel.h
master.cpp
servant.cpp
Die Rechner sind in etwa gleich schnell, waren aber unterschiedlich stark
anderweitig beschäftigt. Auf wmpi01 lief auch der Master, so
daß es nicht verwunderlich ist, daß von dort die meisten Zeilen
stammen.
Bei mehr als 8 Rechnern ist kein wesentlicher Geschwindigkeitsvorteil mehr
festzustellen. Bis zu den getesteten 19 Rechnern stieg die Berechnungszeit
aber auch nicht wieder an.
Servant 1 2 3 4 5 6 7 8 ... 19
Zeit [s] 30.4 17.3 10.1 7.6 6.1 5.1 4.6 4.3 ... 4.0
 
9.6: Dateisperren
Startseite
10: Deadlocks