Warum Threads
Kurz gesagt: Ein Thread ist ein eigener Ausführungsstrang im Programm; Threads beschleunigen Arbeit, die wartet (Netzwerk, Festplatte), während Arbeit, die rechnet, in Python wegen des GIL Prozesse braucht.
Problem
Der Kunde wartet dreimal
Der Kunde klickt auf „Bestellen“. Bevor er „Danke“ sieht, muss der Server drei Dienste über das Netzwerk fragen: die Zahlung (akzeptiert die Bank die Karte), das Lager (ist der Artikel lagernd) und die E-Mail (ist die Bestätigung raus). Jeder antwortet in etwa 200 ms.
Das Netzwerk ist hier Network. call wartet 200 ms und gibt die Antwort des Dienstes zurück. Das Programm fragt die drei nacheinander:
net = Network()payment = net.call("Zahlung", "akzeptiert")stock = net.call("Lager", "verfügbar")email = net.call("E-Mail", "gesendet")print(payment)print(stock)print(email)print(f"Warterunden: {net.rounds}")Zahlung: akzeptiertLager: verfügbarE-Mail: gesendetWarterunden: 3Jede Frage beginnt, nachdem die vorige fertig ist. Der Kunde wartet auf die Summe: etwa 600 ms. Und die ganze Zeit tut der Prozessor nichts: Er wartet nur auf eine Antwort.
Network zählt außerdem die Warterunden: Eine neue Runde beginnt, wenn das Programm fragt und auf keine andere Antwort gewartet wird. Hier sind es drei, eine für jede Frage. In dieser Lektion wird die Geschwindigkeit mit dieser Zahl gemessen und nicht in Millisekunden: Die Zeit hängt von der Maschine ab, die Zahl der Runden ist bei jedem Lauf gleich.
Genauer, für Neugierige
So funktioniert Network. with self.lock stammt aus der Lektion „Threads“: Die beiden Zähler werden von mehreren Threads gleichzeitig angefasst. Warum ohne es Zahlen verloren gehen, zeigt die nächste Lektion, „Race Condition“.
class Network: def __init__(self): self.lock = threading.Lock() self.in_flight = 0 self.rounds = 0
def call(self, service, answer): """Fragt einen Dienst über das Netzwerk: Die Antwort kommt nach ~200 ms.""" with self.lock: if self.in_flight == 0: self.rounds += 1 # niemand wartet: eine neue Runde self.in_flight += 1 time.sleep(0.2) with self.lock: self.in_flight -= 1 return f"{service}: {answer}"Die Idee
Arbeit, die wartet, und Arbeit, die rechnet
Die Arbeit eines Programms gibt es in zwei Arten.
- Arbeit, die wartet (I/O-bound): eine Anfrage an die Bank, eine Abfrage an die Datenbank, das Lesen einer Datei, das Senden einer E-Mail. Das Programm fragt und steht still, bis die Antwort kommt. Der Prozessor ist währenddessen frei.
- Arbeit, die rechnet (CPU-bound): 1000 Rechnungen als PDF, Produkt-Thumbnails, ein Bericht über eine Million Bestellungen. Hier ist der Prozessor die ganze Zeit beschäftigt.
In Python ist das wegen des GIL noch schärfer: Zu jedem Zeitpunkt führt nur ein Thread Python-Code aus. Warten gibt den GIL frei (time.sleep, das Netzwerk, Dateien), also helfen Threads bei Arbeit, die wartet. Rechnen hält ihn, also beschleunigen Threads es nicht. Dafür braucht es Prozesse.
Die erste Frage an ein langsames Programm lautet also: „Worauf wartet es?“ Die Antwort entscheidet, was hilft.
Sieh es dir an
Alle drei auf einmal
ThreadPoolExecutor aus der Lektion „Threads“). submit gibt sofort ein Future zurück, und result() wartet auf die Antwort:net = Network()with ThreadPoolExecutor(max_workers=3) as pool: payment = pool.submit(net.call, "Zahlung", "akzeptiert") stock = pool.submit(net.call, "Lager", "verfügbar") email = pool.submit(net.call, "E-Mail", "gesendet") print(payment.result()) print(stock.result()) print(email.result())print(f"Warterunden: {net.rounds}")Zahlung: akzeptiertLager: verfügbarE-Mail: gesendetWarterunden: 1net = Network()with ThreadPoolExecutor(max_workers=3) as pool: payment = pool.submit(net.call, "Zahlung", "akzeptiert") stock = pool.submit(net.call, "Lager", "verfügbar") email = pool.submit(net.call, "E-Mail", "gesendet") print(payment.result()) print(stock.result()) print(email.result())print(f"Warterunden: {net.rounds}")net = Network()payment = net.call("Zahlung", "akzeptiert")stock = net.call("Lager", "verfügbar")email = net.call("E-Mail", "gesendet")print(payment)print(stock)print(email)print(f"Warterunden: {net.rounds}")Schritt für Schritt, in Worten
- Eine Bestellung braucht drei Antworten über das Netzwerk: die Zahlung, die Lagerprüfung und die Bestätigungs-E-Mail. Jede wartet etwa 200 ms.
- Nacheinander: Der Thread fragt nach die Zahlung und tut nichts, bis die Antwort kommt.
- Erst dann fragt er nach das Lager. Weitere 200 ms Warten.
- Erst dann fragt er nach die E-Mail. Weitere 200 ms Warten.
- 3 Warterunden: Der Kunde wartet etwa 600 ms, und der Prozessor steht die ganze Zeit still.
- Nebeneinander: Die drei Fragen gehen auf einmal raus, jede in einem eigenen Thread, und die drei Wartezeiten überlappen sich.
- Das Programm wartet auf die Antworten. Bis die erste kommt, sind die anderen beiden auch fertig.
- 1 Warterunde: etwa 200 ms, so lange wie die langsamste Antwort. Dieselben Antworten, dieselbe Reihenfolge, ein Drittel der Wartezeit.
Die Antworten sind dieselben und in derselben Reihenfolge. Es gibt eine Warterunde: Alle drei Fragen gehen raus, bevor eine Antwort gekommen ist. Der Kunde wartet etwa 200 ms, so lange wie der langsamste Dienst, statt 600.
Die Threads brauchen hier kaum den Prozessor: drei kurze Läufe voncall und drei Pausen. Deshalb funktioniert das auch auf einem Server mit einem Kern.Genauer, für Neugierige
asyncio: ein Thread, der tausende Wartezeiten jongliert, ohne einen Thread für jede. Das ist die Lektion „async ist kein Thread“.Probier es aus
Die E-Mail wartet auf die Zahlung
Ein Kollege ändert die Bestellung: Die E-Mail muss sagen, ob bezahlt wurde und ob der Artikel lagernd ist, also geht sie erst raus, nachdem Zahlung und Lager geantwortet haben.
net = Network()with ThreadPoolExecutor(max_workers=3) as pool: payment = pool.submit(net.call, "Zahlung", "akzeptiert") stock = pool.submit(net.call, "Lager", "verfügbar") # die E-Mail sagt, ob bezahlt wurde und ob der Artikel lagernd ist print(payment.result()) print(stock.result()) email = pool.submit(net.call, "E-Mail", "gesendet") print(email.result())print(f"Warterunden: {net.rounds}")Die Zahlung und die Lagerprüfung gehen zusammen raus: Das ist die erste Runde. Die E-Mail geht erst nach beiden Antworten raus, wenn auf nichts anderes gewartet wird: eine zweite Runde. Etwa 400 ms. Arbeit, die von anderer Arbeit abhängt, kann sich nicht mit ihr überlappen.
Probier es aus
Auf die Antwort warten
Was wartet auf die Antwort der Zahlung?
payment = pool.submit(net.call, "Zahlung", "akzeptiert")
print(payment.)result() wartet, bis die Aufgabe fertig ist, und gibt ihr Ergebnis zurück.Problem
Tausend Rechnungen am Monatsende
Am Monatsende erstellt der Shop für jede von 1000 Bestellungen eine PDF-Rechnung. Hier wartet nichts auf das Netzwerk: Der Prozessor rechnet die ganze Zeit. render steht für das Zeichnen des PDFs mit einer Berechnung, die nur den Prozessor belastet und eine Prüfsumme zurückgibt:
def render(invoice): """Erzeugt die Rechnung (hier: eine Rechnung, die nur die CPU auslastet).""" h = invoice for i in range(2000): h = (h * 31 + i) % 1_000_003 return h
def render_range(bounds): """Die Rechnungen von start bis end, einschließlich.""" start, end = bounds return sum(render(n) for n in range(start, end + 1))total = render_range((1, 1000))print("Rechnungen: 1000")print(f"Prüfsumme: {total}")Rechnungen: 1000Prüfsumme: 499409728Ein Kern rechnet alles, während die anderen untätig sind. Warterunden gibt es hier keine: Nichts wartet.
Sieh es dir an
Vier Prozesse, vier Kerne
Zuerst mit Threads. Das Ergebnis stimmt, aber wegen des GIL rechnen die vier Threads einer nach dem anderen und werden nicht schneller:
bounds = [(p * 250 + 1, p * 250 + 250) for p in range(4)]with ThreadPoolExecutor(max_workers=4) as pool: total = sum(pool.map(render_range, bounds))print("Rechnungen: 1000")print(f"Prüfsumme: {total}")Rechnungen: 1000Prüfsumme: 499409728Mit ProcessPoolExecutor geht jeder Teil an einen eigenen Prozess: eine eigene Kopie von Python mit eigenem GIL, auf einem eigenen Kern. pool.map gibt die Ergebnisse in der Reihenfolge der Teile zurück. if __name__ == "__main__": verhindert, dass der Hauptcode in jedem Prozess noch einmal läuft:
if __name__ == "__main__": bounds = [(p * 250 + 1, p * 250 + 250) for p in range(4)] with ProcessPoolExecutor(max_workers=4) as pool: total = sum(pool.map(render_range, bounds)) print("Rechnungen: 1000") print(f"Prüfsumme: {total}")Rechnungen: 1000Prüfsumme: 499409728Die Prüfsumme ist dieselbe: Das Teilen ändert das Ergebnis nicht, nur wer es berechnet.
Wie viel schneller es ist, hängt von der Maschine ab. Auf meinem Laptop dauerte es bei einer 25-mal schwereren Berechnung pro Rechnung (50.000 Schritte statt 2000) mit einem Thread etwa 1,2 s, mit vier Threads immer noch etwa 1,2 s und mit vier Prozessen etwa 0,4 s.Das sind Beispielzeiten, kein Versprechen. Mehr Teile als Kerne helfen nicht: Sie wechseln sich nur auf denselben Kernen ab.
Genauer, für Neugierige
- Wie viele Threads. Für Arbeit, die rechnet, so viele wie Kerne:
os.cpu_count()sagt dir, wie viele. Für Arbeit, die wartet, sind viel mehr in Ordnung: Sie schlafen nur. - Nicht alles lässt sich teilen. Wenn sich ein Zehntel der Arbeit nicht teilen lässt (zum Beispiel das Addieren der Teile oder das Schreiben einer Datei), macht keine Zahl von Kernen es mehr als 10-mal schneller. Das ist das Amdahlsche Gesetz.
- Python 3.13 hat außerdem einen Build ohne GIL (free-threaded). Darin rechnen Threads parallel, aber er ist noch experimentell.
- Prozesse teilen keinen Speicher: Ihre Teile werden hineingereicht, und das Ergebnis kommt per Kopie zurück. Deshalb gibst du einem Prozess einen großen Brocken Arbeit, nicht eine Rechnung nach der anderen.
Die Idee
Was hilft
| Arbeit, die wartet | Arbeit, die rechnet | |
|---|---|---|
| Was sie bremst | die Antwort vom Netzwerk, von der Festplatte, von der Datenbank | das Rechnen selbst |
| Was hilft | auf mehrere Dinge gleichzeitig warten | mehr Kerne |
| In Python | Threads, asyncio | Prozesse: ProcessPoolExecutor |
| Auf einem Kern | hilft | hilft nicht |
Threads haben ihren Preis. Solange jeder mit seinen eigenen Daten arbeitet (sein eigener Dienst, seine eigenen 250 Rechnungen), ist alles gut. Sobald zwei Threads dasselbe anfassen (ein gemeinsamer Zähler, der Bestand im Lager), beginnen Fehler, die nicht bei jedem Lauf auftreten. Das ist die nächste Lektion: „Race Condition“.
Übung
Die Produktseite
Löse die Aufgabe in deinem Editor.
Die Produktseite. Bevor der Server die Seite zeigt, fragt er drei Dienste: den Preis, die Bewertungen und die Lieferung. Jeder antwortet in etwa 200 ms und hängt nicht von den anderen ab. Nimm Network aus der ersten Karte und frage alle auf einmal, sodass es eine Warterunde gibt:
Preis: 89 €Bewertungen: 4,6 von 5Lieferung: morgenWarterunden: 1Weitere Aufgaben
- Fünf Dienste. Füge das Lager und einen Rabatt hinzu. Wie viele Runden sind es, wenn der Rabatt vom Preis abhängt?
- Wie viele Kerne. Führe die Rechnungen mit 1, 2, 4 und 8 Teilen aus und miss die Zeit auf deiner Maschine. Wann wird es nicht mehr schneller? Vergleiche mit der Zahl der Kerne deiner Maschine.
- Das falsche Werkzeug. Setze in
renderbei jeder zehnten Rechnung einepause/sleepvon 200 ms ein, als würde sie auf die Festplatte warten. Was hilft jetzt mehr: mehr Threads oder mehr Kerne?
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Exceptions · Listen: append, remove und sort · Lambdas · Threads
- Geht weiter in: Race Condition · Sperren · Atomare Operationen und unveränderliche Objekte · Producer und Consumer
- Aufgaben zum Thema: Nebenläufigkeit und Race Conditions