zurück zum Artikel

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

Oliver Müller

Der in den vergangenen drei Artikeln herangereifte WOPR-Server spendierte jedem Client einen Handler als eigenständigen Betriebssystemprozess. Moderner lässt sich das mit Threads implementieren. Was gilt es beim Umstieg auf Threads zu beachten?

Der in den vergangenen drei Artikeln herangereifte und robuster gestaltete WOPR-Server basierte noch auf parallelen Prozessen. Der Server spendierte jedem Client einen Handler als eigenständigen Betriebssystemprozess. Moderner lässt sich dies mit Threads implementieren. Zumal OpenSSL ebenfalls Thread-fähig ist. Was gilt es bei WOPR und bei OpenSSL beim Umstieg auf Threads zu beachten?

Neben dem klassischen Unix-fork() können OpenSSL-Programme auch auf Threads aufsetzen. OpenSSL ist kompatibel zu mehreren Thread-Implementierungen, beispielsweise Windows-, Solaris- und POSIX-Threads. Um wieder die größtmögliche Kompatibilität zu vielen Systemen zu ermöglichen, konzentriert sich der Artikel auf POSIX-Threads. Sie sind im Gros der Unixoiden und in vielen POSIX-Layern von Nicht-Unix-Systemen implementiert. Der Multithreading WOPR 2.00 ist damit auf den meisten Unix- und Linux Systemen ebenso lauffähig wie auf Windows (über Cygwin). Die großen Systeme werden ebenfalls unterstützt: z/OS – über die UNIX System Services – und diesmal auch OpenVMS. Außen vor bleiben lediglich Systeme, die keine POSIX-Threads implementieren. Darunter sind MINIX, kNIX auf OS/2 beziehungsweise eComStation oder auch natives Windows. Windows benötigt einen Layer oder Wrapper für das Umsetzen seiner Thread-API auf POSIX-Threads. Diese Aufgabe erfüllt jedoch Cygwin vortrefflich.

Mehr Infos

OpenSSL – Struktur und Beispielcode

Den für die Umsetzung in der Praxis benötigten Beispielcode findet man hier [4] (mueller_wopr-2.00.tar).

Der Build des Thread-fähigen WOPR-Servers geschieht genauso mit GNU make wie der der fork()-basierten Versionen 1.00 und 1.01. Lediglich auf dem neu unterstützten OpenVMS erfolgt der Build mit der DCL Command Procedure MAKEVMS.COM, das HP C++ voraussetzt. Ein Aufruf mit @makevms auf dem DCL-Prompt genügt, um den WOPR-Server zu bauen. Das Terminal-Programm bleibt jedoch bei OpenVMS nach wie vor außen vor – aufgrund der aus den ersten beiden Teilen bekannten Einschränkungen im select(). OpenVMS kann somit nur den WOPR-Server bereitstellen. Der lässt sich aber von WOPR-Terminals auf anderen Plattformen problemlos kontaktieren.

Doch nun zum Quelltext und damit in medias res. Beim Wechsel von fork() auf POSIX-Threads erwartet OpenSSL das Implementieren einiger Callback-Funktionen für seine Verwaltung. Die OpenSSL Funktionen operieren intern auf globalen Datenstrukturen. Beim fork()-Ansatz ergibt sich hier kein Problem. WOPR 1.x bestand nur aus einem einzigen Thread. Jeder WOPR-Prozess hatte seinen eigenen Speicherbereich mit einem eigenen Satz der globalen OpenSSL-Datenstrukturen.

Diese existieren aber nur einmal pro (heavy weight) Prozess. Beim Multithreading teilen sich damit alle Threads innerhalb eines Prozesses den einen einzigen Satz dieser globalen Strukturen. Die Konsistenz der Datenstrukturen muss trotz der konkurrierenden Zugriffe der parallel laufenden Threads gesichert sein. Die Sicherung übernimmt OpenSSL zwar grundsätzlich selbst, erwartet jedoch, dass das Anwendungsprogramm bestimmte Callback-Funktionen implementiert. Die Callbacks haben die Aufgabe die konkurrierenden Zugriffe zu koordinieren, beispielsweise durch Mutexes. In ihnen ist im Übrigen die Unabhängigkeit von OpenSSL vom verwendeten Thread-Modell des Systems begründet. Wie der konkurrierende Zugriff verhindert wird, hängt einzig und allein von der Implementierung der Callbacks durch das Anwendungsprogramm ab.

OpenSSL benötigt zum einen einen Callback, der den aktuellen Thread eindeutig identifiziert. Zum anderen ist ein Callback notwendig, der den koordinierten Zugriff auf gemeinsam genutzte Ressourcen implementiert. Dieses klassische Verfahren operiert auf einem fixen Array von Locks, beispielsweise Mutexes. Das Array ist vom Anwendungsprogramm bereitzustellen und hat eine von OpenSSL vorgegebene feste Größe. Seit Version 0.9.5b erlaubt OpenSSL auch das dynamische Erzeugen der Locks. Hierzu sind einige Callbacks zusätzlich notwendig. Die dynamischen Locks ermöglichen einigen OpenSSL-Teilen eine bessere Performanz.

Doch zunächst zum Callback zur eindeutigen Identifizierung der Threads. Die Callback-Funktion hat den Prototypen unsigned long ssl_thread_id(void). (Der Funktionsname ist dabei selbstverständlich frei wählbar.) Der Rückgabewert vom Typ unsigned long kann ein beliebiger, aber eindeutiger Wert sein. Er muss lediglich für die Threads eindeutig und paarweise verschieden sein.

In der Praxis hat sich eingebürgert, schlicht die Thread-ID (pthread_self()) auf unsigned long zu "casten". In den meisten Fällen ist die ID ein Zeiger. Das Verfahren funktioniert, solange er in den genannten Integer-Datentyp passt. Es gibt aber auch Ausnahmen: Die z/OS UNIX System Services beispielsweise implementieren keinen simplen Zeiger, sondern geben eine struct mit einem 8-Byte-Array als einziges Element zurück. Des Weiteren ist beispielsweise das 64-bittige OpenVMS auf Alpha und Itanium ein Vertreter, bei dem die Pointer-Breite (64-bit-Wert) mit der Größe von unsigned long (nur 32 bit) nicht übereinstimmt. Die Gründe liegen in den Tiefen der Aufwärtskompatibilität des 32-bittigen OpenVMS auf VAX begründet.

Die Implementierung des Identitäts-Callbacks im WOPR-Server liegt – wie alle Callbacks – in der Datei ssl-thread.cpp. Die Funktion ssl_thread_id() nutzt hierbei den "klassischen" Cast beziehungsweise im Fall von z/OS das Konvertieren des Arrays aus pthread_t in den erwarteten unsigned long.

Um den Problemen mit dem zwar beliebten, aber teils "beschränkenden" Cast aus dem Wege zu gehen, führt OpenSSL mit Version 1.0.0 einen neuen Identitäts-Callback ein. Er ist jedoch lediglich eine Alternative. Die alte Variante versteht OpenSSL 1.0.0 ebenfalls noch. Es darf allerdings nur die eine oder andere gesetzt werden. Die neue Variante des Callbacks folgt dem Prototypen void ssl_thread_id(CRYPTO_THREADID *id). Statt eines Rückgabewerts liefert der Callback das Identitätskriterium in der struct vom Typ CRYPTO_THREADID zurück, deren Zeiger als Argument übergeben wurde. CRYPTO_THREADID enthält nichts anderes als einmal den alten unsigned long und einmal einen void* als Elemente. Aus Rücksicht auf spätere Erweiterungen oder Änderungen lassen sich die Elemente nur über die OpenSSL-Funktionen CRYPTO_THREADID_set_numeric() und CRYPTO_THREADID_set_pointer() setzen beziehungsweise verändern.

Die Idee ist recht einfach: Alles, was sich noch numerisch in den gegebenen Schranken identifizieren lässt, wandert wie zuvor in den unsigned long; alles, was sich als Pointer darstellen lässt, geht in den neuen void*. Auf die Weise kann kein Informationsverlust mehr auftreten, und die Identität ist gesichert.

Die Implementierung von ssl_thread_id für OpenSSL 1.0.0 zeigt das recht deutlich. Mit CRYPTO_THREADID_set_pointer(id, (void*) pthread_self()) lässt sich – systemunabhängig – der Zeiger von pthread_self() ohne Informationsverlust einfangen. Lediglich bei z/OS geht WOPR 2.00 einen anderen Weg: Hier wird der in einen unsigned long passende (eigenwillige) Rückgabewert von pthread_self() über CRYPTO_THREADID_set_numeric(id, x) in eine Ganzzahl gepackt.

CRYPTO_set_id_callback(ssl_thread_id) übergibt den Callback mit der alten Spezifikation dem OpenSSL-System. CRYPTO_THREADID_set_callback(ssl_thread_id) setzt den Callback mit neuer Syntax ab OpenSSL 1.0.0. Das Registrieren dieser und aller folgenden Callbacks erfolgt in WOPR 2.00 im Konstruktor der Serverklasse WOPR_server.

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.

In puncto Skalierbarkeit wird der Fall haarig. Prozesse als Kernel-Entities kann jeder unixoide Kernel auf einem Multiprozessorsystem auf CPUs verteilen. Grundsätzlich können damit Kindprozesse im fork()-Ansatz auf verschiedenen CPUs wirklich parallel ablaufen. Nimmt die Anzahl der Prozessoren im Computersystem zu, skalieren Programme mit Nebenläufigkeiten in Prozessen grundsätzlich mit.

Bei Threads hängt das stark davon ab, wie die Threads implementiert sind. Kernel-Threads, bei denen Threads als Kernel-Entities ähnlich einem Prozess abgebildet werden, skalieren ebenso über Multiprozessoren wie Prozesse. User-Threads hingegen, bei denen die Thread-Implementierung in einer Bibliothek im User-Space erfolgt, skalieren nicht. Da das Programm selbst die Thread-Verwaltung übernimmt, existiert gegenüber dem Kernel nur eine einzige Kernel-Entity – der eine Prozess. In der Situation bleibt der Prozess mit seinen Threads auf einen Prozessor beschränkt, egal wie viele CPUs sonst noch im System existieren.

Wer jetzt glaubt, alle POSIX- seien als Kernel-Threads realisiert, der irrt. Die lange Odyssee von Linux hin zu Threads, die heute in den auf Kernel-Threads basierten NPTL (Native POSIX Thread Library) gipfelt, ist ein gutes Beispiel. Begann die Reise dort im User-Space. MINIX 3 beispielsweise befindet sich noch am Anfang der Reise. Es bewegt sich erst jetzt langsam in Richtung POSIX-Threads, das allerdings im User-Space. Auch aktuelle Implementierungsbeispiele, wie die POSIX-kompatiblen GNU Portable Threads oder die FSU-Threads für Ada, sind zwar hochgradig portabel, aber auf den User-Space beschränkt. Bezüglich Skalierbarkeit heißt es im Zweifel, einen näheren Blick auf das Zielsystem zu werfen.

Die Frage, ob man OpenSSL-Programme eher mit Prozessen oder mit Threads implementieren sollte, hängt von vielen Details ab. Es ist an sich keine Frage von OpenSSL. Letzteres unterstützt beide Ansätze hinreichend gut und ohne Probleme – passendes Betriebssystem vorausgesetzt.

Der Trend weg von Kindprozessen und hin zu Thread ist in der Softwareentwicklung unverkennbar und grundsätzlich ein richtiger Schritt. Zumal die marktrelevanten und dominanten Systeme den Weg hin zum Kernel-Thread vollzogen haben. Für so manches altes Server-Programm, das nachträglich mit OpenSSL-Funktionen ausgestattet werden will, mag das Festhalten am guten alten fork() die wirtschaftlichere Alternative sein. Für Neuimplementierungen drängt sich die Thread-Variante auf. Die vereinfachte Kommunikation zwischen den Threads und die bei Kernel-Threads ebenfalls gegebene gute Skalierbarkeit sprechen dafür. Darüber hinaus hält OpenSSL die Programmierer durch die einfach zu implementierenden Callbacks von übermäßigem Overhead frei. Nach dem Implementieren der Callbacks lassen sich OpenSSL-Funktionen wie in Single-Thread-Prozessen nutzen. Der Aufwand für das Verwalten von Mutexes für eigene globale Datenstrukturen bleibt dabei erhalten. Dafür liegen diese nur einmal im Hauptspeicher und nicht mehrfach wie bei Prozesskopien.

Oliver Müller
ist Geschäftsführer der OMSE Software Engineering GmbH. Er leitet die Bereiche Software Engineering, Nearshoring und IT-Sicherheit.
(ane [5])


URL dieses Artikels:
https://www.heise.de/-1157918

Links in diesem Artikel:
[1] https://www.heise.de/ratgeber/OpenSSL-Implementierung-innerhalb-eines-Client-und-Server-Programms-Teil-1-1050619.html
[2] https://www.heise.de/ratgeber/OpenSSL-Implementierung-innerhalb-eines-Client-und-Server-Programms-Teil-2-1058771.html
[3] https://www.heise.de/ratgeber/OpenSSL-Implementierung-innerhalb-eines-Client-und-Server-Programms-Teil-3-1157455.html
[4] ftp://ftp.heise.de/pub/ix/developer/
[5] mailto:ane@heise.de