Super article invité sur Trio que l’auteur a oublié de titrer 6   Recently updated !


Ceci est un post invité de touilleMan posté sous licence creative common 3.0 unported.

C’est bon vous avez cédé à la hype ?

Après un n-ème talk sur asyncio vous avez été convaincu que tout vos sites webs doivent être recordé dans cette techno ? Oui, surtout celui de la mairie de Gaudriole-sur-Gironde avec ses 50 visiteurs/jour, Django ça scalera pas et vous aurez sûrement besoin de websockets à l’avenir.

Et puis là pan ! En commençant à utiliser asyncio on se rend compte que ça va pas être aussi marrant que ce que vous a vendu l’enfoiré de hipster dans son talk avec son exemple de crawler web en 20 lignes :

  • la doc de asyncio fait 50 putain de pages, même les plus grands déclarent ne rien y comprendre
  • pdb est aux fraises, un step-over sur un await vous envoi à perpet’ dans l’event loop
  • il faut passer l’event loop en tant qu’argument à chaque fonction, évidemment vous n’allez pas le faire et utiliser get_event_loop() à la place. Et ça va merder sévère à un moment (typiquement quand vous ajouterez des tests non triviaux), et vous allez devoir corriger tout votre code.
  • régulièrement une stacktrace d’une coroutine ayant crashé dégueule de stdout sans que le programme ne bronche, autant de je m’en foutisme on se croirait revenu en PHP !
  • parlons-en de la stacktrace ! Impossible de savoir d’où vient la coroutine, encore une fois on nous renvoi dans les méandres de l’event loop.
  • Et pour initialiser/finaliser proprement votre système alors là c’est la fête totale

Je ne parle même pas des soucis ceinture-noir-2ème-dan du genre high-water mark qui vous tomberons dessus une fois l’appli en prod.

Lourd est le parpaing de la réalité sur la tartelette aux fraises de nos illusions…

1 – Pourquoi c’est (de) la merde ?

Pour faire simple asyncio a été pensé à la base comme une tentative de standardisation de l’écosystème asynchrone Python où chaque framework (Twisted et Tornado principalement) était incompatible avec les autres et devait re-créer son écosystème de zéro.

C’était la bonne chose à faire à l’époque, ça a eu beaucoup de succès (Twisted et Tornado sont maintenant compatible asyncio), ça a donné une killer-feature pour faire taire les rageux au sujet de Python 3 et ça a créé une émulsion formidable concernant la programmation asynchrone en Python.
Mais dans le même temps ça a obligé cette nouvelle lib à hériter des choix historiques des anciennes libs : les callbacks.

Pour faire simple un framework asynchrone c’est deux choses :

  • une grosse boucle infinie (la fameuse « event loop ») qui a configuré les appels d’IO au kernel en mode non-bloquant et qui poll ceux-ci en continu
  • un mécanisme pour garder trace de quel bout de code exécuter quand une IO donnée aura été terminée

Concernant le 2ème point, cela veut dire que si on a une fonction synchrone comme ceci :

def listen_and_answer(sock):
    print('start')
    data = sock.read()
    print('working with %s' % data)
    sock.write('ok')
    print('done')

Il faut trouver un moyen pour la découper en une série de morceaux de codes et d’IO.

Il y la façon « javascript », où on découpe à la main comme un compilo déroulerai une boucle :

def listen_and_answer(sock):
    print('start')
 
    def on_listen(data):
        print('working with %s' % data)
 
        def on_write(ret):
            print('done')
 
        sock.write('ok', on_write)
 
    sock.read(on_listen)

Et là j’ai fait la version simple sans chercher à gérer les exceptions et autres joyeusetés. Autant dire que quand un vieux dev Twisted vous dit le regard vide et la voix chevrotante qu’il a connu l’enfer, ne prenez pas ses déclarations à la légère.

Sinon la façon async/await si chère à asyncio :

async def listen_and_answer(sock):
    print('start')
    data = await sock.read()
    print('working with %s' % data)
    await sock.write('ok')
    print('done')

C’est clair, c’est propre, la gestion des exceptions est totalement naturelle, bref c’est du Python dans toute sa splendeur.
Sauf que non, tout ça n’est qu’un putain d’écran de fumée : pour être compatible avec Twisted&co sous le capot asyncio fonctionne avec des callbacks.

Vous vous souvenez de cette sensation de détresse mêlée d’hilarité devant une stacktrace d’un projet Javascript lambda d’où vous ne reconnaissez que la première ligne ? C’est ça les callbacks, et c’est ça que vous avez dans asyncio.

Concrètement le soucis vient du fait qu’une callback n’est rien d’autre qu’une fonction passée telle qu’elle sans aucune information quant à d’où elle vient. De fait impossible pour l’event loop asynchrone de reconstruire une callstack complète à partir de cela.
Heureusement async/await permettent à python de conserver ces informations de fonction appelante ce qui limite un peu le problème avec asyncio.
Toutefois en remontant suffisamment haut on finira toujours avec une callback quelque part. Et vous savez qui a l’habitude de remonter aussi haut que nécessaire ? Les exceptions.

import asyncio
import random
 
async def succeed(client_writer):
    print('Lucky guy...')
    # Googlez "ayncio high water mark" pour comprender pourquoi c'est
    # une idée à la con de ne pas avoir cette methode asynchrone
    client_writer.write(b'Lucky guy...')
 
async def fail(client_writer):
    raise RuntimeError('Tough shit...')
 
async def handle_request_russian_roulette_style(client_reader, client_writer):
    handlers = (
        succeed,
        succeed,
        succeed,
        fail,
    )
    await handlers[random.randint(0, 3)](client_writer)
    client_writer.close()
 
async def start_server():
    server = await asyncio.start_server(
        handle_request_russian_roulette_style,
        host='localhost', port=8080)
    await server.wait_closed()
 
asyncio.get_event_loop().run_until_complete(start_server())

Maintenant si on lance tout ça et qu’on envoie des curl localhost:8080 on va finir avec:

$ python3 russian_roulette_server.py
Lucky guy...
Lucky guy...
Task exception was never retrieved
future:  exception=RuntimeError('Tough shit...',)>
Traceback (most recent call last):
  File "ex.py", line 18, in handle_request_russian_roulette_style
    await handlers[random.randint(0, 3)](client_writer)
  File "ex.py", line 9, in fail
    raise RuntimeError('Tough shit...')
RuntimeError: Tough shit...
Lucky guy...
Task exception was never retrieved
future:  exception=RuntimeError('Tough shit...',)>
Traceback (most recent call last):
  File "ex.py", line 18, in handle_request_russian_roulette_style
    await handlers[random.randint(0, 3)](client_writer)
  File "ex.py", line 9, in fail
    raise RuntimeError('Tough shit...')
RuntimeError: Tough shit...

Le problème saute aux yeux: asyncio.start_server gère sa tambouille avec des callbacks et se retrouve bien embêté quand notre code remonte une exception. Du coup il fait au mieux en affichant la stacktrace et en faisant comme si de rien n’était. C’est peut-être le comportement qu’on attend d’un serveur web (encore que… si aviez configuré logging pour envoyer dans un fichier vous êtes bien baïzay) mais il existe des tonnes de usecases pour lesquels ça pose problème (et de toute façon on n’a vu que la partie émergée de l’iceberg d’emmerdes qu’est la programmation asynchrone).

Bref, si vous voulez en savoir plus, allez lire ce post, d’ailleurs allez lire tous les posts du blog, ce mec est un génie.

2 – Trio, une façon de faire de l’asynchrone

Ce mec en question, c’est Nathaniel J. Smith et il a eu la très cool idée de créer sa propre lib asynchrone pour Python: Trio

L’objectif est simple: rendre la programmation asynchrone (presque) aussi simple que celle synchrone en s’appuyant sur les nouvelles fonctionnalités offertes par les dernières versions de Python ainsi qu’un paradigme de concurrence innovant. Cette phrase est digne d’un marketeux, vous avez le droit de me cracher à la gueule.

Concrètement ce que ça donne:

# pip install trio asks beautifulsoup4
import trio
import asks
import bs4
import re
 
 
# Asks est un grosso modo requests en asynchrone, vu qu'il supporte trio et curio
# (une autre lib asynchrone dans le même style), il faut donc lui dire lequel utiliser
asks.init('trio')
 
 
async def recursive_find(url, on_found, depth=0):
    # On fait notre requête HTTP en asynchrone
    rep = await asks.get(url)
    print(f'depth {depth}, try {url}...')
 
    # On retrouve le corps de l'article grace à beautiful soup
    soup = bs4.BeautifulSoup(rep.text, 'html.parser')
    body = soup.find('div', attrs={"id": 'mw-content-text'})
 
    # On cherche notre point Godwin
    if re.search(r'(?i)hitler|nazi|adolf', body.text):
        on_found(url, depth)
 
    else:
        async with trio.open_nursery() as nursery:
            # On retrouve tous les liens de l'article et relance le recherche
            # de manière récursive
            for tag in body.find_all('a'):
                if tag.has_attr('href') and tag.attrs['href'].startswith('/wiki/'):
                    child_link = 'https://en.wikipedia.org' + tag.attrs['href']
                    # On créé une nouvelle coroutine par lien à crawler
                    nursery.start_soon(recursive_find, child_link, on_found, depth+1)
 
 
async def godwin_find(url):
    results = []
 
    with trio.move_on_after(10) as cancel_scope:
        def on_found(found_url, depth):
            results.append((found_url, depth))
            cancel_scope.cancel()
 
        await recursive_find(url, on_found)
 
    if results:
        found_url, depth = results[0]
        print(f'Found Godwin point in {found_url} (depth: {depth})')
    else:
        print('No point for this article')
 
 
trio.run(godwin_find, 'https://en.wikipedia.org/wiki/My_Little_Pony')

L’idée de ce code est, partant d’un article wikipedia, de crawler ses liens récursivement jusqu’à ce qu’on trouve un article contenant des mots clés.

Au niveau des trucs intéressants:

async with trio.open_nursery() as nursery:
    for tag in body.find_all('a'):
        if tag.has_attr('href') and tag.attrs['href'].startswith('/wiki/'):
            child_link = 'https://en.wikipedia.org' + tag.attrs['href']
            nursery.start_soon(recursive_find, child_link, on_found, depth+1)

En trio, une coroutine doit forcément être connectée à une nurserie. Cela permet deux choses:

  • Rattacher la coroutine à sa coroutine parente, de cette façon (et vu que trio est implémenté intégralement en utilisant async/await au lieu de callbacks), on a donc une stacktrace claire et une exception sera toujours propagée jusqu’à la racine du programme si il le faut.
  • Borner la durée de vie de la coroutine. La nurserie est un context manager asynchrone, une fois qu’on arrive à la fin du async with, la nursery bloque tant que toutes les coroutines qu’elle gère n’ont pas terminé. Si une coroutine raise une exception, la nursery va pouvoir cancel les autres coroutines avant de re-raise l’exception en question (cf. le point précédent)

Quel intérêt à borner la durée de vie des coroutines ? Si on avait voulu écrire un truc équivalent en asyncio on aurait sans doute utilisé asyncio.gather:

coroutines = [recursive_find(link) for link in links]
await asyncio.gather(coroutines)

Maintenant on fait tourner ce code avec une connection internet un peu faiblarde (au hasard sur la box Orange de Sam ces temps ci…) les ennuis auraient commencé dès qu’une requête http aurait timeout.
L’exception de timeout aurait été récupérée par asyncio.gather qui l’aurait relancé sans pour autant fermer les autres coroutines qui auraient continué à crawler wikipedia en créant des centaines de coroutines (oui recursive_find est un peu bourrin).
De fait si on se place dans le cas d’un code tournant longtemps (typiquement on a un serveur web qui a lancé notre code dans le cadre du traitement d’une requête entrante) on va avoir bien du mal à retrouver l’état ayant mené à ce bordel.

Du coup en trio la seule solution pour avoir une coroutine qui survit à son parent c’est de lui passer une nursery en paramètre:

async def work(sleep_time, nursery):
    await trio.sleep(sleep_time)
    print('work done !')
    # Je vous ai dit qu'une nurserie contient automatiquement un cancel scope ?
    nursery.cancel_scope.cancel()
 
async def work_generator(nursery):
    print('bootstrapping...')
    await trio.sleep(1)
    for sleep_time in range(10):
        nursery.start_soon(work, sleep_time, nursery)
 
async def stop_a_first_work_done():
    async with trio.open_nursery() as nursery:
        await work_generator(nursery)
        print('Waiting for a work to finish...')

Un autre truc cool:

with trio.move_on_after(10) as cancel_scope:
    def on_found(found_url, depth):
        results.append((found_url, depth))
        cancel_scope.cancel()
 
    await recursive_find(url, on_found)

Vu qu’en trio on se retrouve avec un arbre de coroutines, il est très facile d’appliquer des conditions sur un sous-ensemble de l’arbre. C’est le rôle des cancel scope.
Comme pour les nursery, les cancel scope sont des contexts managers (mais synchrone ceux-ci). On peut les configurer avec un timeout, une deadline, ou bien tout simplement les annuler manuellement via cancel_scope.cancel().

Dans notre exemple, on définit un scope dont on sortira obligatoirement au bout de 10s. Pour éviter d’attendre pour rien, on annule le scope explicitement dans la closure appelée quand un résultat est trouvé.
Vu que les nurseries définies à chaque appel de recursive_find se trouvent englobées par notre cancel scope, elles seront automatiquement détruites (et toutes les coroutines qu’elles gèrent avec).

Pour faire la même chose avec asyncio bonne chance:

  • soit on passe un argument de timeout à notre appel pour récupérer la requête HTTP, mais dans ce cas pour peu que chaque requête soit individuellement plus courte que le timeout on ne s’arrêtera jamais
  • soit on gère à la mano le temps à coup de time.monotonic() en passant le temps restant autorisé aux coroutines filles. Bonjour la gueule du code.

En plus comme en parlait un mec (décidemment !), la gestion du timeout dans une socket tcp est foireuse, il suffit de recevoir un paquet (et une requête entière peut contenir beaucoup de paquets !) pour que le timeout soit remis à zéro. Donc encore une fois pas de garanties fortes quant à quand le code s’arrêtera.

3 – Eeeeet c’est tout !

Au final la doc de l’api de trio pourrait tenir sur l’étiquette de mon slip: pas de promise, de futurs, de tasks, de pattern Protocol/Transport legacy. On se retrouve juste avec la sainte trinité (j’imagine que c’est de là que vient le nom) async/await, nursery, cancel scope.

Et évidemment maintenant, l’enfoiré de hipster qui vous vend une techno à coup de whao effect avec un crawler asynchrone de 20 lignes c’est moi…

Remarquez si vous préférez la version longue je vous conseil cet excellent article de Nathaniel (je vous ai dit que ce mec était un génie ?).

4 – L’écosystème

C’est là où on se rend compte que asyncio est malgré ses lacunes une super idée: il a suffit d’écrire une implémentation de l’event loop asyncio en trio pour pouvoir utiliser tout l’écosystème asyncio (ce qui inclus donc Twisted et Tornado, snif c’est beau !).

Allez pour le plasir un exemple d’utilisation de asyncpg depuis trio:

import trio_asyncio
import asyncpg
 
 
class TrioConnProxy:
    # Le décorateur permet de marquer la frontière entre trio et asyncio
    @trio_asyncio.trio2aio
    async def init(self, url):
        # Ici on est donc dans asyncio
        self.conn = await asyncpg.connect(url)
 
    @trio_asyncio.trio2aio
    async def execute(self, *args):
        return await self.conn.execute(*args)
 
    @trio_asyncio.trio2aio
    async def fetch(self, *args):
        return await self.conn.fetch(*args)
 
 
async def main():
    # Ici on est dans trio, c'est la fête
 
    conn = TrioConnProxy()
    await conn.init('postgresql:///')
 
    await conn.execute('CREATE TABLE IF NOT EXISTS users(name text primary key)')
 
    for name in ('Riri', 'Fifi', 'Loulou'):
        await conn.execute('INSERT INTO users(name) VALUES ($1)', name)
 
    users = await conn.fetch('SELECT * FROM users')
    print('users:', [user[0] for user in users])
 
 
# trio_asyncio s'occupe de configurer l'event loop par défaut de asyncio
# puis lance le <code>trio.run</code> classique
trio_asyncio.run(main)

En plus de ça trio vient avec son module pytest (avec gestion des fixtures asynchrones s’il vous plait) et Keneith Reitz a promis que la prochain version de requests supporterait async/await et trio nativement, elle est pas belle la vie !

6 thoughts on “Super article invité sur Trio que l’auteur a oublié de titrer

  • Sieira

    Il manque l’arg limit dans la fonction recursive_find du crawler

  • YvesD

    Hé, merci pour Asks … Depuis Lundi, je cherchais une async lib pour Trio.

    Super article !!

    Sinon dans le ‘work_generator'; ca ne serait pas ‘work’ au lieu du premier ‘nursery’ dans le start_soon

    nursery.start_soon(work, sleep_time, nursery) # — au lieu de nursery.start_soon(nursery, sleep_time, nursery)

  • Morgotth

    C’est étonnant que la communauté JS n’ai pas eu l’idée avant et réécrit toutes les libs existantes pour prendre en compte cette idée #troll

  • Ronan Delacroix

    Lourd est le parpaing de la réalité sur la tartelette aux fraises de nos illusions :) – :love: Boulet

    Ahah je suis bien d’accord avec le début de l’article… La plupart des développeurs ne comprenne déjà pas bien les threads ou les process, les signaux, les queues, etc. Comment bien gérer asyncio si on n’a pas une grosse expérience sur tout ça avant? Parce que ça a beau être emballé différemment, c’est les mêmes techniques, les mêmes outils utilisés derrière, et donc les mêmes problèmes qui se posent au final. Au final je ne pense pas que ces libs simplifient vraiment la vie par rapport à du multiprocessing. Bon c’est pas le point je sais je m’égare.

    Mais putain, pourquoi vouloir se taper du code spaghetti quand 99% du temps en plus il y a pas vraiment besoin d’async…

  • touilleMan Post author

    @Sieira merci, au début j’avais voulu utiliser CapacityLimiter (qui permet de limiter simplement le nombre de coroutine pouvant être tourner en parallèle grace à an contexte manager asynchrone), mais ça complexifiait plus le code qu’autre chose. Du coup j’ai tout viré là ;-)

    @YvesD bien vu !

    @Morgotth le problème de javascript c’est que l’event loop est totalement intégré à la runtime, donc impossible de faire ce que fait trio sans avoir à écrire une nouvelle spec super impactante (et jamais implémenter proprement sans doute) pour tous les navigateurs. Sans compter toutes les api fonctionnant avec des callbacks (ou promise ce qui revient au même) qui deviendrait obsolète, soit quasiment tout l’écosystème javascript (à la différence de python qui reste majoritairement synchrone)…

  • Pouet

    Oui, par contre je ne suis pas d’accord quand tu dis que la stacktrace de trio est claire. Tu y trouves les informations que tu cherches, certes, mais par contre chaque stacktrace est aussi longue que la constitution de la 5e république.

    Je sais, c’est en train d’être fixé, mais du coup je poste ça surtout pour rajouter un truc dont tu n’as pas parlé dans l’article : trio n’est pas encore production-ready, attendez encore un peu avant de lancer vos fusées avec. C’est super prometteur, ça marche déjà carrément bien pour beaucoup de choses, la doc est géniale (sérieusement, ça faisait longtemps que j’avais pas vu une doc aussi bien faite, limpide, un bonheur à lire), mais y’a encore beaucoup de discussions en cours sur le design, quelques changements d’api à prévoir, des angles très anguleux à arrondir…

Leave a comment

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <pre> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>

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