Client-Server Programmierung mit TCP/IP-Sockets

Zur Kommunikation gibt es i. A. eine asymetrische Verhaltensweise der Prozesse. Der eine Prozeß ist Anfrager, der andere ist ein Anbieter. Das angefragte Objekt kann eine Datei, ein Speicherbereich, ein Dienst oder sonstiges sein. Den Anbieter wollen wir im folgenden Server, den Anfrager Client nennen.

Verbindungslegung

Bevor der Client seine Anfrage stellen kann, muß der Server vorhanden sein. Der Client muß wissen, wie er den Server erreichen kann. Der Server braucht nur zu wissen, wer die Anfrage gestellt hat, um eine Antwort senden zu können. Wie gelingt nun die Adressierung zwischen den Prozessen? Zu diesem Zweck ein kleiner Exkurs in das TCP/IP:

Jedes Netzwerkinterface im TCP/IP hat eine eigenständige Nummer. Diese ist 4 Byte lang. Man pflegt diese Nummer der besseren Lesbarkeit halber durch Punkte zu trennen. Eine typische Rechnernummer könnte beispielsweise 192.11.109.14 lauten. Da aber die Zeiten eigentlich vorbei sein sollten, in denen man in der EDV alles mit Nummern bezeichnete, kann man auch einen Namen statt der Nummer verwenden. So könnte der Rechner auch anton heißen. Die Umsetzung vom Namen zur Nummer wird in größeren Netzen durch einen Nameserver erreicht. In kleineren Netzen wird dies auf dem lokalen Rechner durch Einträge in der Datei /etc/hosts aufgelöst. Eine typische Zeile dieser Datei lautet:


192.11.109.14   anton supernova

Der Rechner 192.11.109.14 kann unter dem Namen anton oder supernova angesprochen werden.

Die Bezeichnung des Rechners ist aber nur ein Teil der Miete. Auf einem PC mit nur einem laufenden Programm würde diese Adresse vielleicht reichen. Aber auf einer UNIX-Maschine mit vielen gleichzeitig laufenden Programmen muß angegeben werden, welcher Prozeß die Nachricht empfangen soll. Zu diesem Zweck werden Ports verwendet. Für jeden Dienst gibt es einen Port, der eine eindeutige Nummer hat. An diesem Port hängt ein Server, der Anfragen dieses Dienstes empfängt und beantwortet. Die Portnummern unter 5000 sind für Systemdienste reserviert. Darüeber kann man eine Nummer für den eigenen Server verwenden. Auch hier gilt, daß man der Portnummer einen Namen zuorden kann und sollte. Die Auflösung der Namen erfolgt in der Datei /etc/services und im Programm durch den Aufruf von getservbyname(). Der Port für den Server-Socket wird in der Datei /etc/services eingetragen. Auf jedem Rechner im Netz muß hier der gleiche Eintrag stehen:


hilfe    6004/tcp

hilfe ist die Bezeichnung des Dienstes, 6004 ist eine beliebige Zahl, die lediglich nicht von anderen Diensten benutzt werden darf und größer als 5000 sein sollte. Das tcp ist die Kennung für das Protokoll. Hier gibt es noch die Möglichkeit, daß udp für ein Protokoll tieferer Ebene eingetragen ist. Man spricht von einem well-known-port, da die Clients wissen müssen, auf welchem Port sie ihren Server finden können. Der Client braucht keinen festen Port. Er erbittet sich auf der lokalen Maschine eine freie Nummer und ruft damit den Port des Servers. Der Server erfährt die Nummer des Clients aus der Anfrage und kann ihm unter diesem Port antworten.

Die Systemaufrufe

Zur Kommunikation selbst: Es gibt folgende wichtigen Systemkommandos:
AufrufZweck
socket Anforderung eines Kommunikationsendpunktes
bind Lege die Portnummer fest
listen Festlegen der Pufferzahl für Anfragen
accept Akzeptanz von Anfragen signalisieren
connect Verbindung anfordern
send Senden von Daten
recv Empfangen von Daten
close Schließen des Sockets

Das Szenario zwischen Server und Client sieht wie folgt aus:

Server Client Aktion
socket Server fordert Socket an
bind Server legt seinen Port fest
listen Server gibt die Verbindungszahl fest
Schleife-Anfang
socket Client fordert Socket an
accept Server ist bereit zum Empfang
connect Client meldet Anforderung
recv Server wartet auf Daten
send Client sendet Daten
Server empfängt und bearbeitet die Anfrage
recv Client wartet auf die Antwort
send Server sendet Antwort zurück
close Client hat empfangen und löst seine Verbindung
Schleife-Ende
close Server meldet sich endgültig ab

Betrachten wir erst einmal das Listing des Servers:


#include 

#include 

#include 

#include 

#include 



#include 



#define MAXPUF 1023



main()

{

int IDMySocket, IDPartnerSocket;

struct sockaddr_in AdrMySock, AdrPartnerSocket;

int AdrLen;



char Puffer[MAXPUF];

int MsgLen;



  IDMySocket = socket(AF_INET, SOCK_STREAM, 0);

  /* Socket an Port-Nummer binden */

  AdrMySock.sin_family = AF_INET;

  AdrMySock.sin_addr.s_addr = INADDR_ANY; /* akzept. jeden */



  /* Bestimme Port */

  Service = getservbyname("hilfe","tcp");

  AdrSock.sin_port = Service->s_port;



  bind(IDMySocket, &AdrMySock, sizeof(AdrMySock));

  listen(IDMySock, 5);

  do {

    IDPartnerSocket = accept(IDMySocket, &AdrPartnerSocket, &len);

    MsgLen = recv(IDPartnerSocket, Puffer, MAXPUF, 0);



    /* tu was mit dem Kram */



    send(IDPartnerSocket, Puffer, MsgLen, 0);

    close(IDPartnerSocket);

  } while(1); /* bis zum St. Nimmerlein */

}

Dieser Server bearbeitet nacheinander jede Anfrage, die über den Port hilfe an ihn gestellt wird. Nach jeder Anfrage wird die Verbindung wieder gelöst und ein anderer Client kann anfragen. Ein solcher Server dürfte auf jedem Betriebssystem arbeiten können, das TCP/IP unterstützt, selbst wenn es kein Multitasking beherrscht. Der zugehörige Client könnte wie folgt aussehen:

#include 

#include 

#include 

#include 

#include 



#define MAXPUF 1023



main()

{

int IDSocket;

struct sockaddr_in AdrSock;

int len; /* Die Laenge der Socketstruktur */



struct hostent *RechnerID;

struct servent *Service;



char Puffer[MAXPUF];



  IDSocket = socket(AF_INET, SOCK_STREAM, 0);



  /* Bestimme den Zielrechner */

  RechnerID = gethostbyname(ßerver");

  bcopy(RechnerID->h_addr,&AdrSock.sin_addr,RechnerID->h_length);



  /* Bestimme den Port */

  Service = getservbyname("hilfe","tcp");

  AdrSock.sin_port = Service->s_port;



  connect(IDSocket, (struct sockaddr *)&AdrSock,

          sizeof(AdrSock));



  send(IDSocket, Puffer, MAXPUF, 0);

  recv(IDSocket, Puffer, MAXPUF, 0);

  close(IDSocket);

}

Es gibt zwei Variablen pro Socket. Die eine ist wie bei Dateizugriffen ein einfaches Handle (hier mit ID gekennzeichnet), die andere hält die Adresse der Verbindung, also die Internet-Nummer des Rechners und die Nummer des Ports. Der Server legt die Nummer des Rechners nicht fest, indem die Konstante INADDR_ANY benutzt wird. Der Client dagegen gibt die Adresse des anzusprechenden Servers an. Die recv-Funktion liefert als Rückgabewert die Größe des versandten Speicherbereichs. Die recv-Funktion liefert die Sendung in Happen von maximal 1KB. Wurden größere Pakete verschickt, müssen sie häppchenweise gelesen werden. Das Senden ist nicht beschränkt. Grundsätzlich liefern fast alle Funktionen eine 0 bei Fehlern zurück. Ich habe mir diese Abfragen im Listing verkniffen. Im ``richtigen'' Programm müssen die natürlich drin sein.

Dämon in den Hintergrund

Um aus dem Server einen richtigen Dämon zu machen, schickt man ihn unter UNIX in den Hintergrund. Dies ist natürlich auch durch Anhängen eines \& an den Startaufruf möglich. Besser ist folgender fork-Aufruf:

    if (fork()!=0) exit(0); /* toete den Vater, Sohn ueberlebt */

Der fork-Mechanismus erschlägt 2 Fliegen mit einer Klappe: Der Vater ist Sohn der Benutzershell. Wenn der Benutzer sich abmeldet, erhält der Server ein Signal, daß er sich verabschieden soll. Das soll aber vermieden werden. Wird der Vater getötet, wird der Sohn zum Waisen und wird daraufhin vom init-Prozeß adoptiert. Aufgrund dessen geht der Prozeß ohne explizite Aufforderung in den Hintergrund.

Parallelität

Ein Server wird im Allgemeinen in einer Multitasking-Umgebung gestartet werden. Er soll schließlich mehrere Anfragen parallel abarbeiten können. Unter UNIX gibt es dazu den fork-Mechanismus. Leider wird die Einführung des forks die Portabilität auf andere Systeme aufheben.

  do {

    IDPartnerSocket = accept(IDMySocket, &AdrPartnerSocket, &len);

    if (fork()==0) {

      MsgLen = recv(IDPartnerSocket, Puffer, MAXPUF, 0);



      /* tu was mit dem Kram */



      send(IDPartnerSocket, Puffer, MsgLen, 0);

      close(IDPartnerSocket);

      /* Sohn toetet sich selbst */

      exit(0);

    } /* if fork.. */

    close(IDPartnerSocket); /* der Vater schliesst die Verbindung */

  } while(1);

Der fork-Mechanismus erschlägt auf einfache Weise die Bearbeitung mehrerer paralleler Anfragen. Der Prozeß erzeugt einen Sohn, der auch den Socket erbt, über den die Verbindung zum Client erhalten bleibt.

Zombies

Zombies entstehen dadurch, daß ein Sohn endet, aber der Vater nicht auf ihn wartet. Dadurch bleibt ein Eintrag in der Prozeßtabelle mit dem Exit-wert des Sohnes. Da der Server aber nicht auf seine Söhne wartet, bleibt dieser Eintrag zurück. Das Signal SIGCLD ist genau dieses Signal das in der Prozeßtabelle abgelegt wird. Durch den SIG_IGN wird dieses Signal in Zukunft ignoriert, es gelangt nicht in die Prozeßtabelle und der Zombie entsteht nicht.

    signal(SIGCLD, SIG_IGN);    /* verhindere Zombies */

Es gibt feine Unterschiede zwischen den Unix-Version:\footnote{Vielen Dank an Thomas Weidenfeller Maus F}

Literatur

W. Richard Stevens: Programmieren UNIX-Netzen.
Addison-Wesley, 1992.

Homepage (C) Copyright 1999 Arnold Willemer