Exécuter plusieurs tâches simultanément : threading

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

Bravo, tu es prêt à passer à la suite

Rechercher sur le site

Formulaire de contact

Inscris-toi à Docstring

Pour commencer ton apprentissage.

Tu as déjà un compte ? Connecte-toi.