Qu'est-ce que le threading en Python ?

Découvrez le module threading en Python : créez des threads, gérez la concurrence et optimisez vos programmes avec l'exécution parallèle et le multithreading.
9 min de lecture
Believemy logo

Définition du threading en Python

Le threading est un mécanisme qui permet d'exécuter plusieurs flux d'instructions (appelés threads) simultanément au sein d'un même processus. En Python, le module threading de la bibliothèque standard fournit tous les outils nécessaires pour créer, gérer et synchroniser des threads. Si vous souhaitez maîtriser la programmation concurrente en Python, notre formation complète sur Python vous guidera pas à pas dans ces concepts avancés.

Un thread (ou fil d'exécution) est la plus petite unité d'exécution qu'un système d'exploitation peut planifier. Contrairement aux processus, les threads d'un même programme partagent le même espace mémoire, ce qui les rend légers et rapides à créer, mais aussi plus délicats à gérer en raison des problèmes potentiels d'accès concurrent aux données.

Bon à savoir

En Python, le GIL (Global Interpreter Lock) empêche l'exécution réellement parallèle de plusieurs threads Python sur plusieurs cœurs CPU. Le threading reste néanmoins très utile pour les opérations d'entrée/sortie (I/O-bound) comme les requêtes réseau, la lecture de fichiers ou les attentes de réponses.


Pourquoi utiliser le threading ?

Le threading est particulièrement adapté dans les situations suivantes :

  • Opérations I/O-bound : requêtes HTTP, lecture/écriture de fichiers, interactions avec des bases de données
  • Interfaces graphiques : maintenir la réactivité de l'interface pendant des traitements longs
  • Serveurs : gérer plusieurs connexions clients simultanément
  • Tâches en arrière-plan : journalisation, monitoring, mises à jour périodiques

En revanche, pour les tâches CPU-bound (calculs intensifs), vous devriez plutôt vous tourner vers le module multiprocessing qui contourne le GIL en utilisant des processus séparés.

CaractéristiqueThreadingMultiprocessing
Mémoire partagéeOuiNon (espaces séparés)
CréationRapide et légerPlus lourd
GILLimité par le GILContourne le GIL
Idéal pourI/O-boundCPU-bound
CommunicationVariables partagéesFiles, Pipes


Créer un thread en Python

Méthode 1 : avec une fonction cible

La manière la plus simple de créer un thread est de passer une fonction comme argument target au constructeur de threading.Thread :

PYTHON
import threading
import time

def telecharger_fichier(nom_fichier):
    print(f"Début du téléchargement de {nom_fichier}...")
    time.sleep(2)  # Simule un téléchargement
    print(f"Téléchargement de {nom_fichier} terminé !")

# Création des threads
thread1 = threading.Thread(target=telecharger_fichier, args=("image.png",))
thread2 = threading.Thread(target=telecharger_fichier, args=("video.mp4",))

# Démarrage des threads
thread1.start()
thread2.start()

# Attente de la fin des threads
thread1.join()
thread2.join()

print("Tous les téléchargements sont terminés.")

Dans cet exemple, les deux téléchargements s'exécutent en parallèle au lieu de se succéder. Le temps total d'exécution sera d'environ 2 secondes au lieu de 4 secondes en séquentiel. Remarquez l'utilisation des f-string pour formater les messages de sortie.


Méthode 2 : en héritant de Thread

Vous pouvez également créer un thread en définissant une class qui hérite de threading.Thread et en surchargeant la méthode run() :

PYTHON
import threading
import time

class MonThread(threading.Thread):
    """Thread personnalisé pour effectuer une tâche."""
    
    def __init__(self, nom, duree):
        super().__init__()
        self.nom = nom
        self.duree = duree
    
    def run(self):
        print(f"Thread '{self.nom}' démarré.")
        time.sleep(self.duree)
        print(f"Thread '{self.nom}' terminé après {self.duree}s.")

# Création et démarrage
t1 = MonThread("Tâche A", 3)
t2 = MonThread("Tâche B", 1)

t1.start()
t2.start()

t1.join()
t2.join()

print("Toutes les tâches sont terminées.")

Cette approche est idéale lorsque vous avez besoin d'encapsuler une logique complexe à l'intérieur du thread, en tirant parti de la programmation orientée objet. Notez l'utilisation d'une docstring pour documenter la classe.


Synchronisation des threads

Lorsque plusieurs threads accèdent aux mêmes données, des problèmes de concurrence (race conditions) peuvent survenir. Python fournit plusieurs mécanismes de synchronisation pour éviter ces problèmes.

Le verrou (Lock)

Un Lock est le mécanisme de synchronisation le plus simple. Il garantit qu'un seul thread à la fois peut exécuter une section critique :

PYTHON
import threading

compteur = 0
verrou = threading.Lock()

def incrementer(n):
    global compteur
    for _ in range(n):
        verrou.acquire()
        try:
            compteur += 1
        finally:
            verrou.release()

# Création de deux threads
t1 = threading.Thread(target=incrementer, args=(100000,))
t2 = threading.Thread(target=incrementer, args=(100000,))

t1.start()
t2.start()
t1.join()
t2.join()

print(f"Compteur final : {compteur}")  # Toujours 200000

Sans le verrou, le résultat final pourrait être inférieur à 200000 car les deux threads pourraient lire et écrire la variable compteur en même temps. Notez l'utilisation du mot-clé global pour modifier la variable définie en dehors de la fonction.

Attention

Utilisez toujours un bloc try/finally avec acquire()/release(), ou mieux encore, utilisez le gestionnaire de contexte with pour garantir la libération du verrou même en cas d'exception.


Le verrou avec gestionnaire de contexte

La syntaxe with rend l'utilisation des verrous bien plus propre et sûre :

PYTHON
import threading

compteur = 0
verrou = threading.Lock()

def incrementer(n):
    global compteur
    for _ in range(n):
        with verrou:
            compteur += 1

threads = []
for i in range(5):
    t = threading.Thread(target=incrementer, args=(50000,))
    threads.append(t)
    t.start()

for t in threads:
    t.join()

print(f"Compteur final : {compteur}")  # Toujours 250000

Cette approche est recommandée car elle garantit que le verrou sera libéré automatiquement, même si une exception se produit dans le bloc protégé. Nous utilisons ici une list pour stocker les threads créés.


RLock (verrou réentrant)

Un RLock (Reentrant Lock) peut être acquis plusieurs fois par le même thread sans provoquer de deadlock :

PYTHON
import threading

verrou = threading.RLock()

def fonction_externe():
    with verrou:
        print("Verrou acquis dans fonction_externe")
        fonction_interne()

def fonction_interne():
    with verrou:
        print("Verrou acquis dans fonction_interne")

t = threading.Thread(target=fonction_externe)
t.start()
t.join()

Avec un Lock classique, ce code provoquerait un deadlock car le thread tenterait d'acquérir un verrou qu'il possède déjà. Le RLock résout ce problème en comptant le nombre d'acquisitions.


Les événements (Event)

Un Event permet à un thread de signaler à d'autres threads qu'un événement s'est produit :

PYTHON
import threading
import time

evenement = threading.Event()

def serveur():
    print("Serveur : initialisation en cours...")
    time.sleep(3)
    print("Serveur : prêt !")
    evenement.set()  # Signale que le serveur est prêt

def client(nom):
    print(f"Client {nom} : en attente du serveur...")
    evenement.wait()  # Attend que l'événement soit signalé
    print(f"Client {nom} : connecté au serveur !")

# Démarrage du serveur et des clients
threading.Thread(target=serveur).start()
threading.Thread(target=client, args=("Alice",)).start()
threading.Thread(target=client, args=("Bob",)).start()

Les clients attendent que le serveur soit prêt avant de se connecter. L'événement sert de signal de synchronisation entre les threads.


Les threads démons

Un thread démon (daemon thread) est un thread qui s'exécute en arrière-plan et qui se termine automatiquement lorsque tous les threads non-démons sont terminés :

PYTHON
import threading
import time

def tache_arriere_plan():
    while True:
        print("Surveillance en cours...")
        time.sleep(1)

# Thread démon : se termine quand le programme principal s'arrête
moniteur = threading.Thread(target=tache_arriere_plan, daemon=True)
moniteur.start()

# Programme principal
print("Programme principal en cours...")
time.sleep(3)
print("Programme principal terminé.")
# Le thread démon se termine automatiquement ici

Les threads démons sont très utiles pour les tâches de surveillance, de journalisation ou tout traitement qui doit s'exécuter tant que le programme est actif, sans empêcher sa fermeture.


ThreadPoolExecutor : le threading moderne

Depuis Python 3.2, le module concurrent.futures offre une interface de haut niveau pour gérer des pools de threads. C'est la manière recommandée de travailler avec le threading dans les projets modernes :

PYTHON
from concurrent.futures import ThreadPoolExecutor, as_completed
import time

def traiter_requete(url):
    """Simule le traitement d'une requête HTTP."""
    print(f"Requête vers {url}...")
    time.sleep(1)  # Simule la latence réseau
    return f"Réponse de {url}"

urls = [
    "https://api.exemple.com/users",
    "https://api.exemple.com/posts",
    "https://api.exemple.com/comments",
    "https://api.exemple.com/albums",
    "https://api.exemple.com/photos",
]

# Utilisation d'un pool de 3 threads maximum
with ThreadPoolExecutor(max_workers=3) as executor:
    futures = {executor.submit(traiter_requete, url): url for url in urls}
    
    for future in as_completed(futures):
        url = futures[future]
        try:
            resultat = future.result()
            print(f"Succès : {resultat}")
        except Exception as e:
            print(f"Erreur pour {url} : {e}")

print("Toutes les requêtes ont été traitées.")

Le ThreadPoolExecutor gère automatiquement la création, la réutilisation et la destruction des threads. Nous utilisons ici un dict en compréhension pour associer chaque future à son URL d'origine.


Bonnes pratiques

Voici les bonnes pratiques à suivre lorsque vous utilisez le threading en Python :

  • Préférez ThreadPoolExecutor : utilisez concurrent.futures.ThreadPoolExecutor plutôt que de créer manuellement des threads pour un code plus propre et plus sûr.
  • Minimisez les données partagées : plus vous partagez de données entre threads, plus les risques de bugs liés à la concurrence augmentent. Passez les données en arguments et récupérez les résultats.
  • Utilisez toujours des verrous : si vous devez partager des données modifiables entre threads, protégez-les systématiquement avec un Lock ou un RLock.
  • Évitez les deadlocks : acquérez toujours les verrous dans le même ordre à travers votre programme, et utilisez des timeouts quand c'est possible.
  • Utilisez les threads démons avec précaution : les threads démons sont terminés brutalement. Ne leur confiez pas de tâches critiques nécessitant un nettoyage propre.
  • Gérez les exceptions : les exceptions dans un thread ne se propagent pas au thread principal. Capturez-les toujours dans le thread lui-même.
  • Limitez le nombre de threads : créer trop de threads engendre un surcoût de planification. Utilisez un pool de taille raisonnable.
Bon à savoir

Pour les opérations asynchrones à grande échelle (des centaines ou milliers de connexions), envisagez plutôt asyncio qui offre une meilleure scalabilité que le threading classique.


Questions fréquentes

Question

Quelle est la différence entre le threading et le multiprocessing en Python ?

Le threading utilise des threads au sein d'un même processus, partageant le même espace mémoire. Il est limité par le GIL pour les tâches CPU-bound mais excellent pour les opérations I/O-bound. Le multiprocessing crée des processus séparés avec leurs propres espaces mémoire, contournant ainsi le GIL et offrant un véritable parallélisme pour les calculs intensifs. Le choix dépend de la nature de votre tâche : I/O-bound → threading, CPU-bound → multiprocessing.


Question

Le GIL empêche-t-il vraiment le parallélisme en Python ?

Le GIL (Global Interpreter Lock) empêche l'exécution simultanée de bytecode Python par plusieurs threads sur plusieurs cœurs. Cependant, le GIL est relâché pendant les opérations I/O (réseau, fichiers, etc.) et lors de l'appel à certaines bibliothèques C (comme NumPy). Ainsi, le threading reste très performant pour les tâches I/O-bound. Notez que depuis Python 3.13, un mode expérimental "free-threaded" est en cours de développement pour supprimer le GIL à terme.


Question

Comment éviter les race conditions en Python ?

Pour éviter les race conditions (conditions de concurrence), vous devez protéger l'accès aux ressources partagées à l'aide de mécanismes de synchronisation : Lock, RLock, Semaphore ou Condition. La meilleure approche reste de minimiser les données partagées entre threads en passant les données via des arguments et en récupérant les résultats via des objets Future (avec ThreadPoolExecutor) ou des files (queue.Queue).


Question

Comment apprendre le threading et la programmation concurrente en Python ?

Le threading est un sujet avancé qui nécessite de bonnes bases en Python. Nous vous recommandons de commencer par maîtriser les fondamentaux du langage (fonctions, classes, gestion des exceptions) avant d'aborder la concurrence. Notre formation complète sur Python couvre ces concepts de manière progressive, depuis les bases jusqu'aux sujets avancés comme le threading, vous permettant de construire des compétences solides et directement applicables.

Termes connexes

Découvrez notre glossaire Python

Parcourez les termes et définitions les plus couramment utilisés dans le domaine du développement avec Python.

Partager cet article

Tu veux nous aider ? Fais un lien vers cet article sur tes réseaux ou encore mieux : sur ton site, dans un article ou dans ta newsletter.