Licence CC BY-NC-NDThierry Parmentelat_images/inria-25-alpha.png

premiers exemples

La dernière fois nous avons vu des généralités sur la programmation asynchrone, et nous avons dit que la librairie asyncio était un bon candidat pour ça. Dans cette vidéo nous allons faire tourner quelques exemples pour illustrer les concepts de base.

Pour commencer bien entendu j’ai besoin d’importer le module asyncio

import asyncio

coroutine

Voyons d’abord la notion de coroutine; c’est quoi une coroutine ? à la base cela ressemble beaucoup à une fonction, mais dont on sait qu’elle va faire son traitement en plusieurs segments de code séquentiels.

En voici un exemple; on commence par définir la coroutine presque comme une fonction mais avec async def au lieu de simplement def

Cette coroutine est aussi simple que possible, elle fait un traitement en trois morceaux, qui impriment respectivement ‘debut’, ‘milieu’ et ‘fin’, et on simule des temps morts entre ces trois morceaux.

Il faut surtout remarquer la nouvelle directive await, qui nous permet d’indiquer les étapes du traitement où on sait qu’un délai est attendu - ici j’utilise la coroutine asyncio.sleep() qui nous permet d’attendre pendant un temps fixe; dans la vraie vie, on va plutôt attendre un événement, du genre que des données soient prêtes en lecture, on verra d’autres exemples tout à l’heure.

async def morceaux(message):

    # on appelle le code synchrone normalement
    print(message, "début")
    # avec await on rend la main
    await asyncio.sleep(0.5)

    print(message, "milieu")
    await asyncio.sleep(1)
    
    print(message, "fin")
    return f'{message} par morceaux'

coroutines

Avant de vous montrer ce qu’on peut en faire, tout d’abord regardons l’objet morceaux, il s’agit bien d’une fonction, jusque là tout va bien.

# la fonction coroutine
morceaux
<function __main__.morceaux(message)>

Si j’appelle cette fonction, comme on pourrait avoir envie de le faire

# retourne un objet coroutine
morceaux("run")
<coroutine object morceaux at 0x7f22aa48d840>

comme vous le voyez, il ne se passe rien de ce qu’on attendait, l’appel retourne immédiatement, et rien n’est imprimé.

Le résultat de cet appel est un objet de type coroutine; une des façons d’exploiter un objet de ce type est de l’inclure dans une boucle d’événements, comme ceci

# depuis un notebook, j'exécute un objet coroutine 
# avec l'instruction await
await morceaux('run')
run début
run milieu
run fin
'run par morceaux'

Note - cette façon de faire ne fonctionne que avec Jupyter ou IPython

Mais bon ça n’est pas crucial du tout, et pour ne pas tout embrouiller je suppose dans la suite que tout le monde utilise IPython ou Jupyter

plusieurs traitements

Naturellement avec une seule coroutine ça n’est pas très impressionnant. Voyons tout de suite ce que ça donne avec plusieurs traitements en parallèle.

Pour cela je vais utiliser une coroutine fournie par la librairie, qui s’appelle gather.

Elle est très importante, car elle prend en arguments plusieurs coroutines, et renvoie la liste des résultats. C’est donc ce qu’il nous faut pour exécuter deux instances de la coroutine morceaux en parallèle.

await     asyncio.gather(morceaux("run1"), morceaux("run2"))
run1 début
run2 début
run1 milieu
run2 milieu
run1 fin
run2 fin
['run1 par morceaux', 'run2 par morceaux']

Remarquez bien, quand j’ai évalué la cellule précédente, les deux segments de 3 morceaux sont bien exécutés en parallèle, c’est-à-dire plus ou moins en même temps, les deux début, puis les deux milieu, etc..

run1-run2

boucle d’événements

Pour faire le lien avec ce qu’on avait vu dans la première séquence, voici comment ça se passe en termes de chronologie.

Cachée derrière la scène, il y a une “boucle d’événements” - event loop - qui fait le “chef d’orchestre”; c’est en fait exactement comme le scheduler de l’OS, mais au lieu de donner la main à des threads, elle gère - disons pour simplifier à ce stade - des coroutines.

Ici en l’occurrence le job de la boucle d’événements c’est de faire avancer les deux coroutines. Au début naturellement, il n’est pas possible de savoir à coup sûr laquelle des deux coroutines sera appelée en premier, puisqu’elles sont censées se dérouler en même temps, on en choisit donc une au hasard. Ce qui est important, c’est qu’une fois qu’on est entré dans disons run1, cette coroutine ne va garder le processeur que jusqu’à ce qu’elle rende la main explicitement en faisant await

À ce stade, run1 est mise en standby - avec tout son contexte d’exécution - et le scheduler cherche ensuite une autre coroutine à qui donner la main. Dans notre cas il se trouve que run2 est prête, on l’exécute donc également, mais à nouveau jusqu’à rencontrer un ‘await’. À ce stade on a exécuté les deux débuts, pas tout à fait en même temps mais coup sur coup, et on a mis de coté les deux coroutines, avec leur état, prêtes à être réactivées.

Après le délai qui est codé en dur dans ce petit exemple, la boucle se rend compte que run1 devient prête à reprendre, on lui passe la main, puis à run2, etc. jusqu’à rencontrer un return; une fois que les coroutines sont toutes terminées, la coroutine gather est terminé également et la boucle d’événements nous rend la main.

Une première chose à remarquer, on l’a dit dans la séquence précédente, c’est que tout ceci est executé dans un seul thread, et donc aucun des blocs que j’ai dessinés ici ne peut être interrompu une fois qu’il a commencé.

Et ça c’est très important, parce que ça veut dire que je vais pouvoir me débarrasser de tout un tas de problèmes que j’aurais eus si j’avais utilisé des threads; quand on utilise plusieurs threads, c’est l’OS qui fait la commutation, du coup ça peut arriver n’importe quand, et ça crée des besoins d’accès exclusifs à certains objets; c’est toute une famille de problèmes qui sont beaucoup moins sévères avec asyncio.

ce qu’il ne faut pas faire

Mais ce que ça veut dire aussi, c’est qu’il ne faut pas garder le processeur trop longtemps sans rendre la main avec un await. Voyons ça sur une version légèrement différente de notre coroutine morceaux, que je renomme en famine parce qu’elle ne favorise pas un juste accès aux ressources pour tout le monde

elle fait presque comme morceaux, mais j’ai remplacé un await sleep(1) par un appel bloquant à time.sleep(1), qui est un appel synchrone. Du coup on n’a plus 3 petits fragments, mais 1 petit et un gros de 1 seconde.

import time 

async def famine(message):

    print(message, "début")
    # avec await on rend la main
    await asyncio.sleep(0.5)

    print(message, "milieu")
    # on garde la main au lieu de la rendre
    time.sleep(1)
    print(message, "fin")
    return f'{message} par famine'

famine en action

Si on fait tourner ça, on observe que les impressions ne sont plus aussi bien mélangées entre les deux coroutines

await asyncio.gather(famine("run1"), famine("run2"))
run1 début
run2 début
run1 milieu
run1 fin
run2 milieu
run2 fin
['run1 par famine', 'run2 par famine']

chronologie

dans cette version, chacune des deux coroutines garde la main entre les deux impressions de milieu et de fin

du coup lorsque run1 obtient la main pour imprimer son message milieu, elle la garde jusquà la fin de son travail,  et c'est seulement à ce moment-là que run2` pourra faire sa deuxième moitié

si bien qu’au lieu que le tout dure environ 1.5 secondes comme avec morceaux, ici avec famine le tout prend environ 2.5 s

famine

Dit comme ça c’est évident, mais dans la pratique ce type de comportement se produit plus souvent qu’on ne voudrait, ça n’est pas toujours facile de se rendre compte que c’est ce qui se passe, mais quand ça arrive ça a un gros impact sur la réactivité de votre application.

Donc il faut être en permanence en train de s’assurer qu’on rend la main dès qu’on peut. Vous pouvez toujours insérer une instruction comme await asyncio.sleep(0) où vous voulez pour faire respirer votre code.

conclusion

Pour récapituler cette séquence, on a vu que

. on écrit le code asynchrone sous forme de coroutines; on crée une fonction coroutine avec async def, et sinon le code se présente presque comme des fonctions - et donc qui peuvent implémenter n’importe quelle logique

. on peut appeler une coroutine depuis une autre coroutine grâce à await

. une coroutine peut bien entendu appeler une fonction traditionnelle, en faisant attention à ne pas bloquer trop longtemps

. on peut exécuter une ou plus généralement plusieurs coroutines en parallèle par l’intermédiaire d’une boucle d’événements




annexe : les 1000 façons de lancer du code asynchrone

Note !! pour y revenir plus tard en cas de souci

Comme on l’a vu plus haut, pour exécuter comme on l’a fait une coroutine depuis le toplevel avec await, cela ne marche que avec IPython et les notebooks

pour lancer du code asynchrone depuis le toplevel Python

par contre ça ne marchera pas tel quel dans l’interpréteur Python normal, vous obtiendriez ça

>>>
>>> await morceaux('depuis python')
  File "<stdin>", line 1
SyntaxError: 'await' outside function

pour pouvoir faire comme ça un await au toplevel dans l’interpréteur Python, vous devez lancer l’interpréteur avec l’option -m asyncio

$ python -m asyncio
asyncio REPL 3.9.1 (default, Dec 11 2020, 06:28:49)
[Clang 10.0.0 ] on darwin
Use "await" directly instead of "asyncio.run()".
Type "help", "copyright", "credits" or "license" for more information.
>>>
...
>>> await morceaux('avec python -m asyncio')
avec python -m asyncio début
avec python -m asyncio milieu
avec python -m asyncio fin
'avec python -m asyncio par morceaux'

il y a une autre façon de faire qui marche avec Python sans option, c’est en utilisant asyncio.run() :

$ python
Python 3.9.1 (default, Dec 11 2020, 06:28:49)
[Clang 10.0.0 ] :: Anaconda, Inc. on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import asyncio
...
>>> asyncio.run(morceaux('avec asyncio.run()')
... )
avec asyncio.run() début
avec asyncio.run() milieu
avec asyncio.run() fin
'avec asyncio.run() par morceaux'

enfin sachez qu’en fait ce qui se passe avec ces méthodes qui sont en fait des raccourcis, c’est quelque chose dans le genre de ceci

>>> loop = asyncio.new_event_loop()
>>> loop.run_until_complete(morceaux('avec run_until_complete'))
avec run_until_complete début
avec run_until_complete milieu
avec run_until_complete fin
'avec run_until_complete par morceaux'