Asynchronisme
La programmation asynchrone consiste à écrire une application (multi-tâche) capable de gérer un ensemble de tâches progressant de concert, à leur propre rythme. C’est du multi-tâche coopératif. Le but, pour l’application, est de minimiser le temps passé à ne rien faire. Les tâches sont des instances de co-routines (une co-routine est une fonction pouvant s’exécuter par morceaux).
- Asynchronisme en Python
-
L’asynchronisme est implémenté nativement en Python à partir de la version 3.4, avec l’introduction de 2 nouveaux mot-clés :
awaitetasync. A la suite de quoi le moduleasyncioest apparu avec Python 3.6, a tâtonné avant de se stabiliser avec Python 3.9.asyncioest une boite à outils (parmi d’autres) permettant de faire de l’asynchronisme. Ce document correspond à ce que propose Python depuis la version 3.11.
1. Fonction coroutine
C’est une fonction qui renvoie un objet coroutine.
C’est à rapprocher de la fonction génératrice vue dans un chapitre précédent,
qui renvoie un générateur.
Syntaxiquement, définir une fonction coroutine est identique à définir une fonction,
en remplaçant def par async def :
import time
async def say(n,what):
for i in range(n):
time.sleep(0.1)
print(what, end=" ", flush=True)
L’appeler n’exécute pas ses instructions :
say(5,"hello") # <coroutine object say at 0x7f38f7793790>
L’objet coroutine renvoyé est un callable susceptible de s’exécuter par parties.
| Pour simplifier dans la suite, on ne fera pas de différence entre la fonction coroutine et l’objet coroutine, et on parlera simplement de coroutine. |
2. Exécuter une coroutine
Dans un fonctionnement normal, et contrairement à une fonction, une coroutine ne s’appelle pas directement : l’exécution d’une coroutine se planifie. Une coroutine planifiée pour exécution sera alors exécutée en temps utile par un chef d’orchestre (appelé scheduler ou boucle d’évènement) pour donner naissance à une tâche.
2.1. Exécution unitaire
Malgré tout, pour exécuter immédiatement une coroutine
à des fins de mise au point par exemple,
on peut passer par la fonction asyncio.run :
import asyncio
...
asyncio.run(say(3,"+"))
Mais exécuter de la sorte plusieurs coroutines ne donne pas ce que l’on souhaite :
asyncio.run(say(2,"-"))
asyncio.run(say(3,"|"))
say(2,"-") est totalement exécutée, puis ensuite de même pour say(3,"|").
Donc pas de concurrence ici.
2.2. Exécution depuis une autre coroutine
Une coroutine (et seule une coroutine) peut exécuter une autre coroutine.
Cela s’exprime avec le mot-clé await :
async def repeat(n,l):
for c in l:
print("avant")
await say(n,c) (1)
print("\naprès")
| 1 | Cette instruction exécute et attend le retour de la tâche say(n,c).
En détail, cette instruction :
|
repeat peut être tester avec :
asyncio.run(repeat(10,["un","deux","trois"]))
3. Le multi-tâche
L’intérêt des coroutines se révèle lorsqu’on en exécute plusieurs en concurrence : on planifie leur exécution, ce qui donne des tâches (1 exécution=1 tâche), puis on attend qu’elles se terminent toutes. Pendant ce labs de temps, les tâches progressent de concert, à leur propre rythme.
Concrèrement, on peut réaliser cela de 2 façons :
-
soit créer séquentiellement les tâches dans un context manager
async with asyncio.TaskGroup() -
soit rassembler les tâches via la fonction
asyncio.gather()
3.1. Créer explicitement les tâches (via create_task())
C’est une première façon de faire,
consistant à créer les tâches, dans un context manager,
via create_task() :
async def main():
async with asyncio.TaskGroup() as tg:
task1 = tg.create_task(say(6, '-'))
task2 = tg.create_task(say(9, '*'))
print(f"début : {time.strftime('%X')}")
(1)
print(f"\nfin : {time.strftime('%X')}")
asyncio.run(main())
| 1 | on attend ici que toutes les tâches créées se terminent avant de poursuivre.
Le fonctionnement est le suivant :
|
4. Coopération
Les 2 exemples ci-dessus ne montrent aucun entrelacement dans l’exécution des tâches.
Pour y parvenir, les tâches doivent coopérer,
en redonnant la main de temps en temps au scheduler (cf multitâche coopératif).
Cela passe par l’utilisation de coroutines prédéfinies (native coroutines telles que asyncio.sleep),
qui interagissent avec la boucle d’évènement.
4.1. asyncio.sleep()
La coroutine asyncio.sleep() réalise une attente non bloquante
(contrairement à la fonction time.sleep()).
Par exemple, l’instruction await asyncio.sleep(10) planifie une temporisation de 10 secondes,
et redonne la main au scheduler qui va en profiter pour faire avancer d’autres tâches suspendues.
En d’autres termes, asyncio.sleep(10) suspend la tâche courante pour 10 secondes (dans le meilleur des cas, plus sinon),
Pendant ce temps, la boucle d’évènements prend le contrôle et s’occupe de faire progresser les tâches suspendues.
4.2. asyncio.sleep() vs time.sleep()
Les 2 exemples ci-dessous illustrent la différence entre time.sleep et asyncio.sleep :
import time,asyncio
msg = f"time.sleep(1)"
starttime = time.time()
async def tsleep(i):
print(f"tâche {i}: exécute {msg:20} {time.time()-starttime:.4f}s écoulées depuis le début du programme")
time.sleep(1)
print(f"tâche {i}: retour de {msg:18} {time.time()-starttime:.4f}s écoulées depuis le début du programme")
async def main():
await asyncio.gather(tsleep(1),tsleep(2),tsleep(3))
asyncio.run(main())
donne :
tâche 1: exécute time.sleep(1) 0.0002s écoulées depuis le début du programme tâche 1: retour de time.sleep(1) 1.0005s écoulées depuis le début du programme tâche 2: exécute time.sleep(1) 1.0006s écoulées depuis le début du programme tâche 2: retour de time.sleep(1) 2.0007s écoulées depuis le début du programme tâche 3: exécute time.sleep(1) 2.0007s écoulées depuis le début du programme tâche 3: retour de time.sleep(1) 3.0010s écoulées depuis le début du programme
La coroutine tsleep ne rend jamais la main, elle s’exécute sans coopérer, comme le ferait une fonction.
Les 3 tâches s’exécutent donc séquentiellement.
Par contre :
import time,asyncio
msg = f"asyncio.sleep(1)²"
starttime = time.time()
async def asleep(i):
print(f"tâche {i}: exécute {msg:20} {time.time()-starttime:.4f}s écoulées depuis le début du programme")
await asyncio.sleep(1)
print(f"tâche {i}: retour de {msg:18} {time.time()-starttime:.4f}s écoulées depuis le début du programme")
async def main():
await asyncio.gather(asleep(1),asleep(2),asleep(3))
asyncio.run(main())
f donne :
tâche 1: exécute asyncio.sleep(1) 0.0002s écoulées depuis le début du programme tâche 2: exécute asyncio.sleep(1) 0.0002s écoulées depuis le début du programme tâche 3: exécute asyncio.sleep(1) 0.0002s écoulées depuis le début du programme tâche 1: retour de asyncio.sleep(1) 1.0014s écoulées depuis le début du programme tâche 2: retour de asyncio.sleep(1) 1.0014s écoulées depuis le début du programme tâche 3: retour de asyncio.sleep(1) 1.0014s écoulées depuis le début du programme
Ici la coroutine asleep, quand à elle, coopère en rendant la main au scheduler via asyncio.sleep().
Les 3 tâches progressent donc de concert.
await asyncio.sleep(0) est un moyen de passer la main,
sans délai, à la boucle d’évènement,
donc de coopérer avec les autres tâches concurrentes.
|