Wissen: Einführung in die Netzwerkprogrammierung mit der Socket-API

Was ist ein Socket?

Um es gleich vorweg zu nehmen: Sockets sind kein Hexenwerk. Wer schon einmal mit einer sequentiellen Datei gearbeitet hat, dem wird hier vieles bekannt vorkommen. Diese Verwandtschaft ist nicht durch Zufall entstanden. Man kann sagen, daß TCP/IP in Symbiose mit BSD-Unix entstanden ist. Teil dieser Entwicklung ist, dass der bewährte Unix-Grundsatz „(fast) alles ist eine Datei“ auch hier umgesetzt wurde. Streng genommen handelt es sich bei Sockets zwar nicht um reguläre Dateien, trotzdem kann jedoch in vielen Fällen mit den bekannten Dateifunktionen auf sie zugegriffen werden.

Was ist nun ein Socket? Logisch ist ein Socket eine Datenstruktur, die sich aus einer IP-Adresse und einer Portnummer zusammensetzt. Die IP-Adresse dient dazu, einen bestimmten Rechner in einem Netzwerk zu identifizieren. Die Portnummer stellt die Referenz eines Pufferspeichers auf dem durch die IP-Adresse spezifizierten Rechner dar. Dieser Puffer koppelt die virtuelle Netzwerkverbindung mit einem Prozess. Somit ist ein Socket nichts anderes als eine Adresse, die eine Anwendung und den zugehörigen Host im gesamten betrachteten Netzwerk eindeutig identifiziert. Aus Anwendungssicht werden die Endpunkte einer TCP-Verbindung durch jeweils einen Socket gebildet, so dass Sockets die Schnittstelle zwischen TCP und der Anwendungsschicht bilden.

Vorstellung der wichtigsten API-Funktionen

Die unten stehende Grafik zeigt die einzelnen Schritte, die Client und Server ausführen müssen, um eine TCP-Verbindung zu etablieren und eine Übertragung durchzuführen.

socket-server-bild-1
Client-Server-Kommunikation

 

Sowohl Server als auch Client rufen als erstes die Funktion socket auf, die dem Betriebssystem mitteilt, daß ein neuer Socketdeskriptor benötigt wird. Wie auch bei der Arbeit mit Dateien wird hier nicht mit Zeigern, sondern mit Deskriptoren, also numerischen Referenzen gearbeitet, die eine Zuordnung zu systeminternen Strukturen ermöglichen. Die Funktion besitzt den folgenden Kopf:

 

 

 

 

 

 

 

int   socket ( int   family, int type, int protocol );
familyAF_APPLETALK

AF_INET

AF_IPX

AF_UNIX

AppleTalk DDS

TCP/IP

Novell IPX

Unix Domain

typeSOCK_STREAM SOCK_DGRAMStreaming-Protokoll

Datagramm-Protokoll

protocol0Standardprotokoll

(TCP für SOCK_STREAM, UDP für SOCK_DGRAM)

andere Protokolle können in /etc/protocols nachgeschlagen werden.

Return>0ein gültiger Socketdeskriptor
-1Fehler

 

Um einen Socketdeskriptor zu erhalten, übergibt die Anwendung Parameter, um die entsprechende Protokollfamilie und die Art des gewünschten Sockets zu spezifizieren. In der Tat unterscheiden sich die einzelnen Protokolle in der Programmierung kaum. So kann man auf die gleiche Weise, mit der ein Netzwerkzugriff über TCP/IP-Sockets erfolgt zwei lokale Prozesse mittels Unix-Domain-Sockets kommunizieren lassen.

Nach Aufruf der Funktion steht der Applikation ein gültiger Socketdeskriptor zur Verfügung, sofern kein Fehler aufgetreten ist.

Für die Verwendung in der API wird ein Socket durch die folgenden Strukturen abgebildet:

struct sockaddr {

unsigned short          sa_family;

char                    sa_data[14];

}

 

struct sockaddr_in {

short int               sin_family;

unsigned short int      sin_port;

struct in_addr          sin_addr;

}

 

struct in_addr {

unsigned long int       s_addr;

}

Die Struktur struct sockaddr rührt daher, daß die Socket-API aus einer Zeit stammt, in der ANSI-C noch keine undefinierten Zeiger (void *) zugelassen hat. Um dennoch Unabhängigkeit von den einzelnen Protokollen zu ermöglichen, verwenden alle Funktionen diese „generische“ Struktur, die intern entsprechend des Inhalts von sa_family gecastet wird.

Die übrigen Funktionen werden im folgenden vorgestellt:

int   bind (int   sockfd, const struct sockaddr *servaddr, socklen_t addrlen);
sockfdEin socketdeskriptor
*servaddrDie Protokolladresse, sowie Portnummer des Servers
addrlenDie Größe von servaddr in byte
Return>0Kein Fehler
-1Fehler

Auf der Server-Seite steht ein Aufruf von bind an erster Stelle. Durch diesen Aufruf wird dem Betriebssystem mitgeteilt, daß der angegebene Port der spezifizierten IP-Adresse zugeordnet werden soll. Bevor der Server jedoch im Stande ist, auf eingehende Verbindungen zu reagieren, erfolgt als zweites das Einrichten einer Warteschlange.

int   listen   (int sockfd, int backlog);
sockfdein socketdeskriptor
backlogdie maximale Anzahl der Einträge in der Server-Warteschlange
Return>0Kein Fehler
-1Fehler

Durch listen wird aus dem unverbundenen Socket ein horchender Socket. Treffen im Falle von TCP während des 3-Wege-Handshakes mehrere Anfragen zur gleichen Zeit ein, werden diese vom Betriebssystem in die Warteschlange des Sockets eingereiht.

int   accept (int   sockfd, const struct sockaddr *cliaddr, socklen_t addrlen);
sockfdein socketdeskriptor
*cliaddrdie Protokolladresse, sowie Portnummer der eingegangenen Verbindung
addrlendie Größe von servaddr in byte
Return>0der Socketdeskriptor für die Client-Verbindung
-1Fehler

Mit dem letzten Schritt, dem Aufruf von accept, wird der Server-Prozeß angewiesen, auf das Initiieren einer Verbindung von Client-Seite zu warten. Bis ein Client verbunden hat, blockiert der Prozeß. Das bedeutet, daß er von der Auswahl durch den Scheduler ausgeschlossen ist und somit keine Systemzeit verschwendet. Aktives Warten, wie es durch fortlaufendes Abfragen des Sockets entstehen würde, wird dadurch vermieden. Dennoch kann aus dieser Situation ein Problem werden, wenn der Prozeß an mehrern Stellen gleichzeitig blockiert. Dieser Fall könnte eintreten, wenn mehrer Sockets überwacht würden, oder zusätzlich Eingaben über die Kommandozeile möglich wären. Wie man mit solchen Konstellationen umgeht, wird in einem der folgenden Abschnitte erläutert.

Die Parameter von accept gleichen denen von bind, wobei die Bedeutung hier eine andere ist. Im Gegensatz zu bind findet hier keine Wertübergabe an das Betriebssystem, sonden in umgekehrter Richtung statt. Der Zeiger cliaddr zeigt auf eine Struktur, die die IP-Adresse und Portnummer des entfernten Sockets beinhaltet. Über den zurückgegebenen Deskriptor findet die Kommunikation mit dem Client statt. Es ist wichtig zwischen diesem und dem zuerst eingerichteten Socketdeskriptor des horchenden Sockets zu unterscheiden.

Auf der Client-Seite muß zunächst ebenfalls ein Socketdeskriptor erzeugt werden. Anschließend wird der Verbindungsaufbau über einen Aufruf von connect initiiert.

int   connect (int   sockfd, const struct sockaddr *servaddr, socklen_t addrlen);
sockfdEin socketdeskriptor
*servaddrDie Protokolladresse, sowie Portnummer des Servers
addrlenDie Größe von servaddr in byte
Return>0Kein Fehler
-1Fehler

Als Argument wird der Server spezifiziert, zu dem eine Verbindung aufgebaut werden soll. Nach dem Aufruf wird der Client-Prozess blockiert, bis der 3-Wege-Handshake abgeschlossen ist. Aus diesem Grund ist connect für gewöhnlich nur im Falle von TCP notwendig. Wurde UDP als Transport-Protokoll ausgewählt, kann connect ebenfalls, aber in einer anderen Bedeutung verwendet werden, auf die später eingegangen wird.

Die Übertragung der Nutzdaten kann nun über die bekannten read- und write-Funktionen erfolgen.

size_t   read (int   sockfd, void *data, size_t len);
sockfdein socketdeskriptor, von dem gelesen werden soll
*dataSpeicheradresse, an die die gelesenen Daten geschrieben werden sollen
LenAnzahl der Bytes, die gelesen werden sollen
Return>0die Anzahl der tatsächlich gelesenen Bytes
0die Verbindung wurde von der Gegenseite geschlossen
-1Fehler

{write abschliessen}

size_t   write (int   sockfd, void *data, size_t len);
sockfdein socketdeskriptor, von dem gelesen werden soll
*dataSpeicheradresse, an die die gelesenen Daten geschrieben werden sollen
LenAnzahl der Bytes, die gelesen werden sollen
Return>0die Anzahl der tatsächlich gelesenen Bytes
0die Verbindung wurde von der Gegenseite geschlossen
-1Fehler

Für den Datentransfer stellt die Socket-API mit send und recv speziell an Sockets angepasste Versionen der Funktionen read und write zur Verfügung. Diese unterscheiden sich allerdings von den üblichen Dateifunktionen lediglich durch ein zusätzliches Argument, mit dem der TCP-Protokollstapel parametriert werden kann. Diese sogenannten Socket-Optionen werden hier vorerst nicht genauer betrachtet.

Im Fall einer UDP-Übertragung gestaltet sich die Vorbereitung der Kommunikation etwas einfacher. Da UDP keine Verbindungen kennt, entfallen die Aufrufe von listen, accept und connect. Die Funktion bind nimmt hier eine Sonderrolle ein. Durch ihren Aufruf wird dem Kernel mitgeteilt, daß der aufrufende Prozeß mit der jeweiligen IP-Adresse assoziiert ist. Der Kernel kann dadurch Übertragungsfehler an den aufrufenden Prozeß durch entsprechende Rückgabewerte weiterleiten.

Aufbau von Client-Server-Anwendungen