CodeGym /Kurse /C# SELF /Tiefgehende Analyse von Race Conditions

Tiefgehende Analyse von Race Conditions

C# SELF
Level 57, Lektion 0
Verfügbar

1. Komplexere Beispiele für Race Conditions

Race Condition ist eine der tückischsten Fehlerklassen. Warum? Weil sie meist selten auftreten, von CPU-Geschwindigkeit, OS-Latenzen und zufälligen Thread-Switches abhängen und genau dann auftreten, wenn du eigentlich schon nach Hause wolltest. Solche Bugs fängt kein statischer Analyzer. Unit-Tests finden sie nicht (außer du wärst Hellseher), aber irgenwann... treten sie im Production-Umfeld auf.

Um solche bösen Überraschungen zu vermeiden, muss man verstehen, wie Race Conditions entstehen und wie man damit umgeht.

Beispiel 1. Eine Bank ohne Bankethik

Angenommen, wir haben eine ganz einfache Bankkonto-Klasse:

public class Account
{
    public int Balance = 0;

    public void Deposit(int amount)
    {
        Balance += amount;
    }

    public void Withdraw(int amount)
    {
        Balance -= amount;
    }
}

Stell dir vor, zwei Threads versuchen gleichzeitig, das Konto um 100 und 200 Euro aufzustocken und dann zieht jeder nacheinander 50 Euro ab. In einer Singlethread-Welt wäre der Saldo immer: (0 + 100 + 200 - 50 - 50) = 200.

Und in multithreaded Realität? Führen wir ein Experiment durch:

var acc = new Account();

var t1 = new Thread(() => {
    acc.Deposit(100);
    acc.Withdraw(50);
});
var t2 = new Thread(() => {
    acc.Deposit(200);
    acc.Withdraw(50);
});

t1.Start(); t2.Start();
t1.Join(); t2.Join();

Console.WriteLine(acc.Balance); // Und hier kann jeder Lauf ein anderes Ergebnis liefern!

Erklärung:
Wenn beide Threads Balance als 0 lesen, dann der eine 100 schreibt und der andere 200, und zwischen diesen Aktionen Thread-Switches passieren — kann der Saldo „Geld verlieren“ oder „dazukriegen“. Eine Bank ohne Locks ist ein Traum für Betrüger!

Beispiel 2. Eine Flag-Variable

Sehr häufiger Fehler — eine Flag-Variable als Zustandsindikator nutzen:

bool isReady = false;

void Worker()
{
    while (!isReady)
    {
        // warten...
    }
    // etwas tun
}

Ein anderer Thread könnte isReady = true setzen. Auf den ersten Blick sicher: was kann schiefgehen? Überraschung: selbst Lesen und Schreiben einer bool-Variable kann unsicher sein! Grund: Compiler-Optimierungen, CPU-Cache, Instruction Reordering auf Mehrkern-Systemen.

Was kann passieren?
Ein Thread sieht die Änderung möglicherweise nie und rotiert endlos in der Schleife, obwohl ein anderer Thread längst isReady = true gesetzt hat. Zum Übergeben von Flags zwischen Threads solltest du immer spezielle Primitiven verwenden (volatile, Interlocked, Events etc. — siehe auch offizielle Doku).

Beispiel 3. Null-Check und Objekt-Erstellung

Stell dir ein Singleton vor:

public class Singleton
{
    private static Singleton _instance;

    public static Singleton Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = new Singleton();
            }
            return _instance;
        }
    }
}

Der Code wirkt trivial? Wenn mehrere Threads gleichzeitig Instance aufrufen, können durchaus mehrere Instanzen erstellt werden! Klassisches double-check-Problem ohne Synchronisation: das Feld _instance wird zur Race-Quelle, wenn man lock weglässt.

2. Wie Race Conditions im Code entstehen

Die Operation „Read-Modify-Write“ — die Anatomie des Übels

Schauen wir uns nochmal Increment an: counter++

Auf CPU-Ebene ist das:

  1. Aus dem Speicher lesen: register = counter;
  2. Erhöhen: register = register + 1;
  3. Zurückschreiben: counter = register;

Was passiert, wenn zwei Threads diesen Block nahezu gleichzeitig ausführen?

  • Beide Threads lesen counter = 0.
  • Beide erhöhen ihr Register auf 1.
  • Beide schreiben 1 zurück.

Ergebnis: bei zwei Inkrementen ist der Wert nur um 1 gestiegen! Einer der Inkremente wurde „gefressen“ — und keiner hat's bemerkt.

Visualisierung der „Kollision“ zweier Threads


Thread A        |   Thread B        |   counter-------------------------------------------
Liest (0)       |                   |   0
                |   Liest (0)       |   0
Inkrement (1)   |                   |   0
                |   Inkrement (1)   |   0
Schreibt (1)    |                   |   1
                |   Schreibt (1)    |   1  <-- Ups! Erwartet 2, bekommen 1

Warum sind Race Conditions so schwer zu fangen?

  • Race Conditions „verstecken sich“. Fügst du ein paar Console.WriteLine ein, kann der Fehler verschwinden, weil Threads anders laufen.
  • Der Bug hängt von Kernanzahl, Auslastung, OS- und .NET-Version ab. Gestern lief alles, heute bricht's zusammen.
  • Das Ergebnis ist nicht deterministisch: der Fehler tritt nur unter „speziellen Bedingungen“ auf.
  • Debuggen macht nicht wirklich Spaß.

3. Race Conditions in Collections und .NET-Klassen

Race Conditions treten nicht nur bei Variablen auf. Collections, Queues und sogar Standard-.NET-Klassen sind nicht immer thread-safe.

Beispiel: List<T> ist nicht thread-safe

Wenn zwei Threads gleichzeitig .Add() auf einer normalen List<T> aufrufen, kann das katastrophale Folgen haben:

var list = new List<int>();
var tasks = new List<Task>();

for (int t = 0; t < 10; t++)
{
    int threadNum = t;
    tasks.Add(Task.Run(() => {
        for (int i = 0; i < 1000; i++)
            list.Add(threadNum * 1000 + i);
    }));
}
Task.WaitAll(tasks.ToArray());
Console.WriteLine(list.Count); // Sehr wahrscheinlich < 10000

Die Race Condition entsteht inside Add(): die Collection kann das interne Array vergrößern, Elemente kopieren, während ein anderer Thread gerade hinzufügt... Ergebnis — verlorene Daten, Exceptions oder beschädigte Collection.

Beispiel: Mehrere Threads verwenden denselben Indexer

var array = new int[10];
void Worker(int index, int value)
{
    array[index] = value;
}

Parallel.Invoke(
    () => Worker(5, 1),
    () => Worker(5, 2)
);

Als Resultat kann in array[5] entweder 1 oder 2 stehen — je nachdem, welcher Thread zuletzt geschrieben hat. Manchmal ist das gewollt, öfter jedoch Quelle subtiler Bugs.

4. Nützliche Feinheiten

Race Condition und nicht-atomare Operationen

Sei besonders vorsichtig, wenn eine Operation klein aussieht, aber nicht-atomar ist.

  • i++, i--
  • a = b
  • myObject.Property = value
  • „Flag geprüft — Wert geändert“
  • „Objekt geholt — inneren Zustand geändert“

Das alles sind keine atomaren Aktionen! Andere Threads können zwischen den Mikro-Schritten eingreifen.

Mythen und Fallstricke rund um Race Conditions

Mythos 1: „Wenn die Variable ein int ist, ist sie sicher, ist ja ein Primitiv“.
Realität: Selbst Operationen auf int sind nicht automatisch atomar, wenn sie nicht als volatile markiert sind oder nicht über spezielle APIs laufen.

Mythos 2: „Wenn ein Thread schreibt und ein anderer nur liest — alles gut“.
Realität: Nein! In .NET garantieren Collections keine Konsistenz bei gleichzeitigem Lesen und Schreiben; du kannst defekte Objekte oder seltsame Exceptions bekommen.

Mythos 3: „Race Conditions treten nur in sehr stark belasteten Services auf“.
Realität: Auch in kleinen Tools, Spielen oder Desktop-Apps können Race Conditions auftreten. Sie sind nur seltener oder nicht sofort offensichtlich.

Wie schütze ich mich vor Race Conditions?

  • Nutze Synchronisationsprimitiven (lock, Mutex, Monitor usw.).
  • Für einfache Increment/Decrement benutze Interlocked (siehe Dokumentation).
  • Für Collections verwende thread-safe Klassen (ConcurrentBag, ConcurrentDictionary usw. — siehe Dokumentation).
  • Verwende keine Flags/Zustände ohne Schutz (volatile, Events, Synchronisationsprimitive).

Wann tritt Race Condition auf

Szenario Kann Race Condition auftreten? Wie schützen
Mehrere Threads lesen Daten Nein (wenn keine Änderung vorkommt) -
Ein Thread schreibt, ein anderer liest Ja
lock/volatile
Mehrere Threads schreiben Ja
lock/Interlocked
Mehrere Threads verändern eine Collection Ja Thread-sichere Collections
Mehrere Threads nutzen ein Flag Ja volatile, lock, Events

Atomarität ist keine Allzwecklösung

Manchmal retten selbst atomare Operationen nicht vor logischen Rennen.

Beispiel:

if (!cache.ContainsKey(key))
{
    cache[key] = GetData(key);
}

Wenn zwei Threads gleichzeitig in das if eintreten, sehen beide den Schlüssel als nicht vorhanden und erzeugen den Wert — der zweite überschreibt den ersten! Hier helfen weder atomare int-Operationen noch Interlocked — du brauchst eine vollständige Sperre oder eine spezialisierte Methode (z.B. GetOrAdd von ConcurrentDictionary).

4. Typische Fehler von Studierenden beim Umgang mit Race Conditions

Fehler Nr.1: Sie denken, lock sei nur für „große Sachen“.
Tatsächlich kann selbst eine einfache Variable ohne Schutz kaputt gehen.

Fehler Nr.2: Sie sperren das falsche Objekt.
Zum Beispiel eine String-Instanz oder ein Objekt, das außerhalb der Klasse zugänglich ist. Am Ende funktioniert die Synchronisation nicht wie gedacht.

Fehler Nr.3: Sie verlassen sich darauf, dass das Ergebnis „fast immer korrekt“ ist.
Nur weil ein Bug selten auftritt, heißt das nicht, dass du dir den Schutz sparen kannst.

Fehler Nr.4: Sie nutzen async-Methoden ohne Synchronisation.
Sie hoffen auf Glück und bekommen chaotische Fehler an unerwarteten Stellen.

Fehler Nr.5: Sie schreiben ein Singleton mit doppelter Prüfung, aber ohne lock.
Statt einer einzigen Instanz tauchen mehrere Objekte im Speicher auf und Debugging wird zum Albtraum.

2
Aufgabe
C# SELF, Level 57, Lektion 0
Gesperrt
Synchronisierung des Zugriffs auf eine Ressource mit `Monitor`
Synchronisierung des Zugriffs auf eine Ressource mit `Monitor`
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION