Tutoriel sur la concurrence en Python

Ceci est un résumé de mon tutoriel PyCon 2020. Vous pouvez trouver la vidéo originale et le code source ici : https://github.com/santiagobasulto/pycon-concurrency-tutorial-2020

https://www.youtube.com/watch?v=18B1pznaU1o

C’est un guide/tutoriel rapide sur la façon d’écrire efficacement des programmes concurrents en utilisant Python. La simultanéité en Python peut être déroutante. Il existe de multiples modules (threading, _thread, multiprocessing, subprocess). Il y a aussi la très détestée GIL, mais seulement pour CPython (PyPy et Jython n’ont pas de GIL). Les collections ne sont pas thread safe, sauf pour certains détails de mise en œuvre avec CPython.

L’objectif de ce tutoriel est de fournir des recommandations terre à terre sur la façon d’aborder chaque cas d’utilisation de code concurrent (ou parallèle).

Vous remarquerez que, contrairement à certains autres tutoriels Python plus traditionnels « multithreading only », je commence par un résumé à la fois de l’architecture des ordinateurs et des concepts des systèmes d’exploitation. La compréhension de ces concepts de base est fondamentale pour construire correctement des programmes concurrents. Veuillez sauter ces sections si vous êtes déjà familier avec ces sujets.

Ce tutoriel est divisé en sections suivantes :

  • Concurrence vs Parallélisme
  • Architecture de l’ordinateur
  • Le rôle du système d’exploitation
  • Threads avec Python
  • Synchronisation des threads
  • Multiprocessing
  • Bibliothèques de haut niveau : concurrent.futures et parallel

Concurrence vs Parallélisme

Le parallélisme consiste à exécuter plusieurs tâches en même temps. C’est l’objectif ultime des programmes concurrents. La concurence est moins que le parallélisme, cela signifie que nous lançons plusieurs tâches et que nous jonglons avec elles dans le même laps de temps. Cependant, à tout moment, nous n’en faisons qu’une à la fois.

Appliqués à la cuisine, ce seraient des exemples de concomitance et de parallélisme.

La concomitance n’a qu’une seule personne dans la cuisine:

  • Commencer à couper les oignons
  • Commencer à chauffer la poêle
  • Finir de couper les oignons

Dans ce cas, il est clair que nous ne pouvons pas faire plusieurs choses EN MÊME TEMPS. On peut toutes les commencer, mais il faut rebondir pour les contrôler.

Le parallélisme a plusieurs personnes dans la cuisine :

  • La personne 1 coupe les oignons
  • La personne 2 coupe les poivrons rouges
  • La personne 3 attend que la poêle chauffe

Dans ce cas, il y a plusieurs tâches qui se font en même temps.

Architecture d’ordinateur

Tous les ordinateurs modernes peuvent être simplifiés en utilisant l’architecture de von Neumann, qui a 3 composants principaux : Calcul (CPU), Mémoire (RAM), E/S (disques durs, réseaux, sortie vidéo).

L’architecture de von Neumann

Votre code effectuera toujours des tâches liées à chacun de ces composants. Certaines instructions relèvent du « calcul » ( x + 1), d’autres de la mémoire (x = 1), et d’autres encore des E/S (fp = open('data.csv')).

Pour bien comprendre les détails de la concurrence en Python, il est important de garder à l’esprit les différents « temps d’accès » de ces composants. L’accès au processeur est beaucoup plus rapide que l’accès à la RAM, sans parler des entrées/sorties. Il existe une étude comparant la latence des ordinateurs aux temps relatifs humains : si un cycle du CPU représente 1 seconde de temps humain, une requête réseau de San Francisco à Hong Kong prend 11 ans.

Latence à l’échelle humaine (source)

La différence radicale de temps avec ces « instructions » sera fondamentale pour discuter des threads et de la GIL Python.

Le rôle du système d’exploitation

Si je construisais un cours universitaire sur la Concurrence, j’aurais un cours entier couvrant juste l’histoire des systèmes d’exploitation. C’est la seule façon de comprendre comment les mécanismes de concurrence ont évolué.

Les premiers systèmes d’exploitation destinés au grand public (pensez à MS-DOS) ne supportaient pas le multitâche. Tout au plus, un seul programme s’exécutait à un moment donné. Ils ont été suivis par des systèmes « multitâches coopératifs » (Windows 3.0). Ce type de multitâche ressemblait davantage à une « boucle d’événement » (pensez à asyncio de Python) qu’à un programme multithread moderne. Dans un système d’exploitation multitâche coopératif, l’application est chargée de « libérer » le processeur lorsqu’il n’est plus nécessaire et de laisser les autres applications s’exécuter. Cela n’a évidemment pas fonctionné, car vous ne pouvez pas faire confiance à toutes les applications (ni même à vos développeurs qui ne sont pas assez malins pour savoir quand libérer le processeur). Les systèmes d’exploitation modernes (Windows 95, Linux, etc) ont changé leur conception du multitâche pour le « multitâche préemptif », dans lequel le système d’exploitation est celui qui est chargé de décider quelles apps reçoivent le CPU, à quelle fréquence et pendant combien de temps.

Le système d’exploitation est alors celui qui décide quels processus obtiennent de s’exécuter, et pendant combien de temps. C’est ce qui permet à l’application d’avoir encore l’impression d’être « multitâche » même si elle n’a qu’un seul CPU. Le planificateur du système d’exploitation fait passer les processus de l’un à l’autre très rapidement.

Dans le diagramme suivant, 3 processus (P1, P2, P3) s’exécutent simultanément, partageant le même CPU (la ligne bleue représente le temps CPU). P1 arrive à s’exécuter pendant un certain temps avant de passer rapidement à P2, puis à P3, et ainsi de suite.

3 processus s’exécutent « simultanément »

Le processus

Un processus est l’abstraction que le système d’exploitation utilise pour exécuter votre code. Il enveloppe votre code dans ce processus, et lui attribue de la mémoire et d’autres ressources partagées. L’abstraction de processus permet au système d’exploitation de « distinguer » les programmes en cours d’exécution, afin qu’ils créent des conflits entre eux. Par exemple, le processus 1 ne peut pas accéder à la mémoire réservée par le processus 2. Elle est également importante en termes de sécurité pour les utilisateurs. Si l’utilisateur 1 lance un processus, ce processus pourra lire les fichiers accessibles par cet utilisateur.

Le schéma suivant contient une représentation visuelle d’un processus. Le processus contient le code qu’il doit exécuter, la RAM allouée, et toutes les données créées par le programme (variables, fichiers ouverts, etc).

Une représentation visuelle d’un Processus OS

Nous pouvons maintenant voir le même « programme » (le même morceau de code) s’exécuter dans plusieurs processus simultanément.

Nous pouvons maintenant passer à la deuxième phase : comment la concurrence (ou même le parallélisme) est-elle réellement réalisée ? De votre point de vue de développeur, il existe deux mécanismes que vous pouvez employer pour réaliser la « concurrence » : le multithreading ou le multiprocessing.

Multithreading

Les threads sont des flux d’exécution concurrents au sein d’un même processus. Le processus lance plusieurs fils d’exécution, qui « évoluent » indépendamment en fonction de ce qu’ils doivent faire.

Par exemple, disons que nous avons un programme qui doit télécharger des données à partir de trois sites web, puis les combiner pour un rapport final. Chaque site web prend environ deux secondes pour répondre avec les données. Un programme à thread unique serait quelque chose comme:

d1 = get_website_1() # 2 secs
d2 = get_website_2() # 2 secs
d3 = get_website_3() # 2 secscombine(d1, d2, d3) # very fast

Le temps d’exécution total sera de >6 secondes :

Un programme à thread unique

Un programme multithread commencera à télécharger les données des trois sites web en même temps, et attendra qu’elles soient toutes terminées pour faire le rapport final.

t1 = Thread(get_website_1)
t2 = Thread(get_website_2)
t3 = Thread(get_website_3)wait_for_threads()d1 = t1.result()
d2 = t2.result()
d3 = t3.result()combine(d1, d2, d3)

L’exécution maintenant sera proche de ~2 secondes, car les trois sites web répondront à peu près en même temps :

Un programme multithreadé

En Python, les threads sont créés avec la classe Thread, à partir du module threading, et vous pouvez voir plusieurs exemples dans le premier notebook du repo de mon tutoriel : 1. Les bases du thread.ipynb.

Les threads et les données partagées

Les threads sont explicitement créés par votre programme, ils vivent donc au sein d’un même processus. Cela signifie qu’ils partagent toutes les mêmes données du processus. Ils peuvent lire et modifier les variables locales, et accéder à la même mémoire ou aux mêmes ressources (fichiers, sockets réseau, etc).

Les threads partagent les données du processus

Cela peut causer certains problèmes, spécialement une mutation inattendue des données partagées et des Race Conditions. Vous pouvez voir un exemple de race condition dans le cahier 2. Thread Data & Race Conditions.ipynb. Dans le même notebook, nous introduisons certains mécanismes pour « synchroniser » les threads afin d’éviter les Race Conditions, mais ces mêmes mécanismes peuvent potentiellement introduire le problème d’un Deadlock. Il y a un exemple de Deadlock dans le notebook : 3. Deadlocks.ipynb.

Producteurs, consommateurs et files d’attente

Les problèmes de synchronisation peuvent être résolus en changeant simplement le paradigme.

La meilleure façon d’éviter les problèmes de synchronisation est d’éviter complètement la synchronisation.

Ceci est réalisé en utilisant le pattern producteur-consommateur. Les threads producteurs créent des « tâches en attente » à exécuter, puis les placent sur une file d’attente partagée. Les threads consommateurs liront les tâches de la file d’attente et effectueront le travail réel.

En Python, la classe Queue du module queue est une file d’attente thread-safe qui est utilisée à ces fins.

Il y a un exemple de modèle producteur-consommateur dans le cahier
4. Modèle producteur-consommateur.ipynb.

Le verrou d’interpréteur global de Python

Le GIL est l’une des caractéristiques les plus détestées de Python. En fait, elle n’est pas si mauvaise, mais elle a une réputation terrible.

La GIL est un verrou « global » placé par l’interprète Python qui garantit que seul 1 thread peut accéder au CPU à un moment donné. Cela peut sembler stupide : pourquoi allez-vous vous préoccuper d’un code multithread si un seul thread pourra de toute façon accéder au CPU à un moment donné ?

C’est là qu’il est important de comprendre que votre code ne consiste pas uniquement à utiliser le CPU. Votre code fera différentes tâches, comme lire des fichiers, obtenir des informations du réseau, etc. Vous vous souvenez de nos temps d’accès de latence ? Les voici à nouveau:

Latence à l’échelle humaine (source)

C’est la clé pour combattre la limitation du GIL. Au moment où un thread a besoin de lire quelque chose dans un fichier, il peut libérer le CPU et laisser les autres threads s’exécuter. Cela prendra beaucoup de temps jusqu’à ce que le disque dur renvoie les données, car la plupart des programmes sont ce que nous appelons I/O-Bound. C’est-à-dire qu’ils utilisent beaucoup de fonctionnalités d’E/S (réseau, système de fichiers, etc).

Vous pouvez voir une « preuve » d’une tâche liée aux E/S dans le cahier : 5. Le GIL Python.ipynb.

Multiprocessing

Certains autres programmes sont inévitablement liés au CPU, comme les programmes qui doivent faire beaucoup de calculs. Parfois, vous voulez accélérer les choses. Si vous utilisez le multithreading pour les tâches liées au CPU, vous réaliserez que vos programmes ne sont pas plus rapides à cause de la GIL (ils peuvent même devenir plus lents).

Entrez dans le multiprocessing, le deuxième mécanisme dont nous disposons pour écrire des programmes concurrents. Au lieu d’avoir plusieurs threads, nous allons en fait spawn plusieurs processus enfants qui se chargeront d’exécuter différentes tâches de manière concurrente.

Il y a un exemple de programme basé sur le multiprocessing dans le notebook 6. Multiprocessing.ipynb

concurrent.futures

Pour terminer mon tutoriel, j’aimerais souligner qu’il existe un module de plus haut niveau qui fait partie de la bibliothèque standard de Python et qui devrait être utilisé lorsque cela est possible : concurrent.futures.

Il fournit des abstractions de haut niveau pour créer des pools de threads et de processus, et il est suffisamment mature pour être considéré, lorsque cela est possible, comme l’alternative par défaut pour écrire du code concurrent.

Voilà ! Faites-moi savoir si vous avez des questions. Je suis heureux d’y répondre.

Laisser un commentaire

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