La parallélisation en Python

Python est souvent vanté pour sa simplicité et sa lisibilité, mais lorsqu’il s’agit d’optimiser l’exécution parallèle du code, il soulève des questions complexes. Cet article explore les différences fondamentales entre le multithreading, la programmation asynchrone et le multiprocessing en Python. Il met également en lumière les limitations imposées par le Global Interpreter Lock (GIL) et les défis associés au multiprocessing.

Multithreading vs Programmation asynchrone vs Multiprocessing

Qu’est-ce que le multithreading ?

Le multithreading consiste à exécuter plusieurs threads au sein d’un même processus. Ces threads s’éxécutent sur des coeurs CPU physiques ou logiques potentiellement différents tout en partageant le même espace mémoire, ce qui facilite la communication et réduit les besoins en mémoire. Il est donc en théorie possible d’allouer a chaque coeur CPU un thread dédié et ainsi d’éxécuter plusieurs tâches en parallèle permettant un gain notable de performances.

Cependant, en Python, le multithreading est fortement limité par le GIL (Global Interpreter Lock). Ce dernier empêche plusieurs threads Python d’exécuter du bytecode en même temps, même sur un processeur multi-cœurs. Pour les directives I/O-bound (comme les requêtes réseau ou les opérations I/O), le multithreading reste efficace car le GIL est libéré lors de ces appels. En dehors de ces derniers, seule l’utilisation d’extensions Python écrites en C qui libèrent explicitement ce verrou permettrait d’utiliser réellement plusieurs coeurs, mais peut-on encore parler de parallélisation en Python quand on a absolument besoin d’extensions Python qui ne sont pas écrites en Python pour y arriver…

Exemple de code :

Python

Qu’est-ce que la programmation asynchrone ?

La programmation asynchrone, souvent également appelée “programmation concurrente”, permet d’éxécuter des tâches de manière entrelacée en s’appuyant sur des changements de contextes rapides. Elle crée ainsi l’illusion d’une éxécution des tâches de manière parallèle. Pour ce faire, elle exécute des directives I/O-bound de manière non bloquante, en utilisant un modèle basé sur les événements. Contrairement au multithreading, qui repose sur plusieurs threads concurrents, la programmation asynchrone utilise un thread unique et des coroutines pour gérer plusieurs tâches en concurrence. Cette approche est plus efficace que le multithreading dans un contexte de directives I/O-bound même si elle requiert souvent une expertise de développement moins accessible.

En Python, la programmation asynchrone repose sur des bibliothèques comme asyncioanyio ou trio. A cause du GIL, le multithreading n’apporte, en réalité, absolument rien de plus que la programmation asynchrone pour les tâches I/O-bound. Dans ce cas, je pense qu’il est d’ailleurs préférable d’utiliser la programmation asynchrone, qui offre une meilleure compatibilité avec les futures évolutions du language, en particulier si le GIL vient à être éliminer dans ses futures versions.

Exemple de code :

Python

Qu’est-ce que le multiprocessing ?

Le seul moyen de paralléliser des tâches CPU-bound en Python a longtemps été d’utiliser le multiprocessing qui consiste à créer plusieurs processus fils distincts, chacun ayant son propre espace mémoire, mais surtout, son propre interpréteur et donc, son propre GIL. Chaque processus dispose alors de ses propres threads, réduit à l’équivalent d’un seul thread en Python, s’éxécutant néanmoins en parallèle et permettant, cette-fois ci, d’obtenir un gain de performance notable.

Puisqu’il est nécessaire d’avoir recours à cette technique, en Python plus qu’ailleurs, le module multiprocessing tente d’offrir une API très similaire à l’API du module threading. Il tente ici de masquer nombre de complexités que l’on ne retrouve qu’avec le multiprocessing, notamment en ce qui concerne la communication entre processus fils et la gestion des ressources. Malgré cet effort, nous pensons qu’il est parfois fastidieux voir dangereux de vouloir assimiler de manière transparente ces deux notions tant ces dernières sont différentes.

Nous verrons qu’il existe, en Python, d’autres techniques permettant de contourner le GIL, détaillées plus loin, mais ces dernières restent, malheureusement, encore expérimentales ou peu populaires.

Exemple de code :

Python

Le Global Interpreter Lock: un frein à la parallélisation

Le Global Interpreter Lock est, comme son nom l’indique, un verrou interne propre à l’interpréteur CPython qui restreint l’exécution en parallèle des threads Python. Son rôle principal est de garantir qu’un seul thread à la fois puisse exécuter du bytecode Python, même sur des systèmes multi-cœurs.

D’autres languages ont également recours à un mécanisme similaire comme:

  • Ruby: avec le Global VM Lock (GVL) qui est conceptuellement équivalent.
  • R: le modèle de threading ici aussi particulièrement limité. Seule l’éxécution de codes natifs et, indirectement, l’utilisation de packages explicitement adaptés permettent une éxécution parallèle.
  • PHP: son implémentation Zend Engine utilisé comme module Apache utilise un concept similaire, bien que plus permissif.
  • Lua: intrinsecement monothreadé, même si un système similaire n’existe pas à proprement parler, il est impossible d’éxécuter plusieurs scripts Lua en parallèle au sein d’un même interpréteur.
  • Javascript: tout comme Lua, l’éxécution est monothreadé et repose sur une boucle d’événement et s’assimile donc à de la programmation asynchrone.

Pourquoi le GIL existe-t-il encore ?

Le GIL simplifie significativement l’implémentation de l’interpréteur CPython et des extensions Python écrites en C, qui ne sont, souvent, pas « thread-safe ». En maintenant un verrou global, il permet d’éviter des mécanismes complexes de synchronisation autour de la gestion de la mémoire partagée.

Supprimer le GIL impliquerait des modifications profondes non seulement dans CPython lui-même, mais aussi dans une vaste majorité de bibliothèques tierces, ayant ou non recours à des extensions Python écrites dans d’autres languages, et dont certaines reposent sur des hypothèses spécifiques à sa présence. Ces modifications incluraient la révision des modèles de gestion de mémoire et la mise en place de mécanismes de synchronisation au sein de CPython. Mais surtout, elles nécessiteraient de passer en revue l’ensemble des bibliothèques tierces qui reposent, pour certaines, sur de potentielles mauvaises utilisations de méthodes de synchronisation multithreading, qui, involontairement, grâce au GIL, n’ont jamais été problématiques jusqu’ici.

Quelles évolutions à venir pour le GIL ?

De nombreux efforts ont été faits ces dernières années pour tenter de réduire ou d’éliminer complétement les limitations imposées par celui-ci:

IronPython / Jython

Plusieurs interpréteurs Python alternatifs ont été développés pour pallier certaines limitations de CPython:

  • IronPython : Conçu pour s’exécuter sur le .NET Framework, cet interpréteur n’utilise pas de verrou global, ce qui permet une véritable exécution multithread pour les tâches CPU-bound.Jython : Une implémentation de Python sur la JVM (Java Virtual Machine), qui bénéficie des mécanismes de gestion des threads de la JVM et ne dépend donc pas d’un verrou global.
Ces interpréteurs offrent des alternatives intéressantes à CPython, notamment dans les contextes où l’absence de verrou global est cruciale pour les performances.Néanmoins, un grand nombre d’extensions Python écrites en C ne sont pas (ou pas complètement) supportés par ces interpréteurs. Parmi elles, on retrouve notamment un grand nombre de bibliothèques qui font la popularité de Python aujourd’hui telles que pandas ou numpy rendant la transition vers ces interpréteurs souvent impossible.

Software Transactional Memory (STM) avec PyPy

Le « Software Transactional Memory » (STM) est une approche qui permet de gérer la synchronisation et la concurrence sans avoir recours à des mécanismes de verrouillage explicites comme le GIL. STM s’inspire des transactions utilisées dans les bases de données : les opérations concurrentes sont exécutées dans des « transactions » isolées et, à la fin de chaque transaction, un mécanisme de validation garantit que les modifications effectuées n’ont pas entraîné de conflits. Si tel est le cas, les transactions sont automatiquement rejouées. Il permet une meilleure exploitation des processeurs multi-cœurs pour les applications Python CPU-bound.Bien que CPython ne supporte pas nativement le STM, des projets comme PyPy ont exploré cette approche. Par exemple, PyPy STM permet de gérer la concurrence en évitant les limitations du GIL tout en conservant une compatibilité avec la plupart des programmes Python standards.

Python

L’inconvénient majeur de STM réside dans la complexité de son implémentation et son coût en termes de performances lorsque les conflits sont fréquents ou lorsque le nombre de transactions simultanées est élevé. Actuellement, le support pour STM dans l’écosystème Python reste limité et en cours d’exploration.

Subinterpreters (Python 3.9)

Depuis CPython 3.9, via l’API C pour les extensions Python, il est désormais possible de créer des sous-interpréteurs qui ne dépendent plus du GIL de l’interpréteur parent, chacun disposant de son propre verrou global. Cette fonctionnalité représente une avancée majeure. Toutefois, elle reste limitée dans son usage pratique : elle est essentiellement conçue pour des extensions Python en C, et son intégration nécessite une gestion explicite de la communication et de la synchronisation entre les sous-interpréteurs. Au même titre que l’approche multiprocessing, les données partagées doivent être échangées via des mécanismes comme des queues, des sockets UNIX ou encore des segments de mémoire partagée, augmentant ainsi la complexité de l’implémentation. Par conséquent, malgré son potentiel, cette approche n’a pas encore gagné une adoption significative dans l’écosystème Python standard.

NoGIL (Python 3.13)

PEP 703, intitulée « Making the Global Interpreter Lock Optional in CPython », propose de:

  • Supprimer le GIL de manière facultative.
  • Offrir une compatibilité descendante.
  • Réduire l’impact sur les performances monothreadées.

Rappelons que, sans GIL, inutile d’avoir recours aux approches multiprocessing ou multi-interpreter et leurs complexités. Néanmoins, comme annoncé plus haut, supprimer ce dernier nécessite de passer en revue l’intégralité des bibliothèques existantes, aussi bien celles écrites en Python pure mais également les extensions Python qui devront désormais être thread-safe. Or, même si l’audit de bibliothèque standard de CPython, étant déjà particulièrement étendue, relève à lui seul d’un défi, il existe désormais en Python, fort de son succès, des centaines de milliers de bibliothèques disponibles sur Pypi seul. Une grande majorité d’entre elles ne seront certainement jamais auditées ou mises à jour.Ce changement impacte potentiellement également les applications monothreadées puisqu’il est nécessaire de renforcer la sécurité des threads existants, et ce bien que PEP703 ait prévu des optimisations à ce niveau.Autrement dit, bien que ce changement constitue une avancée majeure, nous sommes encore bien loin de pouvoir en profiter pleinement.

Les problématiques propres au multiprocessing

Bien que le multiprocessing contourne le GIL et permette une parallélisation effective des tâches CPU-bound, il introduit plusieurs problèmes pour lesquels le module multiprocessing fournit quelques outils:

Communication entre processus et gestion des données

Contrairement à une approche monoprocess, il est nécessaire de transférer explicitement les données entre les différents processus fils et leur parent puisqu’ils ne partagent pas le même espace mémoire.

Pour ce faire, on peut utiliser une ou plusieurs des techniques suivantes:

  • Mémoire partagée
    Le module multiprocessing offre des outils comme les Value et Array pour partager des données entre processus qui permet de minimiser les copies de données et d’améliorer les performances.
Python
  • Sockets
    Les sockets permettent d’échanger des données en utilisant des primitives réseau, sur des machines distantes mais également sur la même machine. Dans ce dernier cas, il sera d’ailleurs préférable d’utiliser des sockets UNIX qui offrent de meilleurs performances en s’affranchissant de la couche IP.
Python
  • Queues et Pipes
    Pour des communications plus simples, le module multiprocessing propose des piles (multiprocessing.Queue) qui offrent une interface simple de type producer/consumer et des pipes (multiprocessing.Pipe) qui offre une interface similaire aux sockets mais de plus haut niveau.
Python

Synchronisation

Tout comme les approches multithreading, des primitives comme les locks, les sémaphores et les événements sont nécessaires pour coordonner les processus. Il est néanmoins important de garder à l’esprit que, ces primitives, bien que simples à utiliser, reposent également sur les techniques de communication inter-processus évoquées plus haut. Inutile de dire que ces primitives ont un coût bien plus élevées que leur contrepartie partageant le même espace mémioire.

Et la mémoire ?

Contrairement aux threads, les processus fils ne partagent pas leur espace mémoire, ce qui entraîne nécessairement une utilisation accrue de la mémoire.

Un exemple concret: le logging

Parmi les pièges courants à éviter lorsque l’on commence à s’intéresser à l’approche multiprocessing, on retrouve l’utilisation du module logging. Cela fait, en effet, malheureusement partie des petites subtilités auxquelles on ne pense pas nécessairement aux premiers abords lorsqu’on désire optimiser ses perfomances. Et pour cause, ça n’a souvent rien à voir avec le problème initial et pourtant c’est presque indispensable à partir du moment où on désire faire une application sérieusement.

Pour convertir une application monothreadé doté d’un Logger en Python vers une application utilisant l’approche multiprocessing, il est, en effet, nécessaire de configurer ce-dernier d’une manière spécifique afin d’éviter tout problème d’accès concurrent et ainsi s’assurer d’obtenir des logs cohérents et complets, en particulier lorsque ces derniers sont renvoyés vers le disque. Pour ce faire, voici un exemple de configuration supplémentaire à ajouter à son application:

Python

Avec cette configuration, chaque message de log généré par les processus fils passe donc par une queue en direction du processus parent qui est le seul à réellement logger dans l’application.

Conclusion

Le choix entre le multithreading, la programmation asynchrone et le multiprocessing dépend des besoins spécifiques de votre application.

En Python, les limitations du GIL et les complexités du multiprocessing montrent que la parallélisation n’est pas toujours triviale. En l’occurence, il est facile de penser que l’utilisation d’une approche multithreading apportera à coup sur un gain en termes de performances alors qu’elle n’apportera, en réalité, rien de plus, voir moins, qu’une approche asynchrone. Néanmoins, les futures évolutions, notamment la suppression du GIL, transformeront, sans aucun doute, la manière dont sont développées les applications ayant besoin de parallélisation en Python.

En attendant, une compréhension approfondie des approches existantes reste essentielle pour optimiser vos applications Python.

Selon moi, il est préférable d’opter pour:

  • Calculs longs ou applications persistantes:
    • Méthodes I/O-boundPrivilégier la programmation asynchrone.En particulier, je vous conseille d’utiliser la bibliothèque anyio qui permettra à vos projets d’être compatibles aussi bien avec asyncio qu’avec trio tout en conservant la robustesse de cette dernière. En utilisant cette approche, vous vous assurez également que votre code restera toujours aussi robuste, quelque soit l’implémentation de Python que vous utilisez, avec ou sans le GIL.
    • Méthodes CPU-boundUtiliser le multiprocessing pour contourner le GIL.Vous pourrez alors opter pour un module tiers plus complet tel que joblib qui fournit une interface de haut niveau à la gestion de jobs avec du caching, …N’hesiter pas à vous tourner vers une bibliothèque de plus haut niveau, que ce que propose le module mutliprocessing par défaut, vous vous affranchirez de nombreux problèmes propres à cette approche. Certaines gèrent, par exemple, plus ou moins indirectement, le problème rencontré avec le module loggingdont je vous ai parlé précédemment.

  • Calculs courts ou applications éphémères:
    • Si vous désirez un moyen rapide de développer et tester différentes approches, vous pouvez potentiellement utiliser le module concurrent.futures qui fournit une abstraction relativement simple à la parallélisation et vous permettra de choisir rapidement entre l’approche multithreading ou l’approche multiprocessing.
Python

Quelques liens supplémentaires