Tutoriel pour apprendre la programmation parallèle en Python

Gabor Laszlo Hajba

Traducteur

Christophe LOUVET

Dans ce tutoriel , je vais vous présenter le traitement parallèle en Python avec des threads (appelés aussi processus légers en français) en se concentrant sur Python 3.5.

Commentez

- Titre : Tutoriel pour apprendre la programmation parallèle en Python

- Auteur : Gabor Laszlo Hajba

- Traducteur : Christophe LOUVET

- Parution : 8 mars 2017

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2016 DiscoverSDK. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- 1. Pourquoi le parallélisme ?

- 2. Quelques exemples

- 2-1. Téléchargement des images depuis imgur.com

- 2-2. Décomposition en facteurs premiers

- 3. Comment faire ?

- 3-1. Partage de données entre threads

- 4. Exemples parallélisés

- 4-1. Téléchargeur d'image

- 4-2. Décomposition en facteurs premiers

- 4-3. Facteurs premiers avec expressions lambda

- 5. Conclusion

- 6. Remerciements

1 - Pourquoi le parallélisme ?

Nous avons besoin très souvent d'appeler un service externe (serveur Web, serveur de bases de données, fichiers, etc.) et comme le résultat dépend de la réponse, nous nous retrouvons dans un mode bloquant tant que le résultat n'est pas disponible. Dans ce genre de cas, si nous répartissons notre programme en tâches parallèles, nous pouvons utiliser le temps processeur plus efficacement. Comme de nos jours il y a des processeurs multicœurs sur la plupart des machines, cela signifie que nous avons le parallélisme disponible au niveau matériel.

Il est important d'être familier avec les fonctionnalités de parallélisme de n'importe quel langage pour écrire du code plus efficace.

Python est utilisé sur les applications Web ; quand nous dépendons du temps de réponse du serveur ainsi que de la base de données et d'autres composants, et, plus généralement, si nous écrivons du code autre qu'un script simple, nous pouvons utiliser des threads pour faire fonctionner les choses en parallèle.

Dans ces conditions, nous devons être également familiers avec certains problèmes connus tels que les situations de compétition et l'utilisation d'objets de synchronisation pour les éviter.

2 - Quelques exemples

Dans ce tutoriel, nous regarderons deux exemples de base :

- le téléchargement d'images depuis ingur.com ;

- le calcul de factorisation.

Les deux exemples auront une version de base, où j'introduirai simplement le problème et le code pour le résoudre. Ensuite, j'ajouterai du parallélisme dans les deux exemples et nous verrons ce que nous obtenons : avons-nous obtenu des résultats plus rapides ou plus lents en raison du traitement parallélisé ?

2-1 - Téléchargement des images depuis imgur.com

Pour cela, j'ai préparé un ensemble d'URL pour les images à télécharger, ceci permettant des tests plus stables pour ce tutoriel (si nous ne tenons pas compte de la mise en cache du routeur Web). Cependant, je vais vous montrer les sources vous permettant de manipuler le code.

Regardons maintenant le code :

1. __author__ = 'GHajba'

3. from urllib.request import urlopen, Request

4. import json

5. import os

8. def get_image_urls(client_id):

9. headers = {'Authorization': 'Client-ID {0}'.format(client_id)}

10. with urlopen(Request('https://api.imgur.com/3/g/memes/', headers=headers)) as response:

11. data = json.loads(response.read().decode('utf-8'))

12. return map(lambda image: image['link'], data['data'])

15. def download_images(target_dir, url):

16. path = target_dir + '/' + os.path.basename(url)

18. with open(path, 'wb') as file:

19. file.write(urlopen(url).read())

Ce code télécharge les derniers éléments de la page « memes » de imgur.com dont les liens pointent vers des fichiers .png ou jpg. Je vais éviter d'entrer dans les détails sur la façon d'obtenir votre «  IMGUR_CLIENT_ID », mais cela doit être spécifié dans l'environnement, car l'API imgur requiert une authentification qui est fournie à travers un id client.

Néanmoins, ce code télécharge les images une à une.

Sur mon Mac, l'exécution du code donne les résultats suivants :

1. 52 images téléchargées en 13.790853023529053 secondes avec Python 3

2. 52 images téléchargées en 15.189190864562988 secondes avec Python 3

3. 52 images téléchargées en 13.965453863143921 secondes avec Python 3

4. 52 images téléchargées en 13.087532997131348 secondes avec Python 3

5. 52 images téléchargées en in 14.43852710723877 secondes avec Python 3

Comme vous pouvez le voir, cela prend en moyenne 14 secondes pour télécharger 52 images depuis imgur en utilisant seulement un des huit cœurs dont je dispose.

2-2 - Décomposition en facteurs premiers

Dans cet exemple, je vais utiliser une version pas vraiment optimisée de décomposition en facteurs premiers. Ce calcul est très intensif au niveau CPU. Cela signifie qu'il requiert plus de puissance de calcul de la part de l'ordinateur que l'exemple précédent, où nous passions pas mal de temps à attendre du réseau toutes les données requises.

Le code de calcul des facteurs premiers ressemble à celui-ci :

1. def factors(result, n):

2. if n <= 1:

3. return result

4. for i in range(2, n + 1):

5. if n % i == 0:

6. result.append(i)

7. return factors(result, n // i)

Comme vous pouvez le voir, nous recevons comme arguments de la fonction un nombre n et une liste partielle de résultats nommée « result ».

Pour chaque nombre compris entre 2 et n, nous testons si n est divisible par ce nombre. Si oui, nous ajoutons ce nombre à la liste de résultat et appelons récursivement la fonction factors en lui passant en paramètres la nouvelle liste partielle des résultats et le résultat de la division entière de n (n//i).

Ce n'est pas optimisé, car nous parcourons chaque nombre entre 2 et n fois, et calculons le reste de la division n de chacun de ces nombres, au lieu d'utiliser une liste de nombres premiers (ou un générateur pour le faire plus efficacement). Mais c'est parfait pour cet exemple, car ces tâches nécessitent beaucoup de calculs de la part du processeur.

Lançons maintenant ce code avec une liste de 50 000 nombres et voyons ce qu'il se passe :

1. La factorisation de 50000 nombres a pris  25,89749503135681 secondes avec l'approche série récursive et Python 3.5.0

2. La factorisation de 50000 nombres a pris  26,277130842208862 secondes avec l'approche série récursive et Python 3.5.0

3. La factorisation de 50000 nombres a pris  26,53605008125305 secondes avec l'approche série récursive et Python 3.5.0

4. La factorisation de 50000 nombres a pris  25,725329160690308 secondes avec l'approche série récursive et Python 3.5.0

5. La factorisation de 50000 nombres a pris  25,732399940490723 secondes avec l'approche série récursive et Python 3.5.0

Cela paraît pas mal, mais si vous augmentez le nombre de calculs, nous aurons des temps de calcul plus longs :

1. La factorisation de 55000 nombres a pris  33.56754684448242 secondes avec l'approche série récursive et Python 3.5.0

2. La factorisation de 60000 nombres a pris  37.03928780555725 secondes avec l'approche série récursive et Python 3.5.0

3. La factorisation de 65000 nombres a pris  41.349984884262085 secondes avec l'approche série récursive et Python 3.5.0

4. La factorisation de 75000 nombres a pris  56.48437809944153 secondes avec l'approche série récursive et Python 3.5.0

5. La factorisation de 100000 nombres a pris  100.21058487892151 secondes avec l'approche série récursive et Python 3.5.0

À la fin, nous atteignons la moyenne de 1 seconde pour 1000 nombres. Et ce n'est pas bon si nous voulons traiter plus de nombres.

3 - Comment faire ?

Après avoir étudié les exemples ci-dessus, il est temps d'en dire plus sur comment réaliser la parallélisation en Python 3.

On réalise un traitement parallèle avec des threads en utilisant la bibliothèque threading dans Python. Indépendante de la version, cette bibliothèque a une classe nommée Thread qui assigne à un nouveau thread d'exécution du code que vous définissez.

Il y a deux options pour créer des threads. Une est d'instancier un nouvel objet Thread avec une expression lambda lui disant quoi faire ; l'autre est de créer une classe fille de Thread qui implémente la méthode run, laquelle exécutera la logique du thread quand il est démarré.

Je recommande cette seconde solution, car elle est plus flexible et que vous aurez une classe (un point dans votre base de code) où vous pourrez trouver la logique et ne pas tout éparpiller.

Comme vous le verrez dans les exemples ci-dessous, j'ai positionné les threads en tant que threads démons, pour la raison suivante : un script Python ne s'arrêtera pas tant qu'il contiendra des threads vivants/en fonctionnement ; et même si un thread de travail créé dans l'exemple se termine et que le conteneur de données partagées se vide, il ne s'arrêtera pas de fonctionner, car il pourrait y avoir une charge de travail plus importante. Cependant, les threads démons permettront l'arrêt de l'application, car le thread principal (celui qui démarre le travail et lance le script), n'est pas un démon et il n'y a pas d'autre thread non démon en fonctionnement ; par conséquent, le script va quitter.

3-1 - Partage de données entre threads

Ceci est toujours un sujet intéressant. Si vous travaillez avec le parallélisme indépendamment du langage, la question se pose ; comment pouvez-vous partager des données entre les threads sans avoir un mauvais résultat ou une exception ?

Un mauvais résultat peut arriver si chaque thread télécharge les mêmes images (ou si juste deux threads téléchargent la même image en parallèle).

Nous pouvons utiliser une classe Queue du module queue de Python.

Un objet Queue (file d'attente) est une implémentation FIFO (first-in first-out - premier entré, premier sorti), ce qui signifie que si vous placez un élément A avant l'élément B dans la queue, l'élément A sera sorti avant l'élément B.

J'ai précisé que l'application se termine quand le thread principal se termine lui-même. Cependant, si nous faisons tout le travail dans des threads séparés, nous pourrions terminer avant les autres threads, ce qui signifie que cela peut être super rapide, mais nous n'aurons pas atteint notre but.

4 - Exemples parallélisés

Maintenant que nous savons comment paralléliser notre travail avec Python, il est temps de regarder nos exemples et de répartir la charge de travail entre les différents threads.

4-1 - Téléchargeur d'image

Voyons ce que nous pouvons accomplir quand nous ajoutons des threads pour exécuter les téléchargements en parallèle. Tout d'abord, nous définissons une classe fille de Thread que nous appelons Downloader .

Voici le code :

1. __author__ = 'GHajba'

3. from threading import Thread

4. from queue import Queue

7. class Downloader(Thread):

8. def __init__(self, queue, folder):

9. Thread.__init__(self)

10. self.queue = queue

11. self.folder = folder

13. def run(self):

14. while True:

15. url = self.queue.get()

16. download_images(self.folder, url)

17. self.queue.task_done()

Comme vous pouvez le voir, je crée une classe fille de la classe Thread et initialise le nouvel objet avec la référence de la queue partageant les données dans le dossier dans lequel nous exportons les fichiers. Ces deux éléments ne devraient pas changer pendant la durée de vie du thread.

La méthode run a une boucle infinie, car les threads démons ne savent pas habituellement combien de travail ils ont à faire. À chaque itération, nous obtenons l'URL suivante depuis la queue et appelons la fonction de téléchargement des images avec la nouvelle URL et le dossier où nous voulons que le résultat soit sauvegardé. Une fois le téléchargement fini, nous pouvons informer la queue que nous avons fini avec l'élément que nous avons traité, afin qu'elle puisse être assurée que nous avons effectué notre partie de la charge de travail, et n'attende pas sans fin dans le thread principal (là où nous appelons queue.join()).

Voyons maintenant comment initialiser les threads et comment remplir la queue ;

1. thread_count = 4

3. queue = Queue()

5. for i in range(thread_count):

6. downloader = Downloader(queue, 'images')

7. downloader.daemon = True

8. downloader.start()

10. for url in image_url_list:

11. queue.put(url)

13. queue.join()

Le code ci-dessus démarre l'application avec 4 threads parallèles puis alimente la queue. Comme je l'ai mentionné, nous créons les threads en tant que threads démons et les démarrons. Une fois tous les threads démarrés, nous pouvons remplir la queue avec les données des images pour télécharger les URL.

Finalement, nous bloquons notre thread principal avec queue.join() jusqu'à ce que tous les éléments de la queue aient été traités par les threads.

J'ai utilisé les URL des images comme données en entrée pour la queue, car elles ont une liste définie de multiples éléments (dans le cas de mon test : 52 fichiers .jpg ou .png). Nous pourrions également faire l'extraction en parallèle, mais cela ne donnerait pas beaucoup de gain de performances pour un site. Si nous extrayions un paquet de sites de la galerie memes (pas seulement les 60 premières images du premier site), nous pourrions le paralléliser.

Quand je lance cet exemple (et ajoute un peu de mesure de temps) sur mon Mac, j'obtiens le résultat suivant :

1. 52 images téléchargées en  5.1321189403533936 secondes avec 4 threads et Python 3.5.0

2. 52 images téléchargées en  4.801907062530518 secondes avec 4 threads et Python 3.5.0

3. 52 images téléchargées en  5.145358085632324 secondes avec 4 threads et Python 3.5.0

4. 52 images téléchargées en  4.81237006187439  secondes avec 4 threads et Python 3.5.0

5. 52 images téléchargées en  5.1538519859313965  secondes avec 4 threads et Python 3.5.0

Nettement plus rapide ! C'est ce que nous espérions quand nous avons ajouté plusieurs threads au code. Maintenant, voyons ce qui se passe quand nous utilisons 8 threads :

52 images téléchargées en  3.7929270267486572  secondes avec 8 threads et Python 3.5.0
52 images téléchargées en  3.6049129962921143  secondes avec 8 threads et Python 3.5.0
52 images téléchargées en  3.4051239490509033  secondes avec 8 threads et Python 3.5.0
52 images téléchargées en  3.8651368618011475  secondes avec 8 threads et Python 3.5.0
52 images téléchargées en  3.204490900039673  secondes avec 8 threads et Python 3.5.0

Un peu plus rapide, mais comme vous pouvez le voir, il n'est pas utile d'augmenter encore le nombre de threads. Naturellement, si le script doit télécharger de plus gros fichiers ou si vous n'avez pas une connexion réseau rapide, alors vous pouvez ajouter plus de threads pour utiliser les temps d'attente d'entrées/sorties.

4-2 - Décomposition en facteurs premiers

Pour ce calcul, nous créons encore une sous-classe Thread et l'appelons Factorizer :

1. __author__ = 'GHajba'

3. from threading import Thread

4. from queue import Queue

6. class Factorizer(Thread):

7. def __init__(self, queue):

8. Thread.__init__(self)

9. self.queue = queue

11. def run(self):

12. while True:

13. n = self.queue.get()

14. result = factors([], n)

15. self.queue.task_done()

L'enchaînement des opérations est le même : nous fournissons dans le constructeur la queue des données partagées et dans la méthode run, nous y prenons un élément, créons ses facteurs et informons la queue de ce que nous en avons fait (et nous n'utilisons pas le résultat, mais ça n'a ici pas d'importance).

Pour remplir la queue et initialiser les threads, j'utilise la même approche que précédemment :

1. thread_count = 4

3. queue = Queue()

5. for i in range(thread_count):

6. factorizer = Factorizer(queue)

7. factorizer.daemon = True

8. factorizer.start()

10. for n in numbers:

11. queue.put(n)

13. queue.join()

Les données partagées entre les threads sont les nombres que nous voulons factoriser.

Si nous lançons le script avec 4 threads, nous obtenons le résultat suivant :

1. Factoriser 50000 nombres a pris 30.301374912261963 avec 4 threads et Python 3.5.0

2. Factoriser 50000 nombres a pris 30.535978078842163 avec 4 threads et Python 3.5.0

3. Factoriser 50000 nombres a pris 30.37640905380249 avec 4 threads et Python 3.5.0

4. Factoriser 50000 nombres a pris 30.411335945129395 avec 4 threads et Python 3.5.0

5. Factoriser 50000 nombres a pris 31.054304122924805 avec 4 threads et Python 3.5.0

Quasiment les mêmes durées d'exécution que précédemment. Mais que s'est-il passé ? Ajoutons plus de threads et voyons si nous pouvons faire mieux :

1. Factoriser 50000 nombres a pris 27.70519995689392 secondes avec 8 threads et Python 3.5.0

2. Factoriser 50000 nombres a pris 27.566843032836914 secondes avec 8 threads et Python 3.5.0

3. Factoriser 50000 nombres a pris 27.92934489250183 secondes avec 8 threads et Python 3.5.0

4. Factoriser 50000 nombres a pris 28.050817012786865 secondes avec 8 threads et Python 3.5.0

5. Factoriser 50000 nombres a pris 27.751455068588257 secondes avec 8 threads et Python 3.5.0

Ah non, toujours pas, mais pourquoi ? Et pourquoi cela prend-il plus de temps avec des threads qu'en utilisant une simple boucle for ?

Créer la queue et la remplir avec les nombres, puis créer les threads rend le déroulement du processus un peu plus lent, mais ce n'est pas la raison principale : nous avons la même durée d'exécution à la fin, car la tâche est intensive en temps CPU et l'implémentation CPython de Python (celle que vous pouvez télécharger par défaut sur le site officiel de Python) a un mécanisme nommé Global Interpreter Lock (GIL) qui garantit qu'un seul thread peut exécuter du code Python à un moment donné.

Quand nous téléchargeons des images, nous attendons la fin d'opérations réseau. C'est pourquoi il est possible d'effectuer des opérations en parallèle beaucoup plus vite. Il n'y a pas d'exécution de code quand on est en attente du réseau.

4-3 - Facteurs premiers avec expressions lambda

Je vous ai parlé de deux moyens de créer des threads : à travers la création d'une sous-classe ou avec des expressions lambda. J'ai montré la première version, car je pense qu'elle est plus claire et plus flexible. Je vais cependant maintenant vous montrer la seconde version. L'exemple est la même factorisation vue précédemment, juste à la place de la classe Factorizer, nous créons une classe « anonyme » :

1. queue = Queue()

3. for i in range(thread_count):

4. t = Thread(target = lambda: calculate(queue))

5. t.daemon = True

6. t.start()

8. for n in numbers:

9. queue.put(n)

11. queue.join()

Comme vous pouvez le voir, nous fournissons une expression lambda au constructeur du thread. Cette expression se réfère à une fonction nommée calculate qui prend une queue en paramètre. Et cette queue est la queue des nombres que nous voulons factoriser :

1. def calculate(numbers_queue):

2. while True:

3. number = numbers_queue.get()

4. result = factors([], number)

5. numbers_queue.task_done()

Comme vous pouvez le voir, le corps de la méthode est le même pour le thread sous-classe. La performance est aussi la même. Il n'y a pas d'amélioration en changeant les solutions.

À vous de choisir la version que vous préférez et de l'appliquer.

5 - Conclusion

Nous avons vu que paralléliser des applications en utilisant la bibliothèque de threading rend les exécutions plus rapides à condition que nous donnions suffisamment de temps au processeur pour attendre et utiliser de multiples threads . Si nous avons des tâches qui consomment beaucoup de temps CPU, le nombre de threads assignés n'a pas d'importance ; nous aurons le même résultat qu'en d'exécutant un seul thread. Ceci est dû au GIL (Global Interpreter Lock). Cependant, il y a également une solution à cela : démarrer des processus multiples pour un gain de temps à l'exécution. Mais nous introduirons ce sujet dans un autre article.

6 - Remerciements

Nous remercions Gabor Laszlo Hajba de nous avoir autorisés à traduire et publier son tutoriel Parallel processing in python

Nous tenons également à remercier Chrtophe pour la traduction, lolo78, Deusyss pour leur relecture technique ainsi que f-leb pour la relecture orthographique.