Winsock Tutorial von c-worker.ch (Teil 4: Mehrere Clients mit select())

Dieses Tutorial stammt von www.c-worker.ch.
Es darf auf einer eigenen Seite veröffentlicht werden sofern es unverändert bleibt, und die Seite www.c-worker.ch als Quelle erwähnt wird.
Für die Richtigkeit aller in diesem Dokument beschriebenen Angaben und für eventuelle Schäden die durch dieses Dokument entstehen könnten wird nicht gehaftet.

Hinweise

Falls beim kompilieren einige "Neudefinition" Fehler kommen entfernt die "#include <winsock2.h>" Zeile (wurde in diesem Fall schon in windows.h includiert)
Manche Leute berichten auch, dass Sie den Fehler beheben können indem sie winsock2.h vor windows.h includieren.

Falls der Compiler INADDR_ANY nicht finden kann verwendet ADDR_ANY (oder umgekert, eins von beiden geht schon)
Das Progamm muss gegen ws2_32.lib gelinkt werden. Falls man Visual Studio verwendet muss man bei den Projekteigenschaften unter Linker ws2_32.lib zu den Libraries hinzufügen.
Eventuell schafft aber auch folgende Zeile am Anfang des Quellcodes Abhilfe: #pragma comment( lib, "ws2_32.lib" )

1. Einleitung
2. Varianten zum behandeln mehrerer Clients
3. Das Problem
4. Die Lösung: select()

5. Ein Server mit select()
6. Schlusswort


1. Einleitung

In allen bisherigen Tutorials wurde vom Server immer nur ein Client bedient. In diesem Tutorial soll nun ein Server geschrieben werden der mehrere Clients gleichzeitig behandeln kann.

2. Varianten zum behandeln mehrerer Clients

Es gibt eigentlich recht viele Varianten wie ein Server mehrere Clients bedienen kann:

Wie man schon aus dem Titel entnehmen kann werden wir die select() Variante verwenden. Diese Variante ist auch sicher eine der portabelsten.
Für jeden Client einen eigenen Prozess starten hat den Nachteil das die Kommunikation zwischen den Prozessen irgendwie gehandelt werden muss, der Vorteil ist aber, dass es für jeden Prozess so aussieht als gäbe es nur einen Client. Die Thread Variante hat den Nachteil das die Portabilität wieder etwas darunter leidet da unterschiedliche Plattformen meist andere Thread-Libraries haben. Auch scheinen viele Socket funktionen nicht Thread-save zu sein. Nicht blockierende Sockets sind wie schon erwähnt eine schlechte Idee weil eine solche Lösung die CPU Auslastung in die Höhe treibt. WSAAsyncSelect und WSAEventSelect sind zwar eine gute Sache und vorallem WSAAsyncSelect wird bei Windows Progrämmchen oft verwendet, jedoch stehen diese Funktionen nur unter Windows zur Verfügung und der Code dieser Tutorials soll so einfach wie möglich auf eine andere Platform zu portieren sein.


3. Das Problem

Also wir möchten nun auf Anfragen (Daten) von mehreren Clients antworten können UND gleichzeitig auch noch neue Clients akzeptieren. Um Daten von Clients zu empfangen haben wir bisher die recv() Funktion (mit dem Socket welcher mit dem Client verbunden ist als Parameter) aufgerufen. Das Problem ist aber das recv() blockiert, d.h. die Funktion kehrt nicht zurück bis dieser Client wirklich Daten sendet. Unterdessen haben nun aber vielleicht andere Clients Daten gesendet und diese würden dann erst behandelt falls der erste Client Daten gesandt hat.
Nun kommt man natürlich schnell auf die Idee, das man halt einfach eine Funktion braucht welche prüft ob auf einem bestimmten Socket Daten empfangen werden können, und wenn ja sollen die Daten empfangen werden und eine Antwort verschickt werden. Natürlich könnte man das mit nicht-blockierenden Sockets realisieren. Wenn aber nun z.B. für ein par Sekunden von keinem der Clients Daten gesendet werden, so wird trozdem die ganze Zeit in einer Schleife geprüft ob auf irgend einem Socket Daten empfangen werden können, was die CPU Auslastung in die Höhe treibt. Das schöne an den blockierenden Socket aufrufen war, das während der Zeit in welcher die Funktion blockierte, das Programm keine CPU Zeit benötigte. Es wurde also nur dann CPU Zeit benötigt, wenn auch wirklich etwas zu tun war.


4. Die Lösung: select()

Die select() Funktion löst genau das oben beschriebene Problem. Mit select kann man für mehrere Sockets gleichzeitig prüfen ob irgend etwas ansteht. Ebenfalls blockiert die funktion select d.h. unser Programm wird schlafen gelegt bis auf irgend einem unserer Sockets etwas passiert.

Hier die Definition der select() Funktion:

int select(
  int nfds,
  fd_set FAR *readfds,
  fd_set FAR *writefds,
  fd_set FAR *exceptfds,
  const struct timeval FAR *timeout
);

-nfds: Dieser Parameter wird unter Windows eigentlich gar nicht benötigt und ist nur für die Kompatibilität mit Unix vorhanden. Dort muss dieser Parameter auf den grössten Socket in den Arrays + 1 gesetzt werden.
-readfds: Ein Array von Sockets welche auf lesbarkeit geprüft werden sollen
-writefds
: Ein Array von Sockets welche auf schreibbarkeit geprüft werden sollen
-readfds:
Ein Array von Sockets welche auf Fehler/Ausnahmen geprüft werden sollen
-timeout
: Wie lange select() warten soll. Ist das timeout auf 0 gesetzt so kehrt select() sofort zurück. Wird kein Pointer auf eine timeval Struktur übergebe, d.h. ein NULL Pointer, blockiert select() solange bis auf einem Socket etwas passiert.

Die timeval Stuktur für dem timeout Parameter ist dazu da eine genau Zeitangabe angeben zu können, sie ist folgendermassen definiert:

struct timeval {
  long tv_sec
  long tv_usec
};

-tv_sec: Anzahl Sekungen
-tv_usec: Anzahl Mikrosekungen

Wir werden aber sowieso einen NULL Pointer übergeben, deshalb braucht uns diese Struktur eigentlich nicht weiter zu interessieren.


Viel wichtiger ist der fd_set Datentyp. Dieser ist je nach Betriebssystem anderst aufgebaut, deshalb können wir über den eigentlichen internen Aufbau dieses Typs keine Annahmen machen. Unter windows ist fd_set tatsächlich als ein Array von Sockets implementiert. Unter Linux jedoch ist ein fd_set eine Bitmaske dessen Bits dann auf einen bestimmten Socket verweisen. Aber im eigentlichen Sinne ist ein fd_set schon ein Array von Sockets, jedoch ist die interne darstellung halt einfach unterschiedlich. Um mit fd_set's dennoch unter allen Plattformen gleich arbeiten zu können stehen ein par Makros um diese zu verändern zur Verfügung:

FD_ZERO(*set)
Löscht den Inhalt des fd_set's
FD_SET(s, *set)
Fügt den Socket s dem fd_set hinzu
FD_CLR(s, *set)
Entfernt den Socket s aus dem fd_set
FD_ISSET(s, *set)
Prüft ob der Socket s im fd_set vorhanden ist

Der set Parameter ist immer ein Pointer auf ein fd_set, und s der Socket für welchen die Operation ausgefürt werden soll.

Hier ein kleines Demo wie man 2 Sockets in ein fd_set einfügt und prüft ob der erste darin vorhanden ist:

FD_SET meinSet;
SOCKET socket1;
SOCKET socket2;
int rc;

FD_ZERO(&meinSet); // fd_set leeren
FD_SET(socket1,&meinSet); // socket1 hinzurüfen
FD_SET(socket2,&meinSet); // socket2 inzufügen
rc=FD_ISSET(socket1,&meinSet); // Prüfen ob socket1 in diesem fd_set ist, rc ist in diesem falle TRUE (bzw grösser 0)

Eigentlich gar nicht so schwer. Wie gesagt müssen wir der Funktion select() drei Pointer auf fd_set's übergeben. Von "müssen" kann man aber eigentlich nicht reden, weil man den statt einem Pointer auf ein fd_set für jeden der 3 Parameter auch NULL übergeben kann. Wir werden ebenfalls nur den ersten fd_set Parameter (readfds) übergeben und writefds / exceptfds auf NULL setzen. Wie oben erklärt werden die Sockets in readfds auf "lesbarkeit" geprüft. Was heisst das aber genau? Ein Socket ist in folgenden fällen "lesbar":

Wir sehen also, select() garantiert uns sozusagen das ein recv() bzw. accept() aufruf bei den "lesbaren" Sockets nicht blockieren wird.
Ok, aber wie teilt uns select() nun mit welche der Sockets wie wir im fd_set übergeben haben lesbar sind? Ganz einfach, select() verändert die übergebenen fd_set's und die enthalten bei der rückkehr nur noch Sockets welche die Bedingungen erfüllen, d.h. in unserem Falle lesbar sind. Deshalb wird das fd_set auch als Pointer übergeben. Das heisst aber auch das wir vor jedem select() aufruf das fd_set neu mit Sockets füllen müssen, da es ja von select() verändert wurde.


5. Ein Server mit select()

Ich setzte hier voraus das man das erste Tutorial gelesen hat und werde deshalb nur den neuen Code entsprechend erklären.
Der Anfang ist wieder genau der gleiche wie beim Server aus Tutorial 1: Wir verwenden die startWinsock() Funktion aus dem ersten Tutorial um Winsock zu starten, erstellen einen Socket, binden diesen an den Port auf dem der Server Verbindungen annehmen soll (wir nehmen wieder 12345) und setzten den Socket dann in den listen Modus:

#pragma comment( lib,"ws2_32.lib" )
#include <windows.h>
#include <winsock2.h> // bei manchan compilern muss man nur windows.h includieren (zB VC++)
#include <stdio.h>
#define MAX_CLIENTS 10 int startWinsock(void) { WSADATA wsa; return WSAStartup(MAKEWORD(2,0),&wsa); } int main() { long rc; SOCKET acceptSocket; //SOCKET connectedSocket; SOCKADDR_IN addr; char buf[256]; char buf2[300]; // zusätzliche Variabeln FD_SET fdSet; SOCKET clients[MAX_CLIENTS]; int i; // Winsock starten rc=startWinsock(); if(rc!=0) { printf("Fehler: startWinsock, fehler code: %d\n",rc); return 1; } else { printf("Winsock gestartet!\n"); } // Socket erstellen acceptSocket=socket(AF_INET,SOCK_STREAM,0); if(acceptSocket==INVALID_SOCKET) { printf("Fehler: Der Socket konnte nicht erstellt werden, fehler code: %d\n",WSAGetLastError()); return 1; } else { printf("Socket erstellt!\n"); } // Socket binden memset(&addr,0,sizeof(SOCKADDR_IN)); addr.sin_family=AF_INET; addr.sin_port=htons(12345); addr.sin_addr.s_addr=INADDR_ANY; // gewisse compiler brauchen hier ADDR_ANY rc=bind(acceptSocket,(SOCKADDR*)&addr,sizeof(SOCKADDR_IN)); if(rc==SOCKET_ERROR) { printf("Fehler: bind, fehler code: %d\n",WSAGetLastError()); return 1; } else { printf("Socket an port 12345 gebunden\n"); } // In den listen Modus rc=listen(acceptSocket,10); if(rc==SOCKET_ERROR) { printf("Fehler: listen, fehler code: %d\n",WSAGetLastError()); return 1; } else { printf("acceptSocket ist im listen Modus....\n"); }

Bis hierher ist mehr oder weniger alles beim alten. Unterschiede gegenüber dem Server aus Tutorial 1 sind fett hervorgehoben. So brauchen wir nun den connectedSocket für den Client nicht mehr, dafür aber ein Array von Sockets für all unsere Clients. Die grösse dieses Arrays (und somit die maximale Anzahl Clients) werden mit der Konstante MAX_CLIENTS festgelegt. Ebenfalls wurde ein fd_set erstellt damit wir mit select() arbeiten können.

So, als ersten muss mal das Array mit den Sockets welche für die Verbindungen zu den Clients da sind entsprechen initialisiert werden. Dazu setzten wir alle auf INVALID_SOCKET:

  for(i=0;i<MAX_CLIENTS;i++)
  {
    clients[i]=INVALID_SOCKET;
  }

Nun beginnt die Hauptschleife der Applikation:

  while(1) 
  {

Zuerst müssen wir mal unser fd_set entsprechen abfüllen. Wir möchten wissen ob einer unserer Clients Daten gesenget hat oder der acceptSocket eine neue Verbindung annehmen kann. Deshalb kommen sowohl alle gültigen Client Sockets ins fd_set als auch der acceptSocket:

    FD_ZERO(&fdSet); // Inhalt leeren
    FD_SET(acceptSocket,&fdSet); // Den Socket der verbindungen annimmt hinzufügen

    // alle gültigen client sockets hinzufügen (nur die die nicht INVALID_SOCKET sind)
    for(i=0;i<MAX_CLIENTS;i++) 
    {
      if(clients[i]!=INVALID_SOCKET)
      {
        FD_SET(clients[i],&fdSet);
      }
    }

Ok, nun kommen wir zum eigentlich select() aufruf. Zuerst muss mal der Rückgabewert von select() geprüft werden, und dannach ist zu prüfen welcher unserer Sockets im von select() veränderten fd_set noch enthalten ist. Falls der acceptSocket ebenfalls darin vorhanden ist wird eine neue Verbinung angenommen, falls es ein Client ist senden wir ihm eine antwort. (In diesem falle einfach "Du mich auch <der text vom client>")

    rc=select(0,&fdSet,NULL,NULL,NULL);
    // nicht vergessen den ersten parameter bei anderen betriebssystem anzugeben
    if(rc==SOCKET_ERROR) 
    {
      printf("Fehler: select, fehler code: %s\n",WSAGetLastError());
      return 1;
    }

    // acceptSocket is im fd_set? => verbindung annehmen (sofern es platz hat)
    if(FD_ISSET(acceptSocket,&fdSet)) 
    {
      // einen freien platz für den neuen client suchen, und die verbingung annehmen
      for(i=0;i<MAX_CLIENTS;i++)
      {
        if(clients[i]==INVALID_SOCKET) 
        {
          clients[i]=accept(acceptSocket,NULL,NULL);
          printf("Neuen Client angenommen (%d)\n",i);
          break;
        }
      }
    }
    // prüfen wlecher client sockets im fd_set sind
    for(i=0;i<MAX_CLIENTS;i++) 
    {
      if(clients[i]==INVALID_SOCKET)
      {
        continue; // ungültiger socket, d.h. kein verbunder client an dieser position im array
      }
      if(FD_ISSET(clients[i],&fdSet))
      {
        rc=recv(clients[i],buf,256,0);
        // prüfen ob die verbindung geschlossen wurde oder ein fehler auftrat
        if(rc==0 || rc==SOCKET_ERROR)
        {
          printf("Client %d hat die Verbindung geschlossen\n",i);
          closesocket(clients[i]); // socket schliessen 
          clients[i]=INVALID_SOCKET; // seinen platz wieder freigeben
        } 
        else
        {
          buf[rc]='\0';
          // daten ausgeben und eine antwort senden
          printf("Client %d hat folgendes gesandt: %s\n",i,buf);
          // antwort senden
          sprintf(buf2,"Du mich auch %s\n",buf);
          send(clients[i],buf2,(int)strlen(buf2),0);
        }
      }
    }
  }
}


6. Schlusswort

Nun braucht man nur noch den Server zu kompilieren und zu starten. Client werden wir hier keinen eigenen schreiben, ihr könnt einfach den von Tutorial 1 nehmen. Der Server aus diesem Kapitel und der Client aus Tutorial 1 könnt ihr auch hier herunterladen: sock.c, sockselectsrv.c

Es wurden absichtlich nicht alle Rückgabewerte abgefragt um den Code etwas übersichtlicher zu machen. Auch müsste man noch etwas einbauen um den Server beenden zu können, momentan läuft er einfach in der Endlosschlaufe bis man Ctrl+C drückt. Auch ein WSACleanup() müsste am Schluss noch folgen.
Und die Rechtschreibung habe ich ebenfalls absichtlich nicht geprüft :)