Menu

C# lock-Anweisung: Race Conditions, Interlocked und Deadlocks

Die lock-Anweisung lässt immer nur einen Thread gleichzeitig einen Codeblock ausführen. Sieh, wie eine Race Condition einen Zähler verfälscht, behebe sie mit lock, lerne, auf welches Objekt du lockst, nimm Interlocked für einfache Zähler, vermeide Deadlocks durch die Reihenfolge von Locks und nimm SemaphoreSlim, wenn der Code darin await verwendet.

Diese Seite enthält ausführbare Editoren - bearbeiten, ausführen und Ausgabe sofort sehen.

Wenn mehrere Threads gleichzeitig dieselben Daten lesen und schreiben, können Aktualisierungen verloren gehen oder halb fertig gesehen werden. Die lock-Anweisung macht einen Codeblock wechselseitig ausschließend: Solange ein Thread darin ist, wartet jeder andere Thread, der ein lock auf dasselbe Objekt erreicht, bis er an der Reihe ist.

Das Problem: eine Race Condition

Vier Tasks addieren jeweils 100.000 zu einem gemeinsamen Zähler. Die Antwort sollte 400.000 sein:

Beispielausgabe:

Expected 400000, got 245609

Die Summe fällt meist zu klein aus, und bei jedem Lauf um einen anderen Betrag (auf einem Rechner mit einem Kern kann sie gelegentlich stimmen, was solche Bugs schwer zu finden macht). count++ sieht aus wie eine Operation, sind aber drei: count lesen, eins addieren, zurückschreiben. Zwei Threads können beide 500 lesen, beide 501 berechnen und beide 501 schreiben, und eine Erhöhung ist weg.

Die Lösung: lock

Umschließe das Lesen, Ändern und Schreiben mit einem lock auf ein gemeinsames Objekt:

Ausgabe:

Expected 400000, got 400000

Jetzt kann immer nur ein Thread gleichzeitig im Block sein, jede Erhöhung ist also abgeschlossen, bevor die nächste den Wert liest. Der Lock garantiert außerdem Sichtbarkeit: Ein Thread, der den Lock betritt, sieht jeden Schreibvorgang, den der vorherige Inhaber vor dem Verlassen gemacht hat.

Der Schutz funktioniert nur, wenn jeder Zugriff auf count über denselben Lock läuft. Ein einziges ungeschütztes count++ anderswo im Programm bringt die Race Condition zurück.

Wozu lock kompiliert wird

lock ist die Kurzform für die Klasse Monitor, mit einem try/finally, damit der Lock auch freigegeben wird, wenn der Block wirft:

lock (sync)
{
    count++;
}

// is compiled roughly as:
bool taken = false;
try
{
    Monitor.Enter(sync, ref taken);
    count++;
}
finally
{
    if (taken) Monitor.Exit(sync);
}

Monitor bietet außerdem TryEnter(obj, timeout), das nach einem Timeout aufgibt, statt ewig zu warten, und Wait/Pulse zur Signalisierung zwischen Threads. Ein Thread, der einen Lock bereits hält, kann ihn erneut betreten (Locks sind reentrant), eine gelockte Methode kann also eine andere gelockte Methode auf demselben Objekt aufrufen.

Das Lock-Objekt wählen

Das Objekt in lock (...) ist nur ein Zeichen, auf das sich Threads einigen. Die Regeln:

  • Nimm ein privates, schreibgeschütztes Feld vom Typ object. private readonly object sync = new object();. Privat bedeutet, dass kein externer Code denselben Lock nehmen kann; readonly bedeutet, dass das Zeichen nicht ausgetauscht werden kann, während ein Thread es hält.
  • Nie lock (this). Jeder, der eine Referenz auf dein Objekt hält, kann ebenfalls darauf locken, und sein Code blockiert dann deinen oder gerät mit ihm in einen Deadlock.
  • Nie auf einen String oder typeof(...) locken. String-Literale sind interniert (jedes "orders" im Prozess ist dasselbe Objekt), und Type-Objekte werden in der ganzen App geteilt, unabhängiger Code kann also am Ende um denselben Lock konkurrieren.
  • Nie auf einen Werttyp locken. lock (count) auf einem int kompiliert nicht (Fehler CS0185), und ihn von Hand zu boxen erzeugt jedes Mal ein neues Objekt, würde also nichts sperren.
  • Nimm einen Lock pro Datenmenge, die konsistent bleiben muss, ein statisches Lock-Feld für statische Daten, ein Instanzfeld für Daten pro Instanz.

Mehrschrittige Operationen schützen

lock wird dort am meisten gebraucht, wo eine Prüfung und eine Aktion zusammen passieren müssen. Ein threadsicheres Konto zeigt sowohl das Muster „prüfen, dann handeln“ als auch eine konsistente Momentaufnahme zweier Felder:

Ausgabe:

10 left after 33 withdrawals

Ohne den Lock könnten zwei Threads beide einen Kontostand von 40 sehen, beide die Prüfung bestehen und beide 30 abheben, und das Konto stünde bei minus 20. Die Methode Summary lockt ebenfalls, damit sie nie einen balance von nach einer Abhebung zusammen mit einer Anzahl withdrawals von davor meldet.

Dictionary<TKey, TValue> und List<T> sind ebenfalls nicht threadsicher: Gleichzeitige Schreibvorgänge können ihre internen Arrays beschädigen, nicht nur Aktualisierungen verlieren. Schütze sie mit einem Lock oder nimm ConcurrentDictionary<TKey, TValue> und die anderen Typen in System.Collections.Concurrent.

Interlocked für einzelne Werte

Wenn der gemeinsame Zustand eine Ganzzahl oder eine Referenz ist und die Aktualisierung ein einziger Schritt, erledigt die Klasse Interlocked das atomar ohne Lock:

Ausgabe:

Visits: 10000
Bytes: 5120000
Peak: 10000

Increment, Decrement, Add und Exchange entsprechen atomaren Prozessoroperationen (auf x64 ein einzelner Befehl). CompareExchange(ref location, newValue, expected) schreibt nur, wenn die Stelle noch expected enthält, und gibt zurück, was es gefunden hat, womit du jede Aktualisierung als Wiederholungsschleife bauen kannst. Alles, was zwei Variablen gleichzeitig berührt, braucht weiterhin ein lock.

Deadlocks

Ein Deadlock entsteht, wenn zwei Threads jeweils einen Lock halten, den der andere braucht:

// Thread 1                          // Thread 2
lock (accountA)                      lock (accountB)
{                                    {
    lock (accountB) { /* ... */ }        lock (accountA) { /* ... */ }
}                                    }

Nimmt Thread 1 accountA, während Thread 2 accountB nimmt, wartet danach jeder ewig auf den anderen. Es gibt keine Exception und keinen Timeout; das Programm hängt. Eine Überweisung zwischen zwei Konten, die zuerst „from“ und dann „to“ lockt, erzeugt genau das, wenn zwei gegenläufige Überweisungen gleichzeitig laufen.

Die üblichen Abwehrmaßnahmen:

  • In einer festen globalen Reihenfolge locken. Bei einer Überweisung zuerst das Konto mit der kleineren ID locken, egal in welche Richtung das Geld fließt.
  • Locks kurz halten und langsame Arbeit (I/O, Logging, Netzwerkaufrufe) außerhalb erledigen.
  • Keinen unbekannten Code aufrufen, während du einen Lock hältst: Events, Callbacks und virtuelle Methoden nehmen vielleicht eigene Locks.
  • Monitor.TryEnter mit Timeout verwenden, wo ein Hängen schlimmer wäre als ein Fehlschlag.

Kein await in lock: nimm SemaphoreSlim

await ist in einem lock-Block nicht erlaubt (Compilerfehler CS1996). Nach einem await kann die Methode auf einem anderen Thread weiterlaufen, und ein Monitor-Lock muss von dem Thread freigegeben werden, der ihn genommen hat. Für async-Code wirkt SemaphoreSlim mit einem Zähler von 1 als async-tauglicher Lock:

Ausgabe:

5 saved, one at a time

Kombiniere WaitAsync immer mit Release in einem finally, sonst lässt eine Exception das Tor für immer geschlossen. Ein SemaphoreSlim(3, 3) lässt drei Aufrufer gleichzeitig hinein, so begrenzt du gleichzeitige Anfragen an eine API mit Rate Limit.

System.Threading.Lock (.NET 9)

.NET 9 mit C# 13 fügt einen eigenen Typ System.Threading.Lock hinzu. Wenn das Objekt in einer lock-Anweisung ein Lock ist, verwendet der Compiler statt Monitor dessen schnellere API EnterScope:

private readonly Lock sync = new Lock();

public void Add(decimal amount)
{
    lock (sync)          // uses Lock.EnterScope(), not Monitor
    {
        balance += amount;
    }
}

Die Regeln für die Wahl des Objekts bleiben dieselben. In früheren Versionen ist private readonly object die richtige Wahl.

Häufige Fehler

  • Manche Zugriffe locken, aber nicht alle. Jedes Lesen und Schreiben der gemeinsamen Daten muss denselben Lock nehmen.
  • lock (this), lock (typeof(X)), lock ("name"). Externer Code kann denselben Lock nehmen.
  • Langsames I/O in einem Lock. Jeder andere Thread wartet; halte den Block kurz.
  • Zwei Locks an verschiedenen Stellen in verschiedener Reihenfolge nehmen. Der klassische Deadlock.
  • lock um await verwenden. Es kompiliert nicht; nimm SemaphoreSlim.
  • Ein Lock pro Aufruf. lock (new object()) schützt nichts; das Objekt muss geteilt werden.

Häufig gestellte Fragen

Was macht lock in C#?

lock (obj) { ... } lässt für ein bestimmtes Lock-Objekt immer nur einen Thread gleichzeitig den Block ausführen. Ein zweiter Thread, der ein lock auf dasselbe Objekt erreicht, wartet, bis der erste den Block verlässt. Der Lock wird auch freigegeben, wenn der Block wirft, weil lock zu Monitor.Enter und Monitor.Exit in einem try/finally kompiliert wird.

Auf welches Objekt sollte ich in C# locken?

Auf ein eigenes privates Feld: private readonly object _sync = new object();. Es muss ein Referenztyp sein, den jeder Thread teilt, der die geschützten Daten berührt, und es darf von außerhalb deiner Klasse nicht erreichbar sein. Locke nie auf this, einen Type (typeof(MyClass)) oder einen String, weil anderer Code auf dasselbe Objekt locken und dich blockieren oder in einen Deadlock bringen kann.

Wann sollte ich Interlocked statt lock verwenden?

Wenn der gemeinsame Zustand eine einzelne Zahl oder Referenz ist und die Operation ein Schritt: Interlocked.Increment(ref count), Interlocked.Add(ref total, x), Interlocked.Exchange oder CompareExchange. Das sind atomare Hardwareoperationen und schneller als ein Lock. Für alles, was mehrere Felder oder eine Collection berührt, nimm lock.

Kann ich in C# await in einem lock verwenden?

Nein, der Compiler lehnt await in einem lock-Block ab (Fehler CS1996), weil der Code nach dem await auf einem anderen Thread weiterlaufen kann als dem, der den Lock hält. Nimm stattdessen SemaphoreSlim mit einem Zähler von 1: await sem.WaitAsync(); try { ... } finally { sem.Release(); }.

Wie entstehen Deadlocks mit lock?

Thread 1 hält Lock A und wartet auf Lock B, während Thread 2 Lock B hält und auf Lock A wartet. Keiner kommt weiter, und das Programm hängt ohne Exception. Verhindere das, indem du mehrere Locks immer in derselben globalen Reihenfolge nimmst, Locks so kurz wie möglich hältst und nie unbekannten Code aufrufst, während du einen Lock hältst.

Coddy programming languages illustration

Lerne mit Coddy zu programmieren

LOS GEHT'S