Rheinwerk Computing < openbook > Rheinwerk Computing - Professionelle Bücher. Auch für Einsteiger.
Professionelle Bücher. Auch für Einsteiger

 << zurück
Linux-UNIX-Programmierung von Jürgen Wolf
Das umfassende Handbuch – 2., aktualisierte und erweiterte Auflage 2006
Buch: Linux-UNIX-Programmierung

Linux-UNIX-Programmierung
1216 S., mit CD, 49,90 Euro
Rheinwerk Computing
ISBN 3-89842-749-8
gp Kapitel 11 Netzwerkprogrammierung
  gp 11.1 Einführung
  gp 11.2 Aufbau von Netzwerken
    gp 11.2.1 ISO/OSI und TCP/IP – Referenzmodell
    gp 11.2.2 Das World Wide Web (Internet)
  gp 11.3 TCP/IP – Aufbau und Struktur
    gp 11.3.1 Netzwerkschicht (Datenübertragung)
    gp 11.3.2 Internetschicht
    gp 11.3.3 Transportschicht (TCP, UDP)
    gp 11.3.4 Anwendungsschicht
  gp 11.4 TCP Socket
  gp 11.5 Kommunikationsmodell
  gp 11.6 Grundlegende Funktionen zum Zugriff auf die Socket-Schnittstelle
    gp 11.6.1 Ein Socket anlegen – socket()
    gp 11.6.2 Verbindungsaufbau – connect()
    gp 11.6.3 Socket mit einer Adresse verknüpfen – bind()
    gp 11.6.4 Auf Verbindungen warten – listen() und accept()
    gp 11.6.5 Senden und Empfangen von Daten (1) – write() und read()
    gp 11.6.6 Senden und Empfangen von Daten (2) – send() und recv()
    gp 11.6.7 Verbindung schließen – close()
  gp 11.7 Aufbau eines Clientprogramms
    gp 11.7.1 Zusammenfassung: Clientanwendung und Quellcode
  gp 11.8 Aufbau des Serverprogramms
    gp 11.8.1 Zusammenfassung: Serveranwendung und Quellcode
  gp 11.9 IP-Adressen konvertieren, manipulieren und extrahieren
    gp 11.9.1 inet_aton(), inet_pton() und inet_addr()
    gp 11.9.2 inet_ntoa() und inet_ntop()
    gp 11.9.3 inet_network()
    gp 11.9.4 inet_netof()
    gp 11.9.5 inet_lnaof()
    gp 11.9.6 inet_makeaddr()
  gp 11.10 Namen und IP-Adressen umwandeln
    gp 11.10.1 Name-Server
    gp 11.10.2 Informationen zum Rechner im Netz – gethostbyname und gethostbyaddr
    gp 11.10.3 Service-Informationen – getservbyname() und getservbyport()
  gp 11.11 Der Puffer
  gp 11.12 Standard-E/A-Funktionen verwenden
    gp 11.12.1 Pufferung von Standard-E/A-Funktionen
  gp 11.13 Parallele Server
  gp 11.14 Syncrones Multiplexing – select()
  gp 11.15 POSIX-Threads und Netzwerkprogrammierung
  gp 11.16 Optionen für Sockets setzen bzw. erfragen
    gp 11.16.1 setsockopt()
    gp 11.16.2 getsockopt()
    gp 11.16.3 Socket-Optionen
  gp 11.17 UDP
    gp 11.17.1 Clientanwendung
    gp 11.17.2 Serveranwendung
    gp 11.17.3 recvfrom() und sendto()
    gp 11.17.4 bind() verwenden oder weglassen
  gp 11.18 UNIX-Domain-Sockets (IPC)
    gp 11.18.1 Die Adressstruktur von UNIX-Domain-Sockets
    gp 11.18.2 Lokale Sockets erzeugen – socketpair()
  gp 11.19 Multicast-Socket
    gp 11.19.1 Anwendungsgebiete von Multicast-Verbindungen
  gp 11.20 Nicht blockierende I/O-Sockets
  gp 11.21 Etwas zu Streams und TLI, Raw Socket, XTI
    gp 11.21.1 Raw Socket
    gp 11.21.2 TLI und XTI
    gp 11.21.3 RPC (Remote Procedure Call)
  gp 11.22 IPv4 und IPv6
    gp 11.22.1 IPv6 – ein wenig genauer
  gp 11.23 Netzwerksoftware nach IPv6 portieren
    gp 11.23.1 Konstanten
    gp 11.23.2 Strukturen
    gp 11.23.3 Funktionen
  gp 11.24 Sicherheit und Verschlüsselung


Rheinwerk Computing

11.13 Parallele Server  toptop

Für einige Anwendungen mag ein Beispiel, wie das erste Client-Server-Beispiel, das Sie in diesem Kapitel geschrieben haben, reichen. Der Server wird gestartet, und jeweils ein Client kann mit diesem kommunizieren. Alle anderen Clientprogramme, die ebenfalls mit dem Server kommunizieren wollen, müssen warten. Bei manchen Client-Server-Anwendungen kann dies sogar so gewollt sein. Den Server, den Sie bisher kennen, nennt man einen iterativen Server.

Wenn aber die Bearbeitung der Clientanfragen mehr Zeit in Anspruch nimmt oder mehrere Anfragen gleichzeitig (nach dem Multitasking-Prinzip) abgearbeitet werden müssen, dann ist es nicht sehr sinnvoll, wenn sich der Server immer nur mit einem Client beschäftigt.

Wenn Sie mehrere Clients gleichzeitig bedienen wollen, müssen Sie einen parallelen Server schreiben. Dies können Sie unter Linux recht einfach mittels fork() erledigen. Dabei legt der Server für jede Clientbedienung einen eigenen Kindprozess an. Und das ist einfacher, als Sie vielleicht denken werden. Parallele Server können Sie aber auch mit pthread entwickeln – aber dazu kommen wir noch.

Im folgenden Beispiel soll ein einfaches Netzwerkprogramm entwickelt werden, womit Sie Daten in einem Netzwerk kopieren können. Der Server wartet dabei auf eine Anforderung eines Clients. Zunächst liest der Server den Namen des zu kopierenden Programms aus, das ihm der Client gesendet hat, dann wird in einem entsprechenden Verzeichnis der Serveranwendung (hier lokal beim Server) eine Datei mit dem Namen angelegt. Anschließend schickt der Client dem Server den Inhalt der Datei zum Kopieren. Kopiert werden können dabei alle Dateien, auch ausführbare Dateien und Grafiken. Die Serveranwendung kann auf jedem beliebigen Rechner laufen. Das Beispiel ist eigentlich recht simpel und entspricht im Prinzip dem einfachen Kopiervorgang auf dem lokalen Rechner (abgesehen davon, dass hierbei Sockets verwendet werden).

Bevor ich Ihnen diesen Vorgang anhand eines parallelen Servers zeige, folgt erst die lineare einfache Version, die im nächsten Schritt erst parallelisiert werden soll.

Hier die Serveranwendung, die am Port 1234 auf eine Anfrage wartet:

/* server.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd. h.>
#include <errno.h>
#define  MAX_ZEICHEN  1024
#define  PORT_NUMMER  1234
int main (void) {
  int sockfd, connfd, fd, j, n, ngesamt;
  struct sockaddr_in adresse;
  socklen_t adrlaenge = sizeof (struct sockaddr_in);
  char puffer[MAX_ZEICHEN];
  char path_file[MAX_ZEICHEN];
  const int y = 1;
  if ((sockfd = socket (PF_INET, SOCK_STREAM, 0)) < 0) {
     printf ("Fehler bei socket() ... (%s)\n",
        strerror(errno));
     exit (EXIT_FAILURE);
  }
  printf("Socket erfolgreich angelegt\n");
  adresse.sin_family = AF_INET;
  adresse.sin_port = htons (PORT_NUMMER);
  memset (&adresse.sin_addr, 0, sizeof (adresse.sin_addr));
  setsockopt( sockfd, SOL_SOCKET, 
              SO_REUSEADDR, &y, sizeof(int) );
  if (bind ( sockfd,
             (struct sockaddr *) &adresse,
             sizeof (adresse) ) ) {
     printf ("Fehler bei bind() ... (%s)\n",
        strerror(errno));
     exit (EXIT_FAILURE);
  }
  printf("Server ist bereit und wartet ...\n");
  if (listen (sockfd, SOMAXCONN) != 0) {
     printf ("Fehler bei listen() ... (%s)\n",
        strerror(errno));
     exit (EXIT_FAILURE);
  }
  while ((connfd = accept ( sockfd,
                            (struct sockaddr *) &adresse,
                            &adrlaenge)) >= 0) {
     printf (" ... Daten empfangen\n");
     /* Dateiname */
     j = 0;
     while ((n = read (connfd, &puffer[j], 1)) > 0) {
        if (puffer[j] == '\n') {
           puffer[j] = 0;
           break;
        }
        j++;
     }
     if (n < 0) {
        printf ("Fehler bei read() ...\n");
        exit (EXIT_FAILURE);
     }
     printf ("Dateiname \"%s\" wird kopiert nach ",
        puffer);
     strcpy( path_file, getenv("HOME"));
     strcat( path_file, "/tmp/");
     strcat( path_file, puffer );
     printf("%s\n",path_file);
     /* Datei zum Lesen öffnen */
     if (( fd = open( path_file, 
             O_WRONLY | O_CREAT | O_TRUNC, 0644 ) ) < 0 ) {
        printf ("... kann %s nicht öffnen (%s)\n",
           puffer, strerror(errno));
        close (connfd);
        continue;
     }
     /* Datei aus dem Socket lesen und in lokale */
     /* Kopie schreiben                          */
     ingesamt = 0;
     while((n=read (connfd, puffer, sizeof (puffer))) > 0) {
        if (write (fd, puffer, n) != n) {
           printf("Fehler bei write()...(%s)\n",
              strerror(errno));
           exit (EXIT_FAILURE);
        }
        ingesamt += n;
     }
     if (n < 0) {
         printf ("Fehler bei read() ...\n");
         exit (EXIT_FAILURE);
     }
     printf (".beendet (%d Bytes)\n", ngesamt);
     close (fd);
     close (connfd);
   }
   if (connfd < 0) {
       printf ("Fehler bei accept() ...\n");
       exit (1);
   }
   close (sockfd);
   exit (EXIT_SUCCESS);
}

Als Nächstes die Clientanwendung, die eine Anforderung an den Server stellt und den Namen und anschließend den Inhalt der zu kopierenden Datei sendet.

/* client.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>
#include <arpa/inet.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd. h.>
#include <errno.h>
#define  MAX_ZEICHEN 1024
#define  PORT_NUMMER   1234
int main (int argc, char **argv) {
  int sockfd, i, n, fd;
  ssize_t name_len;
  struct sockaddr_in adresse;
  struct in_addr inadr;
  struct hostent *rechner;
  char puffer[MAX_ZEICHEN];
  char *help;
  if (argc < 3) {
     printf ("Usage: %s rechner datei(en)\n", *argv);
     exit (EXIT_FAILURE);
  }
  if (inet_aton (argv[1], &inadr))
     rechner = gethostbyaddr ( (const void*) &inadr,
                               sizeof (inadr),
                               AF_INET );
  else
     rechner = gethostbyname (argv[1]);
  if (rechner == NULL) {
    herror ("Fehler beim Suchen des Rechners\n");
    exit (EXIT_FAILURE);
  }
  adresse.sin_family = AF_INET;
  adresse.sin_port   = htons (PORT_NUMMER);
  memcpy ( &adresse.sin_addr,
           rechner->h_addr_list[0],
           sizeof (adresse.sin_addr) );
  for (i = 2; i < argc; i++) {
     if ((sockfd = socket (PF_INET, SOCK_STREAM, 0)) < 0) {
        printf("Fehler bei socket() ...(%s)\n",
           strerror(errno));
        exit (EXIT_FAILURE);
     }
     if (connect ( sockfd,
                  (struct sockaddr *) &adresse,
                  sizeof (adresse) ) ) {
        printf("Fehler bei connect() ...(%s)\n",
           strerror(errno));
        exit (EXIT_FAILURE);
     }
     /* Datei, die kopiert wird, soll zum Lesen öffnen */
     if ((fd = open (argv[i], O_RDONLY)) < 0) {
        printf ("kann Datei (%s) nicht oeffnen (%s)\n",
           argv[i], strerror(errno));
        continue;
     }
     strcpy (puffer, argv[i]);
     strcat (puffer, "\n");
     /* Server benötigt den Dateinamen ohne Pfadangabe */
     help = strrchr(puffer,'/');
     if( help == NULL )
        help = puffer;
     else
        *(help++);
     name_len = strlen (help);
     /* Dateinamen an den Server */
     if (write (sockfd, help, name_len) != name_len) {
        printf ("Konnte \"%s\" nicht versenden?\n",
           argv[i]);
        close (fd);
        close (sockfd);
        continue;
     }
     /* Inhalt der Datei auslesen und an Server senden */
     while ((n = read (fd, puffer, sizeof (puffer))) > 0) {
        /* puffer[n] = 0 ist sehr wichtig, wenn die zu */
        /* versendende Datei keine lesbare Datei ist  */
        /* (Ausführbare Datei, Grafik ...) - ohne      */
        /* würde nur Datensalat entstehen              */
        puffer[n]= 0;
        if (write (sockfd, puffer, n) != n) {
           printf("Fehler bei write()...(%s)\n",
              strerror(errno));
           exit (EXIT_FAILURE);
        }
     }
     if (n < 0) {
        printf ("Fehler bei read() ...\n");
        exit (EXIT_FAILURE);
    }
    close (sockfd);
    sleep(2); /* Kurze Unterbrechung ggf. entfernen */
  }
  printf("Datei(en) erfolgreich versendet\n");
  exit (EXIT_SUCCESS);
}

Jetzt das Programm bei der Ausführung. Bitte beachten Sie dabei, welche Adresse Sie angeben, um mit der Serveranwendung in Kontakt zu treten. Sollten Sie Ihre Serveranwendung erst auf dem lokalen Rechner testen (was meistens der Fall sein dürfte), dann ist die IP-Adresse 127.0.0.1 oder der Name »localhost«. Ansonsten, wenn Sie die Serveranwendung extern testen wollen, geben Sie die entsprechende IP-Adresse bzw. den Domainnamen an. Nach der Angabe, wo sich die Serveranwendung (IP-Adresse bzw. Domainname) befindet, können Sie als weitere Argumente in der Kommandozeile Dateien (mitsamt Pfad) angeben, die dann an den Server geschickt werden sollen.

$ gcc -o server server.c
$ gcc -o client client.c
[Rechner mit der Serveranwendung]
$ ./server
Socket erfolgreich angelegt
Server ist bereit und wartet ..
[Rechner mit der Clientanwendung]
$ ./client localhost home/tot/libproc_DE.pdf \
  home/sicherheit/Race_Conditions.pdf ~/Yast2.tif test.c
Datei(en) erfolgreich versendet
[Rechner mit der Serveranwendung]
...Daten empfangen
Dateiname "libproc_DE.pdf"  wird kopiert nach /home/tot/tmp/libproc_DE.pdf
..beendet (341518 Bytes)
 ...Daten empfangen
Dateiname "Race_Conditions.pdf"  wird kopiert nach
/home/tot/tmp/Race_Conditions.pdf
..beendet (3886508 Bytes)
 ...Daten empfangen
Dateiname "Yast2.tif"  wird kopiert nach /home/tot/tmp/Yast2.tif
..beendet (845658 Bytes)
 ...Daten empfangen
Dateiname "test.c"  wird kopiert nach /home/tot/tmp/test.c
..beendet (6512 Bytes)

So weit, so gut. Solange Sie alleine diese Client-Server-Anwendung ausführen, dürften Sie recht zufrieden damit sein. Was aber, wenn Sie diese Serveranwendung als eine Art Backup-Server-Programm in einem großen Unternehmen einsetzen wollen, wo in Stoßzeiten zu Feierabend bis zu hundert solcher Clientanfragen anstehen? Und dabei handelt es sich meistens nicht nur um ein paar KB große Dateien. Sie benötigen also einen parallelen Server, der mehrere Anfragen gleichzeitig abarbeitet.

Lassen wir das ganze Netzwerkzeugs außer Acht – um die Gedanken nicht allzu sehr zu strapazieren. Ein paralleler Server ist hierbei ja nichts anderes als ein neuer Prozess, der die Arbeit verrichtet. Was benötigen Sie also, um einen vernünftigen Prozess vom Start bis zum Ende zu erstellen? Bevor Sie jetzt zurückblättern, hier die einzelnen Punkte:

1. Einen Signalhandler für das Signal SIGCHLD einrichten. Der Signalhandler muss auch waitpid() verwenden, um Zombies zu vermeiden.
       
2. Mit fork() einen neuen Kindprozess starten
       
3. Kindprozess beenden
       

Um mir die anschließende Erklärung zu erleichtern und Ihnen eine trockene Theorie zu ersparen, folgt hierzu die Client-Server-Anwendung des vorherigen Beispiels, wobei die Clientanwendung dieselbe geblieben ist, weshalb ich mir eine Auflistung hier ersparen kann. Sie können also dieselbe Clientanwendung wie im iterativen Server-Beispiel zuvor verwenden. Hierzu die parallele Serveranwendung mit anschließender Erklärung:

/* pserver.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd. h.>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <errno.h>
#define  MAX_ZEICHEN 1024
#define  PORT_NUMMER  1234
typedef void (*sighandler_t)(int);
static sighandler_t
my_signal(int sig_nr, sighandler_t signalhandler) {
   struct sigaction neu_sig, alt_sig;
   neu_sig.sa_handler = signalhandler;
   sigemptyset (&neu_sig.sa_mask);
   neu_sig.sa_flags = SA_RESTART;
   if (sigaction (sig_nr, &neu_sig, &alt_sig) < 0)
      return SIG_ERR;
   return alt_sig.sa_handler;
}
static void no_zombie (int signr) {
  pid_t pid;
  int ret;
  while ((pid = waitpid (-1, &ret, WNOHANG)) > 0)
    printf ("Child-Server mit pid=%d hat sich beendet\n",
       pid );
  return;
}
int main (void) {
  int sockfd, connfd, fd, j, n, ngesamt;
  struct sockaddr_in adresse;
  const int y = 1;
  size_t adrlaenge = sizeof (struct sockaddr_in);
  char puffer[MAX_ZEICHEN];
  char path_file[MAX_ZEICHEN];
  pid_t pid;
  if ((sockfd = socket (PF_INET, SOCK_STREAM, 0)) < 0) {
     printf ("Fehler bei socket() ...(%s)\n",
        strerror(errno));
     exit (EXIT_FAILURE);
  }
  printf ("Socket erfolgreich angelegt\n");
  adresse.sin_family = AF_INET;
  adresse.sin_port = htons (PORT_NUMMER);
  memset (&adresse.sin_addr, 0, sizeof (adresse.sin_addr));
  setsockopt( sockfd, SOL_SOCKET, 
              SO_REUSEADDR, &y, sizeof(int) );
  if (bind ( sockfd,
             (struct sockaddr *) &adresse,
             sizeof (adresse) ) ) {
     printf ("Fehler bei bind() ...(%s)\n",
        strerror(errno));
     exit (EXIT_FAILURE);
  }
  printf ("Server ist bereit und wartet ...\n");
  if (listen (sockfd, 5) ) {
     printf ("Fehler bei listen() ...(%s)\n",
        strerror(errno));
     exit (EXIT_FAILURE);
  }
  my_signal (SIGCHLD, no_zombie);
  while(1) {
     connfd = accept ( sockfd,
                       (struct sockaddr *) &adresse,
                       &adrlaenge );
     if( connfd < 0 ) {
        if( errno == EINTR )
           continue;
        else {
           printf("Fehler bei accept() (%s)\n",
              strerror(errno));
           exit(EXIT_FAILURE);
        }
     }
     printf (" ...Daten empfangen\n");
     /* Neuen Kindprozess-Server starten */
     if ((pid = fork ()) == 0) {
        close (sockfd);
        /* Dateiname */
        j = 0;
        while ((n = read (connfd, &puffer[j], 1)) > 0) {
           if (puffer[j] == '\n') {
              puffer[j] = 0;
              break;
           }
           j++;
        }
        if (n < 0) {
           printf ("Fehler bei read() ...\n");
           exit (EXIT_FAILURE);
        }
        printf("(%d): Dateiname \"%s\"  wird kopiert nach ",
           getpid(),puffer);
        strcpy (path_file, getenv ("HOME"));
        strcat (path_file, "/tmp/");
        strcat (path_file, puffer);
        printf ("%s\n", path_file);
        /* Datei zum Lesen öffnen */
        if ((fd = open ( path_file, 
              O_WRONLY | O_CREAT | O_TRUNC, 0644 ) ) < 0 ) {
           printf ("... kann %s nicht öffnen\n", puffer);
           close (connfd);
           continue;
        }
        /* Datei aus dem Socket lesen und in lokale */
        /*  Kopie schreiben                         */
        ingesamt = 0;
        while((n=read(connfd, puffer, sizeof (puffer)))>0) {
           if (write (fd, puffer, n) != n) {
              printf ("Fehler bei write() ...\n");
              exit (EXIT_FAILURE);
           }
           ingesamt += n;
        }
        if (n < 0) {
           printf ("Fehler bei read() ...\n");
           exit (EXIT_FAILURE);
        }
        printf ("..beendet (%d Bytes)\n", ingesamt);
        close (fd);
        exit(EXIT_SUCCESS);
        /* Ende Kindprozess */
     } //Ende fork()
     close (connfd);
  }//Ende while(1)
  close (sockfd);
  exit (EXIT_SUCCESS);
}

Wenn Sie den Server jetzt starten und mehrere Clientanwendungen zum Kopieren, merken Sie, dass neben einer etwas geänderten Ausgabe die Datenübertragung über verschiedene Rechner wesentlich schneller vor sich geht (natürlich sollten Sie sleep() aus der Clientanwendung entfernen). Natürlich interessiert es Sie erst einmal, was hier hinter den Kulissen vor sich geht.

Wenn der Server bereit ist und mit listen() am Socket lauscht, fällt erst auf, dass hier ein Signalhandler eingerichtet wurde:

static void no_zombie (int signr) {
   pid_t pid;
   int ret;
   while ((pid = waitpid (-1, &ret, WNOHANG)) > 0)
      printf ("Child-Server mit pid=%d hat sich beendet\n",
         pid);
   return;
}
...
...
/* in main() */
my_signal (SIGCHLD, no_zombie);
...

Bei der Signalbehandlung geht es primär darum, Zombieprozesse auf dem Server zu beenden. Denn anders als auf einem Einzelplatzrechner, auf dem einem wohl die Prozesse nicht so schnell ausgehen, kann eine stetig anwachsende Zombiefraktion auf einem Server dafür sorgen, dass kein Platz mehr für neue Prozesseinträge vorhanden ist. Wie Sie das vermeiden, haben Sie ja bereits mit waitpid() gesehen. Dabei wird waitpid() in einer Schleife aufgerufen, womit der Status von jedem beendeten Kindprozess geholt wird. Mit der Option WNOHANG geben Sie an, dass waitpid() nicht blockiert, solange es noch unbeendete Kindprozesse gibt. Bitte beachten Sie auch, dass das Einrichten des Signalhandlers nur einmal erfolgt, denn wie Sie im Kapitel über Prozesse erfahren haben, erbt ja der Kindprozess auch alle eingerichteten Signalhandler. Dies sorgt häufig für Missverständnisse. Gewöhnlich wird der Signalhandler nach dem Aufruf von listen() und vor accept() eingebaut.

Der Vorgang zum Einrichten eines typischen parallelen Servers sieht gewöhnlich so aus:

   while(1) {
      connfd = accept ( sockfd,
                        (struct sockaddr *) &adresse,
                        &adrlaenge );
      if( connfd < 0 ) {   
         if( errno == EINTR )
            continue;
         else {
            printf("Fehler bei accept() ...\n");
            exit(EXIT_FAILURE);
         }
      }
      printf (" ...Daten empfangen\n");
      /* Neuen Kindprozess-Server starten */
      if ((pid = fork ()) == 0) {
         close (sockfd);
         /* Arbeit des Kindprozesses steht hier */
         close(connfd);
         exit(EXIT_SUCCESS);
         /* Ende Kindprozess */
      }
    /* Elternprozess */   
    close (connfd);
  }

Sobald ein Client mit connect() eine Verbindung zum Server herstellt, kehrt die Funktion accept() zurück.


Abbildung
Hier klicken, um das Bild zu vergrößern

Abbildung 11.6    Der Client baut mit connect() eine Verbindung auf.


In Abbildung 9.6 können Sie den Zustand sehen, wenn der Client mit connect() ein Verbindung zum Server anfordert. Direkt nach dieser Anforderung kehrt die Funktion accept() zurück – wie folgende Abbildung demonstriert:


Abbildung
Hier klicken, um das Bild zu vergrößern

Abbildung 11.7    Server bestätigt die Verbindung.


Jetzt kann der Server mit fork() einen neuen Kindprozess aufrufen, um dem Client zu dienen.


Abbildung
Hier klicken, um das Bild zu vergrößern

Abbildung 11.8    Ein neuer Serverprozess wird erzeugt.


Im nächsten Schritt kann der (Server-)Kindprozess das horchende Socket schließen und der Elternprozess das verbundene Socket – das ja wie das horchende Socket bei fork() mit dupliziert wurde (siehe Abbildung 11.9).


Abbildung
Hier klicken, um das Bild zu vergrößern

Abbildung 11.9    Nicht mehr benötigte Sockets schließen


Jetzt haben Sie den gewünschten Status, dass der Kindprozess die Anforderungen des Client abarbeiten kann, und der Elternprozess kann sich wieder dem horchenden Socket mittels accept() widmen, um neue Anforderungen entgegenzunehmen. Wenn der Kindprozess mit der Abarbeitung des Clients fertig ist, schließt auch dieser das Socket mit close() und beendet sich selbst (exit()). Durch die Beendigung des Kindprozesses mittels exit() wird der Signalhandler aktiviert, da dabei das Signal SIGCHLD gesendet wird.

Das war im Grunde auch schon alles, mehr ist nicht nötig, um einen parallelen Server zu erstellen. Etwas Wichtiges sollte aber noch erwähnt werden. Nach accept() überprüfen wir ja auf die Fehlervariable EINTR. Dies wurde deshalb gemacht, weil accept() ein so genannter langsamer Systemaufruf ist. Dies bedeutet, accept() kann für immer blockieren und muss nicht (unmittelbar) zurückkehren.

Es gibt somit auch keine Garantie, dass accept() zurückkehrt, wenn kein Client eine Verbindung mit dem Server aufbaut. Damit kann der Zustand auftreten, dass ein Prozess von einem langsamen Systemaufruf blockiert wird und dieser Prozess ein Signal abfängt und der Signalhandler mit seiner Ausführung fertig ist und somit accept() den Fehler EINTR zurückgibt – obwohl eigentlich kein akuter Fehler vorliegt. Es ist zwar durchaus möglich, dass manche Systeme solche unterbrochenen Systemaufrufe selbstständig neu starten, aber verlassen können Sie sich nicht darauf, wenn Ihre Anwendung auf mehreren Systemen laufen soll. Daher wird ja auch SA_RESTART in den Flags benutzt.

Sie haben jetzt gesehen, wie einfach es unter Linux/UNIX ist, aus einem laufenden iterativen Server einen parallelen Server zu machen. Ein einfaches fork() genügt, und der neue Prozess übernimmt alle Variablen und offenen Socket-Deskriptoren. Ein Manko hat diese Vorgehensweise allerdings bei der Skalierbarkeit. Ein übliches Betriebssystem ist in der Regel für bis zu hundert laufende Prozesse optimiert. Nun kann es aber vorkommen, bei stark frequentierten Servern, wo ein paar tausend Clients gleichzeitig eine Anfrage starten, dass eine Menge an bedeutendem Systemspeicher benötigt wird. Zwangsläufig wird der Server dann zum Swappen von Speicher gezwungen, was die Performance erheblich einbremsen kann. Unter Solaris z. B. dauert außerdem eine neue Prozesserzeugung mittels fork() erheblich lange. Und ein weiterer Nachteil an unserem Modell ist bisher, wenn ein Client eine Verbindung zum Server aufgebaut hat und sich für längere Zeit nicht mehr »meldet«, sollte der Server dem Client ein Timeout senden.

Des Weiteren kommt hinzu, dass der Kontextwechsel zwischen den einzelnen Prozessen (Wechsel vom einem zum anderem Prozess) ebenfalls ein ziemlich langsamer Vorgang ist – wobei es allerdings auch einen Support speziell für die Hardware gibt. Selbst der Speicherbedarf eines leeren Prozesses ist im Linux-System ziemlich groß (nicht wirklich, libc.so.6 ist doch ein Shared Object und wird daher nur einmal im System geladen sein) – was allerdings die Schuld der Glibc und nicht von Linux ist. Es kann sich daher lohnen, eine eigene Bibliothek dazu zu entwickeln (was häufig auch gemacht wird).

Mein Ziel ist es übrigens nicht, hier etwas schlecht zu reden, sondern Sie zum Denken anzuregen, wenn Sie eine Netzwerkanwendung schreiben. Für einfache und gewöhnliche Server, wie diese z. B. im Intranet verwendet werden, werden Sie wahrscheinlich niemals an die Grenzen des Systems stoßen (vorausgesetzt, der Server ist »nicht« entsprechend bestückt). Daher werden Sie jetzt auf den folgenden Seiten noch einige flottere Techniken kennen lernen.

Aber ein Beispiel, das gerne in der Praxis eingesetzt wird, soll hier nicht unerwähnt bleiben. Denn häufig lohnt es sich, schon mehrere Serverprozesse im Voraus zu erzeugen und in dem Moment, wo eine Connection stattfindet, im Background einen neuen Server zu starten, der wiederum die nächste Anfrage bearbeiten kann. Diese Technik wird übrigens auch beim Webserver Apache eingesetzt (genannt Apache-MPM prefork). Sie ist für Angebote geeignet, die aus Kompatibilitätsgründen mit nicht thread-sicheren Bibliotheken Threading vermeiden müssen. Sie ist außerdem das geeignetste MPM (Multi-Processing-Modul), um jede Anfrage isoliert zu bearbeiten, so dass Probleme mit einem einzelnen Prozess keinen anderen beeinflussen.

Datenformat

Häufig müssen Sie sich auch bei der Übertragung von Daten um das vorliegende Format von Daten kümmern. Wollen Sie z. B. Ganzzahlen oder Gleitkommazahlen an den Server verschicken oder der Server an den Client, dann sind die Funktionen sscanf() und sprintf() bzw. snprintf() bestens dafür geeignet. Ebenso sieht es mit der Übergabe von binären Strukturen aus. Auch hierbei wird gerne auf die Konvertierung in einen String zum Versenden der Daten zurückgegriffen. Sicherlich werden Sie sich fragen, warum Sie dabei so vergehen sollten? Wenn Sie nicht sicher sein können, auf was für einen Rechner die Daten übertragen werden, kann es zu Problemen kommen, wenn ein Client Daten im Little Endian-Format schickt, der Server aber auf einem Big Endian-System läuft. Wenn es sich dabei z. B. um Integer handelt, sind Probleme vorprogrammiert. Dasselbe Problem könnte auftreten, wenn Sie einen Wert vom Datentyp int versenden, der auf Ihrem System 64 Bit besitzt, aber auf dem Zielrechner nur 32 Bit. Sie wissen also nie genau, welche Größe die Datentypen int, long und short auf der Gegenseite haben. Zwar gibt es (seit der Einführung des C99-Standards) die unabhängigen Typen wie z. B. int8_t, int16_t etc. und uint8_t etc. in der Headerdatei stdint.h, was aber recht wenig nützt, weil dieser Standard von einigen Compiler-Herstellern (insbes. auf Windows-Systemen) überhaupt nicht beachtet wird (mehr zum C99-Standard siehe Anhang).

Daher die Empfehlung: Um Probleme zu vermeiden, senden Sie nummerische Daten immer im Textformat an die Gegenseite. Natürlich setzt dies voraus, dass die Gegenseite denselben Zeichensatz verwendet. Wenn Sie einen String an einen Rechner schicken, worauf sich nur japanische Schriftarten befinden, kommt nichts dabei raus. Der Nachteil: Es wird Bandbreite verschwendet. Eine 64-Bit-Nummer z. B. kann nämlich über 20 Zeichen lang sein, während im Binärformat gerade mal acht Zeichen dafür benötigt werden.

 << zurück
  
  Zum Rheinwerk-Shop
Neuauflage: Linux-UNIX-Programmierung
Neuauflage:
Linux-UNIX-
Programmierung

bestellen
 Ihre Meinung?
Wie hat Ihnen das Openbook gefallen?
Ihre Meinung

 Buchtipps
Zum Rheinwerk-Shop: Linux-Server






 Linux-Server


Zum Rheinwerk-Shop: Das Komplettpaket LPIC-1 & LPIC-2






 Das Komplettpaket
 LPIC-1 & LPIC-2


Zum Rheinwerk-Shop: Linux-Hochverfügbarkeit






 Linux-
 Hochverfügbarkeit


Zum Rheinwerk-Shop: Shell-Programmierung






 Shell-
 Programmierung


Zum Rheinwerk-Shop: Linux Handbuch






 Linux Handbuch


 Lieferung
Versandkostenfrei bestellen in Deutschland, Österreich und der Schweiz
Info





Copyright © Rheinwerk Verlag GmbH 2006
Für Ihren privaten Gebrauch dürfen Sie die Online-Version natürlich ausdrucken. Ansonsten unterliegt das Openbook denselben Bestimmungen, wie die gebundene Ausgabe: Das Werk einschließlich aller seiner Teile ist urheberrechtlich geschützt.
Alle Rechte vorbehalten einschließlich der Vervielfältigung, Übersetzung, Mikroverfilmung sowie Einspeicherung und Verarbeitung in elektronischen Systemen.


Nutzungsbestimmungen | Datenschutz | Impressum

Rheinwerk Verlag GmbH, Rheinwerkallee 4, 53227 Bonn, Tel.: 0228.42150.0, Fax 0228.42150.77, service@rheinwerk-verlag.de

Cookie-Einstellungen ändern