Go to (in asyncio) considered harmful


Dijkstra était un intellectuel pédant, mais quand il a écrit cette lettre célèbre, il a comme souvent mis le doigt sur un truc fondamental. Et quand l’auteur de Trio, une stack toute neuve concurrente d’asyncio, lui a fait écho 50 ans plus tard, ça a beaucoup discuté sur les mailing lists et les bugs trackers.

Nathaniel J. Smith, le dev susnommé, en a profité pour introduire une nouvelle primitive, actuellement surnommée la nursery, pour répondre au problème. Une idée visiblement tellement bonne que notre Yury préféré a décidé de la porter à asyncio. La boucle d’événements est bouclée, si je puis dire.

Mais une autre chose intéressante en découle : on a mis en lumière la présence d’un goto dans asyncio, et qu’il y a de bonnes pratiques, validées par Guido himself, pour coder avec cette lib pour éviter les douleurs.

What the fuck are you talking about ?

Le problème du goto, c’est que l’instruction permet d’aller de n’importe où à n’importe où. Cela rend le flux du programme très dur à suivre. Pour éviter cela, on a catégorisé les usages clean du goto: répéter une action, changer de comportement en fonction d’un test, sortir d’un algo en cas de problème, etc. Et on en a fait des primitives : les if, les while, les exceptions… Dans les langages les plus modernes, on a carrément viré le goto pour éviter les abus et erreurs. Joie.

Dans asyncio, le “goto” en question se trouve quand on veut lancer des tâches en arrière plan, comme ceci :

import asyncio as aio
loop = aio.get_event_loop()
aio.ensure_future(foo())  # GOTO !
aio.ensure_future(bar())  # GOTO !
loop.run_forever()

Le problème d’ensure_future() est multiple:

  • Comme son nom l’indique, cette fonction retourne un objet… Task. Ca n’a rien à voir, mais je tenais à dire à quel point c’était con de l’avoir nommé ainsi (même si techniquement, Task hérite de Future).
  • Cette ligne ne garantit en aucun cas que foo() ou bar() seront terminées à une zone précise du code. Tout au plus que tuer la boucle tue les taches. Leur flux d’exécution est complètement freestyle et décorrélé de tout le reste du programme, ainsi que de l’une de l’autres. Si ces coroutines font des await, on peut basculer de n’importe où du programme vers elles et inversement à tout moment. goto
  • Cette ligne schedule le démarrage de foo() et bar() dès que la boucle peut les lancer. Ici la boucle ne tourne pas encore. Plus le programme est complexe, plus il va devenir difficile de savoir à quelle étape logique les coroutines vont démarrer.

En prime run_forever() est un piège à con, car les exceptions qui arrivent dans la boucle sont logguées, mais ne font pas crasher le programme, ce qui rend le debuggage super rude, même avec debug mode activé (dont de toute façon personne ne soupçonne l’existence).

La solution asyncio

import asyncio as aio
loop = aio.get_event_loop()
loop.run_until_complete(aio.gather(foo(), bar())

En plus d’être plus court, les exceptions vont faire planter le programme, la loop s’arrêtera quand les coroutines auront fini leur taff, leur flux a un début et une fin encapsulés par le gather(). Ceci est encore plus visible si on met le même code à l’intérieur d’une coroutine à l’intérieur d’une coroutine à l’intérieur d’une coroutine plutôt qu’à la racine du programme. En effet dans un exemple si simple, on se borne au démarrage et à l’arrêt de la boucle. Mais je suis paresseux.

Donc, c’est la bonne pratique, mais tout le monde ne le sait pas.

Pardon, correction.

Tous les devs Python ne connaissent pas asyncio. Parmi ceux qui connaissent asyncio, une petite partie comprend comme ça marche.

Dans ce lot rikiki, un pouillième sait que c’est la bonne pratique.

En fait, gather() est probablement la fonction la plus importante d’asyncio, et pourtant elle apparaît à peine dans la doc. C’est la malédiction d’asyncio, une lib que tout le monde attendait pour propulser Python dans la league des langages avec frameworks modernes, mais qui commence à peine à devenir utilisable par le commun des mortel en 2018. Et encore.

Il ne faut jamais utiliser ensure_future() à moins de vouloir attacher un callback à la main dessus, ce qui n’est probablement jamais ce que vous voulez à cette époque merveilleuse ou existe async/await. ensure_future() est un goto, gather() est un concept de plus haut niveau.

Mais deux problèmes demeurent…

Contrairement au goto banni de Python, ensure_future() est là, et va rester. Donc n’importe quel connard peut dans un code ailleurs vous niquer profond, et en tâche de fond.

ensure_future() (ou son petit frère EventLoop.create_task()) reste le seul moyen valable pour lancer une tâche, faire quelque chose, lancer une autre tâche, puis enfin faire un gather() sur les deux tâches:

async def grrr():
    task1 = aio.ensure_future(foo())
    # faire un truc pendant que task1 tourne
    task2 = aio.ensure_future(bar())
    # faire un truc pendant que task1 et task2 tournent
    # On s'assure que tout se rejoint à la fin:
    await aio.gather(task1, task2)

Et puis, faire une pyramide de gather() dans tout son code pour s’assurer que tout va bien de haut en bas, c’est facile à rater.

La nursery : la solution de trio

Une nursery agit comme un scope qui pose les limites du cycle de vie des tâches qui lui sont attachées. C’est un gather(), sous stéroide, et avec une portée visuellement claire:

async def grrr():
    async with trio.open_nursery() as nursery:
        task1 = nursery.start_soon(foo)
        # faire un truc pendant que task1 tourne
        task2 = nursery.start_soon(bar)
        # faire un truc pendant que task1 et task2 tournent

Les taches sont garanties, à la sortie du with, de se terminer. Le ensure_future() n’a pas d’équivalent en trio, et donc aucun moyen de lancer un truc dans le vent sans explicitement lui passer au moins une nursery à laquelle on souhaite l’attacher.

Résultat, on ne peut plus faire de goto, et le flux du program est clair et explicite.

Notez que, tout comme if et while ne permettaient rien qu’un utilisateur soigneux de goto ne pouvait faire, la nursery ne permet rien qu’un utilisateur soigneux de ensure_future() ne peut faire. Mais ça force un ensemble de bonnes pratiques.

Évidemment, on peut ouvrir une nursery dans un bloc d’une autre nursery, ce qui permet d’imbriquer différentes portées, comme on le ferait avec un begin() de transaction de base de données. Or, une exception à l’intérieur d’une nursery bubble naturellement comme toute exception Python, et stoppe toutes les tâches de la nursery encore en train de tourner. Alors qu’avec asyncio vous l’avez dans le cul.

En définitive, c’était la pièce manquante. La moitié du boulot avait était faite quand on a introduit un moyen de gérer des tâches asynchrones qui dépendent les unes des autres, en remplaçant les callbacks par un truc de haut niveau : async/await. Il restait la gestion des tâches en parallèle qui se faisait encore selon les goûts et compétences de chacun, mais la nursery va remplir ce vide.

Cela devrait être intégré à asyncio en Python 3.8, soit une bonne année et demie pour ceux qui ont la chance de pouvoir faire du bleeding edge.

Comme certains ne voudront pas attendre, je vous ai fait un POC qui vous montre comment ça pourrait marcher. Mais cette version ne sera jamais utilisée. En effet, elle intercepte ensure_future() (en fait le create_task() sous-jacent) pour attacher son résultat à la nursery en cours, évitant tout effet goto, et ça péterait trop de code existant. Mon pognon est plutôt sur un gros warning émis par Python quand on fait une gotise.

Dernier mot: s’il vous plaît, allez voter pour change le nom de nursery. C’est beaucoup trop long à taper pour un truc qu’on va utiliser tout le temps.

26 thoughts on “Go to (in asyncio) considered harmful

  • Oprax

    Bon article (comme d’habitude !) :)

    Petite correction : task2 aio.ensure_future(bar()) => il manque un égal

  • touilleMan

    Ça fait plaisir de ne pas être le seul à avoir trouvé l’article de Nathaniel révolutionnaire (ouai carrément, et je pèse mes mots ^^).

    Sinon au niveau des coquilles: trio.open_nursery() retourne un context manager asynchrone, donc il faut faire async with trio.open_nursery() as nursery:

  • orwell

    Merci pour l’article.

    Petite correction : La moitié du boulot avait était fait => été faite

    Désolé de ne pas avoir de choses plus intéressantes à dire en commentaire que des corrections. Mais cela prouve que vos lecteurs lisent attentivement vos articles car ils sont intéressants.

  • TorraK

    Merci pour l’article.

    Je serais preneur d’un tuto de bonnes pratiques pour asyncio. Sam avec ta pédagogie, asyncio devrait être plus… digeste….

  • Linekio

    C’est pas run_until_complete plutôt ?

    (jveux une image)

    Sinon, async with ne fonctionne pas en python 3.5 ?

  • Morgotth

    Génial comme article, et à voté ! Je pense aussi que scope est mieux, meme si nurserie était assez fun.

  • Sam Post author

    @Linekio:

    Sinon, async with ne fonctionne pas en python 3.5 ?

    Normalement si. Mais asyncio n’est vraiment confortable qu’à partir de Python 3.6.

  • Abjects

    Ce ne serait pas zone précise du coded à la place de zone précise du coded ?!

    Une image, une image, une image stp :-)

  • Synedh

    Ceci est encore plus visible si on met le même code à l’intérieur d’une coroutine à l’intérieur d’une coroutine à l’intérieur d’une coroutine plutôt qu’à la racine du programme.

    Au moins on est sûr que le code attrapera pas froid.

    Excellent article, j’m’étais plusieurs fois interrogé sur l’utilisation du ensure_future(). J’ai dorénavant ma réponse : je m’en passe.

  • Debnet

    Ca serait tellement bien de voir un article d’un vrai cas pratique en asyncio avec tous les changements qui ont été apportés ainsi que toutes les bonnes pratiques associées. Qu’est ce que t’en penses Sam ? :D

  • herissondemer

    Et puis, faire une pyramide de gather() dans tout son code pour s’assurer que tout va bien de haut en bas, c’est facile à rater.

    Oui et on peut aussi oublier de await le gather ;-)

    async def grrr():
        ...
        await aio.gather(task1, task2)
    

    Sans le await, la traceback sera seulement loggée

  • Goldy

    Merci beaucoup pour cet article.

    Il pointe du doigt un des éléments qui m’a toujours dérangé et la raison pour laquelle j’ai énormément de mal à travailler sur du code asynchrone, cette impression de sortir du scope au moment où on lance une tâche asynchrone, que des choses se produisent sans qu’on ne soit véritablement en mesure de savoir quoi, et cette absence de limite claire a fait que j’ai vraiment eu du mal à me mettre à asyncio.

    Je ne connaissais pas trio, mais je sens qu’on va rapidement devenir copain, pouvoir lancer une série de tâches asynchrone dans un block with, c’est vraiment nice.

  • Sam Post author

    @Debnet : Quand j’aurai fini le dossier sur les tests

    @herissondemer : demonstration accomplie.

    @goldy: je pense que faire une bonne surcouche pour asyncio est aussi un truc intéressant à envisager. Trio est un bon concept, et son design est frachement sexy, mais asyncio est dans la stdlib et à déjà un gros ecosystème donc ça serait cool si on pouvait continuer à l’utiliser.

  • Linekio

    Perso je n’utilise asyncio que comme ça:

    async def something(loop):

    f1 = loop.run_in_executor(None, foo)

    f2 = loop.run_in_executor(None, bar)

    await f1

    await f2

    loop = asyncio.get_event_loop()

    loop.run_until_complete(something(loop))

    Mais c’est parce que je ne comprends que ça.

    Pourquoi ce n’est pas possible en python d’avoir un truc similaire aux promises du JS ?

  • Stéphane

    Une proposition et uen correction :

    (que de toute façon personne ne soupçonne l’existence) -> (dont personne ne soupçonne de toute façon)

    un moyen de gérer tâches -> un moyen de gérer des tâches

  • Sam Post author

    Pas mal du tout. Après ça semble souffrir du même bug que mon POC au niveau des tâches attachées au scope parent.

    Mais c’est une bonne base pour rajouter les cancel de scopes, les timeout, les concurrency limits, les warnings des ensure_futures orphelins, etc. Keep it up.

  • Sam Post author

    @Linekio: on a les promesses du JS. On les appelle futures, c’est tout. C’est une API bien inférieure à async / await (on doit créer des callbacks et les attacher explicitement au lieu d’avoir un code impératif), et ça ne résout pas du tout le problème dont s’occupe les nurseries. Un callback dans une promise est un goto. Par ailleurs, la manière dont tu utilise asyncio ne fait que usage des threads, donc ça veut dire que tu ne profites pas du tout de l’infrastructure pour le reseau.

    Mais je comprends pourquoi. Si tu fais une recherche sur comment faire une requête HTTP avec javascript, la réponse est simple et direct. Si tu fais la même chose avec Python, soit tu as requests qui est bloquant, soit tu te lances dans asyncio qui est pas clair.

  • Iwan

    La librairie curio implémente le support asynchrone d’une meilleure façon AMHA. Il y a un point d’entrée: run(coroutine), tout le reste est implémenté avec await. Par exemple au lieu de gather, il y a une fonction spawn pour lancer une nouvelle tâche, donc:

    gather(coro1(), coro2())

    Devient:

    task1 = await spawn(coro1)

    task2 = await spawn(coro2)

    await task1

    await task2

  • Romain

    Du coups j’ai réouvert un vieux script à moi et je vois que j’avais fait des ensure_future de partout ! Je n’avais pas pigé que tu pouvais envoyer directement des coroutines à asyncio.gather (j’ai dû lire la doc les yeux fermés…).

    Bref, faire ce que tu veux proprement avec asyncio, c’est compliqué.

    Je suis d’accord avec toi que gather n’est pas suffisamment mis en avant dans la doc alors que c’est exactement la fonction que tu cherches quand tu veux jouer avec asyncio !

  • NicK

    “Tous les devs Python ne connaissent pas asyncio. Parmi ceux qui connaissent asyncio, une petite partie comprend comme ça marche.

    Dans ce lot rikiki, un pouillième sait que c’est la bonne pratique.”

    Ben oui… Faut avoir le besoin d’utiliser cette lib.
    Continue mon brave, je sors de mon ignorance pythonesque crasse grâce à toi.
    (ironie)(pas taper)

Comments are closed.

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