Coroutines avec asyncio en Python

Ce billet date de plusieurs années, ses informations peuvent être devenues obsolètes.

Ce billet est le dernier d'une série en quatre volets. Voir le volet 1, le volet 2 ou le volet 3.

Entrées/sorties asynchrones

En informatique on dit d'une d'une tâche qu'elle est liée au CPU (CPU-bound) lorsque son temps d'exécution est déterminé par la vitesse du processeur. Lorsque sa durée d'exécution est déterminé par le temps passé à attendre la fin d'opérations de type entrées/sorties (accès au système de fichier, connexion réseau, etc.), on dit qu'elle est liée aux entrées/sorties (I/O bound). Le temps d'exécution des opérations I/O bound est souvent imprévisible.

Avec un modèle synchrone de traitement des E/S, un fil d'exécution démarre une opération et se bloque immédiatement en état d'attente jusqu'à sa fin. On ne peut travailler qu’avec un seul descripteur de fichier à la fois dans un même processus ou thread et on doit attendre la fin de l'opération pour faire autre chose.

Le terme "E/S asynchrones", dans le cadre d'un modèle de programmation, décrit un ensemble de techniques permettant d'éviter le blocage d'un programme lorsqu'on a beaucoup d'opérations liées aux E/S. Dans le cadre de la programmation réseau on parle aussi d'I/O Multiplexing. L'idée est de gérer un ensemble d’événements de façon concurrente.

On peut par exemple tenter d'implémenter la concurrence par les threads mais cela peut poser différents types de problèmes, sans même parler du GIL. Une autre technique consiste à demander au kernel des notifications d’E/S via les fonctions select, poll, epoll, kqueue ou IOCP en fonction du type de système d'exploitation.

Ces fonctions sont à la base de l'event loop d'asyncio mais aussi de celle de Node.js (par l'intermédiaire de libuv).

Introduction à asyncio

Le module asyncio (autrefois Tulip) introduit par la PEP 3156 est inclus dans la librairie standard de Python depuis la version 3.4.

L'idée générale est de pouvoir démarrer des opérations non bloquantes de façon concurrente, puis de faire de la scrutation afin de déterminer leurs états et finalement d'exécuter des callbacks quand elles sont prêtes.

Pour cela, asyncio dispose de deux éléments fondamentaux : une event loop et la classe Future.

L'event loop est en charge de traiter les notifications d'E/S par l'intermédiaire du meilleur mécanisme disponible sur chaque système et de planifier les callbacks, le tout dans un seul thread (politique par défaut d'asyncio).

La classe Future permet d'exprimer une opération asynchrone qui aura un résultat ultérieur :

On peut aussi voir une instance de Future comme un objet pouvant circuler lorsqu'on a besoin de démarrer une tâche à un endroit et de la finir ailleurs.

On peut écrire un programme asyncio sur la base de ces deux blocs uniquement avec des callbacks. Voir ci-dessous l'exemple de la documentation d'Autobahn qui simule un appel non bloquant lent :

import asyncio

def slow_square(x):
   f = asyncio.Future()

   def resolve():
      f.set_result(x * x)

   loop = asyncio.get_event_loop()
   loop.call_later(1, resolve)
   return f

def test():
   f = slow_square(3)

   def done(f):
      res = f.result()
      print(res)

   f.add_done_callback(done)
   return f

loop = asyncio.get_event_loop()
loop.run_until_complete(test())
loop.close()

Coroutines en Python 3.4

Or les callbacks n'ont jamais été la tasse de thé de Guido qui les considère plutôt comme un problème : lecture non séquentielle, callback hell, stack ripping (perte du contexte dans la trace d'appels) etc.

En conséquence le module asyncio permet d'écrire du code asynchrone avec l'event loop et des Futures comme si c'était du code séquentiel grâce à deux autres composants : les coroutines et la classe Task.

Les coroutines (en Python 3.4) sont des générateurs qui utilisent le décorateur @asyncio.coroutine et yield from ("an incredibly cool, but also brain-exploding thing"). Il faut penser à yield from comme une instruction qui suspend la coroutine pendant l'attente d'un résultat de façon à ce que l'event loop soit capable de faire autre chose pendant ce temps.

Une coroutine peut coopérer :

  • avec un objet Future en suspendant son activité jusqu'à ce qu'un résultat soit connu
  • avec une autre coroutine en attendant que l'autre coroutine produise un résultat

À la fin de la chaîne de coopération il y aura une coroutine qui retournera un résultat ou lèvera une exception.

Puisqu'une coroutine est un générateur (d'où mes trois billets précédents), on a besoin d'un mécanisme permettant de démarrer, puis d'itérer en interaction avec l'event loop : c'est le travail d'une Task. On peut comparer un objet Task à un pilote (driver) de coroutine. Techniquement la classe Task dérive de Future et enveloppe une coroutine.

La création d'une Task démarre l'itération sur la coroutine qu'elle enveloppe :

  • si le résultat de l'itération est un objet Future ça veut dire que la coroutine est en pause en attente du résultat d'un travail asynchrone; l'objet Future est capturé par l'objet Task qui programme un callback à exécuter quand le résultat sera connu et qui permettra de poursuivre la coroutine
  • si le résultat est une exception StopIteration associée à une valeur de retour, le résultat de l'objet Task est défini avec set_result (car Task dérive de Future)
  • si le résultat est une autre exception, l'objet Task programme un callback qui lèvera l'exception dans la coroutine

La création d'une Task est implicite et se fait sous le capot via ensure_future() ou BaseEventLoop.create_task(coro).

Ci-dessous l'exemple de la documentation d'Autobahn qui cette fois utilise les coroutines et les tâches sous-jacentes pour réécrire l'exemple précédent qui se lit désormais beaucoup plus facilement :

import asyncio

@asyncio.coroutine
def slow_square(x):
   yield from asyncio.sleep(1)
   return x * x

@asyncio.coroutine
def test():
   res = yield from slow_square(3)
   print(res)

loop = asyncio.get_event_loop()
loop.run_until_complete(test())

Coroutines natives en Python 3.5

À partir de Python 3.5 et suite à la PEP 492, il existe une implémentation native des coroutines avec async et await, complètement décorrélée des générateurs. Son but est de rendre le modèle mental de la programmation asynchrone aussi proche que possible de celui de la programmation synchrone.

Elle vient palier un certain nombre de lacunes liées à l'utilisation des générateurs en tant que coroutines :

  • il est facile de confondre une coroutine et un générateur car ils utilisent la même syntaxe
  • une coroutine est identifiée au niveau syntaxique par la présence de yield ou yield from dans le corps d'une fonction, ce qui peut conduire à des erreurs peu évidentes notamment lors de leur apparition ou disparition pendant un réusinage de code
  • les appels asynchrones sont limités aux expressions dans lesquelles yield est autorisé syntaxiquement, ce qui limite l'utilisation d'autres syntaxes comme with ou for pour des operations asynchrones

À l'intérieur d'une fonction coroutine native, l'expression await peut être utilisée pour suspendre l'exécution de la coroutine jusqu'à l'obtention du résultat d'un objet awaitable.

Par ailleurs la nouvelle syntaxe native des coroutines rend possible la définition de context managers et de protocoles d'itération en mode asynchrone avec async with et async for.

Voici pour finir l'exemple précédent implémenté avec des coroutines natives :

import asyncio

async def slow_square(x):
   await asyncio.sleep(1)
   return x * x

async def test():
   res = await slow_square(3)
   print(res)

loop = asyncio.get_event_loop()
loop.run_until_complete(test())

Conclusion

Voilà qui termine cette introduction à asyncio et ma série de billets par la même occasion. Il y a encore beaucoup de choses à dire, par exemple sur l'API des transports et des protocoles, sur l'API des streams ou sur la possibilité d'exécuter du code bloquant. Mais je vais me laisser un peu de temps pour digérer ça d'abord :)

L'ironie du sort, peut-être, c'est que les coroutines natives n'implémentent ni __iter__, ni __next__ :)

Ressources

Avant Coroutines via générateurs améliorés en Python Après Notes sur les types et la grammaire de JavaScript

Tag Kemar Joint