Mathieu Agopian : Python et asyncio : la recette du bonheur ?

asyncio est une librairie inclue dans la stdlib des dernières versions de python3, et qui permet de faire de la programmation asynchrone.

Oui, ok, mais ça veut dire quoi au juste ?

Asynchrone, concurrent, coroutine, parallèle

Il y a un intrus dans ce titre. Ces termes sont utilisés pour décrire des styles de programmation, mais sont parfois mal compris, ou prêtent à confusion :

L'approche asynchrone/concurrente/non bloquante est légere tant à l'implémentation qu'à l'execution - les coroutines sont très peu gourmandes en mémoire et très rapides à lancer. Elles s'exécutent au sein du même process, ont toutes accès à la même zone mémoire et il n'est donc pas nécessaire d'échanger des messages entre elles pour partager des informations. Cependant, elles n'accélèrent pas le programme car il n'y a toujours qu'un seul process. Le programme ne sera donc plus rapide que dans certains cas particuliers. Dans d'autres, il pourra même être plus lent à cause du surcoût de gestion impliqué par l'utilisation de coroutines.

Dans une approche parallèle, le programme s'exécute sur plusieurs process, ce qui l'accélère mécaniquement jusqu'à potentiellement diviser sa durée d'execution par le nombre de coeurs mobilisés. Selon les langages et les libraries utilisées, il est par ailleurs possible de lancer un programme sur plusieurs machines. C'est une approche beaucoup plus lourde tant à l'implémentation qu'à l'éxécution, car il faut lancer, synchroniser et arréter chaque processus, qui en sus demande davantage de mémoire et l'utilisation d'un système de message pour échanger avec ses voisins.

Oui, d'accord, mais comment ça marche ?

Suivant les langages, il y a différentes façons de concevoir l'exécution asynchrone et/ou de faire des appels non bloquants:

Dans le cas de python/asyncio, c'est grâce aux mots-clés await (ou yield from) qu'un bout de code signale à la boucle d'exécution qu'il a temporairement terminé sa tâche. Cette dernière passe donc la main à d'autres bouts de code qui ont quelque chose à faire, au lieu d'attendre séquentiellement (de manière synchrone) que chaque bout de code se termine.

Asynchrone == plus rapide, ou pas...

Mais alors, si le code s'exécute toujours sur un seul et même process et que la gestion des coroutines implique un surcoût de temps de calcul, est-ce que mon programme ne va être plus lent ?!

Petite expérience :

En synchrone/bloquant

import asyncio

def foo():
    pass

for i in range(10000):
    foo()
real    0m0.087s
user    0m0.071s
sys     0m0.013s

En asynchrone/non bloquant

import asyncio

async def foo():
    pass

loop = asyncio.get_event_loop()
for i in range(10000):
    loop.run_until_complete(foo())
real    0m0.452s
user    0m0.429s
sys     0m0.019s

Un rapide calcul indique que la gestion des 10000 coroutines implique un surcoût d'environ 360ms (l'import du module asyncio, qui se fait une seule fois au chargement du programme, a été fait dans les deux cas afin de ne pas fausser les mesures).

Le but de cet exemple aberrant n'est pas de prouver que les coroutines sont lentes (elles ne le sont pas), mais que la programmation asynchrone en elle-même ne fait pas aller n'importe quel programme plus vite (mais ça, vous vous en doutiez).

Asyncio plus rapide pour IO-bound

Mais alors, asyncio ne sert à rien ?

Laissez-moi vous conter l'histoire de Bob, qui veut télécharger toutes les images de chat de son site préféré. Voici un petit morceau de son (pseudo-) code :

for url in urls:
    img = get_image_from_website(url)
    thumbnails = compute_thumbnails(img)
    ...

Le programme va tour à tour télécharger les images sur le site, puis en faire des miniatures. Il y a donc deux cas différents :

L'idéal serait de pouvoir calculer la miniature d'une image pendant le temps d'attente du téléchargement d'une autre image ! C'est une technique connue depuis bien longtemps dans l'industrie, le "travail en temps masqué" : pendant qu'une machine travaille, l'employé peut faire autre chose, comme remplir le chargeur de la machine, décharger les produits finis, lancer une autre machine, etc...

C'est la grande force de asyncio : pouvoir faire des appels non bloquants, c'est à dire profiter d'un temps d'attente pour pouvoir faire autre chose.

Reprenons notre exemple :

En synchrone/bloquant

import requests
from lxml import html
from PIL import Image

URL_TPL = "http://bonjourlechat.tumblr.com/page/{}"
THUMBNAIL_SIZES = ((100, 100), (200, 200), (300, 300), (400, 400), (500, 500))

def get_image_from_website(url):
    page = requests.get(url)
    # Get the html content as a tree.
    tree = html.fromstring(page.content)
    # Use xpath to get the image url.
    img_url = tree.xpath('//figure//img/@src')[0]
    data = requests.get(img_url, stream=True)
    data.raw.decode_content = True
    img = Image.open(data.raw)
    return img

def compute_thumbnails(img):
    thumbnails = []
    for size in THUMBNAIL_SIZES:
        thumbnails.append(img.thumbnail(size))
    return thumbnails

def get_all_thumbnails():
    for i in range(1, 11):
        img = get_image_from_website(URL_TPL.format(i))
        thumbnails = compute_thumbnails(img)

get_all_thumbnails()
real    0m9.722s
user    0m0.466s
sys     0m0.089s

Soit environ 10 secondes, une seconde par image.

En asynchrone/non bloquant

import aiohttp
import asyncio
from io import BytesIO
from lxml import html
from PIL import Image

URL_TPL = "http://bonjourlechat.tumblr.com/page/{}"
THUMBNAIL_SIZES = ((100, 100), (200, 200), (300, 300), (400, 400), (500, 500))

async def get_image_from_website(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as page:
            # Get the html content as a tree.
            tree = html.fromstring(await page.text())

        # Use xpath to get the image url.
        img_url = tree.xpath('//figure//img/@src')[0]

        # Store the raw image data in a file-like object that Pillow can use.
        memfile = BytesIO()
        async with session.get(img_url) as data:
            memfile.write(await data.read())

    img = Image.open(memfile)
    return img

async def compute_thumbnails(img):
    thumbnails = []
    for size in THUMBNAIL_SIZES:
        thumbnails.append(await loop.run_in_executor(None, img.thumbnail, size))
    return thumbnails

async def get_thumbnail(url):
    img = await get_image_from_website(url)
    thumbnails = await compute_thumbnails(img)


tasks = [get_thumbnail(URL_TPL.format(i)) for i in range(1, 11)]
loop = asyncio.get_event_loop()
thumbnails = loop.run_until_complete(asyncio.gather(*tasks))
real    0m4.139s
user    0m0.795s
sys     0m0.094s

Soit environ 4 secondes, 0.5 seconde par image.

Plusieurs remarques :

Attention aux pièges

import asyncio
import time

async def foo():
    for i in range(10):
        await loop.run_in_executor(None, time.sleep, 1)

loop = asyncio.get_event_loop()
loop.run_until_complete(foo())
real    0m10.137s
user    0m0.079s
sys     0m0.017s

Comment ça, 10 secondes ? Pourtant, les 10 appels à time.sleep(1) semblent asynchrones, non bloquants, concurrents, dans des coroutines qui vont bien ?!

Il y a un piège : dans le code ci-dessus les 10 coroutines sont exécutées les unes après les autres. Il pourrait être réécrit de la façon suivante, qui met bien en valeur le problème :

import asyncio
import time

async def foo():
    await loop.run_in_executor(None, time.sleep, 1)

loop = asyncio.get_event_loop()
for i in range(10):
    loop.run_until_complete(foo())

Lorsqu'une coroutine se lance, on attend qu'elle se termine avant d'en lancer une autre. La façon correcte d'écrire ce code est de lancer toutes les coroutines en même temps avec asyncio.wait() ou asyncio.gather() comme ci-dessous :

import asyncio
import time

async def foo():
    await loop.run_in_executor(None, time.sleep, 1)

loop = asyncio.get_event_loop()
tasks = [foo() for i in range(10)]
loop.run_until_complete(asyncio.wait(tasks))

Asyncio est inutile pour CPU-bound

La programmation asynchrone par coroutines n'est utile que pour les cas IO-bound : lecture/écriture sur le système de fichier, sur un socket...

Il faut imaginer un process comme étant Jean-Michel CPU, employé de Prog-corp, auquel le programme demande d'exécuter une liste de tâches. Si Jean-Michel est déjà surchargé de travail, réarranger ses tâches, les mettre dans le désordre, bloquantes ou non bloquantes, ne changera rien du tout.

Par contre, si Jean-Michel CPU est en train de se tourner les pouces pendant que Bernard IO est en train de trimmer à transporter des paquets de gauche et de droite, alors les choses peuvent être optimisées :

En synchrone/bloquant :

En asynchrone/non-bloquant :

Voilà un autre cas qui a l'air d'être IO-bound, sans que ce soit pourtant le cas :

Les bases de données sont en général bien plus rapides que n'importe quel programme écrit en python. Si, en théorie, une requête à la base de donnée est une lecture/écriture (Input-Output), dans la pratique sa réponse arrive tellement rapidement qu'il n'y a souvent rien à gagner à l'implémenter en asynchrone. Si la base de données est distante, et que le délai (le round-trip) est long, il est possible d'espérer gagner un peu. En général ce n'est pas le cas (et si ça l'est, vous avez d'autres soucis à régler, en particulier au niveau de la base de donnée elle-même). Pire, on perd le temps de la gestion des coroutines.

La programmation asynchrone est vraiment efficace et utile dans quelques cas notables, comme par exemple les lecture/écriture sur un système de fichier ou sur un socket vers un serveur distant.

Gérer des requêtes entrantes sur un serveur web de manière asynchrones grâce à aiohttp, ou des requêtes à postgresql avec aiopg (probablement inutile, comme vu plus haut ?), ou avec le tout nouveau asyncpg, et plus important que tout, télécharger des photos de chat. Voilà les exemples les plus courants croisés dans les tutoriels.

Certains problèmes sont très pénibles à écrire de manière synchrone/séquentielle, alors qu'ils s'expriment de manière tout à fait logique de manière asynchrone. Par exemple un moteur de jeu : une coroutine qui gère l'affichage en continu, et d'autres coroutines pour récupérer/traiter les entrées du joueur.

Merci à Aurélien G. pour la relecture et réécriture de l'article afin de le rendre plus agréable à lire !

All posts

  1. Latence et boucle de rétroaction
  2. Heartbleed, conséquences pour les utilisateurs
  3. DjangoCon Europe
  4. Objets ou fonctions
  5. Quitter Gmail : gestion des contacts
  6. Quitter Gmail : migrer ses mails
  7. Quitter Gmail : créer son compte mail
  8. Quitter Gmail : réserver son nom de domaine
  9. Quitter Gmail
  10. Au revoir Novapost, bonjour FruitLab
  11. Retour sur Pytong 2013
  12. Retour sur Sud Web 2013
  13. Django1.5 : passer au Configurable User Model
  14. Le sport
  15. Création d'un FabLab sur Valence
  16. Lancement de FaitMain.org
  17. Djangocon 2012 Tolosa Edition
  18. Taxonomie des entreprises
  19. Plan de carrière d'un développeur
  20. Automatiser son flake8 avec vim et syntastic
  21. Vim + Screen : le pair-prog des champions !
  22. Le miroir PyPI du pauvre
  23. Vim, Restructured Text et espaces insécables
  24. VIM et la correction orthographique
  25. Djangocong 2012 !
  26. Point-virgule
  27. La bidouille django du jour: appeller un templatetag depuis un autre templatetag
  28. Contribuer à Django, premiers pas (patcher la doc)
  29. Sud Web, c'est bon pour ton web
  30. Contribuer à Django, premiers pas (les outils, l'environnement)
  31. Contribuer à Django, premiers pas (revue de tickets)
  32. Djangocong 2011 : une cuvée d'exception
  33. django et le handler500: retourner une erreur 503
  34. La technique pomodoro : retour après plus d'un mois d'utilisation
  35. La technique pomodoro : retour après deux semaines d'utilisation
  36. django: redimensionner une image à la volée en préservant son ratio
  37. Django forms, HTML5 et fieldsets
  38. Double encodage utf8 : afficher correctement avec python et django
  39. La vie a la couleur qu'on veut bien lui donner
  40. lancer gunicorn avec supervisord
  41. PyCon.fr 2010 : retour sur une conférence organisée par l'AFPY
  42. djangocong : rencontre Django à Marseille
  43. MySQL, mysqldump et PHP : convertir de latin1 vers utf8
  44. Django : Envoyer des emails HTML avec images inline (intégrées)
  45. lancer gunicorn avec runit
  46. gunicorn: un server wsgi ultra simple à utiliser et configurer
  47. Installer PIL (Python Imaging Library) facilement avec pip
  48. Obfuscation de l'email alternative et accessible
  49. Linux: savoir si le processeur est 32bits ou 64bits
  50. PyCON.fr, excellent!
  51. PyCon.fr: venez m'y voir!
  52. Le contrôle de versions de sources: pourquoi?
  53. Django, sqlite et mod_wsgi, attention au piège!
  54. Checklist: différences entre MySQL et les modèles Django
  55. MySQL et les modèles Django
  56. Apprendre à faire, et faire
  57. Django FileField et ImageField, upload_to et shell python
  58. Django svn et mod_wsgi, attention au piège!
  59. 30 ans, et toutes mes dents