1. Einleitung
In einer Multithread-Anwendung ist eine gemeinsame Ressource alles, worauf zwei oder mehr Threads gleichzeitig zugreifen können. Das kann sein:
- Eine Variable (zum Beispiel ein globaler Zähler oder eine Liste).
- Ein Objekt (zum Beispiel eine Collection von Benutzern).
- Eine Datei oder ein Netzwerk-Socket.
- Jede Datenstruktur, die von verschiedenen Threads verändert wird.
In unserer Konsolenanwendung werden wir am häufigsten auf Variablen und Objekte stoßen, die zwischen Threads "geteilt" werden.
Analogie
Stell dir zwei Personen vor, die gleichzeitig versuchen, etwas in dasselbe Heft zu schreiben, ohne sich abzusprechen. Im besten Fall wird die Schrift krakelig, im schlimmsten Fall schreibt einer die Daten des anderen überschreibt. In der Programmierung ist die Situation genau dieselbe, nur dass diese "Personen" Threads sind.
Kurz zu typischen Ressourcen mit race conditions
In der folgenden Tabelle — die häufigsten Ressourcen, die gefährdet sind, wenn mehrere Threads gleichzeitig darauf zugreifen:
| Ressource | Problemgruppen | Beispiel |
|---|---|---|
| Variablen vom Typ int | Falsches Erhöhen/Verkleinern | Zähler, Indizes |
| Gemeinsame Collections | Verlust/Beschädigung von Elementen, Exceptions | Gemeine Bestellliste |
| Objekte | Inkonsistente Zustandsänderungen | Flags, Properties |
| Dateien | Beschädigte Daten, falsches Lesen/Schreiben | Log-Dateien, Konfiguration |
2. Race condition: wie zeigt sie sich?
Beispiel: Besuchszähler
Angenommen, wir wollen zählen, wie oft ein Benutzer auf einen Button geklickt hat (oder in unserem Beispiel, wie oft verschiedene Threads eine Variable inkrementiert haben). Eine einfache Version des Codes:
int counter = 0;
void Increment() {
counter++;
}
Jetzt erstellen wir zwei Threads, in denen jeweils 100_000 Mal Increment() aufgerufen wird:
using System;
using System.Threading;
class Program
{
static int counter = 0;
static void Increment()
{
for (int i = 0; i < 100_000; i++)
{
counter++;
}
}
static void Main()
{
Thread t1 = new Thread(Increment);
Thread t2 = new Thread(Increment);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"Erwartet: 200000, erhalten: {counter}");
}
}
Wie oft sollte counter logischerweise erhöht werden? 200000! Aber wenn du diesen Code mehrmals ausführst, wirst du fast sicher unterschiedliche Zahlen sehen: 185000, 192500, 198765… Warum?
3. Warum ist counter++ keine atomare Operation?
Wie counter++ wirklich funktioniert
In C# und anderen Hochsprachen wird das Programm in eine Reihe von Maschinenanweisungen übersetzt. Leider wird der Operator counter++ nicht zu einem magischen Befehl "addiere 1 zur Variable". So passiert es wirklich:
- Der Thread LIEST den Wert aus dem Speicher (counter).
- Er erhöht diesen Wert um 1 (im Prozessorregister).
- Er schreibt den neuen Wert zurück in den Speicher (counter).
Wenn zwei Threads das fast gleichzeitig tun, können beide denselben alten Wert lesen, ihn erhöhen und beide das Ergebnis zurückschreiben — ein Inkrement geht verloren.
Rennen-Szenario
Angenommen, counter war 1000. Beide Threads lesen diesen Wert (Schritt 1), erhöhen ihn lokal auf 1001 (Schritt 2) und schreiben dann beide 1001 zurück (Schritt 3). Schrecklich: ein Inkrement ist einfach verschwunden!
Visualisierung des Rennens
| Zeitpunkt | Thread 1 | Thread 2 | Wert counter |
|---|---|---|---|
| 1 | Lesen 1000 | 1000 | |
| 2 | Lesen 1000 | 1000 | |
| 3 | Inkrement auf 1001 | Inkrement auf 1001 | 1000 (noch keine Schreiboperation) |
| 4 | Schreiben 1001 | 1001 | |
| 5 | Schreiben 1001 | 1001 |
Am Ende haben zwei Inkremente den Wert nur um 1 erhöht!
4. Noch ein paar Beispiele: "unsichtbare Bugs"
Was, wenn das race condition nicht mit Zahlen passiert?
Stellen wir uns jetzt vor, mehrere Threads fügen Elemente derselben Liste hinzu:
using System;
using System.Collections.Generic;
using System.Threading;
class Program
{
static List<int> numbers = new List<int>();
static void AddNumbers()
{
for (int i = 0; i < 10000; i++)
{
numbers.Add(i);
}
}
static void Main()
{
Thread t1 = new Thread(AddNumbers);
Thread t2 = new Thread(AddNumbers);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"Erwartet: 20000, erhalten: {numbers.Count}");
}
}
Auch dieser Code kann bei jedem Lauf unterschiedliche Ergebnisse liefern: manchmal stürzt das Programm mit einer Exception ab, manchmal siehst du weniger Elemente als erwartet.
Warum? Weil die Collection List<T> standardmäßig nicht thread-safe ist. Wenn zwei Threads gleichzeitig Add aufrufen, kann die interne Struktur der Liste beschädigt werden.
5. Atomare Operationen
Was ist eine atomare Operation?
Eine Operation heißt atomar, wenn sie als Ganzes ausgeführt wird, ohne dass sie von einem anderen Thread mitten drin unterbrochen werden kann. Es ist wie eine "Transaktion": entweder alles passiert, oder nichts.
- Zuweisungsoperationen für Typen wie int (myVar = 42;) sind auf den meisten Plattformen atomar (sofern es sich nicht um ein riesiges Objekt handelt).
- Dagegen ist counter++ nicht atomar — das sind drei aufeinanderfolgende Schritte.
Spezielle atomare Operationen
In .NET gibt es spezielle Klassen für atomare Operationen: zum Beispiel Interlocked. Diese Methode werden wir in kommenden Vorlesungen anschauen.
Beispiel für atomaren Inkrement mit Interlocked.Increment:
using System.Threading;
int counter = 0;
Interlocked.Increment(ref counter); // atomare Operation!
6. Warum sind race conditions schwer zu finden?
Race conditions sind gefährlich, weil:
- Sie sich nur unter hoher Last bemerkbar machen können.
- Sie nicht in 100% der Fälle auftreten, sondern vielleicht in 5% oder sogar 0.01% der Fälle.
- Sie "zufällig" abstürzen und dort auftauchen, wo keiner sie erwartet.
Wie erkennt man das Problem?
Wenn du bei jedem Programmstart unterschiedliche (und falsche) Ergebnisse bekommst, solltest du an ein race condition denken.
Programmierer-Witze
"Wenn ein Fehler selten auftritt und sich durch Hinzufügen von Thread.Sleep(50) beheben lässt — dann hast du größere Probleme, als du denkst."
7. Nützliche Feinheiten
Synchronisation
Um kritische Sektionen (Codeabschnitte, in denen mit gemeinsamen Ressourcen gearbeitet wird) zu schützen, muss man sie synchronisieren. Das ist aber Thema der nächsten Vorlesungen. Jetzt ist das Wichtigste: lernen, das Problem zu bemerken und zu erklären.
Typische Fehler von Anfängern
Viele Einsteiger denken: "Bei mir ist counter++ — was kann da schon schiefgehen?" Leider, sobald du mehr als einen Thread hast, kann alles schiefgehen! Selbst scheinbar einfache Dinge: Lesen und Schreiben von Variablen, Hinzufügen von Elementen zu einer Liste, Ändern des Objektzustands und vieles mehr.
Die Rolle von race conditions in realer Entwicklung
In modernen Multithread-Anwendungen (z.B. in serverseitigen APIs, Web-Request-Verarbeitung, Spielen und mobilen Apps) gibt es fast immer gemeinsame Ressourcen. Ohne Synchronisation führen race conditions zu falscher Auftragverarbeitung, Abstürzen, Speicherlecks und großen Schwierigkeiten beim Debuggen.
Bei Vorstellungsgesprächen für Positionen als middle/senior wird man dich sicher fragen: "Was ist ein race condition? Wie verhindert man ihn?" Wenn du die oben gezeigten Beispiele bringen und die Mechanik erklären kannst — werden die Interviewer zufrieden sein!
GO TO FULL VERSION