Students Save 30%! Learn & create with unlimited courses & creative assets Students Save 30%! Save Now
Advertisement
  1. Code
  2. Python
Code

Introduction à la programmation Parallèle et Concurrente en Python

by
Length:LongLanguages:

French (Français) translation by Stéphane Esteve (you can also view the original English article)

Python est le langage de prédilection en matière de traitement des données et dans leur application scientifique en général. Son écosystème fournit un grand nombre de librairies et d'outils qui facilitent des calculs à haute performance. Cependant, développer une programmation parallèle en Python peut s'avérer épineux.

Au cours de ce tutoriel, nous allons étudié pourquoi le parallélisme est si complexe particulièrement en Python, et pour y parvenir, commençons avec ces quelques principes :

  • Pourquoi le parallélisme est si complexe en Python ? (astuce : c'est à cause du GIL - Global Interpreter Lock)
  • Tâches versus Processus : il existe différents chemins pour parvenir au parallélisme. Quand doit-on privilégie l'un par rapport à l'autre ?
  • Parallèlisme versus Concurrence : pourquoi dans certains cas, devons-nous envisager la concurrence plutôt que le parallélisme ?
  • Construisons un exemple simple mais surtout pratique, en mettant en application l'ensemble des techniques citées.

Global Interpreter Lock

Le Global Interpreter Lock (GIL) est le sujet le plus contreversé du monde Python. En CPython, l'implémentation la plus connu de Python, le GIL est une exclusion mutuelle (mutex) qui rend les choses plus saines au niveau des tâches. Le GIL permet d'intégrer facilement des librairies externes qui ne sont pas à fil sécurisé, et exécute le code non-parallèle plus rapidement. Il y a un coût, forcément. A cause de lui, nous ne pouvons parvenir à un véritable parallélisme via le multithreading. Trivialement, deux tâches issues d'un même processus ne peut pas exécuter du code Python d'un seul coup.

Mais tout n'est pas si mal et en voici la raison : tout ce qui se passe en dehors du GIL est libre d'être "parallèlisé". Dans cette catégorie, on y puise les tâches complexes comme I/O et heureusement, des librairies comme numpy.

Fils versus Processus

Ainsi, Python n'est pas véritablement multithread. Mais qu'est-ce qu'une tâche ? Prenons du recul pour une meilleure compréhension.

Un processus est une abstraction simple du système d'exploitation. C'est un programme qui est lui-même une exécution - en d'autres termes, du code qui fait tourner un autre code. De nombreux processus tournent toujours dans un ordinateur, et s'exécutent en parallèle.

Un processus peut contenir plusieurs tâches. Ils exécutent le même code appartenant au processus parent. Idéalement, ils tournent en parallèle, mais pas nécessairement. Les processus sont insuffisants parce que les applications nécessitent d'être attentive aux actions des utilisateurs, tout en mettant à jour l'affichage et en sauvegardant un fichier.

Si tout ça n'est pas très clair, voici une synthèse :

PROCESSUS
FILS
Les processus ne partagent pas la mémoire
Les tâches partagent la mémoire
Multiplier/échanger de processus est onéreux
Multiplier/échanger des tâches est moins coûteux
Les processus demande plus de ressources
Les tâches sont moins gourmandes en ressource (on les appelle parfois des processus légers)
Pas besoin de synchroniser la mémoire
Vous aurait besoin de mécanismes de synchronisation si vous souhaitez manipuler correctement la donnée

Il n'existe pas une seule recette pour harmoniser le tout. en choisir une consiste à être dépendant du contexte et de la tâche qui vous voulez achever. 

Parallèle versus Concurrent

Désormais, allons plus loin et plongeons directement dans la Concurrence. Elle est souvent mal interprétée et confondu à tort avec le Parallélisme. Il n'en est rien. La Concurrence présuppose de prioriser un code indépendant qui s'exécute de manière coopérative. Il prend comme avantage le fait qu'une partie du code attends des opérations d'entrées/sorties, et peut continuer à exécuter des parties différentes et indépendantes de ses instructions, pendant ce temps.

En Python, nous pouvons parvenir à un comportement de concurrence légère via les greenlets. Dans un but de parallélisation, que ce soit les tâches ou les greenlets restent une démarche équivalente, puisque aucun d'entre eux s'exécutent en parallèle. Les greenlets sont d'ailleurs bien moins gourmands à créer que les tâches. C'est pour cette raison qu'ils sont massivement utilisés pour réaliser un grand nombre de tâches simples d'entrées/sorties, comme celles rencontrées en programmation réseau ou de serveurs web.

Du coup, étant sensibilisé sur la différence entre tâches et processus, parallélisme et concurrence, illustrons ceci par la manière d'accomplir différentes tâches selon ces deux paradigmes. Voici ce que nous allons faire : nous allons exécuté, à plusieurs reprises, une tâche en dehors du GIL et une autre en son sein. Elles seront exécutées de façon sérielle, tantôt par fils, tantôt par processus. Définissons nos tâches :

Nous avons créer deux tâches. Chacune sont considérée comme chronophage, mais seulement crunch_numbers accomplit des calculs activement. Exécutons only_sleep de manière sérielle, puis en multithread et enfin en utilisant plusieurs processus, et comparons les résultats :

Observez les résultats obtenus (les votres pourraient être similaires, seuls les PIDs et les durées pourraient varier quelque peu) :

Quelques observations supplémentaires :

  • Dans le cas de l'approche sérielle, la preuve est faire. Vous faites exécuter les tâches les unes après les autres. Les 4 exécutions sont effectuées dans le même fil et le même processus.

  • Par l'utilisation des processus, nous diminuons le temps d'exécution au quart de son temps principal, tout simplement parce que les tâches sont exécutées en parallèle. Notez que chacune d'elles sont réalisée à la fois dans un processus différent et sur le MainThread de ce processus.

  • Avec les tâches, nous prenons l'avantage sur la concurrence des tâches. Le temps d'exécution est également ramené au quart, bien qu'il n'y ait rien qui tourne en parallèle. Il se passe la chose suivante : nous reproduisons le premier fil et il scrute l'expiration du timer. Nous le mettons en pause, lui permettant d'attendre que le timer expire, et enfin, nous lançons le fil suivant. Nous répétons cette opération pour tous les fils. Au moment où le timer du premier fil se termine, nous changeons son exécution et le résilions. Cet algorithme est le même pour le second et tous les autres fils. Au final, il en résulte la même issue qu'en parallèle. A noter également que les quatre fils proviennent du même processus : MainProcess.

  • Dernière remarque : l'approche par tâche est plus rapide que celle par processus. C'est le résultat de la dispersion des processus multipliés. Pour rappel, la multiplication et l'échange de processus sont des opérations onéreuses.

Effectuons la même routine mais cette fois, on définit crunch_numbers comme tâche principale :

Et nous obtenons :

La principale différence ici provient des résultats de l'approche multithread (tâches multiples). Elle aboutit de la même façon que l'approche sérielle et en voici la raison : parce qu'elle réalise des calculs et que Python ne fonctionne par dans un complet parallélisme, les tâches s'imbriquent les unes aux autres, s'exécutant docilement l'une après l'autre jusqu'à la fin de leur exécution.

L'écosystème de la programmation Parallèle/Concurrente en Python

Python détient une collection inestimable d'API pour créer de la programmation parallèle/concurrente. Dans ce tutoriel, nous allons couvrir les plus populaires d'entre eux, mais vous devez être prévenu qu'en ce domaine, quels que soient vos besoins, il y a sûrement quelque chose de disponible pour parvenir à vos objectifs.

Dans cette partie, nous allons construire une application courante selon plusieurs approches, en usant des librairies préalablement présentées ici. Sans plus attendre, voici quelques uns des modules/librairies que nous allons utilisés ici :

  • threading : La façon standard de travailler avec les fils en Python. Il s'agit d'une couche API de haut-niveau par dessus la capacité définie par le module _thread, qui est une interface de bas-niveau par dessus l'implantation des fils du système d'exploitation.

  • concurrent.futures : Il s'agit d'un module faisant partie de la librairie standard, qui fournit une couche abstraite de haut-niveau par dessus les fils. Les fils sont façonnés comme des tâches asynchrones.

  • multiprocessing : Assez similaire au module threading, il offre une interface quasi identique à celui-ci, mais privilégie les processus au lieu des fils.

  • gevent et greenlets : Greenlets, aussi connu sous le nom de micro-fils, sont des unités d'exécution qui peuvent être priorisés de façon collaborative et peuvent réaliser des tâches en concurrence sans trop de risque.

  • celery : C'est une file d'attente distribuée de haut-niveau. Les tâches sont enfilées et exécutées de façon concurrente selon de multiples paradigmes, comme le multiprocessing ou les gevent.

Création d'une application fonctionnelle

Connaître la théorie : c'est super, mais la meilleure façon d'apprendre consiste à construire quelque chose de fonctionnel, non ? Dans cette partie, nous allons créé une application assez classique, à travers différents modèles.

Produisons une application qui contrôle la durée de fonctionnement de sites web. Plusieurs solutions s'offrent à nous ici, les plus connus étant probablement le Jetpack Monitor et le Uptime Robot. L'objet de ces applications est de vous notifier quand les sites web ne sont plus disponibles et vous permettre de prendre rapidement vos dispositions. Voici comment ça marche :

  • Le programme parcoure très régulièrement une liste d'adresses de site web et vérifie s'ils sont accessibles.
  • Chaque site web devrait être contrôlé toutes les 5-10 minutes pour que le temps d'indisponibilité ne soit pas trop significatif.
  • Plutôt que de lancer une simple requête HTTP GET, il lance une requête de type HEAD afin de ne pas affecter dramatiquement votre trafic.
  • Si le status HTTP se situe dans les zones dangereuses (400+, 500+), le gestionnaire du site est prévenu.
  • Ce gestionnaire est prévenu par email, texto ou notification push.

Découvrons pourquoi il est essentiel d'envisager une approche parallèle/concurrente à ce problème. Au fur et à mesure que la liste des sites web s'amplifie, traverser le long de celle-ci de façon sérielle ne nous garantie pas que chaque site web soit vérifié toutes les cinq minutes. Les sites web peuvent ne plus tourner depuis des heures que le gestionnaire n'en sera pas mieux informé.

Commençons par écrire quelques utilitaires :

Nous avons besoin d'une liste de sites web pour tester notre système. Créez votre liste ou utilisez la mienne :

Normalement, vous conservez cette liste dans une base de données, aux côtés des informations du propriétaire afin qu'il puisse être contacté. Mais comme ça sort du sujet principal ici, et par souci de simplicité, nous n'utiliserons qu'une simple liste Python.

Si vous restez attentif, vous avez dû observer deux noms de domaines très longs qui ne sont en aucun cas valides (en espérant que personne ne s'est offert ceux-ci au moment où vous lisez ceci, pour me mettre en défaut !) Je les ai ajouté pour avoir deux sites web inaccessibles à chaque exécution. Aussi, nommons notre app UptimeSquirrel.

Approche sérielle

Tout d'abord, tentons l'approche sérielle et voyons à quel point elle fonctionne très mal. Nous la considérerons comme point de départ.

Approche par tâche

Nous allons faire preuve de créativité cette fois avec l'implantation de l'approche par tâche. Nous utiliserons une file d'attente pour y placer nos adresses et créer autant de tâche pour, ensuite, les retirer de cette file et les traiter. Nous devrons attendre que la file d'attente soit vide, signifiant alors que toutes les adresses ont été traitées par nos tâches.

concurrent.futures

Telle que définie plus haut, concurrent.futures est une API de haut niveau pour l'utilisation des tâches. Ici, nous nous attacherons à utiliser un ThreadPoolExecutor. Nous allons soumettre quelques tâches au groupe et récupérer des "futures", qui sont les résultats disponible plus tard, dans le futur. Bien sûr, nous attendrons que tous les "futures" deviennent les résultats du moment.

Approche par Multiprocessing

La librairie multiprocessing procure une API alternative quasi-similaire à threading. Ici, nous prendrons une manière aussi identique à concurrent.futures. Composons un multithreading.Pool et soumettons-y nos tâches en adaptant une fonction à la liste d'adresses (voyez-y une forme d'application de la fonction Python map).

Gevent

Gevent est une alternative populaire pour accomplir une concurrence massive. Quelques précisions à connaître avant d'aller plus loin :

  • Le code lancé de façon concurrente par les greenlets sont déterminants. En opposition aux autres approches présentées ici, ce paradigme assure que pour chaque exécution identique, vous obtiendrez les mêmes résultats dans le même ordre.

  • Vous aurez besoin de créer un "monkey patch" des fonctions standards pour qu'elles puissent être intégrées à gevent. Ce que je veux dire, c'est que, normalement, une opération par socket est blocante. Nous devons attendre qu'une opération se termine. Si nous sommes dans un environnement multithread, le planificateur devra se diriger vers un autre fil, pendant que l'autre attendra pour des entrées/sorties. Mais comme nous ne sommes pas dans ce genre d'environnement, gevent reconstruit les fonctions standard afin qu'elles ne soient plus bloquantes et renvoie le contrôle au planificateur de gevent.

Pour installer gevent, lancez : pip install gevent

Et voici comment on utilise gevent pour exécuter notre tâche avec gevent.pool.tool :

Celery

Celery a une approche qui est radicalement différente de tout ce que nous avons vu jusqu'ici. C'est à l'épreuve des balles dès qu'il s'agit d'environnements complexes et ultra-performants. Mettre en place Celery nécessite tout un tas d'astuces que toutes les solutions proposées ici.

D'abord, installons Celery :

pip install celery

Les tâches sont les objets principaux au sein du projet Celery. Tout ce que devez faire exécuter dans Celery doivent être un tâche. Celery montre une grande flexibilité à exécuter des tâches : vous pouvez les faire tourner de façon synchrones ou asynchrones, en temps réels ou de façon programmée, sur la même machine ou plusieurs, en employant des fils, des processus, des Eventlets ou des gevents.

Son organisation sera cependant un peu plus compliquée. Celery utilise d'autres services pour envoyer et recevoir des messages.  Ces messages sont habituellement des tâches ou les résultats des tâches. Nous exploiterons Redis dans ce cas précis. Redis est le meilleur choix parce qu'il est si facile à installer et à configurer, et qu'il est sûrement possible que vous l'utilisiez déjà dans vos applications pour d'autres fins, comme le cache ou les pub/sub.

Vous pouvez installer Redis en suivant les instructions de la page Redis Quick Start. N'oubliez pas non plus d'installer la librairie redis de Python, pip install redis, et l'additif nécessaire à la communication entre Redis et Celery : pip install celery[redis].

Démarrez le serveur Redis de cette façon : $ redis-server

Afin de commencer à écrire des choses avec Celery, nous devons d'abord créer une application Celery. Après ça, Celery voudra connaître quel type de tâches devra-t-il exécuter. Pour y parvenir, nous devons déclarer nos tâches à l'application Celery. Le décorateur @app.task nous le permet.

Pas de panique si rien ne se passe. Rappelez-vous, Celery est un service, et nous devons le lancer avant tout. Jusqu'à maintenant, nous n'avons qu'insérer des tâches dans Redis mais nous n'avons pas démarrer Celery pour les exécuter. Pour ça, nous avons besoin de lancer cette commande à partir du répertoire où réside votre code :

celery worker -A do_celery --loglevel=debug --concurrency=4

Désormais, ré-exécutez votre script Python et observez ce qu'il se passe. Une chose dont il faut être attentif : notez comment nous avons servi l'adresse de Redis vers notre application Redis par deux fois. Le paramètre broker définie où les tâches seront envoyées vers Celery, et backend est l'endroit où Celery place les résultats pour qu'ils soient disponibles dans notre app. Si nous ne spécifions aucun résultat en backend, il n'y a aucun moyen pour nous de connaître quand la tâche est traitée et quel en est sont résultat.

Par ailleurs, soyez averti que les logs sont disponible désormais en sortie standard du processus Celery, donc soyez certain de les récupérer dans le terminal approprié.

Conclusions

En guise d'introduction, j'espère que vous avez fait un formidable voyage à travers le monde du parallélisme/concurrence en Python. Nous avons désormais atteint notre destination, et quelques conclusions se dessinent déjà :

  • Il y a de nombreux paradigmes qui nous permettent des traitements de haute performance avec Python.
  • Pour le paradigme à multi-fils (multithreading), nous avons vu les librairies threading et concurrent.futures.
  • La librairie multiprocessing apporte une interface assez similaire à threading, mais davantage pour des processus que pour des fils.
  • N'oubliez jamais que les processus accomplissent un véritable parallélisme, mais coûtent énormément lors de leur création.
  • Rappelez-vous qu'un processus peut avoir plusieurs fils exécutés en son sein.
  • Ne jamais confondre parallélisme avec concurrence. Souvenez-vous que seule l'approche parallèle permet de profiter pleinement de processeurs à cœurs multiples, tandis que la programmation concurrente distribue intelligemment des tâches. Aussi pendant que des opérations chronophages attendent d'être terminées, des traitements peuvent être faits en parallèle. 
Advertisement
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.