En Python, les threads et l’asyncio s’utilisent ensemble


En Python, les threads et l’asyncio s’utilisent ensemble

Je vois beaucoup dans les tutos ici et là des gens qui opposent asyncio avec l’ancienne manière de faire de l’IO non bloquante : les threads.

C’est une erreur : les deux méthodes ne sont pas opposées, elles sont complémentaires.

Faire des requêtes pendant que ça bloque

En effet, asyncio ne peut être non bloquant que sur le réseau : ça ne gère pas l’IO sur les fichiers. Par ailleurs, toute opération CPU bloque également la boucle d’évènement.

C’est très bien de ne pas bloquer sur le réseau, mais encore faut-il pouvoir faire la requête réseau.

Si votre programme est bloqué à attendre une compression zip qui dure 3 secondes, pendant ce temps, les opérations réseaux déjà lancées tournent bien.

MAIS IL N’EN LANCERA PAS DE NOUVELLES.

Pendant 3 secondes, aucune nouvelle requête ne sera faite puisque le programme ne fait qu’une chose : ziper. Ca marche pour d’autres choses hein : copie de fichier, gros calcul matheux, traverser une grande liste, etc.

Donc si vous avez fini toutes vos requêtes à la seconde 1, pendant 2 secondes, votre programme n’est pas utilisé à son plein potentiel.

Les threads ne permettent pas d’avancer plus vite, mais ils permettent de faire 2 travaux en parallèle. Par exemple, de lancer pendant ces 2 secondes des requêtes supplémentaires sur le réseau.

Asyncio a de l’overhead

On a vendu asyncio comme plus léger que les threads. Ce n’est pas tout à fait exact. asyncio a les avantages suivants :

  • Pas besoin de préallouer des threads. Ca scale toujours pile poil, et ça bouffe moins de mémoire.
  • asyncio est plus performant quand la charge de travail demande de très nombreux threads.
  • un algo asyncio est plus facile à conceptualiser.
  • il est plus difficile d’introduire des erreurs de concurrence.
  • asyncio est plus facile à débugger.

Mais si on a quelques threads, le changement de contexte entre les threads est moins important que ce que coûte asyncio à faire tourner.

Pour certains travaux où le coût d’une opération réseau est faible, mais que cumulativement toutes les opérations ralentissent votre programme, un thread sera plus performant.

Par exemple, beaucoup d’opérations sur les bases de données tombent dans cette catégorie, à moins d’avoir des très longues requêtes.

Dans ce cas, avoir un thread dédié aux opérations de la base de données peut être une bonne décision.

Quoi faire ?

Si vous avez des opérations sur des fichiers ou de grosses opérations CPU, les faire travailler dans un thread peut booster votre programme. Ca tombe bien il y a une lib pour ça.

Si vous avez beaucoup de petites opérations réseau, les grouper, dans un thread à part, peut booster votre programme.

Et asyncio pour le reste, par exemple des requêtes HTTPs, DNS, SMTP, FTP, SSH, etc.

On peut donc copieusement utiliser les deux en parallèle. La bonne nouvelle, c’est que Guido a designé asyncio pour ça:

import asyncio
import aiohttp
 
async def main(loop):
 
    # ça c’est fait avec asyncio
    await aiohttp.get('http://bidule.com')
 
    # ça c’est fait dans un thread
    await loop.run_in_executor(None, gros_calcul, params)
 
loop = asyncio.get_event_loop()
loop.run_until_complete(main(loop))

Notez bien :

  • Faites des tests pour voir si vous y gagner vraiment.
  • Ecrivez un petit wrapper sur tout ça car là c’est verbeux.
  • Vous pouvez passer un ProcessPoolExecutor ou un ThreadPoolExecutor pour choisir la stratégie de parallélisme à adapter : process, thread, nombres de workers, etc.

Bien entendu, chaque fois que vous ajoutez un mécanisme de concurrence, vous ajoutez de la complexité, donc ne le faites que si c’est nécessaire.

Commencez avec un programme synchrone simple. Si c’est trop lent, ajouter les threads ou l’asyncio, si ça ne suffit pas, utilisez les deux.

8 thoughts on “En Python, les threads et l’asyncio s’utilisent ensemble

  • Gordon

    Il manquerait pas un bout de ton snippet, genre la création du ThreadPoolExecutor et son passage à main(), l’import de aiohttp… ?

  • Geekingfrog

    C’est l’import qui est en trop. run_in_executor utilise l’executor par défaut (un threadpoolexecutor) si aucun ne lui est fournit.

  • Sam Post author

    En effet j’ai fais un mix bidon de deux codes : celui ou j’ai testé celui par défaut, et celui où j’ai testé un custo.

  • Gordon

    Il reste un oubli : tu as laissé le paramètre executor dans la déclaration de main

  • Victor Stinner

    Tout à fait. asyncio ne tire pas vraiment sa force du côté asynchrone. Mike Bayer (qui touche sa bille en base de données) a par exemple montré qu’une API asynchrone pour accéder à une base données est plut lente que l’API synchrone : http://techspot.zzzeek.org/2015/02/15/asynchronous-python-and-databases/

    C’est contre intuitif pour les fanboys de l’asynchrone comme moi. Non, asyncio tire sa force de sa capacité à coupler plusieurs briques très différentes et offrir une API générique : réseau, threads, base de données, etc.

    Au sujet des accès disque : bien qu’il existe des syscalls dans le noyau Linux, le code est plus lent que les accès bloquants. Le projet aiofiles permet d’accéder à un fichier sur le disque avec asyncio en lançant tous les accès disques dans des threads de manière transparent pour l’utiisateur. La PEP 492 (async/await) prend tout son sens ici : on peut écrire “async for line in file: …” qui va itérer sur les lignes d’un fichier texte de manière asynchrone !

    Note : en mode debug, asyncio émet un avertissement si une tâche prend plus de 100 ms (délai configurable). Ca aide pour détecter quelles fonctions doivent être lancées dans des threads. https://docs.python.org/dev/library/asyncio-dev.html#debug-mode-of-asyncio

Comments are closed.

Des questions Python sans rapport avec l'article ? Posez-les sur IndexError.