Dans la première partie de ce tutoriel , j'ai expliqué ce qu’était la programmation concurrente et parallèle, ainsi que détaillé les différents types de programmation concurrente et leurs spécificités. Si vous ne l'avez pas lue, je vous conseille de la lire avant de démarrer. Dans cette deuxième partie, nous allons nous concentrer sur l'optimisation d'un programme limité par les entrées/sorties grâce à la programmation concurrente.
Qu'est ce qu'un programme limités par les entrées/sorties ?
C'est un programme qui communique régulièrement avec une ressource plus lente que le CPU (accès au disque dur, bande passante d'un réseau, etc.). Lors de ces communications le CPU va attendre que la ressource en question lui envoie des informations. C'est cette attente qui est un des facteurs d'augmentation du temps d’exécution d'un programme. Or plus le nombre de communications, d'entrées/sorties du CPU est important plus le programme sera lent. On retrouve ce cas de figure lors de requêtes en base de données, lors de requêtes HTTP ou pour les connections réseaux en général.
Comment accélérer un programme limité par les E/S :
Pour illustrer mon propos, j'ai choisi un problème commun : le téléchargement de contenu sur le réseau. J'ai eu envie d'aller récupérer des informations sur des personnage de Star Wars en utilisant la magnifique API https://swapi.co/ .
La version synchrone du programme
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
[crayon-6787a53eb1e31313641529 ]#!/usr/bin/env python3 import requests import time def download_site(url, session): """[summary] :param url : l'url de la page a récuperer :type url : string :param session : l'objet session de la lib request :type session : class """ with session.get(url) as response : print(f"Read {response.content} from {url}") def download_all_sites(sites): """met en place un objet session et exécute la fonction download_site dans ce contexte :param sites : liste des pages a récuperer :type sites : list """ with requests.Session() as session : for url in sites : download_site(url, session) if __name__ == "__main__": sites = [ "https://swapi.co/api/people/%s/"%number for number in range(1,80)] start_time = time.time() download_all_sites(sites) duration = time.time() - start_time print(f"Downloaded {len(sites)} in {duration} seconds") |
Le diagramme d'exécution ressemble à ça :
Quelles sont les avantages de la version synchrone ?
La version synchrone est super facile à écrire, à concevoir et à débogguer. Dans cette version le programme ne fonctionne que sur un seul CPU, de sorte qu'il est très facile de prédire la prochaine étape et comment elle va se comporter.
En effet, chaque opération ne sera exécutée que si la précédente s'est terminée, ainsi pour récupérer des informations sur le Général Grievous (qui est le personnage N°79) nous savons que nous devons d'abord récupérer les informations des 78 personnages précédents.
Les problèmes avec la version synchrone
Le problème ici, c'est que c'est que lorsque nous demandons au serveur de nous renvoyer des informations sur un personnage nous devons attendre sa réponse avant de pouvoir demander qu'il nous envoie le suivant. Cette méthode est relativement lente par rapport aux autres solutions que nous allons proposer. Voici le temps d’exécution sur ma machine :
$ python io_bound_problem_non_concurrent.py
Downloaded 79 in 14.642319917678833 seconds
Remarque : Vos résultats peuvent varier considérablement selon votre connexion. En exécutant ce script, j'ai noté des temps variant de 14.2 à 21.9 secondes. Pour cet article, j'ai pris le temps le plus rapide des trois exécution.
Il faut bien réfléchir avant d'ajouter de la programmation concurrente, en effet si le programme n'est pas exécuté souvent, ce n'est pas grave qu'il soit lent mais que se passe-t-il si il est utilisé fréquemment et qu'il prend des heures pour chaque exécution ? Il est peut être intéressant d'y ajouter de la concurrence, par exemple en le réécrivant à l'aide du multi-threading.
Version multi-threadée
Comme vous l'avez probablement deviné, écrire un programme multi-threadé demande légèrement plus de travail. Vous serez peut-être surpris du peu d'efforts supplémentaires qu'il faut pour les cas simples. Voici à quoi ressemble le même programme avec le multi-threading :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
[crayon-6787a53eb1e36705504452 ]import concurrent.futures import requests import threading import time thread_local = threading.local() def get_session(): """create a session object for each thread :return : a request session object :rtype : object """ if not getattr(thread_local, "session", None): thread_local.session = requests.Session() return thread_local.session def download_site(url): """download content with request inside of a thread :param url : url of the content :type url : string """ session = get_session() with session.get(url) as response : print(f"Read {response.content} from {url}") def download_all_sites(sites): """create a pool of thread with threadpoolexecutor and map download_site on the list :param sites : list of sites :type sites : list """ with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor : executor.map(download_site, sites) if __name__ == "__main__": sites = [ "https://swapi.co/api/people/%s/"%number for number in range(1,80)] start_time = time.time() download_all_sites(sites) duration = time.time() - start_time print(f"Downloaded {len(sites)} in {duration} seconds") |
Lorsque vous ajoutez des threads, la structure générale est la même et vous n'avez besoin que de quelques changements. download_all_sites() a changé de l'appel de la fonction une fois par site en une structure plus complexe.
Dans cette version, vous créez un ThreadPoolExecutor, ce qui semble compliqué.
Nous allons le décomposer : ThreadPoolExecutor = Thread + Pool + Executor.
Cet objet va créer un Pool (un ensemble) de Threadsqui peuvent fonctionner simultanément. Enfin, l'Executor est la partie qui va contrôler quand et comment chacun des threads du pool va s'exécuter.
La bibliothèque standard implémente ThreadPoolExecutor en tant que gestionnaire de contexte afin que vous puissiez utiliser la syntaxe with pour gérer la création et la libération du pool de threads.
Une fois que vous avez un ThreadPoolExecutor, vous pouvez utiliser sa méthode .map(). Cette méthode exécute la fonction transmise (ici download_site) sur chacun des sites de la liste. L'avantage, c'est qu'il les exécute automatiquement en même temps en utilisant le pool de threads qu'il gère.
Ceux venant d'autres langages, ou même de Python 2, se demandent probablement où se trouvent les objets et fonctions habituels qui gèrent les détails auxquels vous êtes habitués lorsqu'il s'agit de threads, de choses comme Thread.start(), Thread.join() et Queue.
Ils sont tous toujours là, et vous pouvez les utiliser pour obtenir un contrôle fin sur la manière dont vos threads sont exécutés. Mais, à partir de Python 3.2, la bibliothèque standard a ajouté un niveau d'abstraction supérieur, les Executors, qui gère de nombreux détails pour vous, donc vous n'avez plus besoin de ce contrôle fin.
L'autre changement intéressant dans notre exemple est que chaque thread doit créer son propre objet requests.Session().
C'est l'une des questions intéressantes et difficiles avec le multi-threading. En effet, ce n'est pas Python mais le système d'exploitation qui contrôle le moment où votre thread est interrompu et qu'un autre thread démarre, ainsi toutes les données qui sont partagées entre les threads doivent être protégées ou sécurisées. Malheureusement, requests.Session() n'est pas thread-safe.
Il existe plusieurs stratégies pour sécuriser l'accès aux données en fonction de la nature des données et de la façon dont vous les utilisez. Une des stratégies est d'utiliser des structures de données sécurisées comme Queue du module queue de Python.
Ces objets utilisent des primitives de bas niveau comme threading.lock pour s'assurer qu'un seul thread peut accéder à un bloc de code ou un bit de mémoire en même temps. Vous utilisez cette stratégie indirectement via l'objet ThreadPoolExecutor.
Une autre stratégie à utiliser ici est ce qu'on appelle le stockage local de thread.
Threading.local() crée un objet qui ressemble à un global mais qui est spécifique à chaque thread individuel. Dans votre exemple, cela se fait avec threadLocal et get_session() :
1 2 3 4 5 6 7 8 9 10 11 |
[crayon-6787a53eb1e3a417612557 ] def get_session(): """create a session object for each thread :return : a request session object :rtype : object """ if not getattr(thread_local, "session", None): thread_local.session = requests.Session() return thread_local.session |
ThreadLocal est dans le module de threading pour résoudre spécifiquement ce problème. Cela semble un peu étrange, mais vous ne voulez créer qu'un seul de ces objets, pas un pour chaque thread. L'objet lui-même se charge de séparer les accès des différents threads aux différentes données.
Quand get_session() est appelé, la session qu'il consulte est spécifique au thread particulier sur lequel il s'exécute. Ainsi, chaque thread crée une seule session la première fois qu'il appelle get_session() et utilisera simplement cette session sur chaque appel suivant tout au long de sa vie.
Enfin, un petit mot sur le choix du nombre de threads. Vous pouvez voir que l'exemple de code utilise 35 threads. N'hésitez pas à jouer avec ce nombre et à voir comment le temps total change. On pourrait s'attendre à ce qu'avoir un thread par téléchargement soit le plus rapide mais, du moins sur mon système, ce n'était pas le cas. J'ai trouvé les résultats les plus rapides entre 30 et 40 threads. Si vous dépasser les 40 , alors les coûts supplémentaires de création et de destruction des threads suppriment tout gain de temps.
Pourquoi la version multi-threadée est super efficace ?
Elle est très rapide. Rappelez vous que la version synchrone a pris plus de 14 secondes :
1 2 3 |
$ python io_bound_thread.py [most output skipped] Downloaded 79 in 4.286174535751343 seconds |
le diagramme d’exécution ressemble a :
Il utilise plusieurs threads pour avoir plusieurs requêtes vers des sites web ouvertes en même temps, ce qui permet à votre programme de chevaucher les temps d'attente et d'obtenir le résultat final plus rapidement ! Cool ! C'était l'objectif.
Les problèmes avec la version multi-threadée
Comme vous pouvez le voir dans l'exemple de la version multi-threadée, il est très important de réfléchir aux données qui sont partagées entre les threads.
Les threads peuvent interagir de manière subtile. Ces interactions peuvent provoquer des races conditions qui se traduisent souvent par des bogues aléatoires et intermittents et qui peuvent être très difficiles à trouver. Ceux d'entre vous qui ne sont pas familiers avec le concept des races conditions voudront peut-être développer et lire la section ci-dessous.
Version asynchrone
Avant d’étudier le code de l'exemple asyncio, nous allons parler plus en détail de la façon dont asyncio fonctionne à partir d'une version simplifiée de l'asyncio.
Asyncio : notions de base
Asyncio permet de définir un objet Python appelé boucle d’événement qui va contrôler quand et comment un ensemble de tâches sera exécutée.
Cet objet, cette boucle d'évènement, est consciente des différentes tâches à exécuter et son travail est d'orchestrer ces dernières de la meilleure façon possible.
Pour ce faire elle connaît plusieurs choses. Premièrement, la liste des tâches à effectuer mais aussi l'état dans lequel se trouve chacune de ces tâches.
Prenons un exemple simple de tâche à deux états (en réalité il peut y en avoir beaucoup plus):
- l'état PRÊT indiquera qu'une tâche a du travail à faire et est prête à être exécutée.
- l'état ATTENTE signifiera que la tâche attend la fin d'un événement externe, comme une opération réseau.
Lorsqu'une tâche passe en attente (attente d'une réponse suite à une requête réseau par exemple) et ne peux plus progresser elle va rendre le contrôle à la boucle d'évènement en lui communiquant exactement ce qu'elle attend. Notre boucle d'évènement va alors s'empresser de ranger cette tâche soit parmi celles en ATTENTE soit parmi celles PRÊTes. Elle va ensuite passer en revue l'ensemble des tâches pour en exécuter une dont le status est passé à PRÊT. Votre boucle d'événements simplifiée choisit la tâche qui a attendu le plus longtemps et l'exécute. Ce processus se répète jusqu'à ce que la boucle d'événements soit terminée.
Un point important de l'asyncio est que ce sont les tâches qui redonnent le contrôle à la boucle d'évènement, jamais l'inverse. Elles ne sont donc jamais interrompues au milieu d'une opération mais seulement quand elles le jugent nécessaire. Cela nous permet de partager les ressources un peu plus facilement en asyncio qu'en threading. Vous n'avez pas à vous soucier de rendre votre code thread-safe.C'est une vue d'ensemble de ce qui se passe avec Asyncio. Si vous voulez plus d'informations, cette réponse StackOverflow fournie quelques bons détails.
async et await
Parlons maintenant de deux nouveaux mots-clés qui ont été ajoutés à Python : async et await. À la lumière de la discussion ci-dessus, vous pouvez voir await comme de la magie qui permet à une tâche de rendre le contrôle à la boucle d'événements. Lorsque votre code attend un appel de fonction, c'est un signal que l'appel est susceptible d'être quelque chose qui prend un certain temps et que la tâche devrait abandonner le contrôle.
Il est plus facile de penser à async comme un flag pour Python lui disant que la fonction sur le point d'être définie utilise await. Il y a des cas où ce n'est pas strictement vrai, comme les générateurs asynchrones, mais cela reste valable dans de nombreuses situations et vous donne un modèle simple pour débuter.
Une exception à cela, que vous verrez dans le code suivant, est l'asynchrone avec l'instruction with qui crée un gestionnaire de contexte à partir d'un objet que vous auriez normalement attendu. Bien que la sémantique soit un peu différente, l'idée est la même : signaler ce gestionnaire de contexte comme quelque chose qui peut être remplacé.
Comme je suis sûr que vous pouvez l'imaginer, il y a une certaine complexité dans la gestion de l'interaction entre la boucle d'événements et les tâches. Pour les développeurs débutant avec asyncio, ces détails ne sont pas importants, mais vous devez vous rappeler que toute fonction qui appelle await doit être marquée avec async. Sinon, vous obtiendrez une erreur de syntaxe.
Retour au Code
Maintenant que vous avez une compréhension de base de ce qu'est asyncio, parcourons cette nouvelle version du code de l'exemple et voyons comment il fonctionne. Notez que cette version ajoute aiohttp. Vous devrez exécuter pip install aiohttp avant de l'exécuter :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
[crayon-6787a53eb1e40970160714 ]import asyncio import time import aiohttp async def download_site(session, url): async with session.get(url) as response : print("Read {0} from {1}".format(response.content_length, url)) async def download_all_sites(sites): async with aiohttp.ClientSession() as session : tasks = [] for url in sites : task = asyncio.ensure_future(download_site(session, url)) tasks.append(task) await asyncio.gather(*tasks, return_exceptions=True) if __name__ == "__main__": sites = [ "https://swapi.co/api/people/%s/"%number for number in range(1,80)] start_time = time.time() asyncio.get_event_loop().run_until_complete(download_all_sites(sites)) duration = time.time() - start_time print(f"Downloaded {len(sites)} sites in {duration} seconds") |
Cette version est un peu plus complexe que les deux précédentes. Elle a une structure similaire, mais il y a un peu plus de travail pour configurer les tâches que pour créer le ThreadPoolExecutor. Commençons par le début de l'exemple.
download_site()
download_site() en haut est presque identique à la version de threading à l'exception du mot-clé async sur la ligne de définition de la fonction et du mots-clés async with lorsque vous appelez réellement session.get().
download_all_sites()
download_all_sites() est l'endroit où vous verrez le plus grand changement par rapport à l'exemple de threading.
Vous pouvez partager la session entre toutes les tâches, de sorte que la session est créée ici en tant que gestionnaire de contexte. Les tâches peuvent partager la session parce qu'elles sont toutes exécutées sur le même thread. Il est impossible qu'une tâche puisse en interrompre une autre alors que la session est dans le mauvais état.
Dans ce gestionnaire de contexte, il crée une liste de tâches à l'aide de asyncio.ensure_future(), qui se charge également de les lancer. Une fois que toutes les tâches sont créées, cette fonction utilise asyncio.gather() pour garder le contexte de la session en vie jusqu'à ce que toutes les tâches soient terminées.
Le code de threading fait quelque chose de similaire à ceci, mais les détails sont traités de manière pratique dans le ThreadPoolExecutor. Il n'y a actuellement pas de classe AsyncioPoolExecutor.
Il y a cependant un petit, mais important, changement enfoui dans les détails ici. Rappelez-vous, nous avons parlé du nombre de threads à créer. Il n'était pas évident de déterminer, dans l'exemple de multithreading, quel était le nombre optimal de threads.
L'un des avantages de l'asyncio est qu'il s'adapte beaucoup mieux que le multi-threading. Chaque tâche prend beaucoup moins de ressources et moins de temps à créer qu'un thread, donc la création et l'exécution d'un plus grand nombre d'entre-elles fonctionne bien. Cet exemple ne fait que créer une tâche séparée pour chaque site à télécharger, ce qui fonctionne très bien.
__main__
Enfin, la nature de l'asyncio signifie que vous devez démarrer la boucle d'événements et lui indiquer les tâches à exécuter. La section __main__ en bas du fichier contient le code pour obtenir_event_loop() et ensuite exécuter_until_complete().
Si vous avez mis à jour Python 3.7, les développeurs du noyau Python ont simplifié cette syntaxe pour vous. Au lieu de asyncio.get_event_loop().run_until_complete() tongue-twister, vous pouvez simplement utiliser asyncio.run().
Pourquoi la version asyncio est la meilleure
C'est vraiment rapide ! Dans les tests sur ma machine, c'était la version la plus rapide du code :
$ python io_async.py
Downloaded 79 sites in 3.088017225265503 seconds
le diagramme de timing d'exécution parait assez similaire a celui de la version multi-threadée sauf que c'est le même thread qui fait toutes requêtes E/S.
L'absence d'un joli wrapper comme le ThreadPoolExecutor rend ce code un peu plus complexe que l'exemple de threading. C'est un cas où vous devez faire un peu de travail supplémentaire pour obtenir de bien meilleures performances.
De plus, il y a un argument courant selon lequel le fait d'ajouter async et await dans les bons endroits est une complication supplémentaire. Dans une certaine mesure, c'est vrai. Le revers de la médaille de cet argument est qu'il vous force à réfléchir au moment où une tâche donnée sera permutée, ce qui peut vous aider à mieux organiser votre code.
Les problèmes avec la version asyncio
Le problème de la mise à l'échelle est également très important ici. L'exécution de l'exemple de threading ci-dessus avec un thread pour chaque site est sensiblement plus lente que son exécution avec une poignée de threads. Exécuter l'exemple d'asyncio avec des centaines de tâches ne l'a pas ralenti du tout.
Il y a quelques problèmes avec asyncio en ce moment. Vous avez besoin de versions asynchrones spéciales des bibliothèques pour profiter pleinement de l'asyncio. Si vous aviez simplement utilisé requests pour télécharger les sites, cela aurait été beaucoup plus lent car requests n'est pas conçues pour avertir la boucle d'événements qu'elle est bloquée. Ce problème est corrigé progressivement quand de plus en plus de bibliothèques adoptent l'asyncio.
Un autre problème, plus subtil, est que tous les avantages du multitâche coopératif disparaissent si l'une des tâches ne coopère pas. Une erreur mineure dans le code peut provoquer l'exécution d'une tâche et le blocage du CPU pendant une longue période, empêchant d'autres tâches d'être exécutées. Rappelez-vous, il n'y a aucun moyen pour la boucle d'événements d'interrompre une tâche qui ne lui retourne pas le contrôle.
Dans cette optique, adoptons une approche radicalement différente de la concurrence, le multi-traitement.
Version multiprocessing
Contrairement aux approches précédentes, la version multiprocessée du code tire pleinement parti des multiples CPU de l'ordinateur.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
[crayon-6787a53eb1e46962352788 ]import requests import multiprocessing import time session = None def set_global_session(): """create a global session object """ global session if not session : session = requests.Session() def download_site(url): """download content :param url : site url :type url : string """ with session.get(url) as response : name = multiprocessing.current_process().name print(f"{name}:Read {len(response.content)} from {url}") def download_all_sites(sites): """map download_site function on multiprocessing pool :param sites : sites list :type sites : list """ with multiprocessing.Pool(initializer=set_global_session) as pool : pool.map(download_site, sites) if __name__ == "__main__": sites = [ "https://swapi.co/api/people/%s/"%number for number in range(1,80)] start_time = time.time() download_all_sites(sites) duration = time.time() - start_time print(f"Downloaded {len(sites)} in {duration} seconds") |
Le code de cet exemple est beaucoup plus court que l'exemple d'asyncio et ressemble beaucoup à l'exemple de threading, mais avant de nous plonger en détail dans le code, faisons un petit tour rapide du multiprocessing.
Le multiprocessing en quelques phrases
Jusqu'à présent, tous les exemples de concurrences de cet article ne fonctionnent que sur un seul CPU dans votre ordinateur. Ceci est lié à la conception actuelle de CPython et à ce qu'on appelle le Global Interpreter Lock, ou GIL.
Cet article ne se penchera pas sur le comment et le pourquoi du GIL. Il suffit pour l'instant de savoir que les versions synchrone, threading et asyncio de cet exemple fonctionnent toutes sur un seul CPU.
multiprocessing dans la bibliothèque standard a été conçu pour briser cette barrière et exécuter votre code sur plusieurs CPU. À un haut niveau, il le fait en créant une nouvelle instance de l'interpréteur Python pour s'exécuter sur chaque CPU et en sous-traitant ensuite une partie de votre programme pour l'exécuter.
Comme vous pouvez l'imaginer, créer un interpréteur Python séparé n'est pas aussi rapide que lancer un nouveau thread dans l'interpréteur Python actuel. Il s'agit d'une opération lourde qui comporte des restrictions et des difficultés, mais selon le problème, elle peut faire une énorme différence.
Le code de la version multiprocessée
Le code a quelques petits changements par rapport à notre version synchrone. Le premier est dans download_all_sites(). Au lieu d'appeler simplement download_site() à plusieurs reprises, il crée un objet multiprocessing.Pool et lui fait mapper download_site vers les sites itérables. Cela devrait vous sembler familier dans l'exemple de threading.
Ce qui se passe ici, c'est que le Pool crée un certain nombre de processus d'interpréteurs Python distincts et va sur chacun, exécuter la fonction spécifiée sur certains des éléments de la liste des sites (de l'itérable plus généralement). La communication entre le processus principal et les autres processus est assurée pour vous par le module multiprocessing.
La ligne qui crée le Pool mérite votre attention. Tout d'abord, il ne précise pas le nombre de processus à créer dans le Pool, bien qu'il s'agisse d'un paramètre facultatif. Par défaut, multiprocessing.Pool() déterminera le nombre de processeurs dans votre ordinateur et le fera correspondre. C'est souvent la meilleure réponse, et c'est ici le cas.
Pour ce problème, l'augmentation du nombre de processus n'a pas accéléré les choses. En fait, cela les a même ralenti car le coût de création et d’arrêt des processus était plus élevé que l'avantage de faire les demandes d'E/S en parallèle.
Ensuite, nous avons la partie initialiser=set_global_session de cet appel. Rappelez-vous que chaque processus de notre Pool a son propre espace mémoire. Cela signifie qu'ils ne peuvent pas partager des choses comme un objet Session. Vous ne voulez pas créer un nouveau dossier à chaque fois que la fonction est appelée, vous voulez en créer un pour chaque processus.
Le paramètre de fonction de l'initialiser n'est conçu que pour ce cas. Il n'y a pas moyen de renvoyer une valeur de retour de l'initialiser à la fonction appelée par le processus download_site(), mais vous pouvez initialiser une variable de session globale pour maintenir la session unique pour chaque processus. Parce que chaque processus a son propre espace mémoire, le global pour chacun sera différent.
C'est vraiment tout ce qu'il y a à faire. Le reste du code est assez similaire à ce que vous avez vu auparavant.
Quelles sont les avantages de la version multiprocessée
La version multiprocessée de cet exemple est géniale parce qu'elle est relativement facile à mettre en place et nécessite peu d'options. Elle tire également pleinement parti de la puissance du processeur de votre ordinateur. Le diagramme de temps d'exécution de ce code ressemble à ceci :
Les problèmes avec la version multiprocessée
Cette version de l'exemple nécessite une configuration supplémentaire, et l'objet de session global est étrange. Vous devez passer un peu de temps à réfléchir aux variables qui seront accessibles dans chaque processus.
Enfin, il est nettement plus lent que les versions asynchrone et multithreadée dans cet exemple.
Ce n'est pas surprenant, car les problèmes limités par les E/S ne sont pas vraiment la raison d'être du multiprocessing. Vous en verrez d'autres au fur et à mesure que vous passerez à la section suivante et que vous regarderez des exemples liés au CPU.
Merci aux relecteurs Kevin Gueuti, Gwenaelle, Plopp et Yoann M.
Laisser un commentaire