Tutoriel sur le multithreading et le multitraitement Python

Publié: 2022-03-11
Remarque : à la demande générale, je démontre certaines techniques alternatives --- y compris async/wait, disponible uniquement depuis l'avènement de Python 3.5 --- j'ai ajouté quelques mises à jour à la fin de l'article. Prendre plaisir!

Les discussions critiquant Python parlent souvent de la difficulté d'utiliser Python pour un travail multithread, pointant du doigt ce que l'on appelle le verrou global de l'interpréteur (affectueusement appelé GIL) qui empêche plusieurs threads de code Python de s'exécuter simultanément. Pour cette raison, le module multithreading Python ne se comporte pas tout à fait comme vous l'attendriez si vous n'êtes pas un développeur Python et que vous venez d'autres langages tels que C++ ou Java. Il doit être clair que l'on peut toujours écrire du code en Python qui s'exécute simultanément ou en parallèle et faire une nette différence dans les performances résultantes, tant que certaines choses sont prises en considération. Si vous ne l'avez pas encore lu, je vous suggère de jeter un œil à l'article d'Eqbal Quran sur la concurrence et le parallélisme dans Ruby ici sur le blog Toptal Engineering.

Dans ce didacticiel sur la concurrence Python, nous allons écrire un petit script Python pour télécharger les images les plus populaires d'Imgur. Nous allons commencer avec une version qui télécharge les images de manière séquentielle, ou une à la fois. Au préalable, vous devrez enregistrer une application sur Imgur. Si vous n'avez pas encore de compte Imgur, veuillez d'abord en créer un.

Les scripts de ces exemples de threading ont été testés avec Python 3.6.4. Avec quelques modifications, ils devraient également fonctionner avec Python 2 - urllib est ce qui a le plus changé entre ces deux versions de Python.

Premiers pas avec le multithreading Python

Commençons par créer un module Python, nommé download.py . Ce fichier contiendra toutes les fonctions nécessaires pour aller chercher la liste des images et les télécharger. Nous diviserons ces fonctionnalités en trois fonctions distinctes :

  • get_links
  • download_link
  • setup_download_dir

La troisième fonction, setup_download_dir , sera utilisée pour créer un répertoire de destination de téléchargement s'il n'existe pas déjà.

L'API d'Imgur nécessite que les requêtes HTTP portent l'en-tête d' Authorization avec l'ID client. Vous pouvez trouver cet ID client à partir du tableau de bord de l'application que vous avez enregistrée sur Imgur, et la réponse sera encodée en JSON. Nous pouvons utiliser la bibliothèque JSON standard de Python pour le décoder. Le téléchargement de l'image est une tâche encore plus simple, car tout ce que vous avez à faire est de récupérer l'image par son URL et de l'écrire dans un fichier.

Voici à quoi ressemble le script :

 import json import logging import os from pathlib import Path from urllib.request import urlopen, Request logger = logging.getLogger(__name__) types = {'image/jpeg', 'image/png'} def get_links(client_id): headers = {'Authorization': 'Client-ID {}'.format(client_id)} req = Request('https://api.imgur.com/3/gallery/random/random/', headers=headers, method='GET') with urlopen(req) as resp: data = json.loads(resp.read().decode('utf-8')) return [item['link'] for item in data['data'] if 'type' in item and item['type'] in types] def download_link(directory, link): download_path = directory / os.path.basename(link) with urlopen(link) as image, download_path.open('wb') as f: f.write(image.read()) logger.info('Downloaded %s', link) def setup_download_dir(): download_dir = Path('images') if not download_dir.exists(): download_dir.mkdir() return download_dir

Ensuite, nous devrons écrire un module qui utilisera ces fonctions pour télécharger les images, une par une. Nous nommerons ce single.py . Celui-ci contiendra la fonction principale de notre première version naïve du téléchargeur d'images Imgur. Le module récupérera l'ID client Imgur dans la variable d'environnement IMGUR_CLIENT_ID . Il invoquera setup_download_dir pour créer le répertoire de destination du téléchargement. Enfin, il récupérera une liste d'images à l'aide de la fonction get_links , filtrera toutes les URL GIF et albums, puis utilisera download_link pour télécharger et enregistrer chacune de ces images sur le disque. Voici à quoi ressemble single.py :

 import logging import os from time import time from download import setup_download_dir, get_links, download_link logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) def main(): ts = time() client_id = os.getenv('IMGUR_CLIENT_ID') if not client_id: raise Exception("Couldn't find IMGUR_CLIENT_ID environment variable!") download_dir = setup_download_dir() links = get_links(client_id) for link in links: download_link(download_dir, link) logging.info('Took %s seconds', time() - ts) if __name__ == '__main__': main()

Sur mon ordinateur portable, ce script a pris 19,4 secondes pour télécharger 91 images. Veuillez noter que ces chiffres peuvent varier en fonction du réseau sur lequel vous vous trouvez. 19,4 secondes, ce n'est pas terriblement long, mais que se passerait-il si nous voulions télécharger plus de photos ? Peut-être 900 images, au lieu de 90. Avec une moyenne de 0,2 seconde par image, 900 images prendraient environ 3 minutes. Pour 9000 photos, cela prendrait 30 minutes. La bonne nouvelle est qu'en introduisant la concurrence ou le parallélisme, nous pouvons accélérer cela de manière spectaculaire.

Tous les exemples de code suivants n'afficheront que les instructions d'importation qui sont nouvelles et spécifiques à ces exemples. Pour plus de commodité, tous ces scripts Python se trouvent dans ce référentiel GitHub.

Concurrence et parallélisme en Python : exemple de thread

Le threading est l'une des approches les plus connues pour atteindre la concurrence et le parallélisme Python. Le threading est une fonctionnalité généralement fournie par le système d'exploitation. Les threads sont plus légers que les processus et partagent le même espace mémoire.

Modèle de mémoire multithread Python

Dans cet exemple de thread Python, nous allons écrire un nouveau module pour remplacer single.py . Ce module créera un pool de huit threads, soit un total de neuf threads, y compris le thread principal. J'ai choisi huit threads de travail car mon ordinateur a huit cœurs de processeur et un thread de travail par cœur semblait un bon nombre pour le nombre de threads à exécuter en même temps. En pratique, ce nombre est choisi avec beaucoup plus de soin en fonction d'autres facteurs, tels que d'autres applications et services exécutés sur la même machine.

C'est presque la même chose que la précédente, à l'exception que nous avons maintenant une nouvelle classe, DownloadWorker , qui est un descendant de la classe Python Thread . La méthode run a été remplacée, ce qui exécute une boucle infinie. À chaque itération, il appelle self.queue.get() pour essayer de récupérer une URL à partir d'une file d'attente thread-safe. Il bloque jusqu'à ce qu'il y ait un élément dans la file d'attente que le travailleur doit traiter. Une fois que le travailleur reçoit un élément de la file d'attente, il appelle ensuite la même méthode download_link que celle utilisée dans le script précédent pour télécharger l'image dans le répertoire images. Une fois le téléchargement terminé, le travailleur signale à la file d'attente que cette tâche est terminée. Ceci est très important, car la file d'attente garde une trace du nombre de tâches mises en file d'attente. L'appel à queue.join() bloquerait le thread principal pour toujours si les travailleurs ne signalaient pas qu'ils avaient terminé une tâche.

 import logging import os from queue import Queue from threading import Thread from time import time from download import setup_download_dir, get_links, download_link logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) class DownloadWorker(Thread): def __init__(self, queue): Thread.__init__(self) self.queue = queue def run(self): while True: # Get the work from the queue and expand the tuple directory, link = self.queue.get() try: download_link(directory, link) finally: self.queue.task_done() def main(): ts = time() client_id = os.getenv('IMGUR_CLIENT_ID') if not client_id: raise Exception("Couldn't find IMGUR_CLIENT_ID environment variable!") download_dir = setup_download_dir() links = get_links(client_id) # Create a queue to communicate with the worker threads queue = Queue() # Create 8 worker threads for x in range(8): worker = DownloadWorker(queue) # Setting daemon to True will let the main thread exit even though the workers are blocking worker.daemon = True worker.start() # Put the tasks into the queue as a tuple for link in links: logger.info('Queueing {}'.format(link)) queue.put((download_dir, link)) # Causes the main thread to wait for the queue to finish processing all the tasks queue.join() logging.info('Took %s', time() - ts) if __name__ == '__main__': main()

L'exécution de cet exemple de script de threading Python sur la même machine utilisée précédemment entraîne un temps de téléchargement de 4,1 secondes ! C'est 4,7 fois plus rapide que l'exemple précédent. Bien que cela soit beaucoup plus rapide, il convient de mentionner qu'un seul thread s'exécutait à la fois tout au long de ce processus en raison du GIL. Par conséquent, ce code est concurrent mais pas parallèle. La raison pour laquelle il est encore plus rapide est qu'il s'agit d'une tâche liée aux E/S. Le processeur transpire à peine lors du téléchargement de ces images, et la majorité du temps est consacrée à attendre le réseau. C'est pourquoi le multithreading Python peut fournir une grande augmentation de la vitesse. Le processeur peut basculer entre les threads chaque fois que l'un d'eux est prêt à effectuer un travail. L'utilisation du module de threading en Python ou dans tout autre langage interprété avec un GIL peut en fait entraîner une réduction des performances. Si votre code exécute une tâche liée au processeur, telle que la décompression de fichiers gzip, l'utilisation du module de threading entraînera un temps d'exécution plus lent. Pour les tâches liées au processeur et une exécution véritablement parallèle, nous pouvons utiliser le module de multitraitement.

Bien que l'implémentation Python de référence de facto - CPython - ait un GIL, ce n'est pas le cas de toutes les implémentations Python. Par exemple, IronPython, une implémentation Python utilisant le framework .NET, n'a pas de GIL, pas plus que Jython, l'implémentation basée sur Java. Vous pouvez trouver une liste des implémentations Python fonctionnelles ici.

Connexes : Meilleures pratiques et astuces Python par les développeurs Toptal

Concurrence et parallélisme dans Python Exemple 2 : Création de plusieurs processus

Le module de multitraitement est plus facile à intégrer que le module de threading, car nous n'avons pas besoin d'ajouter une classe comme l'exemple de threading Python. Les seuls changements que nous devons apporter sont dans la fonction principale.

Tutoriel multitraitement Python : Modules

Pour utiliser plusieurs processus, nous créons un Pool de multitraitement. Avec la méthode map qu'il fournit, nous transmettrons la liste des URL au pool, qui à son tour générera huit nouveaux processus et utilisera chacun pour télécharger les images en parallèle. C'est du vrai parallélisme, mais cela a un coût. La totalité de la mémoire du script est copiée dans chaque sous-processus généré. Dans cet exemple simple, ce n'est pas un gros problème, mais cela peut facilement devenir une surcharge importante pour les programmes non triviaux.

 import logging import os from functools import partial from multiprocessing.pool import Pool from time import time from download import setup_download_dir, get_links, download_link logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logging.getLogger('requests').setLevel(logging.CRITICAL) logger = logging.getLogger(__name__) def main(): ts = time() client_id = os.getenv('IMGUR_CLIENT_ID') if not client_id: raise Exception("Couldn't find IMGUR_CLIENT_ID environment variable!") download_dir = setup_download_dir() links = get_links(client_id) download = partial(download_link, download_dir) with Pool(4) as p: p.map(download, links) logging.info('Took %s seconds', time() - ts) if __name__ == '__main__': main()

Concurrence et parallélisme dans Python Exemple 3 : Distribution à plusieurs nœuds de calcul

Bien que les modules de threading et de multitraitement soient parfaits pour les scripts qui s'exécutent sur votre ordinateur personnel, que devez-vous faire si vous souhaitez que le travail soit effectué sur une autre machine, ou si vous devez évoluer jusqu'à plus que le processeur d'une seule machine ne peut le faire. poignée? Un excellent cas d'utilisation pour cela est les tâches principales de longue durée pour les applications Web. Si vous avez des tâches de longue durée, vous ne voulez pas faire tourner un tas de sous-processus ou de threads sur la même machine qui doivent exécuter le reste de votre code d'application. Cela dégradera les performances de votre application pour tous vos utilisateurs. Ce qui serait formidable, c'est de pouvoir exécuter ces tâches sur une autre machine, ou sur de nombreuses autres machines.

Une excellente bibliothèque Python pour cette tâche est RQ, une bibliothèque très simple mais puissante. Vous mettez d'abord en file d'attente une fonction et ses arguments à l'aide de la bibliothèque. Cela décape la représentation de l'appel de fonction, qui est ensuite ajoutée à une liste Redis. La mise en file d'attente du travail est la première étape, mais ne fera rien pour le moment. Nous avons également besoin d'au moins un travailleur pour écouter cette file d'attente.

Modèle de la bibliothèque de files d'attente RQ Python

La première étape consiste à installer et à exécuter un serveur Redis sur votre ordinateur ou à avoir accès à un serveur Redis en cours d'exécution. Après cela, il n'y a que quelques petites modifications apportées au code existant. Nous créons d'abord une instance d'une file d'attente RQ et lui transmettons une instance d'un serveur Redis à partir de la bibliothèque redis-py. Ensuite, au lieu d'appeler simplement notre méthode download_link , nous appelons q.enqueue(download_link, download_dir, link) . La méthode enqueue prend une fonction comme premier argument, puis tous les autres arguments ou arguments de mot-clé sont transmis à cette fonction lorsque le travail est réellement exécuté.

Une dernière étape que nous devons faire est de démarrer quelques travailleurs. RQ fournit un script pratique pour exécuter les travailleurs sur la file d'attente par défaut. Exécutez simplement rqworker dans une fenêtre de terminal et il démarrera un travailleur écoutant sur la file d'attente par défaut. Veuillez vous assurer que votre répertoire de travail actuel est le même que celui où résident les scripts. Si vous souhaitez écouter une file d'attente différente, vous pouvez exécuter rqworker queue_name et il écoutera cette file d'attente nommée. L'avantage de RQ est que tant que vous pouvez vous connecter à Redis, vous pouvez exécuter autant de nœuds de calcul que vous le souhaitez sur autant de machines différentes que vous le souhaitez ; par conséquent, il est très facile d'évoluer à mesure que votre application se développe. Voici la source de la version RQ :

 import logging import os from redis import Redis from rq import Queue from download import setup_download_dir, get_links, download_link logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logging.getLogger('requests').setLevel(logging.CRITICAL) logger = logging.getLogger(__name__) def main(): client_id = os.getenv('IMGUR_CLIENT_ID') if not client_id: raise Exception("Couldn't find IMGUR_CLIENT_ID environment variable!") download_dir = setup_download_dir() links = get_links(client_id) q = Queue(connection=Redis(host='localhost', port=6379)) for link in links: q.enqueue(download_link, download_dir, link) if __name__ == '__main__': main()

Cependant, RQ n'est pas la seule solution de file d'attente de travaux Python. RQ est facile à utiliser et couvre extrêmement bien les cas d'utilisation simples, mais si des options plus avancées sont nécessaires, d'autres solutions de file d'attente Python 3 (telles que Celery) peuvent être utilisées.

Python multithreading vs multitraitement

Si votre code est lié aux E/S, le multitraitement et le multithreading en Python fonctionneront pour vous. Le multitraitement est plus facile à intégrer que le threading, mais a une surcharge de mémoire plus élevée. Si votre code est lié au processeur, le multitraitement sera probablement le meilleur choix, en particulier si la machine cible possède plusieurs cœurs ou processeurs. Pour les applications Web et lorsque vous devez faire évoluer le travail sur plusieurs machines, RQ sera mieux pour vous.

Connexe : Devenez plus avancé : évitez les 10 erreurs les plus courantes commises par les programmeurs Python

Mettre à jour

Python concurrent.futures

Quelque chose de nouveau depuis Python 3.2 qui n'a pas été abordé dans l'article original est le package concurrent.futures . Ce paquet fournit encore une autre façon d'utiliser la concurrence et le parallélisme avec Python.

Dans l'article original, j'ai mentionné que le module de multitraitement de Python serait plus facile à intégrer dans le code existant que le module de threading. En effet, le module de threading Python 3 nécessitait de sous-classer la classe Thread et de créer également une file d' Queue pour que les threads surveillent le travail.

L'utilisation d'un concurrent.futures.ThreadPoolExecutor rend l'exemple de code de threading Python presque identique au module de multitraitement.

 import logging import os from concurrent.futures import ThreadPoolExecutor from functools import partial from time import time from download import setup_download_dir, get_links, download_link logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) def main(): client_id = os.getenv('IMGUR_CLIENT_ID') if not client_id: raise Exception("Couldn't find IMGUR_CLIENT_ID environment variable!") download_dir = setup_download_dir() links = get_links(client_id) # By placing the executor inside a with block, the executors shutdown method # will be called cleaning up threads. # # By default, the executor sets number of workers to 5 times the number of # CPUs. with ThreadPoolExecutor() as executor: # Create a new partially applied function that stores the directory # argument. # # This allows the download_link function that normally takes two # arguments to work with the map function that expects a function of a # single argument. fn = partial(download_link, download_dir) # Executes fn concurrently using threads on the links iterable. The # timeout is for the entire process, not a single call, so downloading # all images must complete within 30 seconds. executor.map(fn, links, timeout=30) if __name__ == '__main__': main()

Maintenant que nous avons toutes ces images téléchargées avec notre Python ThreadPoolExecutor , nous pouvons les utiliser pour tester une tâche liée au processeur. Nous pouvons créer des versions miniatures de toutes les images dans un script à un seul thread et à un seul processus, puis tester une solution basée sur le multitraitement.

Nous allons utiliser la librairie Pillow pour gérer le redimensionnement des images.

Voici notre scénario initial.

 import logging from pathlib import Path from time import time from PIL import Image logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) def create_thumbnail(size, path): """ Creates a thumbnail of an image with the same name as image but with _thumbnail appended before the extension. Eg: >>> create_thumbnail((128, 128), 'image.jpg') A new thumbnail image is created with the name image_thumbnail.jpg :param size: A tuple of the width and height of the image :param path: The path to the image file :return: None """ image = Image.open(path) image.thumbnail(size) path = Path(path) name = path.stem + '_thumbnail' + path.suffix thumbnail_path = path.with_name(name) image.save(thumbnail_path) def main(): ts = time() for image_path in Path('images').iterdir(): create_thumbnail((128, 128), image_path) logging.info('Took %s', time() - ts) if __name__ == '__main__': main()

Ce script parcourt les chemins du dossier images et pour chaque chemin, il exécute la fonction create_thumbnail. Cette fonction utilise Pillow pour ouvrir l'image, créer une vignette et enregistrer la nouvelle image plus petite avec le même nom que l'original mais avec _thumbnail ajouté au nom.

L'exécution de ce script sur 160 images totalisant 36 millions prend 2,32 secondes. Voyons si nous pouvons accélérer cela en utilisant un ProcessPoolExecutor.

 import logging from pathlib import Path from time import time from functools import partial from concurrent.futures import ProcessPoolExecutor from PIL import Image logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) def create_thumbnail(size, path): """ Creates a thumbnail of an image with the same name as image but with _thumbnail appended before the extension. Eg: >>> create_thumbnail((128, 128), 'image.jpg') A new thumbnail image is created with the name image_thumbnail.jpg :param size: A tuple of the width and height of the image :param path: The path to the image file :return: None """ path = Path(path) name = path.stem + '_thumbnail' + path.suffix thumbnail_path = path.with_name(name) image = Image.open(path) image.thumbnail(size) image.save(thumbnail_path) def main(): ts = time() # Partially apply the create_thumbnail method, setting the size to 128x128 # and returning a function of a single argument. thumbnail_128 = partial(create_thumbnail, (128, 128)) # Create the executor in a with block so shutdown is called when the block # is exited. with ProcessPoolExecutor() as executor: executor.map(thumbnail_128, Path('images').iterdir()) logging.info('Took %s', time() - ts) if __name__ == '__main__': main()

La méthode create_thumbnail est identique au dernier script. La principale différence est la création d'un ProcessPoolExecutor . La méthode map de l'exécuteur est utilisée pour créer les vignettes en parallèle. Par défaut, le ProcessPoolExecutor crée un sous-processus par CPU. L'exécution de ce script sur les mêmes 160 images a pris 1,05 seconde, soit 2,2 fois plus vite !

Asynchrone/Attente (Python 3.5+ uniquement)

L'un des éléments les plus demandés dans les commentaires de l'article original était un exemple utilisant le module asyncio de Python 3. Par rapport aux autres exemples, il existe une nouvelle syntaxe Python qui peut être nouvelle pour la plupart des gens, ainsi que de nouveaux concepts. Une malheureuse couche supplémentaire de complexité est causée par le fait que le module urllib intégré de Python n'est pas asynchrone. Nous devrons utiliser une bibliothèque HTTP asynchrone pour tirer pleinement parti de l'asyncio. Pour cela, nous utiliserons aiohttp.

Passons directement au code et une explication plus détaillée suivra.

 import asyncio import logging import os from time import time import aiohttp from download import setup_download_dir, get_links logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) async def async_download_link(session, directory, link): """ Async version of the download_link method we've been using in the other examples. :param session: aiohttp ClientSession :param directory: directory to save downloads :param link: the url of the link to download :return: """ download_path = directory / os.path.basename(link) async with session.get(link) as response: with download_path.open('wb') as f: while True: # await pauses execution until the 1024 (or less) bytes are read from the stream chunk = await response.content.read(1024) if not chunk: # We are done reading the file, break out of the while loop break f.write(chunk) logger.info('Downloaded %s', link) # Main is now a coroutine async def main(): client_id = os.getenv('IMGUR_CLIENT_ID') if not client_id: raise Exception("Couldn't find IMGUR_CLIENT_ID environment variable!") download_dir = setup_download_dir() # We use a session to take advantage of tcp keep-alive # Set a 3 second read and connect timeout. Default is 5 minutes async with aiohttp.ClientSession(conn_timeout=3, read_timeout=3) as session: tasks = [(async_download_link(session, download_dir, l)) for l in get_links(client_id)] # gather aggregates all the tasks and schedules them in the event loop await asyncio.gather(*tasks, return_exceptions=True) if __name__ == '__main__': ts = time() # Create the asyncio event loop loop = asyncio.get_event_loop() try: loop.run_until_complete(main()) finally: # Shutdown the loop even if there is an exception loop.close() logger.info('Took %s seconds to complete', time() - ts)

Il y a pas mal de choses à déballer ici. Commençons par le point d'entrée principal du programme. La première nouveauté que nous faisons avec le module asyncio est d'obtenir la boucle d'événement. La boucle d'événements gère tout le code asynchrone. Ensuite, la boucle est exécutée jusqu'à ce qu'elle soit terminée et passe la fonction main . Il y a une nouvelle syntaxe dans la définition de main : async def . Vous remarquerez également await et with async .

La syntaxe async/wait a été introduite dans PEP492. La syntaxe async def marque une fonction comme une coroutine. En interne, les coroutines sont basées sur des générateurs Python, mais ne sont pas exactement la même chose. Les coroutines renvoient un objet coroutine similaire à la façon dont les générateurs renvoient un objet générateur. Une fois que vous avez une coroutine, vous obtenez ses résultats avec l'expression await . Lorsqu'une coroutine appelle await , l'exécution de la coroutine est suspendue jusqu'à ce que l'attente soit terminée. Cette suspension permet d'effectuer d'autres travaux pendant que la coroutine est suspendue "en attente" d'un résultat. En général, ce résultat sera une sorte d'E/S comme une requête de base de données ou dans notre cas une requête HTTP.

La fonction download_link a dû être modifiée de manière assez significative. Auparavant, nous comptions sur urllib pour faire le gros du travail de lecture de l'image pour nous. Maintenant, pour permettre à notre méthode de fonctionner correctement avec le paradigme de programmation asynchrone, nous avons introduit une boucle while qui lit des morceaux de l'image à la fois et suspend l'exécution en attendant la fin des E/S. Cela permet à la boucle d'événements de parcourir en boucle le téléchargement des différentes images car chacune a de nouvelles données disponibles pendant le téléchargement.

Il devrait y avoir une, de préférence une seule, manière évidente de le faire

Alors que le zen de Python nous dit qu'il devrait y avoir une façon évidente de faire quelque chose, il existe de nombreuses façons en Python d'introduire la concurrence dans nos programmes. La meilleure méthode à choisir dépendra de votre cas d'utilisation spécifique. Le paradigme asynchrone s'adapte mieux aux charges de travail à haute simultanéité (comme un serveur Web) par rapport au threading ou au multitraitement, mais il nécessite que votre code (et ses dépendances) soit asynchrone pour en bénéficier pleinement.

Espérons que les exemples de threading Python de cet article - et de la mise à jour - vous orienteront dans la bonne direction afin que vous sachiez où chercher dans la bibliothèque standard Python si vous avez besoin d'introduire la concurrence dans vos programmes.