Race Condition
Kurz gesagt: Eine Race Condition ist ein Fehler, bei dem das Ergebnis davon abhängt, wie sich Threads verzahnen: left -= 1 sind auch mit dem GIL drei Schritte, also können zwei Threads einen Verkauf verlieren.
Problem
Der Verkauf wird falsch gezählt
Freitag, Flash-Sale. Kopfhörer zum halben Preis, und die Bestellungen strömen herein. Zwei Kassen verkaufen aus einem Lager: Jeder Verkauf zieht 1 vom Bestand ab.
Die Kassen sind zwei Threads. Jeder verkauft 10.000 Stück und ruft für jedessell() auf. sell liest den Bestand, ruft time.sleep(0) auf (die anderen Threads dürfen kurz laufen) und schreibt erst dann. Warum time.sleep(0) nötig ist, steht weiter unten:class Stock: def __init__(self, left): self.left = left
def sell(self): seen = self.left # 1. lesen time.sleep(0) # hier kommt der andere Thread dazwischen after = seen - 1 # 2. 1 abziehen self.left = after # 3. schreibenstock = Stock(20_000)
def checkout(): for _ in range(10_000): stock.sell()
a = threading.Thread(target=checkout)b = threading.Thread(target=checkout)a.start()b.start()a.join()b.join()print(f"Bestellungen: {2 * 10_000}")print("Verkauft laut Lager:")print(20_000 - stock.left)Die Kunden haben für 20.000 Bestellungen bezahlt. So viele Verkäufe zählt das Lager in fünf Läufen:
Verkauft laut Lager: 10000Verkauft laut Lager: 10000Verkauft laut Lager: 10000Verkauft laut Lager: 10000Verkauft laut Lager: 10000Die Idee
self.left -= 1 sind drei Schritte
self.left -= 1 ist eine Zeile, aber Python macht es in drei Schritten. In sell sind sie ausgeschrieben, und time.sleep(0) sitzt genau zwischen Lesen und Schreiben:class Stock: def __init__(self, left): self.left = left
def sell(self): seen = self.left # 1. lesen time.sleep(0) # hier kommt der andere Thread dazwischen after = seen - 1 # 2. 1 abziehen self.left = after # 3. schreibenSchritt für Schritt, in Worten
- Es sind noch 5 Stück übrig. Kasse A und Kasse B verkaufen im selben Moment je eins.
- Kasse A liest den Bestand: 5.
- time.sleep(0) lässt den anderen Thread hinein. Kasse B liest, bevor A geschrieben hat: ebenfalls 5.
- Kasse A zieht in der eigenen Variable 1 ab: 4.
- Kasse B macht dasselbe von der eigenen 5 aus: ebenfalls 4.
- Kasse A schreibt 4. Ein Verkauf, 4 übrig: bis hierhin stimmt es.
- Kasse B schreibt ebenfalls 4, über die 4 von A. Zwei Verkäufe, und der Bestand ist 4, nicht 3.
- Ein Verkauf geht verloren: Beide lasen dieselbe Zahl, bevor eine schrieb. Das ist eine Race Condition, und das Lager zeigt jetzt einen Artikel, der schon verkauft ist.
- Lesen: den Bestand in eine eigene Variable.
- Abziehen: 1 von deiner Kopie.
- Schreiben: die Kopie zurück.
Zwei Threads können sich zwischen je zwei Schritten abwechseln. Wenn beide 5 lesen, bevor einer schreibt, schreiben beide 4. Zwei Verkäufe, und der Bestand sinkt um eins.
Wenn das Ergebnis davon abhängt, welcher Thread welchen Schritt zuerst macht, ist das eine Race Condition. Die drei Schritte „lesen, ändern, schreiben“ auf gemeinsamen Daten sind ihre häufigste Form.
Der GIL rettet dich nicht. Er lässt immer nur einen Thread Python-Code ausführen, kann aber zwischen den beiden Schritten die Threads wechseln. time.sleep(0) sorgt dafür, dass der Wechsel genau dort passiert. Ohne es ist der Wechsel trotzdem möglich, nur seltener (siehe unten).
Genauer, für Neugierige
Das Modul dis zeigt die Anweisungen, die Python für self.left -= 1 ausführt. Das ist Python 3.12, auf dem der Code geprüft wird:
import dis
class Stock: def __init__(self, left): self.left = left
def sell(self): self.left -= 1 # lesen, 1 abziehen, schreiben
for step in dis.get_instructions(Stock.sell): print(f"{step.opname} {step.argrepr}".strip())RESUMELOAD_FAST selfCOPYLOAD_ATTR leftLOAD_CONST 1BINARY_OP -=SWAPSTORE_ATTR leftRETURN_CONST NoneLOAD_ATTR left liest, BINARY_OP -= zieht ab, STORE_ATTR left schreibt. Drei getrennte Anweisungen: Zwischen je zwei kann der GIL an einen anderen Thread übergeben.
Probier es aus
Drei Kassen, 5 auf Lager
Hier sind drei Kassen, von Hand in einer der möglichen Reihenfolgen verzahnt. Jede liest in eine eigene Variable und schreibt dann 1 weniger:
left = 5seen_a = left # A liestseen_b = left # B liestleft = seen_a - 1 # A schreibtseen_c = left # C liestleft = seen_b - 1 # B schreibtleft = seen_c - 1 # C schreibtprint("Verkäufe: 3")print(f"Bestand: {left}")A und B lesen 5. A schreibt 4. C liest die 4, die gerade geschrieben wurde. B schreibt 4 (seine eigene 5 minus 1), und C schreibt 3. Drei Verkäufe, und der Bestand ist 3 statt 2: Nur der Verkauf von B ging verloren, weil B las, bevor A schrieb.
Die Idee
„Bei mir funktioniert es“
Dasselbe Programm, mit weniger Verkäufen pro Kasse:
Probiere es in Python ohne time.sleep(0): sell ist nur self.left -= 1, und jede Kasse verkauft 100.000:
class Stock: def __init__(self, left): self.left = left
def sell(self): self.left -= 1 # lesen, 1 abziehen, schreibenstock = Stock(200_000)
def checkout(): for _ in range(100_000): stock.sell()
a = threading.Thread(target=checkout)b = threading.Thread(target=checkout)a.start()b.start()a.join()b.join()print(f"Bestellungen: {2 * 100_000}")print("Verkauft laut Lager:")print(200_000 - stock.left)Verkauft laut Lager: 200000Verkauft laut Lager: 200000Verkauft laut Lager: 200000Verkauft laut Lager: 200000Verkauft laut Lager: 200000sell oder Python ohne GIL (3.13) verlieren Verkäufe.Das macht eine Race Condition so tückisch:
- der Test besteht auf dem Laptop und fällt auf dem Server mit 64 Kernen und tausenden Kunden durch;
- der Fehler kommt „manchmal“ und wiederholt sich nicht, wenn du ihn suchst;
- füge eine Print-Zeile hinzu, die Reihenfolge der Threads ändert sich, und der Fehler verschwindet.
Ein Test ohne Verlust beweist nichts. Sicherheit beweist man durch die Art, wie der Code geschrieben ist: welche Daten geteilt werden und wer sie schützt.
Problem
Der letzte Artikel
Ein Artikel ist übrig. Zwei Käufer klicken im selben Moment auf „Kaufen“. buy prüft, ob einer da ist, belastet die Karte (etwa 100 ms) und nimmt erst dann den Artikel:
class Stock: def __init__(self, left): self.left = left
def buy(self): if self.left > 0: # Prüfen: Ist noch einer da? time.sleep(0.1) # die Karte wird belastet, ~100 ms self.left -= 1 # Handeln: den Artikel nehmen return True return Falsestock = Stock(1)bought = []
def buyer(): bought.append(stock.buy())
first = threading.Thread(target=buyer)second = threading.Thread(target=buyer)first.start()second.start()first.join()second.join()print("Bestand vorher: 1")print("Verkauft:")print(bought.count(True))print("Bestand nachher:")print(stock.left)Verkauft: 2, bestand nachher: -1Verkauft: 2, bestand nachher: -1Verkauft: 2, bestand nachher: -1Verkauft: 2, bestand nachher: -1Verkauft: 2, bestand nachher: -1Beide bestehen die Prüfung: Während der erste zahlt, sieht der zweite dieselbe 1. Beide haben bezahlt, und der Bestand steht bei -1. Hier tritt die Race Condition jedes Mal auf, weil zwischen Prüfung und Handlung volle 100 ms liegen.
Das ist die zweite Form einer Race Condition: Prüfen, dann handeln (Check-then-act). Die Entscheidung wird auf einem Wert getroffen, den jemand anderes ändern kann, bevor die Entscheidung ausgeführt wird. Sie ist überall: „Ist ein Zimmer frei“, „Ist der Benutzername frei“, „Ist Geld auf dem Konto“.
Probier es aus
Bestellnummern
Jede Bestellung bekommt eine Nummer: 1001, 1002, 1003… Die Kassen sind Threads und rufen gleichzeitig take() auf. Manchmal bekommen zwei Bestellungen dieselbe Nummer. Nach welcher Zeile muss der andere Thread hereinkommen, damit das passiert?
Wähle die Zeile mit dem Fehler.
Nach dem Lesen. Wenn beide Threads 1001 lesen, bevor einer 1002 schreibt, geben beide 1001 zurück. Zwei Bestellungen mit einer Nummer: Eine überschreibt die andere in der Datenbank oder landet beim falschen Kunden. Es ist dasselbe „lesen, ändern, schreiben“.
Die Idee
Wann es eine Race Condition geben kann
Es braucht drei Dinge gleichzeitig:
- Gemeinsame Daten: etwas, das mehrere Threads sehen können (der Bestand, der Zähler, die Nummer).
- Mindestens ein Thread ändert sie. Wenn alle nur lesen, kann nichts schiefgehen.
- Nichts ordnet den Zugriff: Die Threads gehen in ihre Schritte, wann immer sie an der Reihe sind.
Nimm eines der drei weg, und es gibt keine Race Condition:
- Nicht teilen. Jeder Thread zählt in seiner eigenen Variable, und am Ende werden die Ergebnisse addiert. So liefen die 1000 Rechnungen in „Warum Threads“: Jeder Teil rechnet seinen eigenen.
- Nicht ändern. Ein Objekt, das niemand ändert, können beliebig viele Threads lesen. Das ist die Lektion „Atomare Operationen und unveränderliche Objekte“.
- Den Zugriff ordnen. Immer nur ein Thread in den drei Schritten. Das ist das Lock, die nächste Lektion: „Sperren“.
Genauer, für Neugierige
Es gibt auch ein leiseres Problem: die Sichtbarkeit. In Python mit dem GIL sieht jeder Thread den zuletzt geschriebenen Wert, deshalb tritt dieses Problem hier kaum auf. In Python ohne GIL gelten Regeln wie in Java. Das Lock löst auch das: Was ein Thread vor der Freigabe geschrieben hat, sieht der nächste, nachdem er es genommen hat.
Wie es in anderen Sprachen ist: In Java und C# verliert count++ genau wie hier. In Python verliert es wegen des GIL seltener, aber es verliert. In JavaScript verliert es zwischen Workern auf einem SharedArrayBuffer, und in einem Thread gibt es eine Race Condition nur zwischen zwei await.
Übung
Der Umsatz des Verkaufs
Löse die Aufgabe in deinem Editor.
Der Umsatz. Zwei Kassen verkaufen je 10.000 Kopfhörer zu 3 €. Jede Kasse zählt ihre Verkäufe in einer eigenen Variable, und der Umsatz ist geteilt: ein Revenue-Objekt mit einer Methode add(euros), die total += euros macht. Lies in add, rufe time.sleep(0) auf und schreibe erst dann. Gib die Verkäufe jeder Kasse und den Umsatz aus. Führe das Programm mehrmals aus.
Kasse 1: 10000 VerkäufeKasse 2: 10000 VerkäufeUmsatz (€):60000Die Verkäufe der Kassen stimmen bei jedem Lauf: Jede zählt in ihrer eigenen Variable. Der Umsatz ist kleiner und jedes Mal anders. Die Lösung steht in der nächsten Lektion.
Weitere Aufgaben
- Wie viel geht verloren. Führe den Verkauf mit 2, 4 und 8 Kassen aus. Geht mit mehr Kassen mehr verloren?
- Finde das Geteilte. Nimm ein Programm mit Threads, das du geschrieben hast, und liste die Daten auf, die mehr als ein Thread anfasst. Welche davon ändert ein Thread? - Freies Zimmer. Schreibe ein Hotel mit einer Methode „Zimmer 12 buchen, wenn es frei ist“ und lasse zwei Gäste gleichzeitig laufen, mit einer Pause von 100 ms zwischen Prüfung und Schreiben. Wie viele Gäste bekommen das Zimmer?
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Listen: append, remove und sort · Lambdas · Threads · Warum Threads
- Geht weiter in: Sperren · Atomare Operationen und unveränderliche Objekte · Deadlock · Producer und Consumer
- Aufgaben zum Thema: Nebenläufigkeit und Race Conditions