Python multithread: glissant à travers un goulot d'étranglement d'E / S?

Comment tirer parti du parallélisme en Python peut rendre vos logiciels plus rapides.

J'ai récemment développé un projet que j'ai appelé Hydra: un vérificateur de liens multithread écrit en Python. Contrairement à de nombreux robots d'exploration de sites Python que j'ai trouvés lors de mes recherches, Hydra n'utilise que des bibliothèques standard, sans dépendances externes comme BeautifulSoup. Il est destiné à être exécuté dans le cadre d'un processus CI / CD, donc une partie de son succès dépendait de sa rapidité.

Plusieurs threads en Python sont un peu un sujet mordant (pas désolé) dans la mesure où l'interpréteur Python ne permet pas réellement à plusieurs threads de s'exécuter en même temps.

Global Interpreter Lock de Python, ou GIL, empêche plusieurs threads d'exécuter des bytecodes Python à la fois. Chaque thread qui veut s'exécuter doit d'abord attendre que le GIL soit libéré par le thread en cours d'exécution. Le GIL est à peu près le microphone d'un panel de conférence à petit budget, sauf là où personne ne peut crier.

Cela a l'avantage d'éviter les conditions de course. Cependant, il n'a pas les avantages de performances offerts par l'exécution de plusieurs tâches en parallèle. (Si vous souhaitez un rappel sur la concurrence, le parallélisme et le multithreading, consultez Concurrence, parallélisme et les nombreux threads du Père Noël.)

Bien que je préfère Go pour ses primitives de première classe pratiques qui prennent en charge la concurrence (voir Goroutines), les destinataires de ce projet étaient plus à l'aise avec Python. J'en ai profité pour tester et explorer!

Effectuer simultanément plusieurs tâches en Python n'est pas impossible; cela demande juste un peu de travail supplémentaire. Pour Hydra, le principal avantage est de surmonter le goulot d'étranglement des entrées / sorties (E / S).

Afin d'obtenir des pages Web à vérifier, Hydra doit aller sur Internet et les chercher. Par rapport aux tâches exécutées uniquement par le processeur, la sortie sur le réseau est comparativement plus lente. Combien de temps?

Voici les horaires approximatifs des tâches effectuées sur un PC classique:

TâcheTemps
CPUexécuter une instruction typique1/1 000 000 000 s = 1 nanosec
CPUextraire de la mémoire cache L10,5 nanosec
CPUerreur de prédiction de la succursale5 nanosec
CPUextraire de la mémoire cache L27 nanosec
RAMVerrouillage / déverrouillage Mutex25 nanosec
RAMrécupérer de la mémoire principale100 nanosec
Réseauenvoyer 2K octets sur un réseau de 1 Gbps20 000 nanosec
RAMlire 1 Mo séquentiellement à partir de la mémoire250 000 nanosecondes
Disquerécupérer à partir du nouvel emplacement du disque (chercher)8 000 000 nanosec (8 ms)
Disquelire 1 Mo séquentiellement à partir du disque20 000 000 nanosec (20 ms)
Réseauenvoyer le paquet US en Europe et retour150 000 000 nanosec (150 ms)

Peter Norvig a publié ces chiffres pour la première fois il y a quelques années dans Teach Yourself Programming in Ten Years. Étant donné que les ordinateurs et leurs composants changent d'année en année, les chiffres exacts indiqués ci-dessus ne sont pas pertinents. Ce que ces chiffres aident à illustrer, c'est la différence, en ordre de grandeur, entre les opérations.

Comparez la différence entre l'extraction de la mémoire principale et l'envoi d'un simple paquet sur Internet. Bien que ces deux opérations se produisent en moins d'un clin d'œil (littéralement) d'un point de vue humain, vous pouvez voir que l'envoi d'un simple paquet sur Internet est plus d'un million de fois plus lent que l'extraction de la RAM. C'est une différence qui, dans un programme à un seul thread, peut rapidement s'accumuler pour former des goulots d'étranglement gênants.

Dans Hydra, la tâche d'analyse des données de réponse et d'assemblage des résultats dans un rapport est relativement rapide, car tout se passe sur le processeur. La partie la plus lente de l'exécution du programme, de plus de six ordres de grandeur, est la latence du réseau. Hydra a non seulement besoin de récupérer des paquets, mais des pages Web entières!

Une façon d'améliorer les performances d'Hydra est de trouver un moyen d'exécuter les tâches de récupération de page sans bloquer le thread principal.

Python a quelques options pour effectuer des tâches en parallèle: plusieurs processus ou plusieurs threads. Ces méthodes vous permettent de contourner le GIL et d'accélérer l'exécution de deux manières différentes.

Processus multiples

Pour exécuter des tâches parallèles à l'aide de plusieurs processus, vous pouvez utiliser Python ProcessPoolExecutor. Une sous-classe concrète de Executorfrom the concurrent.futuresmodule, ProcessPoolExecutorutilise un pool de processus engendrés avec le multiprocessingmodule pour éviter le GIL.

Cette option utilise des sous-processus de travail qui correspondent au maximum par défaut au nombre de processeurs sur la machine. Le multiprocessingmodule vous permet de paralléliser au maximum l'exécution des fonctions entre les processus, ce qui peut vraiment accélérer les tâches liées au calcul (ou liées au processeur).

Étant donné que le principal goulot d'étranglement pour Hydra est les E / S et non le traitement à effectuer par le processeur, je suis mieux servi en utilisant plusieurs threads.

Plusieurs threads

Bien nommé, Python ThreadPoolExecutorutilise un pool de threads pour exécuter des tâches asynchrones. Également une sous-classe de Executor, il utilise un nombre défini de threads de travail maximum (au moins cinq par défaut, selon la formule min(32, os.cpu_count() + 4)) et réutilise les threads inactifs avant d'en démarrer de nouveaux, ce qui le rend assez efficace.

Voici un extrait d'Hydra avec des commentaires montrant comment Hydra utilise ThreadPoolExecutorpour atteindre le bonheur multithread parallèle:

# Create the Checker class class Checker: # Queue of links to be checked TO_PROCESS = Queue() # Maximum workers to run THREADS = 100 # Maximum seconds to wait for HTTP response TIMEOUT = 60 def __init__(self, url): ... # Create the thread pool self.pool = futures.ThreadPoolExecutor(max_workers=self.THREADS) def run(self): # Run until the TO_PROCESS queue is empty while True: try: target_url = self.TO_PROCESS.get(block=True, timeout=2) # If we haven't already checked this link if target_url["url"] not in self.visited: # Mark it as visited self.visited.add(target_url["url"]) # Submit the link to the pool job = self.pool.submit(self.load_url, target_url, self.TIMEOUT) job.add_done_callback(self.handle_future) except Empty: return except Exception as e: print(e) 

Vous pouvez afficher le code complet dans le référentiel GitHub d'Hydra.

Un seul thread à multithread

Si vous souhaitez voir le plein effet, j'ai comparé les temps d'exécution pour vérifier mon site Web entre un programme prototype à un seul thread et le multi-têtes - je veux dire multithread - Hydra.

time python3 slow-link-check.py //victoria.dev real 17m34.084s user 11m40.761s sys 0m5.436s time python3 hydra.py //victoria.dev real 0m15.729s user 0m11.071s sys 0m2.526s 

Le programme monothread, qui bloque les E / S, a duré environ dix-sept minutes. Quand j'ai lancé la version multithread pour la première fois, elle s'est terminée en 1m13,358s - après quelques profilages et réglages, cela a pris un peu moins de seize secondes.

Encore une fois, les heures exactes ne signifient pas grand-chose; ils varient en fonction de facteurs tels que la taille du site à explorer, la vitesse de votre réseau et l'équilibre de votre programme entre la surcharge de la gestion des threads et les avantages du parallélisme.

La chose la plus importante, et le résultat que je prendrai chaque jour, est un programme qui exécute des ordres de grandeur plus rapidement.