Tour 7: "threads"

Überblick

Threads unterscheiden sich von Prozessen im Wesentlichen dadurch, dass sie sich einen Adressraum teilen, d.h. insbesondere alle globalen Variablen sowie dynamisch allokierter Speicher stehen allen Threads eines Prozesses zur Verfügung. Dies macht Threads besonders nützlich, wenn die verschiedenen parallelen Ausführungsstränge intensiv miteinander kommunizieren müssen.

Unter Windows sind Threads seit Mitte der 1990er verfügbar. Andere Betriebssysteme bieten ebenfalls Threads oder vergleichbare Mittel. Wenn man portabel bleiben will, dann bieten die POSIX Threads eine geeignete, stabile API. Weiterhin unterstützen neuere Programmiersprachen wie etwa Java oder C++ 11 Threads schon von Haus aus.

Im Rahmen dieses Tutorials werden nur native Threads unter Windows behandelt. Außerdem werden nur minimale Grundlagen für unfallfreies Programmieren vermittelt, aber auf der Seite Wie geht's weiter? gibt es ein paar Hinweise auf weiterführende Literatur.

Wann sollte man Threads benutzen?

Ich möchte in diesem Tutorial eine Lanze für das Singlethreading brechen. Zwar ist Multithreading eine Schlüsseltechnologie für das Ausschöpfen der Ressourcen moderner Multi-Core-Systeme, doch handelt man sich eine nicht zu unterschätzende Menge zusätzlicher Komplexität ein. Man sollte zumindest genau verstanden haben, was select() leisten kann, bevor man leichtfertig zu Multithreading greift.

In Netzwerkprogrammen sollte man Threads benutzen, wenn mehrere Clients parallel bedient werden sollen und dies entweder nennenswert CPU-Zeit benötigt (also "echte Arbeit" anfällt) oder blockierende Aufrufe vorkommen, die nicht asynchron oder über select() abgearbeitet werden können.

Erzeugen neuer Threads

Unter Windows stehen die Funktionen _beginthread(), _beginthreadex() und CreateThread() zur Verfügung. Letzteres ist der eigentliche Systemaufruf, der einen neuen Thread erzeugt. Allerdings erledigen _beginthread() und _beginthreadex() noch ein bisschen was hinter den Kulissen, das wichtig ist, wenn weitere Funktionen der C-Laufzeitbibliothek benutzt werden (zum Beispiel rand() um Zufallszahlen zu erzeugen). Mischt man diese Aufrufe, dann können Speicherlecks entstehen.

Mit _beginthread() wird ein neuer Thread gestartet, über den man jedoch wenig Kontrolle hat: er läuft sofort los und man erhält keine zuverlässige Möglichkeit mit ihm zu interagieren. Zwar gibt die Funktion ein Thread-Handle zurück, aber dieses wird beim Beenden des Threads automatisch geschlossen, was durchaus auch passieren kann, bevor der startende Thread erneut dran kommt. In diesem Fall könnte das Handle bereits einen anderen Thread referenzieren.

Die Funktion _beginthreadex() liefert ebenfalls ein Handle zurück, das jedoch nicht automatisch geschlossen wird. Der startende Thread hat die Verantwortung, das Handle nach Gebrauch zu schließen (andernfalls droht ein Ressourcenleck). Dafür kann es aber auch zuverlässig zur Synchronisation genutzt werden. Weiterhin besitzt die Funktion _beginthreadex() einen Parameter, über den der neue Thread erzeugt, aber angehalten wird, bis er explizit mit ResumeThread() laufen gelassen wird.

Beide Funktionen erlauben es der Threadfunktion einen Parameter vom Typ void* zu übergeben. Die Bedeutung des Parameters kann beliebig sein, wichtig ist jedoch, dass der Speicher gültig sein muss wenn die Threadfunktion darauf zugreift. Da nicht vorhersagbar ist, ob der neue Thread gleich losläuft, oder der aufrufende Thread weiter läuft, eignen sich lokale Variablen dafür überhaupt nicht! Am besten verwendet man dynamisch allokierten Speicher, dessen Besitz an den neuen Thread übergeht, d.h. dieser entscheidet, wann der Speicher nicht mehr benötigt wird und gibt ihn selbst frei.

Beenden von Threads

Ähnlich wie bei Prozessen ist es am besten, wenn sich Threads selbst beenden. Dies passiert, wenn die beim Starten angegebene Threadfunktion verlassen wird. Weiterhin stehen die Funktionen _endthread() und _endthreadex() zur Verfügung, die vom zu beendenden Thread aufgerufen werden können.

Eine weitere, aber sehr gefährliche Funktion ist TerminateThread(). Die Gefährlichkeit ergibt sich dadurch, dass der zu beendende Thread sang und klanglos gestoppt wird, völlig unabhängig davon, was er gerade macht. Dies kann z.B. dazu führen, dass eine betretene Critical Section nicht mehr freigegeben wird, wodurch andere Threads dauerhaft blockieren können. Auch wenn das Programm keine expliziten Critical Sections benutzt gibt es versteckte Synchronisationsmechanismen, z.B. beim Allokieren und Freigeben von dynamischem Speicher (malloc(), free(), new, delete). Solche Fehler sind richtig mies und schwer zu finden.

Warten auf Threads

Wenn Threads sich vorzugsweise selbst beenden sollten, anstatt explizit beendet zu werden, dann ergibt sich auch die Frage, wie andere das feststellen können. Dazu gibt es einerseits die Funktion GetExitCodeThread(), mit der man den Exit-Code eines Threads abfragen kann; wenn der Thread noch läuft ist dieser STILL_ACTIVE. Es gibt aber auch die Möglichkeit auf das Beenden eines Threads zu warten. Hierzu benutzt man WaitForSingleObject() oder WaitForMultipleObjects() und gibt als Objekt das Thread-Handle an.

Eine weitere Möglichkeit, über die Threads sich miteinander verständigen können, sind Events. Das sind Kernel-Objekte, die zwei Zustände kennen, signalisiert und nicht-signalisiert. Mit diesem Mechanismus lassen sich Handshake-Szenarien implementieren, bei denen sichergestellt wird, dass ein Teilnehmer etwas zur Kenntnis genommen hat, das ein anderer Teilnehmer mitgeteilt hat. Events können auch benannt werden und prozessübergreifend benutzt werden. Weitere Mechanismen findet man in der Windows-API-Dokumentation (siehe Wie geht's weiter?).

Was ist so schwierig an Multithreading?

Eingangs hatte ich erwähnt, dass man sich eine Menge zusätzlicher Komplexität einhandelt, wenn man mehrere Threads benutzt. Das Starten und Beenden von Threads sah aber gar nicht so schwierig aus, wo ist also das Problem?

Das Problem

Die Schwierigkeiten ergeben sich aus den gemeinsam genutzten Ressourcen, allen voran Speicher und Dateideskriptoren/Sockets. Dadurch, dass Threads so eng miteinander arbeiten, ist eine Menge Abstimmung notwendig, damit sich keine Inkonsistenzen einschleichen. Einfaches Beispiel ist das Anhängen eines Elements an eine Liste, die durch ein Array gebildet wird:

list[writePos] = newElement;
writePos = writePos + 1;

Wenn hier der erste Thread nach dem Beschreiben des Array-Elements durch einen zweiten Thread unterbrochen wird, der die gleiche Funktion ausführt, dann überschreibt dieser das Element an der Stelle list[writePos] mit seinem neuen Element. Später wird der erste Thread fortgesetzt und inkrementiert den Positions-Index ein weiteres Mal, sodass quasi folgende Sequenz entsteht:

Thread 1                                Thread 2
list[writePos] = newElement;
                                        list[writePos] = newElement;
                                        writePos = writePos + 1;
writePos = writePos + 1;

Die Liste ist danach zwar um zwei Elemente größer, aber eins davon ist das von Thread 2 Gelieferte, das andere ist uninitialisierter Speicher!

Selbst das Erhöhen des Zählers ist anfällig:

Thread 1                                Thread 2
; writePos = writePos + 1;
- Lade Wert aus RAM in Register
- Inkrementiere Register
                                        ; writePos = writePos + 1;
                                        - Lade Wert aus RAM in Register
                                        - Inkrementiere Register
                                        - Schreibe Register-Wert in RAM
- Schreibe Register-Wert in RAM

Hier wäre der Wert in der Variable nur um eins inkrementiert. Bei diesen sogenannten Read-modify-write-Vorgängen hilft es übrigens auch nichts, wenn der dazugehörige Maschinencode aus nur einer einzelnen Assembler-Instruktion besteht. Die CPU muss trotzdem den Wert aus dem RAM holen, bearbeiten und zurückschreiben.

Die Lösung

Die Lösung für diese Art der Wettlaufsituationen besteht darin, die Datenstrukturen vor inkonsistenten Veränderungen zu schützen, bzw. anders ausgedrückt die Manipulationen atomar zu machen. Bei einfachen Operationen wie Inkrementieren gibt es oft spezielle CPU-Befehle bzw. Modifizierer, die für einen atomaren Ablauf sorgen. Solche Instruktionen sind dann z.B. über die Windows-API gekapselt und heißen InterlockedIncrement(), InterlockedExchange(), etc.

Komplexere Abläufe lassen sich durch kritische Abschnitte schützen. Unter Windows stehen hierzu EnterCriticalSection() und LeaveCriticalSection() zur Verfügung. Wenn der erste Thread in solch einen kritischen Abschnitt läuft, dann wird er als "Besitzer" gekennzeichnet und kann sich so viel Zeit wie nötig lassen. Versucht ein weiterer Thread hinein zu laufen, dann wird dieser angehalten und schlafen gelegt, bis der vorherige Thread den kritischen Abschnitt verlassen hat.

Das Problem mit der Lösung

Das mit den kritischen Abschnitten klingt erst mal gut, aber beschwört eine Hand voll weiterer Probleme herauf. Zunächst einmal müssen alle Manipulationen identifiziert werden. Ist man zu großzügig mit dem Einsatz kritischer Abschnitte, so erhält man ein derart eng verzahntes System, dass die parallele Ausführung am Ende gar nicht mehr gegeben ist. Vergisst man eine Stelle, dann bekommt man einen Fehler, der oftmals sehr schwer reproduzierbar und entsprechend schwierig zu analysieren ist.

Besondere Vorsicht ist geboten, wenn es mehrere Ressourcen gibt, die separat voneinander gesperrt werden können. Angenommen es gibt Thread 1 und Thread 2 sowie Ressource A und Ressource B. Beide Threads benötigen beide Ressourcen um zu arbeiten. Wenn Thread 1 nun Ressource A gesperrt hat und Thread 2 der Besitzer von Ressource B ist, dann kann es passieren, dass Thread 1 auf Ressource B wartet, während Thread 2 auf Ressource A wartet. In dieser Situation sind beide Threads blockiert und warten auf ein Ereignis, das niemals eintreten kann. Eine solche Situation wird Deadlock genannt.

Eine weitere unerwünschte Situation tritt ein, wenn Thread 1 eine niedrige Priorität hat während Thread 2 eine hohe Priorität besitzt. Wenn jetzt Thread 2 auf Ressource A wartet, die von Thread 1 gehalten wird, dann hilft ihm seine hohe Priorität nichts, weil er dennoch darauf warten muss, dass Thread 1 seine Arbeit mit Ressource A abgeschlossen hat. Eine solche Situation nennt man Priority inversion.

Das sehr lehrreiche Spiel The Deadlock Empire hilft solche Situationen besser zu verstehen. Dabei spielt man selbst das Betriebssystem, das entscheidet welcher Thread wann laufen darf, und muss vorher definierte Fehlerszenarien verursachen.

Wie oft passiert das denn überhaupt?

Kann man das Problem einfach ignorieren? Die seriöse Antwort muss natürlich "nein" lauten. Aber eine berechtigte Frage ist, wie oft so etwas in der Praxis passiert. Und da lautet die Antwort "überraschend oft". Die Wahrscheinlichkeit hängt natürlich davon ab, wie viele Threads beteiligt sind und wie häufig diese auf die gemeinsamen Datenstrukturen zugreifen. Aber letztendlich ist das auch nur ein Glücksspiel, weil zwei konkurrierende Zugriffe am Tag schon ausreichen können.

Man neigt in der Entwicklungsphase vielleicht dazu, es erst einmal ohne Synchronisierung zu probieren, vielleicht um eine bestimmte neue Technik auszuprobieren. Aber auch hier kann es sein, dass man nicht nachvollziehbare Fehler beobachtet und nach stundenlangem Suchen an den verdächtigen Stellen doch feststellt, dass es "nur" so eine doofe Wettlaufsituation ist. Daher ganz klar die Empfehlung: gleich richtig machen!


Zurück