En Python, le threading permet de lancer plusieurs tâches en parallèle dans le même programme en utilisant des threads (ou fils d'exécution). Dans un script Python, par défaut, le code s'exécute dans un unique thread principal. Mais avec le module threading, il est possible de créer plusieurs threads pour exécuter des opérations concurrentes.
Le module threading est particulièrement efficace pour lancer plusieurs tâches qui fonctionnent de manière indépendante. Les exemples les plus courants sont les lectures de fichiers ou les requêtes réseau. Contrairement au multiprocessing, les threads partagent le même espace mémoire.
Un programme Python utilisant le multithreading ne se terminera qu'une fois que tous les threads auront terminé leur exécution.
Le GIL (Global Interpreter Lock)
Le GIL est un verrou qui empêche plusieurs threads d'exécuter du code en même temps : Python exécute uniquement un thread à la fois. Le GIL est acquis par un thread juste avant l'exécution du code Python. Il est relâché quand le thread exécute une opération bloquante (comme l'attente d'une réponse réseau), ou périodiquement (toutes les 5 millisecondes).
Création des threads
Il existe deux façons principales de créer des threads.
En passant une fonction à la classe Thread :
import threading import time def tache_longue(nom: str) -> None: print(f"Debut de {nom}") time.sleep(3) print(f"fin de {nom}") # Création de 2 threads thread1 = threading.Thread(target=tache_longue, args=("Tache 1", )) thread2 = threading.Thread(target=tache_longue, args=("Tache 2", ))
Ou en héritant de la classe Thread :
import threading import time class MyThread(threading.Thread): def __init__(self, name): super().__init__() self.name = name def run(self): print(f"Debut de {self.name}") time.sleep(3) print(f"fin de {self.name}") # Création des threads thread3 = MyThread("Tache 3") thread4 = MyThread("Tache 4")
Exécuter les threads
Pour exécuter les threads, on utilise deux méthodes : start et join.
import threading import time def tache_longue(nom: str) -> None: print(f"Début de {nom}") time.sleep(3) # Simule une tâche qui prend du temps (comme une requête réseau) print(f"Fin de {nom}") # Création de 2 threads thread1 = threading.Thread(target=tache_longue, args=("Tâche 1",)) thread2 = threading.Thread(target=tache_longue, args=("Tâche 2",)) print("Lancement des threads...") # Démarrage des threads - ils s'exécutent en parallèle thread1.start() thread2.start() print("Les threads sont lancés et continuent en arrière-plan") # Attente de la fin des threads # Sans join(), le programme principal continuerait sans attendre la fin des threads thread1.join() thread2.join() print("Tous les threads sont terminés")
La méthode start lance l'exécution du thread en parallèle du programme. Sans elle, la fonction ne s'exécuterait pas dans un nouveau thread. Le programme peut donc continuer sans attendre la fin du thread.
La méthode join permet d'éviter que le programme se termine avant la fin d'exécution de tous les threads.
Dans notre exemple, sans threading l'exécution serait séquentielle : la tâche 1 se terminerait complètement avant que la tâche 2 ne commence. Les tâches prennent chacune 3 secondes, donc un total de 6 secondes.
Avec threading, les tâches démarrent presque simultanément et se terminent presque en même temps. Les deux tâches vont donc prendre environ 3 secondes.
Le Lock (verrou)
Quand plusieurs threads accèdent aux mêmes ressources (variables, fichiers, etc.), sans verrou des problèmes de concurrence peuvent survenir. Le verrou protège les ressources partagées de sorte qu'un seul thread à la fois y accède.
import threading import time # Variable partagée compteur = 0 def incrementer_sans_lock(): global compteur current = compteur time.sleep(5) # Simule un traitement long compteur = current + 1 threads = list() for _ in range(5): t = threading.Thread(target=incrementer_sans_lock) threads.append(t) t.start() for t in threads: t.join() print(compteur) # Résultat : 1 (et non 5 comme on pourrait s'y attendre) # Tous les threads lisent 0 et écrivent 0 + 1
Dans l'exemple ci-dessus, chaque thread lit la valeur 0 du compteur, chaque thread l'incrémente de 1 en écrasant les incrémentations précédentes.
À noter
L'utilisation de global de cette façon (et même en règle général...) est déconseillée, nous l'utilisons ici uniquement pour illustrer le problème que peut poser le verrou.
Avec un verrou, on protège l'accès à cette variable partagée :
import threading import time # Variable partagée compteur = 0 def incrementer_avec_lock(lock): print("Traitement long") time.sleep(5) with lock: # permet d'éviter les erreurs de concurrence global compteur current = compteur compteur = current + 1 lock = threading.Lock() threads = list() for _ in range(5): t = threading.Thread(target=incrementer_avec_lock, args=(lock, )) threads.append(t) t.start() for t in threads: t.join() print(compteur) # Résultat : 5 (comme attendu)
Dans ce cas précis on instancie la classe threading.Lock() pour passer l'instance en argument et l'utiliser dans un context manager (l'instruction with) : ce qui permet de protéger la variable des accès concurrents.
Limites de threading
Bien que threading soit utile pour les opérations d'I/O (entrées/sorties) comme les requêtes réseau, on notera une limite importante pour les opérations de calcul intensif. Ce qui nous amène à évoquer multiprocessing, librairie utilisée pour les tâches CPU-bound (tâches liées au processeur) comme des calculs intensifs.
Pourquoi threading est limité pour le calcul ?
threading permet d'exécuter plusieurs threads, mais avec le GIL, ils vont s'exécuter sur un seul cœur du processeur. multiprocessing permet de créer plusieurs processus Python indépendants avec leurs propres espaces mémoire. Plusieurs cœurs du processeur peuvent être utilisés, contournant ainsi le GIL.
Nous pouvons aussi évoquer async, une approche efficace pour les opérations I/O comme threading.
async ou threading ?
Si la librairie utilisée est compatible avec async, alors on utilisera async. Cependant, il arrive que certaines librairies ne soient pas compatibles avec async. C'est à ce moment-là que l'on préférera threading.
À noter
Lorsque c'est possible, on préférera async pour les opérations I/O, c'est plus léger que threading et un unique thread est utilisé.