Threads: Nebenläufigkeit in einem Prozess
Ein Thread ist eine unabhängige Ausführungslinie innerhalb eines Programms, die ihren Speicher mit den anderen Threads teilt. Ein Tradingprogramm, das Kurse von zehn Handelsplätzen holen, ein Log schreiben und eine Oberfläche reaktionsfähig halten muss, nutzt Threads, damit das Warten auf einen die übrigen nicht aufhält. Pythons threading-Modul und bequemer concurrent.futures.ThreadPoolExecutor starten und verwalten sie. Weil Threads Variablen teilen, kommunizieren sie schnell, und Fehler sind gefährlich.
# Ten slow "network calls" in a thread pool wait at the same time, so the total is about one call, not ten
import time
from concurrent.futures import ThreadPoolExecutor
def fetch_quote(symbol):
time.sleep(0.1) # stands in for waiting on a venue
return symbol, len(symbol) * 10.0
symbols = ["AAPL", "MSFT", "NVDA", "TSLA", "AMZN", "META", "GOOG", "NFLX", "ORCL", "INTC"]
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=10) as pool:
quotes = list(pool.map(fetch_quote, symbols))
elapsed = time.perf_counter() - start
print(quotes[0], len(quotes)) # ('AAPL', 40.0) 10
print(elapsed < 0.5) # True about 0.1 s in total, not 10 x 0.1 sDie GIL: wartende und rechnende Threads
CPython hat eine Global Interpreter Lock (GIL): Nur ein Thread führt zu jedem Zeitpunkt Python-Bytecode aus. Ein Thread gibt sie ab, während er auf das Netz, eine Datei oder time.sleep wartet, daher überlappen Threads das Warten sehr gut: Zehn Threads, die Kurse holen, sind in etwa der Zeit eines einzigen fertig. Bei CPU-gebundenem Python-Code (eine reine Python-Schleife mit Arithmetik) wechseln sich die Threads aber ab, sodass vier Threads nicht schneller sind als einer, oft sogar etwas langsamer. Code, der außerhalb von Python läuft, etwa NumPy oder eine C-Erweiterung, die die GIL freigibt, kann in Threads parallel laufen. Für schwere reine Python-Berechnung nutzen Sie getrennte Prozesse (nächstes Thema) oder, wo verfügbar, den GIL-freien (free-threaded) Build von Python 3.13+.
# Splitting a list into chunks for workers: sizes differ by at most one, the first chunks get the extra items
def split_chunks(items, workers):
size, extra = divmod(len(items), workers)
chunks, start = [], 0
for index in range(workers):
stop = start + size + (1 if index < extra else 0)
chunks.append(list(items[start:stop]))
start = stop
return chunks
chunks = split_chunks(list(range(7)), 3)
print(chunks) # [[0, 1, 2], [3, 4], [5, 6]]
print([sum(chunk) for chunk in chunks]) # [3, 7, 11] one partial sum per worker
print(sum(sum(chunk) for chunk in chunks)) # 21Gemeinsame Daten, Race Conditions und Locks
Threads, die dieselbe Variable aktualisieren, können sie beschädigen, denn total += amount besteht aus drei Schritten (lesen, addieren, schreiben), und dazwischen kann ein Threadwechsel erfolgen: Zwei Threads lesen beide 100, addieren 5, schreiben 105, und ein Update geht verloren. Das ist eine Race Condition; sie tritt selten und unvorhersehbar auf und ist deshalb die schlimmste Art von Bug. Die Abhilfe ist ein Lock: with lock: lässt jeweils nur einen Thread den Block ausführen, und Lesen wie Schreiben müssen darin liegen. Noch besser ist, das Teilen zu vermeiden: Geben Sie jedem Worker seinen eigenen Teil und sein eigenes Ergebnis und fassen Sie die Ergebnisse am Ende zusammen, oder leiten Sie Arbeit und Ergebnisse über eine queue.Queue, die bereits threadsicher ist.
# A lock makes the read-modify-write a single step; the total comes out exact
import threading
totals = {"AAPL": 0}
lock = threading.Lock()
def safe_add(shared, key, amount, lock):
with lock:
shared[key] = shared.get(key, 0) + amount
return shared[key]
def worker():
for _ in range(10_000):
safe_add(totals, "AAPL", 1, lock)
threads = [threading.Thread(target=worker) for _ in range(4)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
print(totals["AAPL"]) # 40000 nothing lost: four threads x 10,000 additions# A back-of-envelope estimate before writing any threaded code
import math
def estimate_time(n_tasks, seconds, workers, kind):
if kind in ("io", "processes"):
return math.ceil(n_tasks / workers) * seconds
return n_tasks * seconds # "cpu_threads": the GIL makes the threads take turns
print(estimate_time(40, 0.5, 8, "io")) # 2.5 forty slow requests, eight at a time
print(estimate_time(40, 0.5, 8, "cpu_threads")) # 20.0 forty CPU-bound tasks in threads: no gain
print(estimate_time(40, 0.5, 8, "processes")) # 2.5 separate processes use separate coresasyncio (weiter vorn in diesem Lernpfad) meist besser und hat keine Races zwischen awaits. Für Arithmetik auf Arrays vektorisieren Sie mit NumPy. Für CPU-gebundenes Python nutzen Sie Prozesse. Teilen Sie doch Daten zwischen Threads, halten Sie den gesperrten Abschnitt kurz, rufen Sie nie langsamen Code auf, während Sie ein Lock halten, und nehmen Sie mehrere Locks überall in derselben Reihenfolge, sonst können zwei Threads ewig aufeinander warten (ein Deadlock).- Threads teilen Speicher und überlappen Wartezeiten gut (Netz, Dateien); ein
ThreadPoolExecutorkann viele Handelsplätze gleichzeitig abfragen. - Die GIL lässt nur einen Thread gleichzeitig Python-Bytecode ausführen: kein Speed-up für CPU-gebundenes reines Python, aber NumPy und C-Erweiterungen können sie freigeben.
- Schützen Sie gemeinsame Updates mit
with lock:um Lesen und Schreiben, teilen Sie lieber nichts (Teile, Queues) und achten Sie auf Deadlocks.
Als Nächstes: Multiprocessing, mehrere Prozesse nutzen, um jeden CPU-Kern arbeiten zu lassen.
Lab: Was bewertet wird
- Mit
split_chunksdie Arbeit verteilen25 Pkt. - Mit
run_in_chunkseine Funktion auf die Teile anwenden25 Pkt. - Gemeinsame Daten mit
safe_addschützen25 Pkt. - Mit
estimate_timewissen, was Threads bringen25 Pkt.