Threads : de la concurrence dans un seul processus
Un thread est une ligne d'exécution indépendante dans un même programme, qui partage sa mémoire avec les autres threads. Un programme de trading qui doit récupérer des cotations de dix places, écrire un journal et garder une interface réactive utilise des threads pour que l'attente de l'un n'arrête pas les autres. Le module threading de Python et, plus commodément, concurrent.futures.ThreadPoolExecutor les lancent et les gèrent. Comme les threads partagent des variables, ils communiquent vite et il est dangereux de se tromper.
# 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 sLe GIL : des threads qui attendent et des threads qui calculent
CPython a un verrou global de l'interpréteur (GIL) : un seul thread exécute du bytecode Python à un instant donné. Un thread le cède pendant qu'il attend le réseau, un fichier ou time.sleep, si bien que les threads se chevauchent très bien dans l'attente : dix threads récupérant des cotations finissent en à peu près le temps d'un seul. Mais pour du code Python lié au processeur (une boucle Python pure faisant de l'arithmétique), les threads se relaient, donc quatre threads ne vont pas plus vite qu'un, et souvent un peu moins vite. Le code qui s'exécute hors de Python, comme NumPy ou une extension C qui relâche le GIL, peut tourner en parallèle dans des threads. Pour un calcul Python pur lourd, utilisez des processus séparés (sujet suivant), ou la version sans GIL (free-threaded) de Python 3.13+, quand elle est disponible.
# 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)) # 21Données partagées, conditions de course et verrous
Des threads qui mettent à jour la même variable peuvent la corrompre, car total += amount fait trois étapes (lire, additionner, écrire) et un changement de thread peut survenir entre elles : deux threads lisent tous deux 100, ajoutent 5, écrivent 105, et une mise à jour est perdue. C'est une condition de course, qui apparaît rarement et de façon imprévisible, ce qui en fait le pire type de bug. Le remède est un verrou : with lock: laisse un seul thread à la fois exécuter le bloc, et la lecture comme l'écriture doivent y être. Mieux encore, évitez le partage : donnez à chaque travailleur son propre morceau et son propre résultat, puis combinez les résultats à la fin, ou faites passer travail et résultats par une queue.Queue, déjà sûre pour les threads.
# 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 (vu plus tôt dans ce parcours) passe en général mieux à l'échelle et n'a pas de courses entre les await. Pour l'arithmétique sur tableaux, vectorisez avec NumPy. Pour du Python lié au processeur, utilisez des processus. Si vous partagez des données entre threads, gardez la section verrouillée courte, n'appelez jamais de code lent en tenant un verrou, et prenez plusieurs verrous dans le même ordre partout, sinon deux threads peuvent s'attendre pour toujours (un interblocage).- Les threads partagent la mémoire et se chevauchent bien dans l'attente (réseau, fichiers) ; un
ThreadPoolExecutorpeut interroger de nombreuses places à la fois. - Le GIL ne laisse qu'un thread exécuter du bytecode Python à la fois : pas d'accélération pour du Python pur lié au processeur, mais NumPy et les extensions C peuvent le relâcher.
- Protégez les mises à jour partagées par
with lock:autour de la lecture et de l'écriture, préférez ne pas partager (morceaux, files) et surveillez les interblocages.
À suivre : le multiprocessing, utiliser plusieurs processus pour faire travailler chaque cœur du processeur.
Lab : ce qui est noté
- Répartir le travail avec
split_chunks25 pts - Appliquer une fonction aux morceaux avec
run_in_chunks25 pts - Protéger des données partagées avec
safe_add25 pts - Savoir ce que les threads apportent avec
estimate_time25 pts