Mehrkern-Prozessoren sind ein Problem für Multithread-Code

Im einem unserer Blog-Beiträge werden einige der Stolperfallen von Multithreading beschrieben, in die Entwickler tappen können. An dieser Stelle widme ich mich der damit verknüpften Frage der Portierung eines bereits multithreading-fähigen Projekts von Einzelprozessorplattformen auf Mehrprozessorplattformen (einschließlich Mehrkern-Plattformen).

Dass es Probleme gibt, die speziell bei der Portierung von Multithread-Code in eine Mehrkern-Umgebung auftreten, mag der Intuition widersprechen. Müsste es nicht so sein, dass man die Anwendung einfach nur portiert und dann vom Leistungsplus profitiert, weil die Threads ja auf mehreren Prozessoren parallel ausgeführt werden? Ganz so einfach ist die Sache nicht. Man ist sich heute weitgehend einig, dass sich Multithreading-Bugs mit größerer Wahrscheinlichkeit auf Mehrprozessorplattformen manifestieren. Dieser Beitrag untersucht eine Reihe von Gründen für diesen Umstand.

Zunächst sei angemerkt, dass Multithreading eine Technologie ist, die zwei Zwecken dient:  Threads werden genutzt, um parallele Software für Mehrprozessorsysteme zu entwickeln. Darüber hinaus dienen sie aber auch der asynchronen Verarbeitung von Interaktionen mit anderer Software und der realen Welt. Dieser zweite Nutzungsfall ist auch dann relevant, wenn die Software auf nur einem Prozessor läuft. Deshalb gab es schon vor der Ausbreitung der Mehrprozessorsysteme jede Menge Multithread-Code.

Selbstgestrickte Synchronisierung funktioniert auf einem einzelnen Prozessor mitunter

Im ersten Beispiel (Sh. Bild 1)  schauen wir uns das „träge“ Initialisierungsmuster an:

multithreaded-1-300dpi

Dieser Code alloziert eine globale Instanz eines speziellen Objekts zu und initialisiert sie. Das erfolgt jedoch erst, wenn das Objekt tatsächlich gebraucht wird (deshalb die Bezeichnung „träge“). Dieser Code funktioniert in einem Ein-Thread-Kontext perfekt, kann in einem Multithread-Kontext jedoch jämmerlich versagen.

Überlegen Sie, was passiert, wenn ein Thread in den konditionalen Block eintritt, weil „obj“ NULL ist, und dann vom Scheduler unterbrochen wird. Ein zweiter Thread ruft „get_special_object“ auf und nimmt die Zuweisung und Initialisierung selbst vor, weil „obj“ immer noch NULL ist. Wenn der erste Thread wieder weiterläuft, initialisiert er sich selbst. Jetzt schwirren zwei unterschiedliche Kopien des speziellen Objekts durch die Gegend. Im besten Fall ist das ein Speicherleck; wahrscheinlicher ist jedoch, dass es Programmlogik gibt, die von der unveränderlichen Größe abhängt, dass es höchstens eine Instanz des speziellen Objekts gibt.

Der einzige einfache und gangbare Weg, dieses Muster Multithreading-fähig zu machen, ist die Zusammenfassung der gesamten Prozedur in einem kritischen Abschnitt (z. B. mit einer Mutex). Mitunter scheuen Programmierer den Mehraufwand einer Synchronisierung bei jedem Aufruf von „get_special_object“ und versuchen dies mit dem Muster der „doppelt überprüften Sperrung“ zu umgehen. Wenn Ihnen dieses Muster mit seinen Fallstricken nichts sagt, empfehlen ich Ihnen eine kurze Google-Suche.

Wenn Sie mit den Fallstricken vertraut sind, erstaunt es Sie möglicherweise, dass es eine Version gibt, die tatsächlich funktioniert – sofern es Ihnen irgendwie gelingt, Ihren Compiler davon zu überzeugen, keine interessanten Optimierungen vorzunehmen (oder das Ganze direkt in Maschinencode schreiben) und der Code nur auf einem Einzelprozessorsystem ausgeführt wird: [Verwenden Sie Code wie diesen nie in einem Produktionssystem!] (sh. Bild 2)

multithreaded-2-300dpi

Dass dieser Code sich in Ein- und Mehrprozessorsystemen unterschiedlich verhalten kann, hat mit Caches zu tun. Alle gängigen Mehrprozessor-Architekturen haben komplexe Speichermodelle, die nicht garantieren, dass die von einem Prozessor abgewickelten Speichervorgänge aus Sicht eines anderen Prozessors in derselben Reihenfolge erscheinen. Grund für die Komplexität dieser Speichermodelle ist der, dass einzelne Prozessoren im Hinblick auf die Verwaltung ihrer Caches Flexibilität brauchen.

In diesem Beispiel ist es möglich, dass ein Thread, der auf einem anderen Prozessor ausgeführt wird, den aktualisierten „obj“-Pointer „sieht“, bevor er den initialisierten Speicher „sieht“, auf den er verweist. Das hat zur Folge, dass der Thread dem Pointer folgen und irgendwelchen nicht initialisierten Schrott lesen könnte. Diese Fehlermöglichkeit ist auf einem Einzelprozessorsystem nicht gegeben, weil auch bei Unterbrechung des initialisierenden Threads zu einem unpassenden Zeitpunkt alle Threads dieselbe Cache-Hierarchie nutzen und daher nicht diese inkonsistenten Speicherinhalte sehen können, wie sie auf Mehrprozessorsystemen möglich sind.

Um es ganz klar zu sagen: Ich rate davon ab, sich auf hochgradig plattformabhängiges und komplexes Verhalten zu verlassen. Der einzig gangbare Weg, Probleme dieser Art zu vermeiden, besteht darin, dafür zu sorgen, dass Ihr Code richtig synchronisiert ist (das heißt, dass er sich nach dem Multithread-Speichermodell ihrer Quellsprache richtet). Viele Multithread-APIs bieten ein „Einmal“-Primitive, das Sie für alle Erfordernisse Ihrer „trägen“ Initialisierung nutzen sollten.

Als wichtige Schlussfolgerung lässt sich an dieser Stelle sagen, dass die umfassende, gar erschöpfende Validierung auf einem Einzelprozessorsystem keine Garantie dafür bietet, dass Ihr Multithread-Code frei von Bugs ist, die sich auf einem Mehrprozessorsystem manifestieren.

Mehrere Kerne treiben Programme in merkwürdige Ecken des Scheduling-Universums

Das Vermeiden exotischer Synchronisierungsmuster wie die doppelt überprüfte Sperrung verringert zwar den Unterschied zwischen Einzel- und Mehrprozessorsystemen, lässt ihn aber nicht verschwinden. Schauen Sie sich diese Prozedur an, die als Thread-Einstiegspunkt dient (sh. Bild 3):

multithreaded-3-300dpi

Nehmen Sie an, dass innerhalb von do_some_initialization verschiedene Bits mit globalem Status aktualisiert werden, um die Existenz des neuen Threads zu registrieren. Nehmen Sie weiter an, dass diese Statusaktualisierungen individuell synchronisiert werden (so dass zu keinem Datenrennen kommt). Nehmen Sie zum Schluss an, dass es für einen anderen Thread schlecht ist, einen teilweise aktualisierten Status zu beobachten. Das ist der klassische Nebenläufigkeitsfehler, der als Atomicity-Verstoß bezeichnet wird. Der Programmierer geht stillschweigend davon aus, dass eine Sammlung von Statusaktualisierungen (die Initialisierung) atomisch (als Ganzes) erfolgt, auch wenn es technisch möglich ist, dass ein anderer Thread einen teilweise aktualisierten Status beobachtet.

Dieses Beispiel ist aus folgendem Grund besonders interessant: Wenn ein neuer Thread auf einem Einzelprozessorsystem erzeugt wird, hat er in der Regel relativ viel Zeit (in Software-Maßstäben) für seine Ausführung, bevor er für die Ausführung eines anderen Prozesses unterbrochen wird. Es ist also durchaus möglich, dass sich ein Atomicity-Verstoß ganz am Anfang der Existenz des Threads auf einem Einzelprozessorsystem nie zu einem Problem auswächst. Wird dieser Code jedoch auf einem Mehrprozessor-System ausgeführt, kann der übergeordnete Thread weiter ausgeführt werden, während der untergeordnete sich selbst initialisiert. In dieser Situation ist die Wahrscheinlichkeit groß, dass der übergeordnete Thread den schlechten, teilweise aktualisierten Status beobachtet. Durch den Umstieg von einem Einzelprozessor- auf ein Mehrprozessorsystem haben wir die Wahrscheinlichkeit des tatsächlichen Auftretens dieses Atomicity-Verstoßes von nahezu Null auf einen Wert gebracht, der jeden verantwortungsbewussten Programmierer beunruhigen sollte.

Im Allgemeinen haben viele Nebenläufigkeitsfehler relativ geringe Wahrscheinlichkeitsfenster. Auf einem Einzelprozessorsystem hängt das Auftreten dieses Fehlers davon ab, ob ein Thread vom Scheduler in der Mitte eines solchen Fensters unterbrochen wird. Im Normalbetrieb neigen Thread-Scheduler zu einem relativ vorhersagbaren und schlüssigen Verhalten (z. B. lassen sie einen Thread für einen bestimmte Zeit laufen oder entscheiden, nach einem ziemlich einheitlichen Muster welcher Thread als nächster ausgeführt werden soll). Folglich bleiben beim Testen von Multithread-Code auf einem Einzelprozessorsystem in der Regel große Teile des Universums der möglichen Interaktionen zwischen Threads unerforscht. Auf Mehrprozessorsystemen können selbst kleine Störungen wie Cache-Fehlzugriffe große Auswirkungen darauf haben, wie sich Events (wie Speicherlese-/-schreibvorgänge) aus verschiedenen Threads relativ zueinander ausrichten. Diese Änderungen können ihrerseits Nebenläufigkeitsfehler auslösen, die auf einem Einzelprozessorsystem praktisch nie auftreten würden.

Wie lässt sich das Problem lösen?

Die Kernbotschaft lautet hier wie folgt: Bei der Portierung eines Multithread-Programms, das auf Einzelprozessorsystemen gründlich getestet wurde, auf Mehrprozessorsysteme besteht ein erstaunlich hohes Risiko, dass bislang verborgene Nebenläufigkeitsfehler zutage treten.

Die Beseitigung dieser Nebenläufigkeitsfehler in bestehenden Codebasen kann ein sehr teures Unterfangen sein. Eine kleine, aber unüberhörbare Gruppe von Nebenläufigkeitsexperten glaubt, dass die meisten Anwendungsentwickler überhaupt keine Threads verwenden sollten. Bei der reaktiven/interaktiven Programmierung sind Event-Handling-Schleifen in der Regel deutlich weniger fehlerträchtig als Threads. Und bei der parallelen Verarbeitung sind isolierte Prozesse im Allgemeinen sicherer (wenn auch unpraktischer) als Threads.

Bei vielen Projekten sind Threads jedoch immer noch das Mittel der Wahl für die interaktive und/oder parallele Programmierung. Wie lassen sich solche Programme sicherer und stabiler machen? Eine wichtige Erkenntnis ist die, dass herkömmliche Testverfahren – vor allem auf Einzelprozessorsystemen – für das Aufspüren subtiler Nebenläufigkeitsfehler nutzlos sind.

Ein Ansatz, der das Problem zumindest teilweise löst, besteht im Rückgriff auf ein Tool wie Chess von Microsoft Research. Chess untersucht systematisch eine sorgfältig zusammengestellte Teilmenge der möglichen Schedules von Multithread-Programmen. Der Ansatz von Chess besteht darin, dass der Thread Scheduler beim Testen fortwährend schlechte Entscheidungen treffen und so die Wahrscheinlichkeit der Entdeckung von Nebenläufigkeitsfehler drastisch erhöhen kann – verglichen mit einem zufälligen Scheduling.

Neben Testtools können statische Analysetools dazu beitragen, potentiell unsichere Nebenläufigkeitsmuster zu entdecken. Statische Analysetools nutzen symbolische Ausführungs-Engines, um mögliche Probleme zu ermitteln, ohne dazu konkrete Eingabedaten oder Thread-Schedules identifizieren zu müssen, bei denen die Probleme zutage treten würden. Daher stellen die durch statische Analyse ermittelten Probleme in der Regel eine Ergänzung der mit Hilfe von Tests ermittelten Probleme dar.

Zu den statischen Analysetools, die Nebenläufigkeitsfehler finden, zählen ThreadSafe von Contemplate und CodeSonar von GrammaTech. Die aktuellste Version von CodeSonar beinhaltet die Prüfung auf unsicheren Code und kann helfen, schwere Nebenläufigkeitsfehler zu finden.

 

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.