OpenSSL: Implementierung innerhalb eines Client- und Server-Programms, Teil 4

Seite 2: Mutex-Initialisierung

Inhaltsverzeichnis

Bevor sich die Callbacks für das (statische) Locking registrieren lassen, ist zunächst das Mutex-Array zu initialisieren. Das geschieht zu Beginn des Konstruktors von WOPR_server mit ssl_init_mutex(). Diese Funktion erfragt über CRYPTO_num_locks(), wie viele Locks OpenSSL verlangt. Die betreffende Anzahl von POSIX-Thread-Mutex-Verfahren wird erzeugt und anschließend in einer Schleife initialisiert.

Der Callback ssl_lock() ist für das Setzen und Rücksetzen der statischen Locks zuständig. Relevant sind für die Funktionsweise die Argumente mode für den Modus (Setzen oder Rücksetzen) und der Index n, der den Mutex im Array zum Ziel hat. file und line sind lediglich für Debugging und Tracing vorgesehene Informationen. Das Registrieren des Callbacks übernimmt CRYPTO_set_locking_callback(ssl_lock) im Konstruktor der Server-Klasse.

Für das Verwalten der dynamischen Locks existieren drei Callbacks: zum Erzeugen ssl_dynlock_create(), zum Zerstören ssl_dynlock_destroy() und zum Setzen/Rücksetzen ssl_dynlock_lock(). Alle drei Funktionen operieren auf der struct CRYPTO_dynlock_value. Sie ist in OpenSSL zwar deklariert, aber nicht implementiert. Das Implementieren erfolgt zwecks Flexibilität im Anwendungsprogramm (siehe ssl-thread.h).

Das Erzeugen und Zerstören der dynamischen Mutex-Verfahren ist selbsterklärend. Ebenso erschließt sich die Funktion von ssl_dynlock_lock() durch die Ausführungen zum statischen Pendant
ssl_lock(). Das Registrieren der drei Callbacks übernehmen die Funktionen CRYPTO_set_dynlock_create_callback(ssl_dynlock_create), CRYPTO_set_dynlock_destroy_callback(ssl_dynlock_destroy)
und CRYPTO_set_dynlock_lock_callback(ssl_dynlock_lock).

Zusätzlich zu den Mutex-Verfahren für OpenSSL führt WOPR 2.00 auch welche für sich selbst ein. Sowohl der Standardausgabekanal als auch der -fehlerkanal sind gemeinsam genutzte Ressourcen. Konsequenterweise sind auch sie durch die Mutex-Verfahren cout_mutex und cerr_mutex abgesichert.

Die Methode WOPR_server::run() wechselt fork() gegen POSIX-Threads. Bevor WOPR den neuen Thread erzeugt, holt sich das System das Client-BIO via BIO_pop() und erzeugt einen neuen SSL-Kontext. Beides landet zusammen mit der Client-Nummer und einem Zeiger auf den zugehörigen Server in einer Instanz der neuen Klasse WOPR_client_attrib. Dieses Objekt wca kapselt die relevanten Daten des Clients.

Das Erzeugen des Client-Thread erfolgt mit pthread_create(), wobei wca als Thread-Argument dient. Der Thread ist durch die Methode _thread() repräsentiert und die wiederum als Klassenmethoden von WOPR_server definiert. Die meisten C++-Compiler erlauben es, statt eines Zeigers auf eine C-Funktion eine C++-Klassenmethode zu übergeben. Bei widerspenstigen C++-Implementierungen, wie auf z/OS, ist ein "Trick" notwendig. Hier implementiert WOPR _thread() als freie Methode mit "C-Symbolik" – also außerhalb der Klasse WOPR_server. (Mit dem kleinen Manko, dass WOPR_server::handle_client() nun public sein muss, da sie von außen – aus _thread() – aufgerufen wird.) _thread() ist einfach gestrickt. Es ermittelt aus dem wca den WOPR_server und ruft dessen Methode handle_client() mit dem wca als Argument auf.

handle_client() selbst hat sich nur unwesentlich gegenüber WOPR 1.x verändert. Die primären Änderungen beziehen sich darauf, dass das SSL-Handling von run() jetzt hier residiert und statt Objektattributen für SSL-Context, BIO et cetera nun die von wca Anwendung finden. Sonst ändert sich an der grundlegenden Logik und damit an der Funktionen dieser Methode nichts. Mal abgesehen davon, dass – infolge der Mutex-Verfahren für cout und cerr – andere Methoden für die Ausgaben und Fehlermeldungen zuständig sind.

Die Parallelisierung von Programmabläufen lässt sich grundsätzlich über Prozesse oder über Threads realisieren. Programme, die die Parallelisierung mit Prozessen erreichen, sind mit fork() sicherlich einfach zu programmieren. Bei dem Ansatz hat jeder Kindprozess seinen eigenen sequenziellen Programmablauf. Nebenläufigkeiten innerhalb der Prozesse existieren nicht. Die Parallelisierung mit Prozessen ist aber nicht völlig frei von prozessübergreifenden Problemen, wie beim File-Deskriptor-Problem in WOPR 1.00 deutlich zu sehen.

Neben der einfachen Programmierung sind die parallelen Abläufe in Prozessen aber auch gut gegeneinander durch das Betriebssystem abgegrenzt. Kommt es in einem Kindprozess zum Crash, beispielsweise durch eine Speicherschutzverletzung (Segmentation Fault), beendet das System nur diesen einen Prozess. Die anderen laufen ungehindert weiter. Nun könnte man anführen, dass Programme einfach sauber programmiert werden müssten. Die Praxis zeigt jedoch, dass – trotz aller Sorgfalt – Programme mit Fehlern behaftet sind. In einer perfekten Welt würden Computer nicht in regelmäßigen Abständen mit Blinken, Piepsen und Sprechblasen auf die Existenz wichtiger, jetzt zur Installation bereiter Updates hinweisen.

Die Kapselung der parallelen Abläufe in Prozessen gibt es jedoch nicht gratis. Sie erkauft man sich durch Redundanz. Bei jedem Aufruf von fork() entsteht eine exakte und vollständige Kopie des Prozesses. Darin sind auch Speicherbereiche enthalten, die sich gut und gern von den Prozessen gemeinsam nutzen lassen.

Das ist der Vorteil von Threads. Die einzelnen Threads können sich gemeinsam genutzte Datenstrukturen problemlos und ressourcensparend teilen. Hierfür ist aber zusätzlicher Aufwand beim konkurrierenden Zugriff in Form eines Locking, wie bei WOPR mit Mutexes, notwendig. Die Implementierung und der Zweck der Callback-Funktionen von OpenSSL kreisen einzig und allein um dieses Thema des konkurrierenden Zugriffs. Die Programmierung mit Threads ist aufgrund der Nebenläufigkeiten im Prozess allgemein aufwendiger.

Darüber hinaus haben gravierende Programmierfehler, die beispielsweise zum Segmentation Fault führen, verheerende Auswirkungen. Lässt sich beim Realisieren der Parallelität im Programmablauf über Prozesse nur der "Amok laufende" Kindprozess beenden, reißt eine Speicherschutzverletzung in einem Thread den gesamten Prozess mit allen anderen Threads in den Abgrund.

Das WOPR-Beispiel war einfach und erforderte keine Kommunikation zwischen Kindprozessen beziehungsweise Threads. Komplexe Serverprogramme erfordern das jedoch meist. Im Hinblick auf die Kommunikation rächt sich die systemseitige Trennung von Kindprozessen. Sie können nur über die klassischen Mittel der Interprozesskommunikation miteinander in Kontakt treten, wie Signale, Shared Memory oder Named Pipes. Threads spielen an der Stelle ihre Trümpfe aus. Sie gestatten eine direkte und einfache Kommunikation, da sie in einem einzigen Prozess und damit in einem gemeinsamen Adressenbereich liegen.