Posted on Leave a comment

Interprozesskommunikation / Sockets – Ein Chatprogramm in Python implementieren – Teil 1

Interprozesskommunikation / Sockets – Ein Chatprogramm in Python implementieren – Teil 1

Interprozesskommunikation (IPC) klingt nach einem recht komplexen Thema. Obwohl es sicherlich einige Aspekte gibt, die bei IPC etwas kniffliger werden können, ist der Einstieg jedoch nicht so schwer, wie man denkt. Viele Programmiersprachen stellen von Haus aus mächtige Bibliotheken bereit, mit denen IPC auf ganz verschiedene Arten und Weisen umgesetzt werden kann.

In diesem Tutorial werden wir Sockets als eine dieser Möglichkeiten kennenlernen und ein eigenes kleines Chatprogramm, das aus Server und Client besteht, in Python implementieren. Bevor wir allerdings mit dem Coden beginnen, werden wir zuerst einen Blick auf die Begrifflichkeiten und die theoretische Basis werfen.

Was ist Interprozesskommunikation?

Wenn ein Computerprogramm gestartet wird, wird ein Prozess erzeugt. Dieser Prozess hat einen bestimmten Zustand und Ressourcen, die vom Betriebssystem verwaltet werden. Für gewöhnlich werden auf einem Rechner mehrere Prozesse gleichzeitig, oder zumindest dem Anschein nach gleichzeitig, ausgeführt. Einzelne Prozesse können sich gegenseitig beeinflussen und dementsprechend entweder kooperierend (co-operating) oder unabhängig (independent) sein.

Damit verschiedene Prozesse miteinander kommunizieren oder sich gegebenenfalls sogar synchronisieren können, wird ein Mechanismus benötigt – die Interprozesskommunikation (englisch interprocess communication, kurz IPC). Für diese Kommunikation gibt es zwei Möglichkeiten: Shared Memory und Message passing. Bei Shared Memory gibt es, wie der Name vermuten lässt, einen Speicherbereich, der zwischen den kooperierenden Prozessen geteilt wird. Dieser Ansatz ist performant, da die gemeinsamen Daten nicht zwischen den Prozessen versendet und separat gespeichert werden müssen. Allerdings erfordert Shared Memory Synchronisation, um fehlerhafte Speicherzustände zu vermeiden.

Bei der zweiten Variante, dem Message passing, gibt es keinen geteilten Speicher zwischen beiden Prozessen. Stattdessen bauen beide Prozesse einen Kommunikationskanal auf, über den sie Daten versenden und empfangen können. Dazu können beispielsweise Message Queues, Pipes oder Sockets verwendet werden. Message passing hat den Vorteil, dass es grundsätzlich einfacher zu implementieren ist und Parallelisierung besser unterstützt als Shared Memory. Es ist allerdings langsamer, da die Prozesse sich erst miteinander verbinden und Daten übertragen müssen.

In diesem Tutorial werden wir genauer auf die Funktionsweise von Message passing mit Sockets eingehen – betrachten wir aber zunächst eine konkrete Architektur, die IPC erfordert.

Das Client-Server-Modell

Das Client-Server-Modell (auch Client-Server-Architektur) beschreibt eine Möglichkeit, wie Ressourcen oder Dienstleistungen innerhalb eines Netzwerkes verteilt werden können. Die beiden zentralen Akteure dieser Architektur sind der Server, der eine Dienstleistung oder Ressource zur Verfügung stellt, und der Client, der sie anfragt und verwendet. Der Client eröffnet dazu bei Bedarf die Kommunikation, der Server nimmt die Anfrage entgegen und bearbeitet sie. Dabei kann ein Server meistens mehrere Clients bedienen. Server und Client müssen sich nicht auf demselben Computer oder, wie beispielsweise bei Webservern im Internet, nicht mal im selben lokalen Netzwerk befinden. Allerdings müssen sie dieselbe „Sprache“ sprechen. Für die Kommunikation werden deshalb fest definierte Protokolle verwendet.

Für dieses Tutorial soll es einen Server geben, der Clients zu einem Chatraum hinzufügen kann. Ein Client kann dem Server daraufhin Nachrichten zusenden, die der Server an alle anderen Clients, die aktuell mit ihm verbunden sind, weiterleitet.

Definition und Funktionsweise von Sockets

Um diese Funktionalität zu implementieren, können die eben erwähnten Sockets verwendet werden. Sockets sind Kommunikationsendpunkte, die durch das Betriebssystem verwaltet werden und durch Anwendungssoftware angefordert werden können. Ein Socket hat eine eindeutige Adresse, die sich aus einer IP-Adresse und einer Portnummer zusammensetzt. Sockets sind in der Regel bidirektional, das heißt sie können Daten in beide Richtungen der Verbindung versenden und empfangen. Zudem sind sie plattformunabhängig und standardisiert, was die Verwendung unabhängig vom konkreten Betriebssystem ermöglicht.

Man unterscheidet generell zwischen Stream und Datagram Sockets. Stream Sockets kommunizieren über einen Zeichendatenstrom und verwenden das TCP-Protokoll, währen Datagram Sockets einzelne Nachrichten versenden und das UDP-Protokoll verwenden. Wir werden für die Implementierung unseres Chatprogramms Stream Sockets verwenden. Die Kommunikation hat dabei folgenden Ablauf:

Server-seitig:

  1. Server-Socket initialisieren.
  2. Socket an eine Adresse (IP-Adresse und Port) binden. Über die Adresse können nun Anfragen entgegengenommen werden.
  3. Auf Anfragen durch Clients warten.
  4. Eine eingehende Anfrage akzeptieren und ein neues Socket-Paar für den entsprechenden Client erstellen. Dazu wird von dem listening Socket ein neuer connected Socket abgeleitet. Der ursprüngliche Socket bleibt bestehen, der neue Socket wird nur für die Kommunikation mit einem spezifischen Client verwendet. Alle Sockets des Servers hören auf die gleiche IP-Adresse und den gleichen Port und werden über die 1-zu-1-Beziehung mit den jeweiligen Clients eindeutig identifiziert.
  5. Anschließend über das Socket-Paar kommunizieren. Zum Versenden wird der Datenstrom (Stream) mit Bytes befüllt. Zum Empfangen wird eine bestimmte Menge Bytes aus dem Stream entnommen und verarbeitet. Für diesen Vorgang stehen Network Buffer zur Verfügung. Die Menge Daten, die mit einem Mal verarbeitet werden kann, ist daher durch die Größe des Buffers limitiert.
  6. Nach der Kommunikation gegebenenfalls Schließen des Sockets.

Client-seitig:

  1. Client-Socket initialisieren.
  2. Den Socket mit der Adresse des Servers, von dem Daten oder Dienstleistungen angefordert werden sollen, verbinden.
  3. Anschließend über das Socket-Paar kommunizieren (s.o.).
  4. Socket schließen und Verbindung trennen.

Ein eigenes Chatprogramm schreiben

Nach diesem theoretischen Einstieg wollen wir nun mit der Implementierung unseres Chatprogramms beginnen. Dazu werden wir im ersten Teil einen Server und einen Client implementieren, die sich miteinander verbinden, und eine Nachricht vom Server zum Client senden. Im zweiten Teil des Tutorials werden wir Clients erlauben, sich mit einem Namen zu identifizieren und dem Server Nachrichten zu senden. Der Server wird die eingehenden Nachrichten an alle mit ihm verbundenen Clients weiterleiten.

Die Server-Basis implementieren

Für dieses Tutorial müssen Sie Python 3 auf Ihrem Computer installieren. Eine kurze Anleitung dazu finden Sie hier.

Beginnen Sie nach der Installation damit, zwei Dateien, server.py und client.py,ineinemProjektordneranzulegen. Öffnen Sie die server.py entweder mit der Python IDLE oder einem Texteditor Ihrer Wahl. Wir werden zuerst einen einfachen Socket implementieren, der auf eingehende Verbindungen wartet. Dazu müssen wir das Modul socket importieren. Dieses ist bereits in der Python-Standardinstallation enthalten.

In der ersten Zeile des Codes wird der Socket mit zwei Parametern initialisiert. Der erste Parameter ist die Adressfamilie, der zweite Parameter ist der Socket-Typ. Die Adressfamilie gibt an, ob es sich bei der Adresse beispielsweise um IPv4 oder IPv6 handelt, während der Socket-Typ zwischen Stream oder Datagram Sockets unterscheidet. Wir benötigen an dieser Stelle einen Stream Socket (SOCK_STREAM) mit IPv4 (AF_INET)-Adresse.

Anschließend wird der Socket an eine Adresse, bestehend aus einem Tupel mit IP und Port, gebunden. Die IP-Adresse kann dabei der Name des Computers sein, auf dem der Server läuft. Diesen können Sie mit socket.gethostname() ermitteln. Alternativ können Sie direkt 127.0.0.1, die localhost-IP-Adresse, verwenden. Jedes Adress-Tupel kann nur genau einmal gebunden, also vom Betriebssystem angefordert, werden.

Zum Schluss sagen wir dem Socket mit der Methode listen, dass er auf Verbindungen hören und dabei bis zu fünf Verbindungen maximal zulassen soll. Wenn Sie dieser Methode keinen Parameter übergeben, wird ein Defaultwert verwendet. Zusätzlich geben wir nach der Initialisierung auf der Konsole aus, wo sich unser Server aktuell befindet.

Wenn Sie innerhalb eines Python-Strings Variablen verwenden wollen, können Sie wie in der letzten Zeile zu sehen den Python-Formatter über ein vorgestelltes f verwenden. Variablen und auch ganze Ausdrücke, die innerhalb des Strings in geschweiften Klammern geschrieben werden, werden dann ausgewertet.

Starten Sie das Programm am besten mit einer Kommandozeile in Ihrem aktuellen Projektordner, indem Sie server.py aufrufen. Eventuell wird Python dabei noch durch die Windows Firewall blockiert. In diesem Fall müssen Sie den Zugriff zulassen.

Nach der Ausführung sollte auf der Konsole Folgendes erscheinen:

Wie Sie sehen, wird der Server gestartet, aber das Skript direkt nach der Ausgabe beendet. Der Server muss also noch dauerhaft auf eingehende Anfragen hören und reagieren, indem er eine Verbindung aufbaut. Dazu können wir eine while-true-Schleife verwenden.

Die accept-Methode in der ersten Zeile der Schleife nimmt eine eingehende Verbindung an und gibt einen Socket, der zum Versenden von Daten verwendet werden kann, und eine Adresse, bestehend aus der IP-Adresse und dem Port des Clients, zurück. Wenn dies geschieht, geben wir zum einen eine Nachricht auf der Konsole aus und schicken zum anderen mit der Methode send eine kurze Nachricht an den Client. Der String, den wir versenden wollen, wird in ein bytes-Objekt mit Codierung UTF-8 umgewandelt. Eine klare Definition der Codierung ist notwendig, damit der Client die Zeichen korrekt dekodieren und weiterverwenden kann.

Sie können das Programm nun noch einmal starten.

Der Server läuft in einer Endlosschleife, aber bisher wird keine Verbindung geöffnet – dafür müssen wir den Client implementieren.

Die Client-Basis implementieren

Um den Client zu schreiben, öffnen Sie die client.py-Datei. Für einen einfachen Client, der lediglich eine Verbindung herstellen möchte, benötigen wir folgenden Code:

Zuerst erstellen wir wieder einen Socket und verbinden uns mit der IP-Adresse und dem Port des Servers.

Anschließend wartet der Client, bis er eine Nachricht auf dem Socket über die Methode recv (kurz für receive) erhält. Der Parameter, der der Methode übergeben wird, definiert, wie viele Bytes für den Buffer des Streams verwendet werden sollen. Zum Schluss wird die Nachricht, insofern es eine gibt, dekodiert auf der Konsole ausgegeben.

Starten Sie einen Server und zwei Clients in jeweils einer eigenen Kommandozeile. Sie sollten folgende Ausgabe beobachten können:

Damit haben wir nun drei Prozesse, die rudimentär miteinander kommunizieren können.

Einen Header festlegen

Für den letzten Schritt des ersten Teils dieses Tutorials werden wir uns nun noch mit der Frage auseinandersetzen, wie wir damit umgehen, wenn eine Nachricht mehr Zeichen enthält als die Größe des Buffers. Verkleinern Sie dazu probeweise den Clientbuffer auf zehn Bytes und versenden Sie vom Server eine längere Nachricht, beispielsweise „Hello there, how are you?“. Starten Sie Server und Client neu. Wie Sie sehen, erhalten wir zwar alle Teile der Nachricht, allerdings nur in Bruchstücken.

Woran liegt das? Wie bereits erwähnt, funktionieren Stream Sockets mit einem fortlaufenden Datenstrom. Durch die Methode send wird dieser Datenstrom befüllt und mit der Methode recv geleert. Die Methode recv definiert die Buffergröße, sodass wir den ersten Block verarbeiten und ausgeben. Anschließend ist der Stream noch nicht leer. Wenn wir das nächste Mal über die Endlosschleife des Clients gehen, verarbeiten wir also die nächsten zehn Byte und geben sie aus. Damit fahren wir fort, bis der Stream leer ist.

Das hat zwei Implikationen: Ein zu kleiner Buffer ist ungünstig, um längere Nachrichten zu empfangen. Ein zu großer Buffer läuft Gefahr, vielleicht schon die nächste Nachricht zu lesen. Da wir die Verbindung aber auch nicht nach dem Versenden der Nachricht herunterfahren möchten, wäre es praktisch zu wissen, wie lang die zu empfangene Nachricht ist – dann kann ein entsprechend großer Buffer zur Verfügung gestellt werden.

Die Länge der Nachricht ist eine sogenannte Metainformation. Eine Möglichkeit, Metainformationen zu versenden, sind Header, wie man sie beispielsweise von HTTP-Requests kennt. Hier wird durch den Header unter anderem der Medientyp oder das Encoding definiert. Wir definieren an dieser Stelle ein eigenes, einfacheres Header-Format: Jede Nachricht hat acht vorangestellte Zeichen, in denen die Länge steht. Für „Hello there“ (11 Zeichen) und „Hello there, how are you?“ (25 Zeichen) würden sich also folgende Gesamtnachrichten ergeben:

11      Hello there

25      Hello there, how are you?

Client und Server können für eingehende Nachrichten nun zuerst einen Buffer von acht Zeichen bereitstellen, aus diesem die benötige Buffergröße für die eigentliche Nachricht ermitteln, und diese schlussendlich vollständig empfangen.

Damit wir den Header-Code für Client und Server nicht doppelt schreiben müssen und gemeinsam ändern können, legen wir eine Datei header_utils.py an, die wir als Modul importieren. Diese Datei beinhaltet zum einen eine Konstante für die Headergröße und zum anderen eine Methode, um eine Nachricht entsprechend unserer Konvention zu formatieren.

Ist die message, die dieser Methode übergeben wird, leer, geben wir None zurück. Ansonsten formatieren wir zuerst den Header, sodass er die Länge der Message mit einem Padding auf insgesamt acht Zeichen enthält. Dafür wird der Operator :< verwendet. Anschließend geben wir sowohl den Header als auch die ursprüngliche Nachricht zurück. 

Importieren Sie das Modul zuerst im Server, um die versendete Nachricht zu formatieren:

Anschließend muss der Client angepasst werden. Da der Client noch keine Nachrichten versendet, braucht er zum Empfangen nur den Wert HEADER_SIZE. Zuerst erhalten wir eine Nachricht mit der Methode recv, die exakt die Länge des Headers hat. Insofern dieser Wert nicht leer ist, wird er in einen int konvertiert und anschließend für den nächsten Buffer verwendet. Nur der Inhalt dieses Buffers wird schlussendlich auf der Konsole ausgegeben.

Testen Sie, ob die Implementierung funktioniert, indem Sie wieder Client und Server starten. Der Client sollte die vollständige Nachricht ausgeben, nachdem er sich verbunden hat.

Damit ist der erste Teil des Tutorials abgeschlossen. Im zweiten Teil werden wir Nachrichten vom Client versenden und über den Server an alle anderen verbundenen Clients broadcasten. Außerdem werden wir unseren Header um die Information, welcher Nutzer die Nachricht versendet hat, erweitern und eine Möglichkeit geben, die Verbindung client-seitig zu beenden.

Ähnliche Produkte

Schreibe einen Kommentar