Erweitertung einer Netzwerkbibliothek um bidirektionale Many-to-Many Kommunikation

Die Seite wird erstellt Jan Fuhrmann
 
WEITER LESEN
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