Erweitertung einer Netzwerkbibliothek um bidirektionale Many-to-Many Kommunikation
←
→
Transkription von Seiteninhalten
Wenn Ihr Browser die Seite nicht korrekt rendert, bitte, lesen Sie den Inhalt der Seite unten
Bachelorarbeit im Rahmen des Studiengangs Scientific Programming Fachhochschule Aachen, Campus Jülich Fachbereich 9 – Medizintechnik und Technomathematik Erweitertung einer Netzwerkbibliothek um bidirektionale Many-to-Many Kommunikation Jülich, den 11. September 2019 Frederik Peters
Eigenhändigkeitserklärung Diese Arbeit ist von mir selbstständig angefertigt und verfasst. Es sind keine ande- ren als die angegebenen Quellen und Hilfsmittel benutzt worden. (Ort, Datum) (Unterschrift) Diese Arbeit wurde betreut von: 1. Prüfer: Prof. Ulrich Stegelmann 2. Prüfer: Ingo Heimbach (FZJ, PGI/JCNS) Sie wurde angefertigt in der Forschungszentrum Jülich GmbH im Peter Grünberg Institut / Jülich Centre for Neutron Science.
Im Peter Grünberg Institut / Jülich Centre for Neutron Science werden im Rahmen der Visualisierung von Forschungsergebnissen und Simulationen, unter Verwendung des hauseigenen Visualisierungsframeworks, dem GR Framework, häufig Daten zwi- schen zwei oder mehreren Prozessen ausgetauscht. Aus diesem Grund wurde bereits in der Seminararbeit mit dem Titel "Nachrichtenbasierte Kommunikation zwischen wissenschaftlichen Applikationen" eine Bibliothek entwickelt, welche den Datenaus- tausch zwischen zwei Prozessen, asynchron und nach dem Request-Response-Muster ermöglicht. Um beispielsweise Nachrichten mehrerer Sender zu sammeln als auch Daten an meh- rere Empfänger verteilen zu können, soll im Rahmen dieser Bachelorarbeit nun die Bibliothek unter Beibehaltung der Asynchronität um Many-to-Many-Kommunikaton erweitert werden. Da verschiedene Anwendungsfälle denkbar sind, in denen ein beliebi- ger an der Kommunikation teilnehmender Prozess Datenaustausch initiieren möchte, beispielsweise zum Versenden von Steuerbefehlen an einen datenerzeugenden Prozess, soll die Kommunikation nun bidirektional erfolgen, sodass alle miteinander verbun- denen Prozesse einen Datenaustausch initiieren können.
Inhaltsverzeichnis 1 Motivation 1 2 Grundlagen 2 2.1 Konzepte der zugrunde liegenden Seminararbeit . . . . . . . . . . . . . . 2 2.2 Mutex-Locks . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3 2.3 Bidirektionale Kommunikation . . . . . . . . . . . . . . . . . . . . . . . . . 4 3 Hauptteil 6 3.1 Spezifikation der Bibliotheksanforderungen aus Anwenderperspektive . 6 3.2 Realisierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7 3.3 Verwalten mehrerer Verbindungen . . . . . . . . . . . . . . . . . . . . . . . 8 3.4 Erzeugen eines neuen Kontextes und Erstellung eines Netzwerk-Threads 8 3.5 Erstellen einer neuen Verbindung . . . . . . . . . . . . . . . . . . . . . . . 9 3.6 Versenden von Daten . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10 3.6.1 Überwachen von Verbindungen im Netzwerk-Thread . . . . . . . 11 3.6.1.1 select . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12 3.6.1.2 poll . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14 3.6.1.3 epoll . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15 3.6.2 Parallelisierung, Senden und Empfangen von Daten . . . . . . . . 17 3.6.3 Datenübertragung . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18 3.6.4 Eintreffende Daten . . . . . . . . . . . . . . . . . . . . . . . . . . . 19 3.6.5 Implementierung Threadpool . . . . . . . . . . . . . . . . . . . . . 19 3.6.5.1 Thread-pro-Verbindung . . . . . . . . . . . . . . . . . . . 20 3.6.5.2 Thread-pro-Anfrage . . . . . . . . . . . . . . . . . . . . . 21 3.6.5.3 Evaluation . . . . . . . . . . . . . . . . . . . . . . . . . . . 21 3.6.6 Versenden von Daten über mehrere Verbindungen . . . . . . . . . 23 3.7 Empfangen von Daten . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23 3.8 Schließen von Verbindungen . . . . . . . . . . . . . . . . . . . . . . . . . . 25 3.9 Erstellen eines Servers . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26 3.9.1 Erzeugen eines Endpunktes . . . . . . . . . . . . . . . . . . . . . . 26 3.9.2 Warten auf Verbindungsanfragen . . . . . . . . . . . . . . . . . . . 27 3.9.3 Empfangen von Daten auf Serverseite . . . . . . . . . . . . . . . . 27 3.9.3.1 Behandeln von Nachrichten . . . . . . . . . . . . . . . . 28 3.10 Erkennung von Verbindungsabbrüchen . . . . . . . . . . . . . . . . . . . . 29 3.10.1 Erneuter Verbindungsaufbau . . . . . . . . . . . . . . . . . . . . . 31 3.11 Zusammenfassung und Ausblick . . . . . . . . . . . . . . . . . . . . . . . . 32 3.11.1 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32 i
Inhaltsverzeichnis 3.11.2 Ausblick . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33 3.11.2.1 Multiplexing . . . . . . . . . . . . . . . . . . . . . . . . . 33 3.11.2.2 Integration in das GR Framework . . . . . . . . . . . . . 35 ii
Abbildungsverzeichnis 2.1 Thread-Kommunikaiton . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3 2.2 Verwendung eines Mutex Locks . . . . . . . . . . . . . . . . . . . . . . . . 4 2.3 Arten der Client-Server-Kommunikation . . . . . . . . . . . . . . . . . . . 5 3.1 Leeres Kontextobjekt im Haupt-Thread mit zugehörigem Netzwerk- Thread . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7 3.2 Im Kontext gespeicherte Verbindung . . . . . . . . . . . . . . . . . . . . . 9 3.3 Erstellen einer neuen Verbindung . . . . . . . . . . . . . . . . . . . . . . . 10 3.4 Übermittlung eines Sendewunsches an eine Verbindung . . . . . . . . . . 11 3.5 Performancevergleich select vs poll vs epoll [10, S. 1365] . . . . . . . 16 3.6 Aufgabenverteilung an Threadpool . . . . . . . . . . . . . . . . . . . . . . 18 3.7 Thread-pro-Verbindung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20 3.8 Thread-pro-Anfrage . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21 3.9 Zwei Nachrichten über dieselbe Verbindung . . . . . . . . . . . . . . . . . 24 3.10 Verwaltung der Verbindungsaktivitäten . . . . . . . . . . . . . . . . . . . 30 3.11 Timeout . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31 3.12 Bisherige Implementierung . . . . . . . . . . . . . . . . . . . . . . . . . . . 33 3.13 Verwaltung der Verbindungsaktivitäten . . . . . . . . . . . . . . . . . . . 34 iii
1 Motivation Im Peter Grünberg Institut (PGI) und Jülich Centre for Neutron Science (JCNS) nutzen Wissenschaftler ein hauseigenes, plattformunabhängiges Visualisierungsfra- mework, das sogenannte GR Framework [8], zur Visualisierung und dem besseren Verständnis der aus Experimenten und Simulationen erzeugten Daten. Im Rahmen der Visualisierung durch das GR Framework werden Daten zwischen einem daten- erzeugenden Prozess und einem visualisierenden Prozess ausgetauscht. Da die bisher verwendeten Möglichkeiten in Bezug auf Funktionsumfang und Robustheit nicht voll- ständig den gestellten Anforderungen entsprechen, wurde im Rahmen der Seminarar- beit mit dem Titel "Nachrichtenbasierte Kommunikation zwischen Wissenschaftlichen Applikationen" [12] im September 2018 eine Bibliothek entwickelt, welche den nach- richtenbasierten Datenaustausch zwischen zwei Prozessen, lokal oder über das Netz- werk, ermöglicht. Diese basiert auf dem Request-Response-Kommunikationsmuster und ermöglicht den Austausch von beliebigen Nachrichten zwischen zwei Prozessen sowohl synchron [11] als auch asynchron [9]. Da in der Praxis Anwendungsfälle auf- treten, in denen mehr als zwei Prozesse miteinander kommunizieren, beispielsweise wenn ein Prozess die Daten, die durch zwei andere Prozesse erzeugt werden, visuali- siert, wird die Kommunikation in dieser Bachelorarbeit um Many-to-Many Kommu- nikationsmuster erweitert, um die Kommunikation unter beliebig vielen Prozessen zu ermöglichen. Damit auch der in der Regel empfangene Prozess die Möglichkeit hat, zu gegebenen Anlässen Nachrichten wie beispielsweise Steuerbefehle zu versenden, wird das bisher verwendete Request-Response-Kommunikationsmuster durch bidirektionale Kommu- nikation erweitert, sodass der Datenaustausch, nachdem der Verbindungsaufbau er- folgt ist, gleichberechtigt stattfinden kann. 1
2 Grundlagen 2.1 Konzepte der zugrunde liegenden Seminararbeit Die Kommunikation in der Seminararbeit erfolgte zwischen exakt einem Client- und einem Server-Prozess (Punkt-zu-Punkt-Verbindung). Hierbei wird der Verbindungs- aufbau sowie die Nachrichtenübertragung stets vom Client-Prozess initiiert. Nach ei- nem erfolgreichen Verbindungsaufbau kann der Client asynchron oder synchron eine Vielzahl von Nachrichten variabler Länge an den Server schicken, welcher diese bear- beitet und gegebenenfalls beantwortet. Damit die Haupt-Threads des Clients und des Servers Aufgaben wie Nutzerinteraktion oder Nachrichtenbearbeitung und zeitgleich dazu Netzwerkkommunikation betreiben können, wurde diese jeweils in einen separa- ten Netzwerk-Thread verlagert. Unter einem Thread versteht man eine Aktivität innerhalb eines Prozesses, die durch den Prozessor parallel zu den Aktivitäten des Haupt-Threads ausgeführt wird. Als Subaktivitäten eines Prozesses teilen sich Threads einen gemeinsamen Speicherbe- reich [13, S. 383]. Um die Kommunikation zwischen dem Haupt-Thread und dem Netzwerk-Thread zu ermöglichen, wurde ein Konzept aus einem Warteschlangen-Pipe-Paar verwendet. Ei- ne Warteschlange (Queue) ist eine Datenstruktur, in der mehrere Elemente gespei- chert werden können. Diese können der Reihe nach aus der Warteschlange entnommen werden, wobei das zuerst in der Warteschlange gespeicherte Element diese als erstes wieder verlässt. Diese Datenstruktur eignet sich für die Übermittlung von Nachrich- ten zwischen Haupt- und Netzwerk-Thread und gewährleistet eine sinnvolle Abar- beitungsreihenfolge. Aufgrund des gemeinsam genutzten Addressraumes werden die Daten nicht zwischen den Threads übertragen. Stattdessen werden Zeiger, die auf den jeweiligen Speicherbereich zeigen, in die Warteschlange geschrieben. Um sich gegen- seitig darüber zu benachrichtigen, dass Zeiger auf Nachrichten in den Queues liegen, wurden Pipes genutzt. 2
2.2 Mutex-Locks Queue Pipeline Haupt-Thread Netzwerk-Thread Neztwerk Pipeline Queue Abbildung 2.1: Thread-Kommunikaiton Eine Posix-Pipe dient zur Übertragung von Daten zwischen zwei Endpunkten. Hier- bei werden die durch die Pipe übertragenen Daten tatsächlich kopiert, weswegen es im Anwendungsfall der Thread-Kommunikation ratsam ist, die Datenübertragung über Pipes minimal zu halten und stattdessen Zeiger auszutauschen. Der Vorteil einer Pi- pe bezogen auf Warteschlangen liegt darin, dass sich diese auf Aktivität überwachen lassen, ohne wertvolle Rechenzeit zu nutzen, um abzufragen, ob neue Daten in der Warteschlange sind. Aus diesem Grund wird der jeweils andere Thread über die Pipe benachrichtigt, dass ein Zeiger auf Daten in der Warteschlange liegt. Das Warten auf Daten in der Pipe geschieht mithilfe der Funktion poll [10, S. 8], die das parallele Überwachen mehrerer Dateideskriptoren, also auch den Endpunkten einer Pipe, ermöglicht. Die Übertragung der Binärdaten zwischen den Netzwerk-Threads erfolgt über eine TCP-Verbindung zwischen den jeweiligen Sockets. TCP bietet gegenüber UDP den Vorteil, dass Daten, sofern die Verbindung nicht abbricht, garantiert übertragen wer- den. Während UDP performanter ist, aufgrund der Tatsache, dass nicht überprüft wird, ob Daten angekommen sind. Da dies jedoch zu fehlenden Datenpaketen führen kann, ist diese Übertragungsweise nicht geeignet für die Bibliothek, da diese beispiels- weise im GR Framework zur Übertragung von Steuerungsbefehlen zwischen Prozessen eingesetzt wird, die eine 100%ige Übertragungsrate voraussetzt. 2.2 Mutex-Locks Bei der Verwendung mehrerer Threads, die auf denselben Speicher zugreifen, muss sichergestellt werden, dass dieser nicht zeitgleich von mehr als einem Thread beschrie- ben oder gelesen wird, da andernfalls inkonsistente Daten entstehen können. Dies ge- schieht, im Bezug auf den oben beschriebenen Anwendungsfall, wenn beispielsweise der Haupt-Thread und der Netzwerk-Thread zeitgleich auf eine der Warteschlangen zwischen diesen zugreifen. Aus diesem Grund wird der kritische Code, in dem Threads 3
2 Grundlagen auf einen Speicherbeich zugreifen, geschützt. Dies geschieht über sogenannte Mutex-Locks. Ein Mutex-Lock ist ein Synchronisati- onsmechanismus, mit dessen Hilfe ein kritischer Codeabschnitt vor der Ausführung durch mehrere Threads zeitgleich, geschützt werden kann. Es wird dargestellt durch einen Wert, der die Zustände "gelockt" und "nicht gelockt" annehmen kann. Möchte ein Thread einen kritischen Codeabschnnitt ausführen, versucht er durch eine ato- mare "compare and swap"-Operation den Mutex zu locken, in dem er den Wert entsprechend setzt. [2] Wenn ein anderer Thread als der, derzeit den markierten Co- de ausführende Thread diesen ausführen will, wartet er in einem Wartebereich mit allen derzeit wartenden Threads. Nachdem das Mutex entlockt wurde, greift zufällig einer der wartenden Threads auf den kritischen Code zu, während die anderen weiter warten. Speicher- Thread A Thread B Mutex Element mutex_lock() Schreib- oder Lesezugriff mutex_lock() mutex_unlock() Schreib- oder Lesezugriff mutex_unlock() Abbildung 2.2: Verwendung eines Mutex Locks Abbildung 2.2 zeigt einen Anwendungsfall, in dem Thread A und Thread B auf das selbe Speicherelement zugreifen wollen. Da Thread A zuerst den als kritisch markier- ten Code betritt, muss Thread B warten, bis dieser von Thread A nach dem Zugriff wieder verlassen wird. 2.3 Bidirektionale Kommunikation Bidirektionale Kommunikation beschreibt im Bezug auf Client-Server-Kommunikation eine Art der Datenübertragung, die sowohl von Client als auch von Server ausge- 4
2.3 Bidirektionale Kommunikation hen kann. Der Server braucht nicht, wie im bisher verwendeten Request-Response- Kommunikationsmuster [6, S. 123], eine Anfrage des Clients auf welche er antwor- tet, sondern kann dem Client eigenständig Nachrichten zukommen lassen. Hieraus folgend werden nun auch clientseitig Funktionalitäten benötigt, um nicht erwartete Nachrichten bearbeiten zu können. Die Unterscheidung zwischen Client und Server liegt bei bidirektionaler Kommunikation lediglich darin, dass der Server einen Verbin- dungsendpunkt bereitstellt, zu dem der Client dann eine Verbindung aufbauen kann. Die anschließende Kommunikation ist gleichberechtigt. Abbildung 2.3a veranschau- licht die in [12] verwendete bisherige Kommunikation, in welcher der Client asynchron Requests an den Server versendet, welche von diesem beantwortet werden. Abbildung 2.3b zeigt die angestrebte bidirektionale Kommunikation, in welcher der Server eben- falls die Nachrichtenübertragung initiieren kann. Die Beantwortung einer Nachricht ist nicht mehr zwingend notwendig. Client Server Client Server Zeit Zeit (a) vom Client initiierte Kommunikation nach dem Request-Response Muster (b) Bidirektionale Kommunikation Abbildung 2.3: Arten der Client-Server-Kommunikation 5
3 Hauptteil 3.1 Spezifikation der Bibliotheksanforderungen aus Anwenderperspektive Zunächst werden die aus Anwenderperspektive an die Bibliothek gestellten Anforde- rungen betrachtet. Die Bibliothek soll eine leicht verwendbare API zur Übertragung von Binärdaten zwischen verschiedenen Endpunkten, sowohl lokal als auch über das Netzwerk, bieten. Die erste in diesem Kontext anfallende Teilaufgabe liegt im Erzeu- gen eines Verbindungsendpunktes (Servers) und dem Verbindungsaufbau als Client zu diesem. Die in der Seminararbeit verwendete Punkt-zu-Punkt Kommunikation wird im Rahmen dieser Arbeit auf Many-To-Many-Kommunikation erweitert. Hieraus re- sultierend werden Verbindungen von einem Client zu mehreren Servern ermöglicht, während ein Server für Verbindungswünsche von mehreren Clients zeitgleich emp- fänglich ist. Im Bezug auf Datenübertragung über Verbindungen sollen Daten nun an eine Untermenge der bestehenden Verbindungen geschickt werden können. Die Erweiterung von Kommunikation nach dem Request-Response-Muster auf bidi- rektionale Kommunikation und die damit entstandene Möglichkeit, dass Daten vom Server initiiert an den Client gesendet werden, erfordert eine Behandlung durch den Nutzer, beispielsweise in Form von Callbacks, die vom empfangenen Client aufgerufen werden, um eingetroffene Daten zu behandeln. Die Datenübertragung findet in der Regel asynchron statt. Es können also mehrere Anfragen von einem Kommunikationspartner an den anderen gestellt werden, ohne blockierend auf deren Beantwortung zu warten. Die eintreffenden Daten müssen durch die Bibliothek für den Zugriff durch den Anwender bereitgestellt werden, zum Beispiel in Warteschlangen. Aus den oben geschilderten Anforderungen ergeben sich folgende Funktionalitäten, die als Schnittstelle zwischen Anwender und Bibliothek dienen sollen: 1. Aufbauen und Verwalten einer bzw. mehrerer Netzwerkverbindungen als Client 2. (Asynchrones) Versenden von Daten über eine oder mehrere Netzwerkverbin- dungen 3. Aktives Empfangen von Daten über eine zuvor erstellte Verbindung 4. Passives Empfangen von Daten über eine zuvor erstellte Verbindung durch Call- backs 5. Erzeugen eines Servers, der von verschiedenen Clients angesteuert werden kann 6. Schließen von Verbindungen 6
3.2 Realisierung 3.2 Realisierung Zur Verwaltung von mehreren Verbindungen und Servern wird ein Container (Kon- text) erzeugt, in dem logisch zusammengehörige Verbindungen und Server gespeichert werden. Um Blockieren zu verhindern, wird pro Kontext ein Netzwerk-Thread benö- tigt, welcher die Netzwerkaufgaben für alle verwalteten Server und Verbindungen übernimmt. Die Funktionalitäten, die der Netzwerk-Thread für den Haupt-Thread übernimmt, liegen in der Erzeugung und Speicherung einer Netzwerkverbindung oder eines Servers, sowie dem Versenden und Empfangen von Daten über bestehende Ver- bindungen und der anschließenden Bereitstellung dieser für den Haupt-Thread. Zur Übergabe von Daten zwischen den Threads wird das in den Grundlagen beschriebene Konzept aus Warteschlangen und Pipes genutzt. Hierbei wird ein Warteschlangen- Pipe-Paar erzeugt, über das beide Threads kommunizieren können, wie in 2.1 be- schrieben. Bei einer Datenübertragung zwischen den Threads müssen Daten in die Pipe zwischen diesen geschrieben werden, um in der Warteschlange liegende Daten ankündigen zu können, da diese ohne CPU-Auslastung überwacht werden kann. Des- halb eignet sich diese, um die in die Warteschlange gelegten Daten, wie beispielsweise einen neuen Verbindungswunsch, durch die Übertragung sinnvoll besetzter Bytes zu charakterisieren. Haupt-Thread Netzwerk-Thread Pipe Pipe Request Queue Response Queue Abbildung 3.1: Leeres Kontextobjekt im Haupt-Thread mit zugehörigem Netzwerk- Thread 7
3 Hauptteil 3.3 Verwalten mehrerer Verbindungen Um die Punkt-zu-Punkt-Verbindung zwischen Client und Server auf Many-To-Many zu erweitern, wird ein neues Konzept zur Verwaltung von Verbindungen benötigt. Der Anwender soll nun im Gesamtkontext mehrere Verbindungen zu verschiedenen Servern zeitgleich aufbauen und diese verwalten bzw. adressieren können. Ebenfalls sollen in jedem Gesamtkontext mehrere Server erstellt werden können, dazu später mehr. Eine tatsächliche Verbindung besteht zwischen den Netzwerk-Threads der jeweiligen Kommunikationspartner und wird primär durch deren Verbindungssocket identifi- ziert, über das die Datenübertragung stattfindet. Vor der Übertragung müssen die Daten zunächst vom Haupt-Thread zum Netzwerk-Thread und nach dem Empfangen vom Netzwerk-Thread zum Haupt-Thread gelangen. Hierzu ist es ungünstig, die in Abb. 3.1 gezeigten Warteschlangen zwischen den Threads zu nutzen, da diese nur der Reihe nach abgearbeitet werden können. Wenn beispielsweise viele hintereinan- derliegende zu verschickende Nachrichten in der Warteschlange liegen, kann, bis der Netzwerk-Thread alle dieser Nachrichten entnommen hat, keine andere Funktionali- tät in Anspruch genommen werden. Beim Zugriff auf bereits empfangene Daten tritt das gleiche Problem auf, da die Daten in der Reihenfolge bearbeitet werden müssten, in der sie in der Warteschlangen liegen. Um diese beiden Probleme zu lösen, ist es sinnvoll für jede erzeugte Verbindung ein eigenes Warteschlangen-Pipe-Paar zu generieren, das nur zur Übermittlung von Daten, die diese Verbindung betreffen, verwendet wird. Nachdem eine Verbindung in Form eines Sockets und einem Warteschlangen-Pipe-Paares durch den Netzwerk- Thread erstellt wurde, wird diese schließlich im Gesamtkontext gespeichert. Um auf diese zuzugreifen, beispielsweise um Daten zu verschicken, werden Verbindungen mit einer Verbindungs-Id für den Anwender abstrahiert. Die elementaren Funktionen, die aus Anwendersicht für die Verwaltung von Ver- bindungen anfallen, sind folgende: 1. Erzeugen eines neuen Gesamtkontextes als Container für Verbindungen 2. Erzeugen einer neuen Netzwerkverbindung und hinterlegen dieser im Kontext 3. Daten über eine Netzwerkverbindung senden 4. Eingetroffene Daten, (nicht) blockierend, über einer Verbindung empfangen 5. Schließen einer Netzwerkverbindung 3.4 Erzeugen eines neuen Kontextes und Erstellung eines Netzwerk-Threads Bevor Verbindungen aufgebaut werden können, muss zunächst ein Kontext erstellt werden, welcher diese speichert und verwaltet. Bei der Erstellung des Kontextes wird 8
3.5 Erstellen einer neuen Verbindung ebenfalls ein zugehöriger Netzwerk-Thread gestartet, der für alle in diesem Kontext angelegten Verbindungen zuständig ist. Für die Thread-Kommunikation zwischen Haupt- und Netzwerk-Thread werden, wie zuvor beschrieben, jeweils ein Warteschlan- gen-Paar und ein Pipe-Paar erzeugt, über die Nachrichten ausgetauscht werden kön- nen. Der erstellte Kontext ist sowohl im Haupt- als auch im Netzwerk-Thread bekannt, sodass beide Threads Zugriff auf die darin enthaltenen Verbindungen haben. Nach der Erstellung des Netzwerk-Threads und der Erzeugung der Warteschlangen und Pipes wartet der Netzwerk-Thread auf Aktivitäten aus der zu ihm führenden Pipe. Die ein- zelnen Funktionalitäten des Netzwerk-Threads, die der Haupt-Thread nutzen kann, werden durch Übertragung eines jeweiligen Bytes durch die Pipe angesprochen. Der Zeiger auf die notwendigen Daten erfolgen anschließend durch die Warteschlange. Haupt-Thread Netzwerk-Thread Verbindung 1 To Network Queue Connection-ID Network Queue To Context Queue Context Queue Network Pipe Pipes Context Pipe Pipe Pipe Request Queue Response Queue Abbildung 3.2: Im Kontext gespeicherte Verbindung 3.5 Erstellen einer neuen Verbindung Um eine neue Verbindung zu einem Server zu erstellen, werden zunächst verschiede- ne Parameter benötigt, die diese charakterisieren. Hierzu zählen die IP-Adresse und der Port des Servers, zu dem die Verbindung aufgebaut werden soll. Als Protokoll wird immer TCP/IP verwendet. Diese Parameter werden zum Erzeugen einer neuen Verbindung nach Übergabe an eine Funktion new_connection, an den dem jeweili- gen Kontext zugehörigen Netzwerk-Thread unter Verwendung des Warteschlangen- 9
3 Hauptteil Pipe-Paares mitgeteilt. Nachdem der Netzwerk-Thread das entsprechend in die Pipe geschriebene Byte ausgelesen hat, entnimmt er die übergebenen Parameter aus der Warteschlange und beginnt mit der Erzeugung der neuen Verbindung. Zu diesem Zweck wird ein neues Verbindungssocket vom Betriebssystem angefordert. Anschlie- ßend wird dieses an die gewünschte Serveradresse, in Form von Server-IP und Port, gebunden und mittels connect mit dem Server verbunden. War der Verbindungsauf- bau erfolgreich, werden die zur Kommunikation über die neue Verbindung benötigten Pipes und Warteschlangen durch den Netzwerk-Thread allokiert und in einem ent- sprechenden struct mit dem Verbindungssocket zusammengefasst. Zuletzt speichert der Netzwerk-Thread die neue Verbindung in der Liste der aktiven Verbindungen des Kontextes. Hierbei wird eine eindeutige Verbindungs-ID generiert, die zur Abstrahie- rung der Verbindung gegenüber dem Anwender genutzt wird. Diese wird der aufgeru- fenen Funktion unter Verwendung der Warteschlange zwischen Netzwerk-Thread und Kontext-Objekt mitgeteilt, welche bis zum Erhalt dieser blockiert. Nach dem Erhalt der ID gibt die new_connection-Funktion diese zurück an den Anwender. Damit ist die Erzeugung einer neuen Verbindung abgeschlossen. New connection Verbindungs Struct Netzwerkthread Server Kontext () wunsch Verbindung Aufruf mit Serveradresse erzeugt lege Wünsche in Warteschlange connect() erzeugt speichert Verbindung im Kontext Legt Verbindungs-ID in Warteschlange return ID Abbildung 3.3: Erstellen einer neuen Verbindung 3.6 Versenden von Daten Um Daten über eine bestehende Verbindung eines Kontextes zu verschicken, werden der Kontext sowie die ID der jeweiligen Verbindung und die zu übertragenden Daten mit deren Länge benötigt. Die Übergabe der Länge ist notwendig, da es in C kei- ne Möglichkeit gibt die Länge der Daten, auf die ein Zeiger zeigt, zu ermitteln, der Netzwerk-Thread aber wissen muss, wieviel Byte er übertragen soll. Eine Funktion 10
3.6 Versenden von Daten send_data empfängt diese Parameter und greift mittels der Verbindungs-ID auf die Verbindung im Kontext zu. Zur Datenübertragung wird nun das Warteschlangen- Pipe-Paar der spezifischen Verbindung genutzt, über das die Daten zusammen mit ihrer Länge und einer zuvor generierten Anfragenummer übertragen werden. Dieser Vorgang wird in Abb. 3.4 veranschaulicht. Die Anfragenummer wird beim Empfangen einer Nachricht dazu genutzt, diese einer zuvor verschickten Nachricht zuzuordnen. Nach der Übergabe der Daten an den Netzwerk-Thread gibt die send_data-Funktion diese Anfragenummer zurück. Das tatsächliche Verschicken der Daten geschieht asyn- chron im Hintergrund durch den Netzwerk-Thread, sodass der Haupt-Thread nicht blockiert und andere Aufgaben erledigen kann. Kontext Netzwerk-Thread Verbindung 1 To Network Queue Connection-ID D Network Queue To Context Queue Context Queue Network Pipe überwacht Pipes Context Pipe überwacht Pipe Pipe Request Queue Response Queue Abbildung 3.4: Übermittlung eines Sendewunsches an eine Verbindung 3.6.1 Überwachen von Verbindungen im Netzwerk-Thread Der Netzwerk-Thread befindet sich, sofern keine Aufgaben zu erledigen sind, in einem überwachenden Zustand. Zum einen überwacht er die Pipe seines zugehörigen Kon- textes, zum anderen die Ein- und Ausgangskanäle, in Form von Verbindungssocket und der zu ihm gerichteten Pipe, aller im Kontext befindlichen Verbindungen. Die hierbei entstehende Problematik liegt darin, alle Aktivitäten der überwachten Datei- deskriptoren zeitgleich zu überwachen. Um dies zu tun, ohne die CPU durch ständiges Nachfragen unnötig zu belasten, kommen folgende Systemaufrufe in Frage: 11
3 Hauptteil • select • poll • epoll/kqueue Da epoll und kqueue ähnliche Systemaufrufe mit vergleichbarer Performance (O(n)) sind, wobei epoll linuxspezifisch und kqueue Mac OS spezifisch ist, wird im Folgen- den epoll stellvertretend für beide Aufrufe betrachtet. 3.6.1.1 select int select(nfds, readfds, writefds, *exceptfds, timeout); Die im POSIX.1-2001 Standard veröffentlichte select-Funktion empfängt die Men- ge der zu überwachenden Dateideskriptoren als Übergabeparameter. Diese besteht intern aus einem Vektor von Bitfeldern aus jeweils drei Bits, wobei jedes Bitfeld stell- vertretend für einen Dateideskriptor steht und angibt, ob dieser überwacht werden soll oder nicht. Der Vektor hat systemunabhängig eine Länge von 1024 Bitfeldern. So- mit ist die select-Funktion auf 1024 Dateideskriptoren begrenzt [7]. Zusätzlich wird, um unnötigen Iterationen vorzubeugen, beim Funktionsaufruf ein Integer-Wert, der die Nummer des höchsten zu überwachenden Dateideskriptors+1 enthält, übergeben. Nach dem Aufruf der blockierenden select-Funktion iteriert diese über den Vektor der Dateideskriptoren bis zur übergebenen Maximalanzahl und fügt den Prozess den jeweiligen Warteschlangen der Dateideskriptoren, auf die gewartet werden soll, hinzu. Anschließend schläft der Prozess und wird geweckt, sobald Daten über wenigstens einen der Dateideskriptoren empfangbar sind. Sobald dieser Fall eingetroffen ist, gibt die select-Funktion die Anzahl der Dateideskriptoren, über die Daten eingetroffen sind, zurück. Nach der Rückgabe enthält die zuvor übergebene Deskriptormenge nun Informationen darüber, welche Deskriptoren gefeuert haben im jeweiligen Bitfeld. Mittels FD_ISSET(deskriptor, &deskriptormenge) lässt sich für einen konkreten Deskriptor abfragen, ob dieser gefeuert hat. Vorteile: • Aufgrund der frühen Veröffentlichung der select-Funktion ist diese auf allen, auch älteren, verbreiteten Systemen verfügbar, weist also eine hohe Portabilität auf. [7] • Die Verwaltung der zu überwachenden Deskriptoren wird durch die select API abstrahiert und ist somit entwicklerfreundlicher als bei poll. Nachteile und Probleme: • Da die select-Funktion auf der Menge der Dateideskriptoren arbeitet und die- se verändert, wird zu jedem Funktionsaufruf eine neue Menge benötigt. Dies 12
3.6 Versenden von Daten geschieht entweder über eine erneute Iteration über die zu überwachenden Da- teideskriptoren oder durch Erzeugen einer Kopie der Menge pro Funktionsaufruf [7]. • Das Übergeben der höchsten Nummer der zu überwachenden Dateideskriptoren +1 erfordert eine erneute Ermittlung dieser pro Funktionsaufruf. Hierzu wird jedes mal, wenn ein weiterer Deskriptor zur Menge der Deskriptoren hinzugefügt wird, dessen Nummer mit dem bisherigen Maximum verglichen und gegebenfalls das Maximum aktualisiert. Bei einer hohen Anzahl an Deskriptoren führt dies zu signifikantem Performanceverlust [10, S. 1335]. • Durch die auf 1024 begrenzte maximale Länge des Vektorfeldes lassen sich nur 1024 Dateideskriptoren überwachen. In verschiedenen Anwendungsfällen ist es denkbar, dass diese Anzahl überschritten wird. Auf manchen Systemen lässt sich die Anzahl erhöhen, jedoch nicht auf Linux, da diese dort in der Standard- bibliothek als Makro definiert ist [1]. • select iteriert über den Vektor bis zur größten Zahl der zu überwachenden Dateideskriptoren. Im Falle von sehr wenigen zu überwachenden Dateideskrip- toren, bei denen die höchste Zahl jedoch sehr hoch ist, entstehen hierbei für jeden Durchlauf unnötige Iterationen. Dies kann, gerade für den Anwendungs- fall der Verwendung der Bibliothek im GR Framework, signifikant sein, falls viele Nachrichten über eine einzelne Verbindung geschickt werden. Listing 3.1 veranschaulicht eine mögliche Implementierung unter Verwendung von select, die die erstellten Verbindungen überwacht. Die Menge der überwachten De- skriptoren muss vor jedem Aufruf neu gebildet werden. 1 FD_ZERO (& readfds ) ; /* Leere Dateideskriptor Menge */ 2 FD_SET ( context_pipeline , & readfds ) ; /* Fuege Pipeline in beobachtete Menge */ 3 int max_fd = context_pipeline ; 4 /* Beobachte Pipeline und V erbind ungss ocket aller Verbindungen */ 5 for ( int h = 0; h < list_size ( context - > connections ) ; h ++) { /* iteriere ueber Verbindungen */ 6 struct si ngle_c onnect ion * act_con = 7 ( struct singl e_con nectio n *) get_by_index ( context - > connections , h); 8 /* greife auf Socket und Pipeline zu */ 9 int act_pipe = act_con - > pipe_from_user ; 10 if ( max_fd < act_pipe ) { max_fd = act_pipe ;} 11 int act_sock = act_con - > socket ; 12 if ( max_fd < act_sock ) { max_fd = act_sock ;} 13 FD_SET ( act_pipe , & readfds ) ; 14 FD_SET ( act_sock , & readfds ) ; 15 } 16 max_fd ++; 17 activity = select ( max_fd , & readfds , NULL , NULL , NULL ) ; Listing 3.1: Überwachen aller Verbindungen 13
3 Hauptteil 3.6.1.2 poll int poll (*fds, nfds, timeout); poll ist eine zu select ähnliche Funktion, die den Prozess äquivalent zu select in die Warteschlangen der verschiedenen Deskriptoren einreiht und diesen weckt, so- bald Daten über einen dieser empfangen werden können. Allerdings gibt es zwischen select und poll einige Implementierungsunterschiede. poll empfängt einen Zeiger auf einen Speicherbereich in dem mehrere Dateideskriptor-Strukturen liegen, weswe- gen es bei poll keine Limitierung an maximalen Deskriptoren gibt. Diese bestehen aus der jeweiligen Deskriptornummer, sowie zwei short Werten, event und revent. Mit event kann spezifiziert werden, bei welchen Ereignissen die poll-Funktion einen Wert zurückgeben soll. Bei der Rückgabe sind die zutreffenden Ereignisse in revents gesetzt. Als zweiten Parameter nfds empfängt poll die Anzahl der zu überwachen- den Dateideskriptoren. Der Rückgabewert ist die Anzahl der Dateideskriptoren, die gefeuert haben [3]. Vorteile: • Da poll im Gegensatz zu select die events von den revents trennt, muss die Menge der überwachten Deskriptoren nicht vor jedem Funktionsaufruf neu erzeugt werden, sondern kann immer wieder verwendet und um neue Deskripto- ren erweitert werden. Die revents werden bei jedem Funktionsaufruf von poll zurückgesetzt, so dass dies nicht manuell implementiert werden muss [3]. • Die höchste Nummer aller Deskriptoren muss nicht ermittelt werden [5]. • Bei select werden die ersten max_fd Deskriptoren überprüft, da die Deskriptor- nummer den Index im Bitvektor darstellt. Da bei poll, die zu überwachenden Deskriptoren hintereinander im Speicher liegen und die Anzahl dieser bekannt ist, werden keine unnötigen Deskriptoren, die nicht überwacht werden sollen, überprüft [7]. • Der Systemaufruf poll ist nicht auf eine maximale Anzahl von 1024 Deskrip- toren begrenzt, da ein dynamischer Speicherbereich mit nfds Deskriptoren er- wartet wird, anstatt einem statischen Vektor [7]. • Im Falle von wenigen Deskriptoren mit hohen Nummern sind gegenüber select keine unnötigen Iterationen notwendig [5]. Nachteile: • Auf sehr alten Systemen, die den Posix-Standard vor 2001 nutzten, unter ande- rem MacOS X, ist poll teilweise nicht unterstützt [4]. Dieser Nachteil ist jedoch vernachlässigbar. 14
3.6 Versenden von Daten 1 /* Reserviere Platz fuer 10000 D ateide skrip toren */ 2 struct pollfd fds [10000]; 3 /* Lege die Pipeline des Kontextes in die Menge */ 4 fds [0]. fd = context_pipeline ; 5 /* POLLIN , da interessiert daran , wenn Daten ueber diese empfangbar sind */ 6 fds [0]. events = POLLIN ; 7 /* Anzahl der aktuell ueberwachten Deskriptoren ist 1 */ 8 int nfds = 1; 9 /* Aufruf poll , blockiert bis zur ersten Aktivitaet */ 10 int activity = poll ( fds , nfds , -1) ; 11 /* Abfragen der revents - Bitmaske auf Aktivitaet mit & Operator */ 12 if ( fds [0]. revents & POLLIN ) { 13 piperesult = read ( context_pipeline , & ch , 1) ; Listing 3.2: Nutzen von Poll zur Deskriptorüberwachung Listing 3.2 veranschaulicht die Nutzung von poll. Im Vergleich zu Listing 3.1 wird vor dem poll-Aufruf keine neue Menge gebildet, da diese bei poll beständig bleibt und nur angepasst wird, wenn neue Deskriptoren dazu kommen oder entfernt werden. 3.6.1.3 epoll int epoll_create(size); int epoll_ctl(epfd, op, fd, *event); int epoll_wait(epfd, *events, maxevents, timeout); epoll ist ein zu select und poll alternativer Linux-spezifischer Systemaufruf, dessen Zeitaufwand zur Feststellung der I/O bereiten Dateideskriptoren im Gegensatz zu den beiden vorher erläuterten Möglichkeiten nicht mit der Anzahl der überwachten Da- teideskriptoren, sondern lediglich mit der Anzahl der auftretenden Events skaliert [5]. Beim Erzeugen einer neuen epoll-Instanz durch Aufrufen von epoll_create werden im Linux-Kernel zwei Listen erstellt. Die erste enthält die Deskriptoren, die über- wacht werden sollen (interest list). Die andere enthält eine Untermenge der ersten, die aus den Deskriptoren besteht, über die derzeit Daten empfangen werden können (ready list) [10, S. 1363]. Eine epoll-Instanz hat im Gegensatzt zu poll und select eine eigene Warteschlange, in der Prozesse vermerkt werden, welche derzeit mittels epoll_wait warten. Beim Hinzufügen eines neuen Dateideskriptors durch epoll_ctl wird ein struct eventpoll zur jeweiligen Warteschlange des Dateideskriptor hinzu- gefügt [10, S. 1364]. Sobald Daten über einen der überwachten Deskriptoren eintreffen, wird dieser durch Callbacks zur ready list hinzugefügt. Anschließend werden alle derzeit auf die epoll- Instanz wartenden Prozesse aufgeweckt. Vorteile: • Im Gegensatzt zu select und poll bleiben die im Kernel angelegten Daten- 15
3 Hauptteil strukturen bei epoll erhalten und müssen nicht jedes Mal neu angelegt werden. Durch epoll_ctl können Deskriptoren zu dieser hinzugefügt oder entfernt wer- den. • Der größte Vorteil gegenüber poll und select liegt darin, dass der wartende Prozess nicht allen Warteschlangen der zu überwachenden Deskriptoren hinzu- gefügt werden muss, sondern nur in die Warteschlange der derzeit wartenden Prozesse. Dies liegt daran, dass die epoll-Infrastruktur im Kernel während der Lebenszeit der epoll-Instanz beständig ist und diese bereits in die Warteschlan- gen der verschiedenen überwachten Deskriptoren hinzugefügt wurde. Hierdurch liegt die Laufzeitkomplexität von epoll_wait() anders als bei select und poll bei O(1) statt O(n) [5]. Nachteile: • Der größte Nachteil von epoll liegt in der Portabilität, da epoll nur in Linux verfügbar ist. Hierdurch kommt der Systemaufruf für andere Betriebssyteme nicht in Frage. überwachte Dateideskriptoren CPU Zeit in Sekunden select time poll time epoll time 10 0.73 0.61 0.41 100 3 2.9 0.42 1000 35 35 0.53 10000 930 990 0.66 Abbildung 3.5: Performancevergleich select vs poll vs epoll [10, S. 1365] Wie in Tabelle 3.5 zu sehen, welche die Reaktionszeiten der CPU bis zum Erwachen, nachdem ein zufälliger Dateideskriptor gefeuert hat, zeigt, ist die Performance von epoll auch bei einer hohen Anzahl an überwachten Dateideskriptoren sehr gut, wäh- rend select und poll etwas schlechter als linear skalieren. Ebenfalls zu sehen ist, dass poll bei wenigen Verbindungen einen signifikanten Reaktionsvorteil gegenüber select vorweist. Fazit: Da die Bibliothek ebenfalls auf Betriebssystemen wie Windows verfügbar sein soll, fallen epoll und kqueue als einzig verwendetete Systemaufrufe zur Überwachung der Dateideskriptoren trotz überlegener Performance heraus, sind aber im Zuge betriebs- systemabhängiger Programmierung auf Linux/MacOS die beste Wahl. Für andere Betriebssysteme fällt die Wahl auf poll gegenüber select. Dies liegt hauptsächlich an der besseren Performance im Falle von wenigen zu überwachenden Dateideskrip- toren, sowie dem fehlenden Limit an Deskriptoren und der Tatsache, dass nicht für jeden Aufruf eine neue Menge erzeugt werden muss. 16
3.6 Versenden von Daten 3.6.2 Parallelisierung, Senden und Empfangen von Daten Nachdem der Netzwerk-Thread durch eine feuernde Verbindungs-Pipe angestoßen wird, müssen die zugehörigen Daten aus der Verbindungsqueue entnommen und ver- sendet werden. Das blockierende Senden von Daten im Netzwerk-Thread kann gerade bei großen Datenmengen zu einer Verzögerung führen, da der Netzwerk-Thread wäh- rend des Sendevorgangs keine anderen Dateideskriptoren überwachen kann, sondern erst nachdem die Daten vollständig versendet wurden. Vielmehr ergibt die Verschie- bung des Sendevorgangs in einen anderen Thread Sinn, um die Zeitspanne, in denen der Netzwerk-Thread nicht ansprechbar ist, zu minimieren. Damit kein Overhead durch die Erzeugung eines neuen Threads entsteht, eignet sich ein Threadpool, an welchen einzelne Aufgaben, wie das Versenden oder Empfangen von Daten übergeben werden können. Nachdem ein Worker-Thread eine Aufgabe erledigt hat, kehrt er in den Threadpool zurück und wartet auf eine neue Aufgabe anstatt sich zu beenden, so dass jeder Worker-Thread nur einmal erzeugt werden muss. Die Aufgabenverteilung an den Pool wird vom Netzwerk-Thread übernommen, wel- cher, wie oben beschrieben, mittels poll abfragt, welche Deskriptoren gefeuert haben und daraus resultierend, welche Aufgaben zu erledigen sind. Wenn die Pipe einer Verbindung gefeuert hat, also der aufrufende Prozess Daten über das Netzwerk ver- schicken will, kann das Herausnehmen der Daten und ihrer Länge aus der Warte- schlange der Verbindung sowie das Versenden dieser über das Netzwerk durch einen Worker-Thread aus dem Pool übernommen werden. Wenn andernfalls ein Verbin- dungssocket feuert, sind Daten aus dem Netzwerk eingetroffen, die es zu empfangen gilt. In diesem Fall können der Empfangsvorgang und das Speichern der Daten in die entgegengesetzte Warteschlange von einem Worker-Thread aus dem Pool über- nommen werden. Ein Worker-Thread benötigt also das Verbindungssocket sowie das Warteschlangen-Pipe-Paar einer Verbindung, welche, wie bereits beschrieben, in ei- nem struct connection im Kontext gespeichert sind. Die Übermittlung des Sockets und des Warteschlangen-Pipe-Paares an die Worker-Threads findet über Warteschlan- gen der jeweiligen Threads statt. Abbildung 3.6 zeigt die Aufgabenaufteilung seitens des Netzwerk-Threads an den Threadpool. In der Abbildung gilt es zwei Nachrichten, c1 und c2 über das Netzwerk zu versenden, sowie eine Nachricht s1 die aus dem Netzwerk eingetroffen ist, zu emp- fangen. Hierzu werden die jeweiligen Verbindungen, über deren Sockets die Daten gesendet/empfangen werden sollen, an die Worker-Threads des Threadpools verteilt, so dass diese die jeweiligen Nachrichten aus der Warteschlange der Verbindung ent- nehmen und versenden, bzw. die Nachrichten über das Verbindungssocket empfangen und diese anschließend in die Verbindungswarteschlange einfügen können. 17
3 Hauptteil Haupt- Netzwerk- Threadpool Thread Thread Vc1 Worker 1 poll c1 send() send_message() c2 Vc2 send_message() Worker 2 s1 send() Vs1 Worker 3 Netzwerk recv() Abbildung 3.6: Aufgabenverteilung an Threadpool 3.6.3 Datenübertragung Die eigentliche Übertragung der Daten über das Netzwerk findet durch Nutzung der primitiven C-Funktionen send und recv statt. Die send-Funktion empfängt das Socket, über das Daten versendet werden sollen, die Länge der zu übertragenden Daten und einen Zeiger auf diese im Speicher. Hierbei ist zu beachten, dass verschie- dene Systeme Daten in unterschiedlicher Byte-Reihenfolge speichern (Little-Endian, Big-Endian), weswegen die zu übertragenden Daten vor der Übertragung in Netz- werkreihenfolge zu bringen sind [14, Absch. 25.4.2]. Dies bedeutet, dass Daten, die über das Netzwerk verschickt werden, stets in Big-Endian-Speicherung verschickt wer- den, so dass Little-Endian Systeme vor dem Senden und Empfangen von Daten diese entsprechend umwandeln müssen. Die Daten, welche vom Sender an den Empfän- ger übermittelt werden sollen, sind Anfragenummer, Länge und darauf folgend die tatsächlichen Daten. Dazu wird die send-Funktion dreimal separat aufgerufen, wie vereinfacht in 3.3 veranschaulicht. 1 sent = 0; 2 /* uebertrage Laenge Amit Empfaenger Speicher reservieren kann */ 3 while ( sent < sizeof ( size_in_no ) ) { 4 sent += send ( socket , (( void *) & size_in_no ) + sent , sizeof ( size ) - sent , 0) ; 5 } 6 sent = 0; 7 /* uebertrage Anfragenummer */ 8 while ( sent < sizeof ( request_number ) ) { 9 sent += send ( socket , (( void *) & request_number ) + sent , sizeof ( request_number ) - sent , 0) ; 10 } 11 sent = 0; 12 /* uebertrage eigentliche Daten */ 18
3.6 Versenden von Daten 13 while ( sent < size ) { 14 sent += send ( socket , datapointer + sent , size - sent ,0) ; 15 } Listing 3.3: Versenden von Daten über Netzwerk 3.6.4 Eintreffende Daten Die auf Senderseite verschickten Daten werden auf der Empfängerseite mittels recv empfangen. Diese Funktion erhält das Socket der Verbindung, über die Daten emp- fangen werden sollen, sowie einen Zeiger auf einen Speicherbereich, in den die emp- fangenen Daten geschrieben werden sollen und deren Länge. Um die vom Sender ver- schickten Daten zu empfangen, werden der Reihe nach Länge, Anfragenummer und tatsächliche Daten empfangen, wobei Anfragenummer und Länge nach dem Empfan- gen gegebenenfalls wieder auf die Speicherreihenfolge des Systems angepasst werden müssen. Nach dem Übertragen der Länge kann die Empfängerseite dynamisch Spei- cher allokieren, um die zu empfangenen Daten in diesen zu schreiben. 1 /* empfange Datenlaenge */ 2 while ( recvd < sizeof ( DATALENGTH ) ) { 3 recvd += recv ( socket , (( void *) & size + recvd ) , sizeof ( DATALENGTH ) - recvd , 0) ; 4 } 5 /* Netzwerkordnung zu Systemordnung */ 6 size = fntohll ( size ) ; 7 recvd = 0; 8 /* empfange Anfragenummer */ 9 while ( recvd < sizeof ( DATALENGTH ) ) { 10 recvd += recv ( socket , (( void *) request_number + recvd ) , sizeof ( DATALENGTH ) - recvd , 0) ; 11 } 12 /* Allokiere Speicher fuer Daten */ 13 buffer2 = ( void *) malloc ( size ) ; 14 result = sizeof ( DATALENGTH ) + size ; 15 recvd = 0; 16 /* Empfange Daten in Speicher */ 17 while ( recvd < size ) { 18 recvd += recv ( socket , buffer2 + recvd , size - recvd , 0) ; 19 } 20 * buffer = buffer2 ; Listing 3.4: Empfangen von Daten über Netzwerk 3.6.5 Implementierung Threadpool Sende- und Empfangsvorgang werden, wie bereits erwähnt, von den Worker-Threads des Threadpools übernommen. Hierbei hat jeder Kontext einen eigenen Threadpool, auf den Anfragen verteilt werden können. Ein Threadpool besteht aus einer Menge 19
3 Hauptteil von einzelnen Threads, welche sich in zwei unterschiedlichen Zuständen befinden kön- nen. Entweder wartet ein Thread im Threadpool, bzw. wartet darauf, dass Aufträge ver- fügbar sind, oder er bearbeitet derzeit einen Auftrag. Sobald ein wartender Thread einen Auftrag erhält, begibt er sich in den bearbeiten- den Zustand, in dem er nicht mehr für weitere Aufträge empfänglich ist, bis er die Aufgabe abgeschlossen hat. Im oben beschriebenen Anwendungsfall besteht ein Auftrag aus dem Versenden von Daten über eine bestehende Verbindung. Hierzu erhält ein Worker-Thread die jewei- lige Verbindung durch eine eigene Warteschlange zugeteilt. Aus deren Warteschlange entnimmt er dann die zu versendenen Daten, welche dann mittels send versendet werden. Die Aufgabe des Threadpools ist es, Sende- und Empfangsvorgänge für mehrere offe- ne Verbindungen zu übernehmen, um so den Netzwerk-Thread zu entlasten, so dass dieser nicht blockiert. Zum Senden oder Empfangen von Nachrichten braucht ein Worker-Thread das jeweilige Verbindungsstruct, welches das Verbindungssocket und das Warteschlangen-Pipe-Paar der Verbindung enthält. Zur Handhabung der Verbin- dungen kommen zwei mögliche Strategien in Frage, die in den folgenden Unterab- schnitten beschrieben werden. 3.6.5.1 Thread-pro-Verbindung Für jede bestehende Verbindung wird ein Thread erzeugt, welcher sich um die Sende- und Empfangsvorgänge dieser Verbindung kümmert. Um dies zu tun, müsste ein je- weiliger Thread die Ein- und Ausgangskanäle in Form der Warteschlangen-Pipe-Paare der zugehörigen Verbindung, sowie das Verbindungssocket überwachen und im Falle von zu übertragenden oder zu empfangenen Daten den jeweiligen Vorgang einleiten. Nachdem dieser beendet ist, überwacht der Thread die Kanäle erneut. Abbildung 3.7 veranschaulicht die Nachrichtenverwaltung durch das Thread-pro-Verbindung Modell, indem jeder Verbindung ein Thread des Pools zugewiesen wird. Kontext Threadpool Verbindung Thread send 1 1 Verbindung Thread recv Netzwerk 2 2 Verbindung Thread send 3 3 Abbildung 3.7: Thread-pro-Verbindung 20
3.6 Versenden von Daten Wenn eine Verbindung geschlossen wird, kehrt der Thread zurück in den Thread- pool und kann für eine neu erstellte Verbindung wiederverwendet werden. Die Anzahl der erzeugten Threads skaliert mit der Anzahl der zeitgleich aktiven Verbindungen, da bei jeder neuen Verbindung, falls nötig, ein neuer Thread erzeugt wird. 3.6.5.2 Thread-pro-Anfrage Eine weitere Möglichkeit zum Umgang mit Sende- und Empfangsvorgängen liegt dar- in, diese verbindungsunabhängig an Threads aus dem Workerpool zu übertragen, welche diese übernehmen, so dass den Threads keine spezifische Verbindung, sondern nur einzelne Aufträge zugeweisen werden, wie in Abbildung 3.8 veranschaulicht, in der derzeit nur Operationen über zwei Verbindungen stattfinden, welche zufällig den Threads des Pools zugewiesen wurden. Hierzu würde ein Threadpool mit fester Anzahl an Worker-Threads erzeugt werden, welcher sämtliche Sende- und Empfangsvorgän- ge übernimmt. Die Ein- und Ausgangskanäle einer betroffenen Verbindung müssten hierbei jedes Mal an einen der Worker-Threads übergeben werden, bevor dieser den Auftrag ausführt. Kontext Threadpool Verbindung Thread send 1 1 Verbindung Thread Netzwerk 2 2 Verbindung Thread recv 3 3 Abbildung 3.8: Thread-pro-Anfrage 3.6.5.3 Evaluation Vorteile des Thread-pro-Verbindung-Modells gegenüber des Thread-pro-Anfrage Mo- dells liegen in der stärkeren Parallelisierung, da jede derzeit versendende oder emp- fangende Verbindung zeitgleich bedient wird, so dass kurze Nachrichten nicht auf die Fertigstellung des Sende- oder Empfangsvorgangs einer langen Nachricht warten muss, sondern direkt durch den entsprechenden Verbindungs-Worker-Thread versen- det wird. Aufgrund des immensen Overheads durch die Thread-Erzeugung im Thread-per- Connection-Modell wurde sich jedoch gegen diese und für die besser skalierbare Thread- per-Request-Variante entschieden. Die kostspielige Erzeugung neuer Threads ist ab- gesehen von der in Anspruch genommenen Zeit und der beanspruchten Ressourcen ineffizient, da ein Thread, während er über eine Verbindung wacht, keine anderen 21
3 Hauptteil Aufgaben ausführen kann, sodass er, wenn derzeit keine Daten über diese Verbin- dung zu übertragen sind, untätig ist. Im-Thread-per-Request-Model hingegen wird ein größerer Anteil, in dem die Worker-Threads arbeiten, erzielt, ohne dass zeitgleich zusätzliche Ressourcen beansprucht werden. Nachteile hierbei liegen hauptsächlich in der anspruchsvolleren Implementierung bezüglich der Anfragenverwaltung. Bei der Verwendung mehrerer Threads, die auf denselben Speicherbereich zugrei- fen, können Synchronisationsprobleme auftreten. Um dies zu verhindern, kann kriti- scher Code mit einem Mutex-Lock geschützt werden, um zu verhindern, dass dieser von mehreren Threads zur selben Zeit ausgeführt wird. Dies ist hier beim Senden und Empfangen der Daten notwendig, da es möglich ist, dass mehrere Anfragen hin- tereinander über eine Verbindung verschickt werden sollen, was ohne ausreichenden Schutz zur Folge hätte, dass der Netzwerk-Thread mehrmals dieselbe Verbindung an den Threadpool weiterreicht, sodass verschiedene Threads versuchen, zeitgleich Da- ten über dasselbe Socket zu verschicken, wodurch diese vermischt und somit unsinnig werden. Die Verwendung eines Mutex-Locks zur Sicherung des Sendevorgangs wird in Listing 3.5 gezeigt. 1 /* Versendet einzelne Nachricht */ 2 void message_send ( struct message * message ) { 3 /* Verwendet Mutex - Lock um den Sendevorgang vor anderen Threads zu schuetzen */ 4 pt hr ea d_ mu te x_ lo ck (&( message - > scon - > connection_mutex ) ) ; 5 /* Versenden der Daten */ 6 send_data ( message - > scon - > socket , message - > data , 7 message - > datalength , message - > number , 0) ; 8 9 /* Verlaesst den kritischen Codebereich */ 10 p t h r e a d _ m u t e x _ u n l o c k (&( message - > scon - > connection_mutex ) ) ; 11 } Listing 3.5: Geschütztes Übertragen von Daten in Worker-Thread Nachdem Daten übertragen wurden, ist ein Worker-Thread mit seiner Aufgabe fertig und kehrt in den Threadpool zurück, nimmt also den wartenden Zustand an. Damit der Netzwerk-Thread bzw. dessen Threadpool weiß, dass ein Thread wieder bereit für die Übertragung einer neuen Aufgabe ist, muss der Worker-Thread dies mitteilen. Hierzu erhält das struct workerthread einen Statuswert, welcher den aktuellen Zustand des Threads enthält. Die Worker-Threads, die ein Threadpool ent- hält, werden in einer Liste gespeichert, über die dieser iterieren kann, bis er einen verfügbarer Thread gefunden hat, dem ein Auftrag zugeteilt werden kann. Falls der- zeit alle Worker-Threads beschäftigt sind, wird der Auftrag in eine Warteschlange gelegt, auf die die Threads, sobald sie ihren Auftrag ausgeführt haben, zugreifen können. Ein einzelner Worker-Thread enthält zu seinem Zustand zusätzlich ein ei- genes Warteschlangen-Pipe-Paar, über das er mit dem Threadpool kommunizieren 22
3.7 Empfangen von Daten kann, sowie eine Identifikationsnummer, über die er adressiert werden kann. Nach der Erzeugung eines Worker-Threads befindet sich dieser im wartenden Zustand und überwacht die Pipe vom Netzwerk-Thread zum Worker-Thread mittels poll. Sobald etwas in diese geschrieben wurde und das blockierende poll einen Wert zurückgibt, liest der Worker-Thread das in die Pipe gesetzte Byte aus. Handelt es sich um einen Sendeauftrag, holt der Worker-Thread die Verbindung über die Daten versendet wer- den sollen aus der Warteschlange und beginnt damit, diese, wie in Listing 3.5 gezeigt, zu versenden. Anschließend setzt der Thread seinen Zustand zurück auf wartend und kehrt in den überwachenden Zustand zurück. 3.6.6 Versenden von Daten über mehrere Verbindungen Die Datenübertragung an mehrere Server zeitgleich von einem Client aus (Multi- cast) wird realisiert, indem die zu übertragende Nachricht an jeden Server einzeln verschickt wird. Hierzu wird eine Funktion send_multicast verwendet, welche statt einer einzelnen Verbindungs-ID und den zugehörigen Daten eine Menge von ID’s und die zu verschickenden Daten enthält. Diese iteriert über die Verbindungen, an die Da- ten verschickt werden sollen und ruft für diese einzeln die send_data-Funktion auf. Im Rahmen dieser werden wie in 3.6 beschrieben Zeiger auf die zu verschickenden Daten in die Queues der jeweiligen Verbindungen gelegt und Bytes in den entspre- chenden Pipes gesetzt. Der Netzwerk-Thread liest die Bytes aus den Pipes aus und verteilt die einzelnen Sendeaufträge an die Worker-Threads des Threadpools. Diese arbeiten alle auf demselben Speicherbereich und versenden die Daten parallel an die entsprechenden Server. 3.7 Empfangen von Daten Das Empfangen von Daten über Verbindungen beginnt mit dem Eintreffen der Daten aus dem Netzwerk. Wenn eines der Verbindungssockets, über die der Netzwerk-Thread wacht, feuert, sind Daten eingetroffen, die es zu empfangen gilt. Das Empfangen, sowie das Senden wird von den Worker-Threads des Threadpools übernommen, damit zum einen der Netzwerk-Thread nicht verzögert und zum anderen die Daten parallel empfangen werden können. Die parallele Abarbeitung ist insbesondere wichtig, für den Fall, dass kurze Nachrichten nach langen Nachrichten empfangen werden müssen, da es ohne Parallelisierung des Empfangsvorgangs dazu käme, dass die kurze Nachricht erst empfangen und bearbeitet werden könnte, nachdem die lange vollständig empfangen wurde, wodurch Verzögerungen auftreten würden. Bei der Verteilung der Verbindungen über die Daten aus dem Netzwerk empfangen werden sollen, auf die Worker-Threads, können Probleme auftreten. Wenn Daten über ein Socket eintreffen und der Netzwerk-Thread die Verbindung an den Threadpool weitergegeben hat, begibt er sich, sofern keine anderen Dinge zu erledigen sind, wieder in den überwachenden Zustand mittels poll. Wenn er sich wieder in diesem befindet und die Daten noch nicht vollständig über das Socket empfangen wurden, wird der 23
Sie können auch lesen