Zwei Anfragen treffen im Abstand von einer halben Millisekunde auf denselben “100€ abheben”-Endpunkt. Das Konto hat 150€. Beide Anfragen lesen diesen Kontostand, beide sehen genug Guthaben, beide werden genehmigt, beide ziehen 100€ ab. Das Konto landet bei -50€, und kein Codepfad hätte das zulassen dĂĽrfen. Das ist eine Race Condition: Das Ergebnis hängt davon ab, welche Operation zufällig zuerst fertig wird, und “zuerst” war nie eine Entscheidung, die der Code getroffen hat.

Was verursacht eine Race Condition?

Man braucht zwei Zutaten gleichzeitig: gemeinsam genutzten veränderlichen Zustand und mehr als einen Ausführungspfad, der unkoordiniert darauf zugreift. Die Pfade können Threads, Async-Tasks, Prozesse oder zwei Server sein, die auf dieselbe Datenbankzeile zugreifen. Nimmt man eine der beiden Zutaten weg, verschwindet der Bug. Ein einzelner Thread kann nicht gegen sich selbst konkurrieren, und unveränderlicher Zustand lässt sich durch zweimaliges Lesen nicht beschädigen.

Die klassische Form ist Check-then-Act: Man liest einen Wert, trifft darauf basierend eine Entscheidung und handelt entsprechend. Führt ein anderer Pfad seine eigene Lesen-Entscheiden-Handeln-Sequenz in der Lücke zwischen Ihrem Lesen und Ihrem Schreiben aus, treffen beide eine Entscheidung, die nur für einen Moment gültig war – und dieser Moment ist beim Schreiben schon vorbei.

Ein gemeinsam genutzter Zähler zeigt den Mechanismus ohne alles andere drum herum. counter += 1 sieht nach einer einzigen Operation aus. Es sind in Wirklichkeit drei: den aktuellen Wert lesen, eins addieren, das Ergebnis zurückschreiben. Führen zwei Threads diese Sequenz auf einem Zähler aus, der bei 0 beginnt, und lesen beide, bevor einer von beiden schreibt, berechnen beide 1 und schreiben beide 1. Ein Inkrement ist verschwunden. Keine Exception, keine Log-Zeile.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import threading

counter = 0

def increment():
    global counter
    for _ in range(100_000):
        counter += 1

threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter)  # expected 400000, usually comes out lower

Führen Sie es ein paar Mal aus. Der Fehlbetrag ist nie derselbe, und das ist die zweite Signatur einer Race Condition: Sie reproduziert sich nicht zuverlässig, weil sie vom Scheduling abhängt, das sich von Lauf zu Lauf und von Maschine zu Maschine unterscheidet.

Wie behebt man eine Race Condition mit einem Lock?

Machen Sie die Lesen-Ändern-Schreiben-Sequenz atomar, sodass kein anderer Thread sich einmischen kann, sobald ein Thread sie beginnt. Ein Lock erreicht das, indem er jeweils nur einem Thread erlaubt, ihn zu halten. Jeder andere Thread, der acquire() aufruft, blockiert, bis der Thread, der ihn hält, release() aufruft.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import threading

counter = 0
lock = threading.Lock()

def increment():
    global counter
    for _ in range(100_000):
        with lock:
            counter += 1

threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter)  # 400000, every time

with lock: erwirbt den Lock beim Eintritt und gibt ihn beim Verlassen frei, auch wenn im Block eine Exception ausgelöst wird. So bleibt nie ein Lock zurück, den niemand freigibt.

Der Fehler, der den Bug auch nach dem Hinzufügen eines Locks am Leben hält: das Schreiben zu sperren, aber nicht die Prüfung. Diese Dosierungsfunktion wirkt sicher, ist es aber nicht:

1
2
3
4
5
6
SAFE_DOSAGE = 50

def increase_dosage(patient, amount):
    if patient.dosage + amount < SAFE_DOSAGE:   # check — not locked
        with patient.lock:
            patient.dosage += amount             # act — locked

Zwei Threads können die Prüfung beide bestehen, bevor einer von beiden den Lock übernimmt. Beide addieren ihren Betrag, und die Summe übersteigt den Grenzwert. Der Lock muss Prüfung und Aktion als eine einzige kritische Sektion umschließen, nicht nur die Zeile, die gefährlich aussieht:

1
2
3
4
def increase_dosage(patient, amount):
    with patient.lock:
        if patient.dosage + amount < SAFE_DOSAGE:
            patient.dosage += amount

Unter Pythons GIL funktioniert die knapp gesperrte Variante oft nur durch Zufall. Deshalb überlebt sie die Code-Review: Sie besteht jeden Test auf Ihrem Laptop und schlägt dann unter Produktionslast fehl, oder an dem Tag, an dem Sie zu einem Free-Threaded-Build wechseln.

Kann async JavaScript Race Conditions haben?

Ja. JavaScript fĂĽhrt Ihren Code im Browser und in Node auf einem einzigen Thread aus, aber der Fehlermodus zeigt sich ĂĽberall dort, wo zwei await-Aufrufe in einer Reihenfolge enden können, die Sie nicht geplant hatten. Ein Suchfeld ist der typische Fall. Der Nutzer tippt “Katze”, eine Anfrage geht raus, er tippt weiter zu “Katzenfutter”, eine zweite Anfrage geht raus. Ist die erste Anfrage langsamer, kommt ihre Antwort nach der zweiten an und ersetzt die neueren Ergebnisse durch veraltete.

1
2
3
4
5
6
7
8
9
let latestId = 0;

async function search(term) {
  const id = ++latestId;
  const res = await fetch(`/api/search?q=${encodeURIComponent(term)}`);
  const data = await res.json();
  if (id !== latestId) return; // a newer search started; drop this response
  renderResults(data);
}

Jeder Aufruf markiert sich mit einer ID, bevor er irgendetwas awaitet. Kommt die Antwort zurück, wird sie nur gerendert, wenn sie noch zur aktuellsten Anfrage gehört. AbortController ist das andere gängige Werkzeug: Er bricht die veraltete Anfrage direkt ab, statt sie fertig laufen zu lassen und ihr Ergebnis wegzuwerfen, was zusätzlich den Netzwerk-Roundtrip spart.

Die subtilere Variante hat ĂĽberhaupt keinen Netzwerkaufruf, nur eine gemeinsam genutzte Variable, die einen await ĂĽberspannt:

1
2
3
4
5
6
7
let totalLength = 0;

async function addPage(n) {
  totalLength += await getPageLength(n); // read happens now, write happens later
}

await Promise.all([addPage(1), addPage(2)]);

Beide Aufrufe lesen totalLength als 0, bevor einer der beiden await-Aufrufe aufgelöst wird. Beide berechnen ihre neue Summe vom selben veralteten Ausgangspunkt, und das zweite Schreiben überschreibt das erste. Lesen und Schreiben liegen auf entgegengesetzten Seiten einer await-Pause, und während diese Pause läuft, kann alles Mögliche passieren. Falls dieses Scheduling noch nicht geläufig ist, erklärt /de/posts/javascript/async-await-und-event-loop-javascript/, was die Event Loop bei jedem await tut. Die Lösung: den Wert zuerst auflösen und in einem einzigen synchronen Schritt in den gemeinsamen Zustand einfalten (const len = await getPageLength(n); totalLength += len;), oder die Additionen sequenziell statt mit Promise.all ausführen.

Wie verhindert man Race Conditions in einer Datenbank?

Verschiebt man den Zähler in eine Zeile, wird die LĂĽcke zwischen Check und Act breiter, weil sie jetzt einen Round Trip zur Datenbank und zurĂĽck umfasst. Zwei Anwendungsserver, die jeweils “Kontostand lesen, prĂĽfen, neuen Kontostand schreiben” auf derselben Zeile ausfĂĽhren, können beide 150€ lesen und beide eine Abhebung von 100€ genehmigen. Das ist das Eingangsbeispiel, auf SQL-Ebene.

Zwei Lösungen mit unterschiedlichen Kosten.

Pessimistisches Locking nimmt den Zeilen-Lock im Voraus. In PostgreSQL ist das SELECT ... FOR UPDATE:

1
2
3
4
5
BEGIN;
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- balance is 150; the row is now locked
UPDATE accounts SET balance = balance - 100 WHERE id = 42;
COMMIT;

Eine zweite Transaktion, die ein SELECT ... FOR UPDATE auf dieselbe Zeile versucht, blockiert, bis die erste committet oder zurückrollt. Korrekt, aber jede gleichzeitige Anfrage für diese Zeile reiht sich jetzt hinter dem Lock ein. Das kostet Latenz, wenn mehrere Transaktionen um dieselbe Zeile konkurrieren, und kann zu einem Deadlock führen, wenn zwei Transaktionen Zeilen in entgegengesetzter Reihenfolge sperren. Halten Sie die Transaktion kurz: Hier wird die Zeile über den Primärschlüssel gefunden, also per Index-Lookup. Filtert man auf eine Spalte ohne Index, wird aus dem Lookup ein Scan, der jede Transaktion verlängert, während die anderen warten (die Mechanik steht in /de/posts/databases/wie-funktionieren-datenbankindizes/).

Optimistisches Locking verzichtet auf den Lock und prĂĽft stattdessen beim Schreiben auf einen Konflikt, ĂĽblicherweise mit einer Versionsspalte:

1
2
3
UPDATE accounts
SET balance = balance - 100, version = version + 1
WHERE id = 42 AND version = 7;

Hat eine andere Transaktion die Zeile bereits aktualisiert, ist version nicht mehr 7, null Zeilen passen, und Ihre Anwendung erkennt das und wiederholt den Vorgang mit dem frischen Wert. Das skaliert besser, wenn Konflikte selten sind. Es funktioniert nur, wenn VersionsprĂĽfung und Schreiben in derselben Anweisung passieren. Lesen Sie die Version und fĂĽhren Sie dann ein separates UPDATE darauf basierend aus, ist die LĂĽcke, die Sie schlieĂźen wollten, wieder da.

Was ist der Unterschied zwischen einer Race Condition und einem Deadlock?

Beide landen in der Schublade “Concurrency-Bug” und scheitern auf entgegengesetzte Weise:

Race ConditionDeadlock
Was passiertDie AusfĂĽhrung endet, mit einem falschen oder inkonsistenten ErgebnisDie AusfĂĽhrung bleibt stehen; nichts endet
UrsacheUnsynchronisierter Zugriff auf gemeinsamen ZustandZwei oder mehr Locks, die in einem Zyklus aufeinander warten
Wie man es merktZahlen, die nicht aufgehen, Daten, die “manchmal” falsch sindDas Programm oder die Anfrage hängt
Typische LösungLocks, atomare Operationen oder den gemeinsamen veränderlichen Zustand entfernenKonsistente Lock-Reihenfolge, Timeouts

Eine Lösung für das eine kann das andere einführen. Einen Check-then-Act-Bug mit einem Lock zu umschließen beseitigt die Race. Nimmt dieser Code auch noch einen zweiten Lock, in einer anderen Reihenfolge als ein anderer Codepfad, haben Sie stille Datenkorruption gegen ein Hängen eingetauscht. Erwerben Sie Locks überall in derselben Reihenfolge, und halten Sie die gesperrte Sektion so kurz, wie es die Korrektheit erlaubt.

Wie entdeckt man Race Conditions vor der Produktion?

Races zeigen sich selten in einem Single-Thread-Test, der allein auf Ihrem Laptop läuft. Sie sind eine häufige Ursache fĂĽr flaky Tests, die Art, die in /de/posts/testing/unit-tests-vs-integrationstests-vs-end-to-end-tests/ besprochen wird. Ein Test, der unter CI-Last einmal von fĂĽnfzig Läufen fehlschlägt und bei jedem lokalen erneuten Lauf besteht, verdient einen genaueren Blick, bevor man ihn als “flaky” abhakt.

Go bringt dafĂĽr einen eingebauten Detector mit. Kompilieren oder testen Sie mit -race, und die Runtime instrumentiert Speicherzugriffe und meldet jeden Fall, in dem zwei Goroutinen gleichzeitig dieselbe Variable anfassen und mindestens eine davon schreibt.

1
2
go test -race ./...
go build -race -o myapp .

Er erkennt nur Races, die während des Laufs tatsächlich ausgeführt werden, also findet er mehr, je näher Ihre Tests an eine echte gleichzeitige Last herankommen. Er vervielfacht außerdem CPU- und Speicherverbrauch, gehört also in die CI, nicht in das Produktions-Binary. Python und Java haben nichts Eingebautes, das daran heranreicht, ein weiterer Grund, sich auf Locks und atomare Datenbankoperationen zu verlassen, statt sich in falscher Sicherheit zu wiegen, nur weil die Tests bestehen.

Caches verstecken denselben Bug. Zwei Anfragen, die denselben SchlĂĽssel auf einen veralteten Wert prĂĽfen und ihn dann setzen, sind wieder Check-then-Act, und das ist eine LĂĽcke, die das Cache-Aside-Muster in /de/posts/backend/redis-caching-funktionsweise/ berĂĽcksichtigen muss.

Bevor Sie zu einem Lock greifen, fragen Sie sich trotzdem, ob der gemeinsame veränderliche Zustand überhaupt existieren muss. Ein Kontostand, der nur über eine einzige atomare Anweisung wie UPDATE accounts SET balance = balance - 100 WHERE id = 42 AND balance >= 100 aktualisiert wird, hat keine Lücke zwischen Check und Act, weil die Datenbank beides in einem Schritt erledigt. Die billigste Lösung für eine Race Condition ist, den zweiten Pfad zu löschen, der gegen den ersten konkurriert. Beginnen Sie Ihre nächste Review also damit, jede Stelle aufzulisten, die in diesen Zustand schreibt.

Verwandte Artikel