Patrice Ferlet
Patrice Ferlet
Créateur de ce blog.
Publié le août 4, 2020 Temps de lecture: 15 min

Me suivre sur Mastodon

Démystifier Python Async

thumbnail for this post

C’est pas si évident qu’avec JS ou Go, mais c’est vraiment puissant et utile, et c’est pas si compliqué.

Python propose l’asynchronisme natif depuis la version 3.4. C’est à dire depuis 2013… et pourtant, malgré l’intérêt et la puissance de ce paradigme, on remarque que peu de développeurs Python s’en servent. Et je vais être honête, je en m’en suis pas beaucoup servi. Le fait est que Go propose un système de “goroutine1” qui abstrait énormément de contraintes alors que Python, malgré toute l’affection que j’ai pour ce langage, demande un peu plus de réflexion pour la gestion des tâches asynchrones.

La plupart des langages propose de quoi faire des tâches asynchrones, Javascript propose les “Promises”, Go propose ses “goroutines”, et j’en passe. Et tout le monde en est content. Si les développeurs Python utilise encore trop peu les coroutines c’est que quelque chose ne va pas, n’est-ce pas ?

Je vous partage ma théorie: c’est tout simplement mal expliqué.

Clairement, il y a quelques années, j’ai trouvé la documentation de asyncio austère, c’était obscure, ça donnait pas envie. Mais par la force des choses, j’ai insisté et j’ai très vite compris que c’était très puissant, utile, et au final absolument pas compliqué.

J’aurai aimé, à cette époque, qu’on me montre asyncio avec des exemples plus bruts - voilà donc ici comment j’aurais aimé qu’on me présente les choses.

TL;DR

Pour faire court:

import asyncio

async MachinTruc():
    # faire plein de chose
    # mais il faut avoir un appel
    # à await, au moins une fois
    await asyncio.sleep(0)


# conseil: avoir une fonction principale, asynchrone
async main():
    # démarre une coroutine
    await MachinTruc()

    # ou alors
    t = MachinTruc()
    await t

    # ou alors démarrer créer une tache
    # elle démarre "quasi immédiatement"
    task = asyncio.create_task(MachinTruc())

    # et l'attendre plus tard
    await task

    # ou alors attendre une liste de taches
    await asyncio.wait(liste_de_taches)

    # ou, pour avoir les résultats
    resultats = await asyncio.gather(liste_de_taches)


if __name__ == "__main__":
    # on crée une boucle d'évènements
    asyncio.run(main())

    # ou avec python < 3.7
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())

C’est tout ce qu’il faut savoir: await attend une coroutine ou une tache et permet à d’autres coroutines d’avoir une chance d’exécuter des trucs.

Maintenant, voilà le détail.

D’abord c’est quoi l’asynchronisme ? C’est un thread ?

Cette partie est utile seulement si vous n’avez jamais entendu parler de coroutine, d’asynchronisme, ni de “Promise”.

Ne tournons pas en rond: NON (enfin pas exactement, et zut on commence à s’embrouiller), une coroutine n’est pas un Thread.

Les tâches asynchrones permettent de faire comme si vous aviez des threads, sans pour autant avoir des tâche en parallèle. L’idée c’est que vos tâches sachent attendre pour laisser la place à d’autres tâches. Il faut “ordonnancer” ces tâches.

Cela impose quelques règles, dont une qui va vous poser un gros souci, mais qu’on saura résoudre par la suite: il ne faut pas qu’une tâche soit bloquante (et si c’est le cas, pas de panique je vais vous donner des solutions)

“Bloquer” ça veut dire prendre le CPU pour soi et ne pas permettre à quelqu’un d’autre de l’utiliser. C’est là où les Threads sont très différents dans le fonctionnement. Eux vont savoir gérer une tâche bloquant le CPU. Mais pour cela, les Thread utilisent une “commutation de contexte” (context switch) qui demande à l’OS de deplacer régulièrement un processus pour qu’un coeur de CPU puisse gérer toutes les tâches.

Les Threads ont donc moins de contraintes pour le développeur, mais si vous en avez des miliers à gérer, cela peut être très brutal en terme de resources.

Bref, les coroutines, asynchrones, sont éxécutées dans un seul “process”, et vont simplement se partager du temps.

Dans énormément de cas, vous aurez l’impression de faire du parallélisme, gagner en temps, et en plus éviter de défoncer votre CPU et la RAM. Mais dans les faits, c’est surtout que vous allez faire en sorte qu’une tâche “pas prête” saura attendre en laissant la main à d’autres tâches de faire leur cuisine.

Les coroutine, et l’asynchronisme, vous les avez certainement utilisé dans d’autres langages. Par exemple les “promesses” en Javascript, ou les “goroutines” dans Go. Notez toutefois que Go a un avantage certain, c’est qu’il sait démarrer un Thread si votre tâche est bloquante…

await - on en parle de suite

Je préfère commencer par un truc qui est généralement expliqué après coup. Ne sautez pas cette section - même si vous avez une idée de ce à quoi ça sert, parce que bien souvent c’est cette notion qui fait défaut et qui empêche son utilisation.

Python propose plusieurs types qui permettent l’asynchronisme, ces types sont dit “Awaitable” (qu’on peut attendre) c’est-à-dire qu’ils peuvent entrer dans un gestionnaire d’évènements (on en parle après), et qu’on doit les attendre. Le mot clef await est justement fait pour ça, mais pas seulement.

D’une part, oui, l’idée est de pouvoir attendre qu’une fonction réponde, mais en quoi ce serait différent d’appeler une fonction non asynchrone? Et bien c’est là qu’on zappe un principe très important de ce que fait ce fameux await.

await permet aussi, et surtout, de dire à Python qu’on peut aller voir si une autre coroutine en attente peut continuer de travailler.

En d’autres termes, c’est quand Python rencontre le mot clef “await” qu’il va donner une chance à une autre coroutine de continuer son travail. En soit, await c’est le checkpoint, l’étape.

Mais c’est quoi une coroutine ?

Créons une function qui prend du temps à répondre. Dans cette fonction, nous simulons un traitement long en utilisant asyncio.sleep(). Nous ne pouvons pas utiliser time.sleep() parce que, comme je vous l’ai dit auparavent, il faut appeler await pour que Python puisse laisser les autres coroutines s’exécuter periodiquement.

import asyncio
import random
import time  # on s'en servira après

# async => la fonction est une coroutine
async def bigWork(i):
    print('Big work %d starts' % i)
    delay = random.uniform(0, 1.5)
    # surtout n'utilisez pas time.sleep()
    # pour rappel, il faut avoir un appel à await pour que
    # la coroutine bigWork puisse laisser une chance aux autres
    # de tourner
    await asyncio.sleep(delay)
    print('Big work %d ends after %.2f seconds' % (i, delay))

Cette fonction est déclarée “asynchrone” via le mot clef async, cela veut dire qu’elle pourra être ordonnancé, c’est une “coroutine”.

Voyons ce qu’il se passe si on appelle la fonction bigWork() (ici, dans iPython):

>>> bigWork(1)
<coroutine object bigWork at 0x7f1814622440>

Ha… ça n’a rien lancé ! Mais à la place, j’ai reçu une “coroutine”, un objet.

C’est la première chose à retenir: une fonction asynchrone, on ne l’appelle pas à l’arrache, on doit l’ordonnancer ou l’attendre…

Le souci, c’est que si je veux l’attendre, il faut que j’appelle la fonction avec await et ça doit être fait dans une coroutine:

  File "/tmp/a.py", line 11
    await bigWork(1)
    ^
SyntaxError: 'await' outside function

Alors je sais ce qui commence à vous prendre la tête (et c’est tout à fait normal): si je dois attendre une coroutine au sein d’une coroutine, qu’il faut attendre aussi, on tourne en rond là.

La réponse est moins évidente qu’en JS ou en Go, Python demande une “boucle d’évènements” qu’on appelle aussi un “ordonnancement”.

Un ordonnancement, c’est un gestionnaire de tâches qui va faire tourner des “trucs” et les orchestrer. Quand une tâche peut attendre, on passe à une autre, et quand l’autre a fini, on regarde si une autre peut répondre et ainsi de suite. Et si vous avez suivi, c’est quand on rencontrera le mot await que l’ordonnancement passera d’une routine à l’autre.

Cet ordonnancement est très simple à démarrer, asyncio.run prend en argument une coroutine, et il attend qu’elle soit finie. Si d’autres coroutines sont démarrées par cette coroutine, il attendra aussi. Il va gérer toutes les coroutines au sein d’un processus.

# si vous travaillez avec Python >= 3.7
asyncio.run(bigWork(1))

# sinon...
loop = asyncio.get_event_loop()
loop.run_until_complete(bigWork(1))

Tout ce que dit cette (ou ces) ligne(s) c’est: “Donne moi un gestionnaire de coroutine, et démarre bigWork(1)”.

Là, pour le moment, on a rien fait de très intéressant. On démarre une coroutine et on l’attend, ça ne change pas grand chose à démarrer une fonction non-asynchrone, pire c’est même plus compliqué à coder.

Il est temps d’aller plus loin

Allons de l’avant, et passons à ce qui nous intéresse: avoir plein de coroutines en “parallèle” (oui, en concurrence…). Nous allons crére une fonction générale qui va lancer plein de tâches, et cette fonction sera donc celle qui sera attendu par la boucle d’évènements. Ce sera plus simple pour la suite.

async def main():
    # on va lancer 5 coroutines
    for i in range(5):
        await bigWork(i)

# et ici je chronomètre l'ensemble
start = time.time()
asyncio.run(main())  # on démarre ici
end = time.time() - start
print('Total time: %.2f' % end)

Encore une fois (je vous tease un peu hein), pas trop d’intérêt mais vous voyez que je peux lancer plein de tâches les unes après les autres, et quand tout est terminé alors mon script s’arrête:

$ python /tmp/a.py
Big work 0 starts
Big work 0 ends after 0.57 seconds
Big work 1 starts
Big work 1 ends after 0.42 seconds
Big work 2 starts
Big work 2 ends after 0.22 seconds
Big work 3 starts
Big work 3 ends after 0.34 seconds
Big work 4 starts
Big work 4 ends after 0.46 seconds
Total time: 2.05

Mais, on est d’accord, le temps total correspond plus ou moins à la somme des temps des coroutine. Elles ont démarré dans l’ordre et aucune coroutine ne démarre avant que l’autre ait terminé.

Faisons une pause deux secondes avant de continuer. Dans l’ensemble, si vous aviez d’autres coroutines dans votre code, alors votre script est très bien écrit. Chaque itération de la boucle for fait un await et permet donc à une autre coroutine de tourner. Mais, dans notre exemple, nous voulons faire tourner nos coroutines bigWork en concurrence. Donc, effectivement, à partir de maintenant, on va chercher à étudier des fonctions qui permettent d’aller plus loin, mais dans vos développements vous pouvez déjà estimer que ce bout de code est viable.

Reprenons, nous voulons maintenant faire en sorte que toues les coroutines bigWork tournent en concurrence.

Rappelez vous maintenant ce qu’il s’est passé quand on a appelé bigWork() sans utliser await. Vous vous souvenez que la fonction n’est pas appelée mais que nous avions une “coroutine” en retour. Et bien cherchons à les stocker puis à les attendre en même temps, et ce avec une des méthodes proposées par le module asyncio, par exemple wait():

async def main():
    tasks = []
    for i in range(5):
        tasks.append(bigWork(i))

    # on attend que la liste
    # de coroutines soit terminée
    await asyncio.wait(tasks)

Et maintenant, tout se passe comme on le veut:

$ python /tmp/a.py
Big work 1 starts
Big work 2 starts
Big work 3 starts
Big work 0 starts
Big work 4 starts
Big work 1 ends after 0.20 seconds
Big work 3 ends after 0.30 seconds
Big work 0 ends after 1.08 seconds
Big work 2 ends after 1.32 seconds
Big work 4 ends after 1.46 seconds
Total time: 1.47

Le temps total a pris à peine plus de temps que la tâche la plus longue. Et là c’est ce qu’on attendait !

Remarquez aussi que les tâches n’ont pas été démarrées dans l’ordre, ça peut paraitre étonnant mais c’est aussi un effet de l’asynchronisme, les tâches sont ordonnancées de manière non déterministe (on ne peut pas prévoir).

Task et Coroutine

Parlons maintenant d’un piège, celui de vouloir créer des coroutines et de les attendre via await sans passer pas asyncio.wait:

async def main():
    tasks = []
    for i in range(5):
        tasks.append(bigWork(i))

    for t in tasks:
        await t

C’est une erreur logique, mais si vous lancez votre script, vous allez vous retrouver dans le même état que lorsque vous faisiez await biWork(i).

Mais pourquoi ? Comment fait asyncio.wait() pour s’en sortir ?

C’est à ce moment qu’il faut parler des Task

Un coroutine est un objet qui représente une fonction asynchrone, mais pour qu’elle soit exécutée il faut l’encapsuler dans une tâche (Task). Sauf que Python le fait pour vous, de manière transparente, quand vous utilisez await (si la coroutine n’est pas encapsulée). Quant à asyncio.wait(), ainsi que d’autres fonctions de ce module, il va aussi le faire pur vous.

Alors comment faire ça manuellement ? Comment démarrer une coroutine tout de suite, et les attendre après coup ?

async def main():
    tasks = []
    for i in range(5):
        # je découpe tout pour vous montrer les étapes
        coroutine = bigWork(i)
        task = asyncio.create_task(coroutine)
        tasks.append(task)

    for t in tasks:
        await t

Le principe est donc d’avoir une coroutine, et de l’encapsuler avec create_task, cela à pour effet de démarrer tout de suite la tâche. Le mot clef await voit bien que vous avez non plus une coroutine mais une tâche (qui est Awaitable) et va juste attendre qu’elle soit terminée.

Effectivement, on aura plutôt tendance à faire asyncio.create_task(bigWork(i)) mais je vous ai découpé les opérations pour que ce soit bien clair.

Bon, si vous lancez votre script, on revient à ce que fait asyncio.wait(). Les tâches sont ordonnancées, tout va bien.

Et quand ça bloque ?

Nous y voilà… Le souci est que vous ne maitrisez pas forcément tout le code, vous allez utiliser des packages externes, ou des anciens développements, et bien souvent cet historique de travail ne contient pas de code asynchrone. Alors, on s’en sort comment… ?

Prenons notre code bigWork et rendons le synchrone:

  • en supprimant async
  • en remplacant asyncio.sleep par time.sleep
# synchrone...
def bigWork(i):
    print('Big work %d starts' % i)
    delay = random.uniform(0, 1.5)
    time.sleep(delay)  # là ça va bloquer
    print('Big work %d ends after %.2f seconds' % (i, delay))

Impossible d’utiliser “await” car la fonction n’est pas déclarée aysnchrone, et en plus time.sleep n’est pas awaitable donc elle va bloquer le processus.

Heureusement, asyncio va nous permettre de nous en sortir malgré tout. L’idée est d’utiliser a boucle d’évènements et de lui demander de gérer des Thread (oui… des threads) qu’on saura attendre.

Il y a plusieurs manière de faire, la première est de demander à la boucle d’évèenements de prendre en charge un Thread:

# on récupère la boucle
loop = asyncio.get_event_loop()

# None => on voit ça après, mais disons
# que là, on ne lui donne pas, donc il se débrouille...
loop.run_in_exector(None, function)

function correspond à la fonction à ajouter à l’exécution, par exemple bigWork, donc sans argument… Sauf que voilà, nous avons un argument à donner. Pour se sortir de l’impasse, on va utiliser un partial qui vient du module functools:

# un partial est une fonction
# partielle, on lui fourni les arguments
# en liste...
coroutine = functools.partial(bigWork, i)
# et au final:
loop.run_in_exector(None, coroutine)

run_in_exector retourne une task, et on va pouvoir l’atendre de la même manière qu’avant:

async def main():
    tasks = []
    loop = asyncio.get_event_loop()
    for i in range(5):
        coroutine = functools.partial(bigWork, i)
        task = loop.run_in_executor(None, coroutine)
        tasks.append(task)

    await asyncio.wait(tasks)

Ce qui va nous donner:

$ python a.py
Big work 0 starts
Big work 1 starts
Big work 2 starts
Big work 3 starts
Big work 4 starts
Big work 1 ends after 0.35 seconds
Big work 4 ends after 0.56 seconds
Big work 2 ends after 0.70 seconds
Big work 0 ends after 1.16 seconds
Big work 3 ends after 1.23 seconds
Total time: 1.23

Magnifique, ça fonctionne (c’était prévu…)

Cela veut dire que si vous avez des fonctions non asynchrones, il est possible d’utiliser la boucle d’évènements pour exécuter des Threads.

Une autre manière de faire est d’utiliser un pool, encore une fois via asyncio pour utiliser la boucle d’évènements. Le package natif concurrent.futures propose un système de Pool de Thread ou de Process. Voici comment faire:

async def main():
    tasks = []
    loop = asyncio.get_event_loop()
    with futures.ThreadPoolExecutor() as pool:
        for i in range(5):
            coroutine = functools.partial(bigWork, i)
            # et cette fois, on lui donne l'exécutor,
            # donc pas None, mais le pool
            t = loop.run_in_executor(pool, coroutine)
            tasks.append(t)

    await asyncio.wait(tasks)

Gros intérêt, on peut limiter le nombre de Threads (ou de Process en utilisant ProcessPoolExecutor) via l’argument max_workers. C’est une excellente manière de ne pas surcharger vos CPU avec des miliers de Threads à switcher.

On résume

Donc, pour faire simple:

  • Une fonction asynchrone doit être déclarée avec le mot cle async
  • Elle doit attendre un truc, même un await asyncio.sleep(0) suffit, mais il faut attendre avec await, sinon ça bloque les autres coroutines
  • Vous devez faire passer une fonction à asyncio.run() pour gérer une boucles d’évènements
  • Vous pouvez ordonnancer des fonctions non asynchrones, via des Thread ou Process, en utilisant un executor

Notez aussi que:

  • asyncio.wait est annulable, alors que asyncio.gather vous retourne une liste de résultats des fonctions asynchrones
  • asyncio.create_task(coroutine) envoloppe une coroutine pour avoir une tâche, une tâche démarre au plus tôt, alors qu’une coroutine doit être attendu pour démarrer.

Conclusion

Certes, Python demande un peu plus de boulot qu’avec Go ou JS pour démarrer une coroutine, surtout avec des fonctions bloquantes (que Go sait tellement bien gérer), mais après quelques heures vous allez avoir beaucoup moins de mal à gérer des coroutines.

Notez que le module asyncio propose des équivalents à pas mal de fonctions que vous utlisiez, par exemple:

  • asyncio.sleep au lieu de time.sleep
  • asyncio.create_subprocess_exec au lieu de suprocesss.Process
  • asyncio.Queue au lieu de queue.Queue
  • et j’en passe.

Allez voir la page de documentation asyncio qui est une ressource de connaissances exemplaire, et prenez le temps de bien lire la page d’api qui est assez claire.

L’avantage de asyncio est de limiter l’utilisation de Queue, des context switch CPU, et de gérer des tâches en concurrence alors que le parallélisme est souvent inutile. Vous allez limiter la charge OS en ayant un seul processus tout en ayant une gestion de tâches concurrente qui va accélerer les traitements. À terme, les Threads seront votre solution de recours, et non pas la solution initiale.

L’idée n’étant pas de dénigrer les Threads et les sous-processus, mais de les utiliser que dans le cas où ils sont vraiment utiles.

En espérant que mon article vous ait servit.


  1. non je n’ai pas fait une faute de frappe, c’est bien avec un “g”, les goroutines sont des coroutines spécifiques à Go. ↩︎

comments powered by Disqus