
Un thread permet à un programme Python d'exécuter plusieurs tâches en parallèle apparent, sur un seul processus, en partageant la même mémoire. Les threads brillent sur les tâches I/O bound (attente réseau, disque, base de données) où le programme passe son temps à patienter. En revanche, à cause du GIL (Global Interpreter Lock), ils n'accélèrent quasiment pas les tâches CPU bound (calcul pur). Ce guide couvre threading.Thread (start, join, daemon), les conditions de course, leur correction avec un Lock, les primitives RLock, Event, Condition, Semaphore, l'isolation avec threading.local et la file thread-safe queue.Queue. Les exemples ont été exécutés avec Python 3.12.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Lancer et joindre des threads avec
threading.Thread, comprendre les threadsdaemon. - Reproduire une vraie condition de course qui produit un résultat faux.
- Corriger cette course avec un
Lock, et choisir entreLock,RLock,Event,Condition,Semaphore. - Isoler l'état par thread avec
threading.localet communiquer sans verrou viaqueue.Queue. - Décider quand les threads accélèrent (I/O bound) et quand le GIL les rend inutiles (CPU bound).
Lancer un thread avec threading.Thread
Section intitulée « Lancer un thread avec threading.Thread »La classe threading.Thread est le point d'entrée. On lui passe une fonction target et ses arguments, puis on appelle start() pour démarrer l'exécution en arrière-plan et join() pour attendre sa fin. Sans join(), le programme principal continue sans se soucier du thread, ce qui mène vite à des sorties incohérentes.
import threadingimport time
def telecharger(nom: str, duree: float) -> None: print(f"[{nom}] debut") time.sleep(duree) print(f"[{nom}] termine apres {duree}s")
debut = time.perf_counter()t1 = threading.Thread(target=telecharger, args=("rapport.pdf", 1.0))t2 = threading.Thread(target=telecharger, args=("archive.zip", 1.0))t1.start()t2.start()t1.join()t2.join()print(f"Total : {time.perf_counter() - debut:.2f}s")Les deux téléchargements simulés durent une seconde chacun, mais ils s'exécutent en même temps pendant l'attente. Le total mesuré est donc d'environ une seconde, pas deux.
[rapport.pdf] debut[archive.zip] debut[rapport.pdf] termine apres 1.0s[archive.zip] termine apres 1.0sTotal : 1.00sCe gain vient du fait que time.sleep relâche le GIL pendant l'attente : c'est exactement le comportement d'un appel réseau réel. Le thread principal orchestre, les threads secondaires attendent en parallèle, et le programme s'exécute deux fois plus vite qu'une version séquentielle.
Les threads daemon
Section intitulée « Les threads daemon »Un thread daemon est un thread d'arrière-plan qui ne doit pas empêcher le programme de se terminer. Quand tous les threads non daemon (thread principal compris) se sont terminés, le programme s'arrête et les threads daemon encore actifs sont tués immédiatement, sans attendre leur fin. On les réserve aux tâches accessoires (surveillance, heartbeat, nettoyage périodique) dont l'interruption brutale ne pose pas de problème.
import threadingimport time
def surveiller() -> None: while True: time.sleep(0.5) print("... surveillance en cours")
veilleur = threading.Thread(target=surveiller, daemon=True)veilleur.start()
time.sleep(1.2)print("programme principal termine")La boucle infinie ne bloque pas la sortie : dès que le thread principal atteint sa dernière ligne, le veilleur daemon est stoppé net.
... surveillance en cours... surveillance en coursprogramme principal termineLe partage d'état et les conditions de course
Section intitulée « Le partage d'état et les conditions de course »Le vrai danger des threads n'est pas leur lancement, c'est la mémoire partagée. Plusieurs threads qui lisent et écrivent la même variable sans coordination provoquent une condition de course (race condition) : le résultat dépend de l'ordre imprévisible d'exécution, et il devient faux de façon intermittente. L'exemple suivant fait déposer 10 sur un solde commun, 50 fois par thread, sur 8 threads. Le résultat correct est donc 4000.
import threadingimport time
solde = 0
def deposer(nb_operations: int) -> None: global solde for _ in range(nb_operations): courant = solde # 1. lecture du solde time.sleep(0) # 2. bascule pendant le "traitement" solde = courant + 10 # 3. ecriture du nouveau solde
threads = [threading.Thread(target=deposer, args=(50,)) for _ in range(8)]for t in threads: t.start()for t in threads: t.join()
print(f"Attendu : {8 * 50 * 10}")print(f"Obtenu : {solde}")L'opération solde = courant + 10 n'est pas atomique : elle se décompose en une lecture, un calcul et une écriture. Entre la lecture et l'écriture, un autre thread peut lire la même valeur périmée et écraser le dépôt du premier. Le time.sleep(0) place la bascule pile dans cette fenêtre vulnérable, ce qui rend la course systématique et non plus dépendante de la chance. Le résultat obtenu est catastrophiquement faux.
Attendu : 4000Obtenu : 540Chaque exécution donne un chiffre différent (540, 550, 580...), toujours très inférieur à 4000. Des centaines de dépôts sont perdus parce qu'ils écrasent le travail d'un autre thread. C'est le bug le plus vicieux du parallélisme : il passe les tests unitaires en séquentiel et casse en production sous charge.
Corriger la course avec un Lock
Section intitulée « Corriger la course avec un Lock »Un Lock (verrou mutex) garantit qu'un seul thread à la fois entre dans la section critique. Les autres attendent leur tour. On l'utilise toujours avec le gestionnaire de contexte with : le verrou est acquis à l'entrée du bloc et libéré automatiquement à la sortie, même en cas d'exception. C'est la seule modification à apporter au code précédent.
import threadingimport time
solde = 0verrou = threading.Lock()
def deposer(nb_operations: int) -> None: global solde for _ in range(nb_operations): with verrou: # section critique protegee courant = solde time.sleep(0) solde = courant + 10
threads = [threading.Thread(target=deposer, args=(50,)) for _ in range(8)]for t in threads: t.start()for t in threads: t.join()
print(f"Attendu : {8 * 50 * 10}")print(f"Obtenu : {solde}")La séquence lecture-calcul-écriture devient indivisible du point de vue des autres threads. Le résultat est désormais exact et reproductible à chaque exécution.
Attendu : 4000Obtenu : 4000Le verrou a un coût : la section critique devient séquentielle, ce qui annule une partie du parallélisme. La règle est donc de verrouiller le moins possible, sur la plus petite portion de code qui touche à l'état partagé, jamais autour d'un calcul long ou d'un appel réseau.
RLock, le verrou réentrant
Section intitulée « RLock, le verrou réentrant »Un Lock classique se bloque lui-même si le thread qui le détient tente de l'acquérir une seconde fois. Le RLock (verrou réentrant) résout ce cas : le même thread peut l'acquérir plusieurs fois de suite, à condition de le relâcher autant de fois. C'est utile quand une méthode verrouillée appelle une autre méthode qui verrouille le même objet.
import threading
verrou = threading.RLock()
def solde_total(comptes: list[int]) -> int: with verrou: return sum(comptes)
def audit(comptes: list[int]) -> None: with verrou: # premier acquire total = solde_total(comptes) # second acquire imbrique : OK avec RLock print(f"total audite : {total}")
audit([100, 250, 75])Avec un Lock ordinaire, l'appel imbriqué se figerait sur place. Le RLock compte les acquisitions et laisse passer le thread propriétaire.
total audite : 425Event, signaler entre threads
Section intitulée « Event, signaler entre threads »Un Event est un drapeau booléen partagé qui permet à un thread d'en réveiller d'autres. Les threads en attente appellent wait() et restent bloqués tant qu'un autre thread n'a pas appelé set(). C'est le mécanisme idéal pour un démarrage coordonné ou un arrêt propre, bien plus fiable qu'une boucle qui interroge une variable en continu.
import threadingimport time
pret = threading.Event()
def travailleur() -> None: print("en attente du signal...") pret.wait() # bloque tant que l'evenement n'est pas leve print("signal recu, demarrage")
t = threading.Thread(target=travailleur)t.start()time.sleep(0.5)print("configuration terminee, envoi du signal")pret.set() # debloque tous les threads en attentet.join()Le travailleur ne consomme aucun CPU pendant son attente : il dort jusqu'au signal. C'est la différence essentielle avec une attente active.
en attente du signal...configuration terminee, envoi du signalsignal recu, demarrageCondition, attendre qu'un état change
Section intitulée « Condition, attendre qu'un état change »La Condition combine un verrou et un mécanisme de notification. Elle sert au patron producteur / consommateur : un consommateur attend qu'une ressource devienne disponible, un producteur la crée puis notifie les threads en attente. La méthode wait() libère le verrou pendant l'attente et le reprend au réveil, ce qui évite tout blocage.
import threadingimport time
condition = threading.Condition()stock: list[str] = []
def consommateur() -> None: with condition: while not stock: condition.wait() # libere le verrou et attend d'etre reveille article = stock.pop() print(f"consomme : {article}")
def producteur() -> None: time.sleep(0.3) with condition: stock.append("colis-42") print("produit : colis-42") condition.notify() # reveille un consommateur en attente
c = threading.Thread(target=consommateur)p = threading.Thread(target=producteur)c.start(); p.start()c.join(); p.join()Le while not stock autour du wait() est obligatoire : un thread peut être réveillé sans que la condition soit réellement remplie (réveil intempestif). On revérifie donc toujours l'état après le réveil.
produit : colis-42consomme : colis-42Semaphore, limiter les accès simultanés
Section intitulée « Semaphore, limiter les accès simultanés »Un Semaphore autorise un nombre fixe de threads à entrer en même temps dans une section, au lieu d'un seul comme le Lock. On l'utilise pour plafonner la concurrence : limiter les connexions ouvertes vers une API, le nombre de requêtes simultanées vers une base ou les téléchargements en parallèle, afin de ne pas saturer une ressource externe.
import threadingimport time
limite = threading.Semaphore(2) # 2 acces simultanes maximumactifs = 0verrou = threading.Lock()
def requete(numero: int) -> None: global actifs with limite: with verrou: actifs += 1 print(f"requete {numero} demarre (actifs simultanes : {actifs})") time.sleep(0.2) with verrou: actifs -= 1
threads = [threading.Thread(target=requete, args=(i,)) for i in range(6)]for t in threads: t.start()for t in threads: t.join()Malgré 6 threads lancés d'un coup, le compteur d'actifs ne dépasse jamais 2. Le sémaphore fait patienter les threads supplémentaires jusqu'à ce qu'une place se libère.
requete 0 demarre (actifs simultanes : 1)requete 1 demarre (actifs simultanes : 2)requete 2 demarre (actifs simultanes : 1)requete 3 demarre (actifs simultanes : 2)requete 4 demarre (actifs simultanes : 2)requete 5 demarre (actifs simultanes : 2)threading.local, un état propre à chaque thread
Section intitulée « threading.local, un état propre à chaque thread »Parfois, on ne veut pas partager du tout : chaque thread a besoin de sa propre copie d'une donnée. L'objet threading.local fournit exactement cela. Un attribut posé dessus est invisible des autres threads, ce qui supprime le besoin de verrou. C'est le mécanisme utilisé en interne par de nombreux frameworks pour stocker la connexion de base de données ou l'utilisateur courant d'une requête.
import threading
donnees = threading.local()
def traiter(nom: str) -> None: donnees.utilisateur = nom # propre a chaque thread print(f"thread {threading.current_thread().name} -> {donnees.utilisateur}")
threads = [threading.Thread(target=traiter, args=(n,), name=n) for n in ["Alice", "Bob", "Chloe"]]for t in threads: t.start()for t in threads: t.join()Chaque thread lit sa valeur, sans interférence. Il n'y a aucune course possible puisqu'il n'y a aucun partage.
thread Alice -> Alicethread Bob -> Bobthread Chloe -> Chloequeue.Queue, communiquer sans verrou manuel
Section intitulée « queue.Queue, communiquer sans verrou manuel »La meilleure façon d'éviter les verrous est souvent de ne pas partager d'état mutable du tout, mais de faire circuler des messages. La classe queue.Queue est une file d'attente thread-safe : ses méthodes put et get gèrent le verrouillage en interne. Elle implémente à elle seule le patron producteur / consommateur, ici un pool de 3 ouvriers qui traitent des pages web.
import queueimport threadingimport time
taches: "queue.Queue[str]" = queue.Queue()
def ouvrier(numero: int) -> None: while True: url = taches.get() # bloque jusqu'a recevoir une tache if url is None: # sentinelle d'arret taches.task_done() break print(f"ouvrier {numero} traite {url}") time.sleep(0.1) taches.task_done() # signale la tache terminee
ouvriers = [threading.Thread(target=ouvrier, args=(i,)) for i in range(3)]for t in ouvriers: t.start()
for page in ["accueil", "produits", "contact", "blog", "cgv"]: taches.put(page)
taches.join() # attend que toutes les taches soient traitees
for _ in ouvriers: taches.put(None) # une sentinelle par ouvrierfor t in ouvriers: t.join()print("File videe, ouvriers arretes")Le taches.join() bloque jusqu'à ce que chaque get ait été suivi d'un task_done(). La sentinelle None sert de signal d'arrêt propre : on en envoie une par ouvrier pour que chaque boucle se termine. Aucun Lock explicite n'apparaît, la file s'en charge.
ouvrier 0 traite accueilouvrier 1 traite produitsouvrier 2 traite contactouvrier 1 traite blogouvrier 0 traite cgvFile videe, ouvriers arretesQuand utiliser les threads : le rôle du GIL
Section intitulée « Quand utiliser les threads : le rôle du GIL »Voici la question décisive. Le GIL est un verrou global de l'interpréteur CPython qui n'autorise qu'un seul thread à exécuter du bytecode Python à la fois. Conséquence : sur du calcul pur (CPU bound), plusieurs threads ne s'exécutent pas vraiment en parallèle et n'apportent aucun gain. Mais dès qu'un thread attend une ressource externe (I/O bound), il relâche le GIL, ce qui laisse les autres avancer. Le test suivant mesure les deux cas côte à côte.
import threadingimport time
def io_bound() -> None: time.sleep(1) # simule un appel reseau / disque
def cpu_bound() -> None: total = 0 for i in range(20_000_000): total += i
def mesurer(nom: str, cible, n: int) -> None: debut = time.perf_counter() threads = [threading.Thread(target=cible) for _ in range(n)] for t in threads: t.start() for t in threads: t.join() print(f"{nom:22} {n} threads : {time.perf_counter() - debut:.2f}s")
mesurer("io_bound (4x)", io_bound, 4)mesurer("cpu_bound (4x)", cpu_bound, 4)Le contraste est net et sans appel. Les 4 tâches I/O bound s'exécutent en 1 seconde au lieu de 4 : les threads attendent en parallèle. Les 4 tâches CPU bound, elles, sont plus lentes que si on les avait enchaînées, à cause du surcoût de bascule du GIL entre threads.
io_bound (4x) 4 threads : 1.00scpu_bound (4x) 4 threads : 1.71sLa règle est donc tranchée. Pour de l'I/O bound (requêtes HTTP, lectures disque, appels base), les threads sont parfaitement adaptés, tout comme asyncio qui pousse le modèle plus loin sur un seul thread. Pour du CPU bound (compression, traitement d'image, calcul scientifique), les threads sont inutiles à cause du GIL : il faut alors des processus séparés, chacun avec son propre interpréteur et son propre GIL.
Contrôle de connaissances
Section intitulée « Contrôle de connaissances »Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.
Contrôle de connaissances
Validez vos connaissances avec ce quiz interactif
Informations
- Le chronomètre démarre au clic sur Démarrer
- Questions à choix multiples, vrai/faux et réponses courtes
- Vous pouvez naviguer entre les questions
- Les résultats détaillés sont affichés à la fin
Lance le quiz et démarre le chronomètre
Vérification
(0/0)Profil de compétences
Quoi faire maintenant
Ressources pour progresser
Des indices pour retenter votre chance ?
Nouveau quiz complet avec des questions aléatoires
Retravailler uniquement les questions ratées
Retour à la liste des certifications
À retenir
Section intitulée « À retenir »- On lance un thread avec
threading.Thread(target=...),start()puisjoin()pour attendre sa fin. - Un thread
daemonest tué à la sortie du programme : réservé aux tâches accessoires, jamais aux opérations critiques. - Le partage de mémoire sans coordination provoque une condition de course : un résultat faux et intermittent.
- Un
Lockutilisé avecwithrend la section critique indivisible et corrige la course ; verrouillez le minimum de code. RLockautorise le même thread à réacquérir le verrou ;Eventsignale,Conditionattend un changement d'état,Semaphoreplafonne la concurrence.threading.localdonne un état propre à chaque thread, sans verrou ;queue.Queuefait circuler des messages de façon thread-safe.- Le GIL rend les threads efficaces sur l'I/O bound et inutiles sur le CPU bound, où il faut des processus.
Pour aller plus loin
Section intitulée « Pour aller plus loin »- asyncio en Python : la concurrence I/O bound sur un seul thread, sans verrou ni condition de course.
- Gestionnaires de contexte : comprendre le
withqui libère automatiquement unLock, même en cas d'exception. - Journalisation avec logging : le module standard, thread-safe par conception, pour tracer un programme multithread.
- Gérer les exceptions : sécuriser le code d'un thread, dont l'erreur ne remonte pas au thread principal.
- concurrent.futures : le
ThreadPoolExecutorqui gère le cycle de vie des threads et la remontée des résultats à votre place. - Comprendre le GIL : le verrou qui explique pourquoi les threads accélèrent l'I/O mais pas les calculs.