Aller au contenu
Développement medium

Threads en Python : threading, Lock et le GIL

22 min de lecture

logo python

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.

  • Lancer et joindre des threads avec threading.Thread, comprendre les threads daemon.
  • Reproduire une vraie condition de course qui produit un résultat faux.
  • Corriger cette course avec un Lock, et choisir entre Lock, RLock, Event, Condition, Semaphore.
  • Isoler l'état par thread avec threading.local et communiquer sans verrou via queue.Queue.
  • Décider quand les threads accélèrent (I/O bound) et quand le GIL les rend inutiles (CPU bound).

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 threading
import 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.0s
Total : 1.00s

Ce 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.

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 threading
import 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 cours
programme principal termine

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 threading
import 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 : 4000
Obtenu : 540

Chaque 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.

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 threading
import time
solde = 0
verrou = 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 : 4000
Obtenu : 4000

Le 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.

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 : 425

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 threading
import 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 attente
t.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 signal
signal recu, demarrage

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 threading
import 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-42
consomme : colis-42

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 threading
import time
limite = threading.Semaphore(2) # 2 acces simultanes maximum
actifs = 0
verrou = 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)

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 -> Alice
thread Bob -> Bob
thread Chloe -> Chloe

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 queue
import threading
import 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 ouvrier
for 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 accueil
ouvrier 1 traite produits
ouvrier 2 traite contact
ouvrier 1 traite blog
ouvrier 0 traite cgv
File videe, ouvriers arretes

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 threading
import 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.00s
cpu_bound (4x) 4 threads : 1.71s

La 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.

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

6 questions
6 min.
70% requis

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

  • On lance un thread avec threading.Thread(target=...), start() puis join() pour attendre sa fin.
  • Un thread daemon est 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 Lock utilisé avec with rend la section critique indivisible et corrige la course ; verrouillez le minimum de code.
  • RLock autorise le même thread à réacquérir le verrou ; Event signale, Condition attend un changement d'état, Semaphore plafonne la concurrence.
  • threading.local donne un état propre à chaque thread, sans verrou ; queue.Queue fait 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.
  • asyncio en Python : la concurrence I/O bound sur un seul thread, sans verrou ni condition de course.
  • Gestionnaires de contexte : comprendre le with qui libère automatiquement un Lock, 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 ThreadPoolExecutor qui 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.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn