Multithreading en Python

Vous êtes-vous déjà trouvé dans une situation où vous devez attendre longtemps pendant le traitement de vos données ? Honnêtement, cela m’arrive souvent. Certains brandirons les drapeaux Spark /MapR.
Cependant, dans cet article, nous allons apprendre à utiliser rien que les ressources locales (ordinateur/serveur) disponibles pour paralléliser nos calculs.
Ainsi, dans cet article, nous présentons les concepts de multithreading en Python.

Déjà qu’est-ce qu’un thread ?

Déjà en informatique, un processus est une instance d’un programme informatique en cours d’exécution.
Un thread est alors une entité au sein d’un processus.
En d’autres termes, un thread est une séquence d’instructions d’un programme qui peut être exécutée indépendamment du reste du code.

multi-threading
Un processus avec deux threads

Le multithreading ?

On parle de multithreading lorsque plusieurs threads sont exécutés au sein d’un même processus. Pour faire plus simple, lorsque c’est qu’un programme utilise plusieurs threads.
Le fait d’utiliser plusieurs threads au sein d’un même programme a pour but, d’exécuter en parallèle certaines tâches afin d’améliorer ou accélérer les performances de notre programme.

Le multithreading en python, un cas particulier

Contrairement à des nombreux langages de programmation, utiliser plusieurs threads en python ne veut pas forcément dire que nos différents threads s’exécutent en parallèle.

En effet, il faut s’avoir qu’en python, il existe un verrou appelé Global Interpreter Lock (GIL) qui empêche plusieurs threads d’exécuter du bytecode Python au même instant. C’est-à-dire que deux threads python d’un même programme ne peuvent pas s’exécuter en même temps.
Cela complique les choses, vu qu’on veut que ces exécutions se fassent en parallèles !
Cependant, il y a quelques exceptions : les taches qui font de I/O (Ouvrir/écrire des fichiers, des requêtes http,…).

Ainsi pour les autres cas, si on veut exécuter des tâches en parallèle en python, il faudra faire du multi-Processing. (Nous en parlerons dans un prochain article)

Allez on passe au concret !

Le multi-threading en python, Exemple

Pour créer un thread en python, c’est relativement simple. On utilise la librairie threading.

import threading
...
thread = threading.Thread(target=my_function, args=(...))
thread.start()
thread.join()
...

Comme dit plus haut, l’exécution d’un thread est totalement indépendante du reste du code. Ainsi, il est possible qu’un programme se termine bien avant que l’exécution du thread soit terminé. C’est peut-être pas le comportement souhaité. Pour ce faire, on utilise la fonction .join() qui nous permettra d’attendre la fin de l’exécution du thread en question.

On peut essayer d’aller plus loin dans notre exercice. Prenons pour exemple ce code suivant qui a pour but d’effectuer plusieurs requêtes HTTP. L’idée sera donc de pouvoir exécuter en parallèle ces requêtes HTTP. Selon mes tests le script s’exécute en 12 s en moyenne (cela dépend vraiment de l’environnement dans lequel le code est exécuté).

 import threading
import time
import requests as r

BASE_URL='https://archive.ics.uci.edu/ml/datasets'
URLS = [ f'{BASE_URL}/Energy+efficiency',f'{BASE_URL}/Planning+Relax',f'{BASE_URL}/Cloud',
f'{BASE_URL}/Protein+Data',f'{BASE_URL}/Spambase'] *3

def get_dataset (urls:list):
    for url in urls :
        response = r.get(url)
        print( f"Got data form {url}: content length: {len(response.content)}")


if __name__ == "__main__":
    start = time.perf_counter()
    get_dataset(URLS)
    finish = time.perf_counter()
    print(f'Finished in {round(finish-start, 2)} second(s)')
    # Finished in 12.08 second(s)


Pour ce faire, nous allons faire quelques petites modifications.
Le code utilisant les threads est le suivant :

import threading
import time
import requests as r

BASE_URL='https://archive.ics.uci.edu/ml/datasets'
URLS = [ f'{BASE_URL}/Energy+efficiency',f'{BASE_URL}/Planning+Relax',f'{BASE_URL}/Cloud',
f'{BASE_URL}/Protein+Data',f'{BASE_URL}/Spambase'] *3

def get_dataset (url):
    response = r.get(url)
    print( f"Got data form {url}: content length: {len(response.content)}")


if __name__ == "__main__":
    start = time.perf_counter()
    threads = []
    for url in URLS:
        t = threading.Thread(target=get_dataset, args=(url,))
        t.start()
        threads.append(t)
    for thread in threads:
        thread.join()
    finish = time.perf_counter()
    print(f'Finished in {round(finish-start, 2)} second(s)')
    #2 seconds

Ce code s’exécute en 2 s soit 6 fois plus vite. Si vous regardez entre les lignes vous verrez que la boucle for a été sortie de la fonction get_dataset pour une raison évidente : L’idée, c’est de « paralléliser » !

Ensuite pour chaque URL dans la liste URLS, je crée un thread que je démarre avec la méthode start() puis je rajoute chaque thread courant dans une liste.

Pour terminer, je parcours cette liste de thread afin d’exécuter pour chaque thread la méthode .join() ce qui nous permet d’attendre la fin de l’exécution de tous ces threads.

Cependant en termes de lisibilité et d’utilisation des ressources, on peut encore faire mieux.
Dans l’exemple précédent, on créait un thread pour chaque requête, le mieux aurait été de pouvoir réutiliser les threads libres.
Bonne nouvelle, il y a un autre module qui permet de faire du multithreading en Python avec une syntaxe plus souple et qui pourra nous aider à faire cela.

Utilisation du module concurrent.futures

Ce module permet de faire soit du multithreading ou du multiprocessing ! Ce module a été ajouté depuis Python 3.2 dans l’optique de fournir aux développeurs une interface de haut niveau pour la gestion des tâches asynchrones.
En fait, il s’agit d’une couche d’abstraction des modules threading et multiprocessing…
Ainsi, en utilisant le module concurrent.futures on obtient le code suivant.

import concurrent.futures
import time
import requests as r

BASE_URL='https://archive.ics.uci.edu/ml/datasets'
URLS = [ f'{BASE_URL}/Energy+efficiency',f'{BASE_URL}/Planning+Relax',f'{BASE_URL}/Cloud',
f'{BASE_URL}/Protein+Data',f'{BASE_URL}/Spambase'] *3

def get_dataset (url:str):
    response = r.get(url)
    print( f"Got data form {url}: content length: {len(response.content)}")


if __name__ == "__main__":
    start = time.perf_counter()
    with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
        executor.map(get_dataset, URLS)

    finish = time.perf_counter()
    print(f'Finished in {round(finish-start, 2)} second(s)')
    #3 seconds

Si on analyse ce code, on s’aperçoit qu’on ne crée plus de thread pour chaque tâche, mais plutôt on crée un pool de threads. J’entends par pool de threads, un ensemble de thread qui seront dédiés à notre traitement (le nombre de threads maximal est fixé par l’argument max_workers). Cela nous permet ainsi que répondre ainsi à la problématique présentée plus haut.
On termine ainsi en utilisant la fonction map de notre objet executor pour créer et planifier l’exécution de nos threads. Je vous conseille vivement de faire un tour sur la documentation python de ce module.

Ressources complémentaires

Concurrent.futures — Launching parallel tasks

Threading — Thread-based parallelism

Le code source de l’article.

Conclusion

Dans cet article, nous avons présenté le mutlithreading de façon générale et fait quelques implémentations en python. En effet, il y a plusieurs approches en python qui nous permettent de manipuler les threads. Dans ce tutoriel, nous avons présenté quelques-unes utilisant les modules threading et concurrent.futures

De plus, il existe un bon nombre de notions intéressantes autour des threads que je vous recommande à explorer (la synchronisation,le deadlock,les Race Conditions par exemple)

N’hésitez pas à partager vos avis et questions sur cet article. Vale !

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée.

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.