CodeGym /Kurse /C# SELF /Sperren: lock und die...

Sperren: lock und die Klasse Monitor

C# SELF
Level 56 , Lektion 1
Verfügbar

1. Einführung

Betrachten wir eine bekannte Situation beim Arbeiten mit Threads. Nehmen wir an, wir haben einen gemeinsamen Zähler für Erfolge in einer sehr einfachen Anwendung.

int counter = 0;

void IncrementCounter()
{
    for (int i = 0; i < 100_000; i++)
    {
        counter++; // Nicht atomar!
    }
}

// Wir starten zwei Threads:
Thread t1 = new Thread(IncrementCounter);
Thread t2 = new Thread(IncrementCounter);

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

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

Console.WriteLine($"Counter: {counter}");

Führe diesen Code mehrmals aus. Fast nie siehst du 200_000! Warum? Zwei Threads stören sich ständig gegenseitig, manchmal lesen beide die Variable gleichzeitig, erhöhen sie — und schreiben dasselbe Ergebnis zurück. Am Ende gehen einige Inkremente "verloren".

Das ist ein Race Condition. Ohne Einhaltung einer "Warteschlange" kämpfen Threads praktisch um die Daten.

Kritische Sektion: was ist das?

Kritische Sektion — das ist ein Codeabschnitt, den immer nur ein Thread gleichzeitig ausführen darf. Zurück zur Küchen-Analogie: das ist wie ein offener Wasserhahn — wenn zwei Leute gleichzeitig versuchen, sich am selben Waschbecken zu waschen, sind Schweiß und Zahnpasta garantiert überall. Vereinbaren wir, dass man nacheinander ins Bad geht!

In unserem Beispiel ist die kritische Sektion die Zeile counter++.

2. Das Schlüsselwort lock

In C# gibt es eine knappe und sichere Möglichkeit, eine kritische Sektion zu erstellen — das Schlüsselwort lock. Es verbirgt die komplizierte Arbeit mit Synchronisationsprimitiven und sorgt dafür, dass nur ein Thread gleichzeitig in den geschützten Codeblock eintreten kann.

Wie man lock benutzt

Syntax:

lock (lockerObject)
{
    // Code, der nur von einem Thread gleichzeitig ausgeführt werden darf
}

lockerObject — das ist ein beliebiges Objekt, das über die Lebenszeit des Programms existiert. Üblicherweise macht man es so:

private static object locker = new object();

Beachte: verwende niemals für diesen Zweck Strings, Zahlen oder Objekte, auf die irgendjemand anders zufällig zugreifen kann! Nur private Objekte, die du sicher sonst nirgends benutzt.

Lassen wir unser Beispiel korrekt laufen

private static object locker = new object();
int counter = 0;

void IncrementCounter()
{
    for (int i = 0; i < 100_000; i++)
    {
        lock (locker)
        {
            counter++; // Jetzt ist das atomar!
        }
    }
}

Jetzt werden zwei oder zehn Threads diesen Codeabschnitt nacheinander betreten. Das Ergebnis ist perfekt 200_000. Die Kätzchen sind zufrieden!

3. Wie lock innen funktioniert? Die Klasse Monitor

Intern arbeitet das Schlüsselwort lock mit der Klasse System.Threading.Monitor. Das ist ein echter Sekretär, der nur mit einem speziellen Pass reinlässt.

Die Syntax, die lock entspricht (aber "nackter" ist):

Monitor.Enter(locker);
try
{
    // Kritische Sektion
}
finally
{
    Monitor.Exit(locker);
}

Der wichtigste Unterschied — du bist verpflichtet, selbst sicherzustellen, dass Monitor.Exit aufgerufen wird. Normalerweise benutzt man dafür try...finally. Wenn man den Exit()-Aufruf vergisst, bleibt der Thread ewig "drinnen" und weitere Threads warten ewig — das Programm hängt wie ein alter Windows-PC beim Installieren von Updates.

Tabelle: lock vs. manuelles Monitor

Methode Fehlersicherheit Einfacher zu schreiben Flexibilität
lock(obj)
Ja Ja Nein
Monitor
Nur mit try/finally Nein Ja

In 99% der Fälle benutze lock. Manuelles Monitor brauchst du nur, wenn maximale Flexibilität nötig ist: zum Beispiel, wenn du eine Lock-Methode mit Timeout implementieren willst.

4. Argumente für lock: was geht, was nicht?

Ein sehr häufiger Anfängerfehler: für die Sperre einen String oder ein anderes "sichtbares" Objekt verwenden. Zum Beispiel:

lock ("mylock") { /*...*/ } // Sehr schlecht!

Das Problem ist, dass Strings internisiert werden (einzigartig für die ganze Anwendung), und so kann es leicht zu Konflikten mit Drittanbieter-Bibliotheken kommen und am Ende zu einem "toten" Programmzustand. Verwende immer private Objekte:

private readonly object myLock = new object();

lock (myLock)
{
    // nur dein Code kennt myLock
}

5. lock: Beispiel mit Konsolenausgabe

Zeit zum Üben! Erstellen wir eine Mini-Anwendung, in der zwei Threads Zeilen ausgeben, aber der Zugriff auf die Konsole ist ebenfalls synchronisiert — damit sich der Text nicht vermischt.

private static object consoleLock = new object();

void PrintMessages(string name)
{
    for (int i = 0; i < 5; i++)
    {
        lock (consoleLock)
        {
            Console.WriteLine($"{name}: Nachricht {i + 1}");
            Thread.Sleep(50); // Wir simulieren Verarbeitung
        }
    }
}

Thread t1 = new Thread(() => PrintMessages("Thread 1"));
Thread t2 = new Thread(() => PrintMessages("Thread 2"));

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

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

Ergebnis: die Zeilen erscheinen sauber nacheinander, kein Durcheinander. Diese Vorgehensweise wird oft fürs Logging verwendet, damit man keine "Krakeleien" in den Logs liest.

6. Nützliche Feinheiten

Manuelle Steuerung der Sperre: fortgeschrittenes Monitor

Wenn das Standard-lock nicht ausreicht (z.B. wenn du versuchen willst, in die Sektion einzutreten ohne ewig zu warten), kannst du Monitor.TryEnter verwenden.

if (Monitor.TryEnter(locker, 100)) // 100 ms Wartezeit
{
    try
    {
        // Kritische Sektion
    }
    finally
    {
        Monitor.Exit(locker);
    }
}
else
{
    Console.WriteLine("Konnte die Sperre in 100 Millisekunden nicht bekommen");
}

Das ist praktisch, wenn dein Programm nicht "hängen" soll — zum Beispiel kannst du dem Benutzer eine Nachricht zeigen oder etwas Nützliches tun, während der Zugriff auf die gemeinsame Ressource belegt ist.

Visualisierung: wie die Sperre arbeitet (Diagramm)

flowchart LR
    A[Thread 1: möchte in die kritische Sektion eintreten]
    B[Thread 2: möchte in die kritische Sektion eintreten]
    C[locker ist frei]
    D[Thread 1 führt Code innerhalb von lock aus]
    E[Thread 2 wartet]
    F[Thread 1 hat lock verlassen]
    G[Thread 2 bekommt Zugriff]
    
    A -- Überprüft locker --> C
    C -- locker frei --> D
    B -- Überprüft locker --> D
    D -- lock belegt --> E
    D -- Arbeit beendet --> F
    F -- locker freigegeben --> G
    E -- locker jetzt frei --> G

Sperren und Performance

Sperren funktionieren einfach: nur ein Thread zur Zeit darf den Code zwischen den geschweiften Klammern ausführen. Das ist großartig für Datenintegrität, aber... je mehr Threads in der Warteschlange stehen, desto langsamer wird alles. Deshalb ist Synchronisation keine Allheilmittel: versuche, kritische Sektionen so klein wie möglich zu halten.

Lifehack: wenn die Ausführung der kritischen Sektion Bruchteile einer Millisekunde dauert — super. Wenn dort lange Berechnungen, I/O, Netzwerk- oder Dateioperationen stattfinden — führe sie besser außerhalb des lock aus. Lese/berechne zuerst, und aktualisiere dann schnell den gemeinsamen Wert innerhalb der Sperre.

Beim Vorstellungsgespräch und im echten Leben

In jeder ernsthaften Anwendung mit Threads werden Arbeitgeber dich sicher fragen: "Was tun, wenn zwei Threads auf dieselbe Variable zugreifen?" ZeigCode mit einer Sperre — und dein Lebenslauf verschwindet nicht im schwarzen Kasten des HR-Automaten.

In der Praxis, besonders in hochbelasteten Systemen, verwendet man auch fortgeschrittenere Synchronisationsmechanismen — aber lock und Monitor bleiben die Goldstandards für einfache Fälle.

7. Besonderheiten beim Einsatz von Sperren und typische Fehler

Der häufigste Fehler — das "Vergessen", dasselbe Objekt als Lock zu benutzen. Zum Beispiel:

void Foo() { lock (a) { ... } }
void Bar() { lock (b) { ... } }

Wenn in beiden Methoden dieselbe Variable gesteuert wird, aber die Objekte a und b unterschiedlich sind, hast du nur eine Schein-Sperre erstellt — Threads arbeiten gleichzeitig an der Variable!

Fazit: benutze immer dasselbe Objekt, um dieselben Daten zu schützen.

Ein weiterer Fall — eine zu "weite" Sperre. Zum Beispiel lock (this) innerhalb einer normalen Klasse, wenn du nicht sicher bist, dass niemand von außen dieses Objekt für eine Sperre nutzt. Das kann ebenfalls zu Deadlocks und anderen lustigen, aber unerwünschten Bugs führen.

Und zuletzt: BLOCKIERE NICHT lange oder externe Operationen (Datei-, Netzwerkzugriffe) innerhalb von lock. Du riskierst, anderen Threads für lange Zeit den Zugriff zu verwehren, was die Performance senkt. Kritische Sektion = nur das, was wirklich nicht parallelisiert werden darf!

Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION