Autour du codeDevelopper toujours mieux
Posté le

Faire de l'asynchrone avec python

asyncio est un module disponible dans python 3.4 qui permet de développer des applications asynchrones avec des entrées-sorties non-bloquantes.

La boucle d'évènement

Toutes applications développées avec asyncio va utiliser une boucle d'évènement qui est un objet de type BaseEventLoop. C'est dans cette boucle que seront placées toute les tâches en cours d'exécution. Pour obtenir une boucle d'évènement, on va utiliser la fonction get_event_loop() puis on va appeler la méthode run_forever() pour la lancer. Voici un exemple qui ne fait rien d'autre que de lancer la boucle est la referme dès que l'utilisatrice fait un ctrl-c

import asyncio

loop = asyncio.get_event_loop()
try:
    loop.run_forever()
except KeyboardInterrupt:
    loop.close()

C'est palpitant wink

Ok voyons comment lancer une tâche, BaseEventLoop propose deux méthodes pour lancer directement un callable call_soon qui lance une callback dés que possible et call_later qui la lance au bout d'un certain nombre de secondes. call_later prend en premier paramètre un nombre de secondes et en deuxième n'importe quel objet appelable. Si l'appelable prend des paramètre, il peuvent être passé à la suite à call_later. Voici un exemple qui affiche Hello world au bout de deux secondes

import asyncio

loop = asyncio.get_event_loop()
loop.call_later(2, print, 'Hello')
loop.call_later(2, print, 'World')
try:
    loop.run_forever()
except KeyboardInterrupt:
    loop.close()

Vous remarquerez que Hello et world sont affiché au même moment, l'appel de call_soon ou call_later ne sont pas bloquant.

Lorsque vous utiliserez asyncio, il ne faudra jamais utiliser des fonctions bloquantes, on va tout de suite voir pourquoi. Le code ci-dessous contient une fonction now() dont le seul but est de retourner l'heure joliment formatée puis une fonction qui va bloquer pendant 2 secondes avant de finir de s'exécuter en affichant l'heure à laquelle elle finis.

loop = asyncio.get_event_loop()

def now():
    return "{:%H:%M:%S}".format(datetime.today())

def syncro_func():
    sleep(2)
    print(now(), "Done")

print(now(), "loop.call_soon")
loop.call_soon(syncro_func)
loop.call_soon(syncro_func)
print(now(), "loop.call_soon")

try:
    loop.run_forever()
except KeyboardInterrupt:
    loop.close()

Voici ce que ce code affiche:

23:32:21 loop.call_soon
23:32:21 loop.call_soon
23:32:23 Done
23:32:25 Done

Comme vous pouvez le voir il n'y a pas blocage entre les différents appels de call_soon par contre le premier appel de syncro_func a bloqué la boucle d'événement pendant 2 secondes.

Un client HTTP asynchrone

Avant de faire un client, il va nous falloir un serveur, voici un petit serveur fait avec CherryPy Il propose 3 urls, http://127.0.0.1:8080/index qui affiche Helo world http://127.0.0.1:8080/random qui retourne 0, 1 ou 2 http://127.0.0.1:8080/smile/N où N vaut 0, 1 ou 2 et qui affiche un smile

Ce serveur met volontairement 2 secondes à répondre afin que l'on puisse voir ce qui ce passe si l'on envoie plusieurs requêtes.

import cherrypy
from time import sleep
from random import randint

class HelloWorld(object):

    @cherrypy.expose
    def index(self):
        sleep(2)
        return "Hello World!"

    @cherrypy.expose
    def smile(self, n):
        sleep(2)
        return (':-)', ';-)', ':-D')[int(n)]

    @cherrypy.expose
    def random(self):
        sleep(2)
        return str(randint(0, 2))

cherrypy.quickstart(HelloWorld())

Les fonctions non-blocantes que propose asyncio sont en réalité des fonctions qui retournent des coroutines. Pour utiliser ces fonctions il faut les appeler dans une autre fonction avec la syntaxe suivante

result = yield from fonction_generatrice_de_coroutines

La fonction appelante devra être décoré par coroutine elle pourra alors être lancée de façon asynchrone. Dans l'exemple, je vais utiliser open_connection qui ouvre une socket TCP. Comme on fait du HTTP, il va faloir parcer un peu la réponse. la réponse HTTP resemble à ça:

HTTP/1.1 200 OK\r\n
Date: Wed, 08 Apr 2015 22:22:27 GMT\r\n
Content-Length: 25\r\n
Content-Type: text/html;charset=utf-8\r\n
\r\n
<html>la page html</html>

Ou Content-Length indique la taille de ma page.

@asyncio.coroutine
def http_client(host, port, url, name):
    request = ('GET {url} HTTP/1.1\r\n'
               'Host: {host}\r\n'
               'Connection: Close\r\n'
               '\r\n').format(**locals())

    reader, writer = yield from asyncio.open_connection(host, port)
    print('[{}] {} connected'.format(name, now()))

    writer.write(request.encode())
    body_length = 0
    header_line = yield from reader.readline()
    header_line = yield from reader.readline()
    while header_line != b'\r\n':
        header, value = header_line.split(b':', 1)
        if header.rstrip().lower() == b'content-length':
            body_length = int(value)
        header_line = yield from reader.readline()

    data = yield from reader.read(body_length)
    print('[{}] {} {}'.format(name, now(), data.decode()))
    writer.close()
    return data


loop = asyncio.get_event_loop()

asyncio.async(http_client('127.0.0.1', 8080, '/index', 'client-1'))
asyncio.async(http_client('127.0.0.1', 8080, '/index', 'client-2'))
asyncio.async(http_client('127.0.0.1', 8080, '/index', 'client-3'))

try:
    loop.run_forever()
except KeyboardInterrupt:
    loop.close()

Voici ce qu'affiche le code:

[client-2] 00:26:23 connected
[client-3] 00:26:23 connected
[client-1] 00:26:23 connected
[client-2] 00:26:25 Hello World!
[client-1] 00:26:25 Hello World!
[client-3] 00:26:25 Hello World!

Les requêtes, on bien été traité de manière asynchrone il n'y a pas eu de blocage de la boucle par l'attente d'une réponse.

Voici ce que ça donne avec du code bloquant si j'utilise HTTPConnection pour me simplifier la vie :

@asyncio.coroutine
def http_client(host, port, url, name):
    print('[{}] {} connected'.format(name, now()))
    connection = HTTPConnection(host, port)
    connection.request("GET", url)
    response = connection.getresponse()
    data = response.read()
    print('[{}] {} {}'.format(name, now(), data))
    return data

loop = asyncio.get_event_loop()

asyncio.async(http_client('127.0.0.1', 8080, '/index', 'client-1'))
asyncio.async(http_client('127.0.0.1', 8080, '/index', 'client-2'))
asyncio.async(http_client('127.0.0.1', 8080, '/index', 'client-3'))

try:
    loop.run_forever()
except KeyboardInterrupt:
    loop.close()

Voici un exemple de ce que ce code affiche

[client-1] 00:32:19 connected
[client-1] 00:32:21 b'Hello World!'
[client-2] 00:32:21 connected
[client-2] 00:32:23 b'Hello World!'
[client-3] 00:32:23 connected
[client-3] 00:32:25 b'Hello World!'

Vous comprenez maintenant pourquoi c'est mal et pourquoi il faut toujours utiliser des coroutines avec asyncio

Comment synchroniser tout ça

Le but va être de faire une première requête vers l'url random pour avoir un nombre et d'utiliser ce nombre pour généré une url vers smile.

La fonction asyncio.async renvoie un objet Future. Cet objet est similaire au promise en JavaScrip, il possède une méthode add_done_callback qui exécute une fonction quand la fonction qui a retourné l'objet Future

Dans cet exemple, le code de la fonction http_client reste inchangée. Une fonction give_me_a_smile est ajouté pour générer la nouvelle url.

loop = asyncio.get_event_loop()

def give_me_a_smile(future):
    num = int(future.result())
    url = '/smile?n={}'.format(num)
    asyncio.async(http_client('127.0.0.1', 8080, url, 'client-3'))

loop = asyncio.get_event_loop()
asyncio.async(http_client('127.0.0.1', 8080, '/index', 'client-1'))
future = asyncio.async(http_client('127.0.0.1', 8080, '/random', 'client-2'))
future.add_done_callback(give_me_a_smile)
future.add_done_callback(give_me_a_smile)

try:
    loop.run_forever()
except KeyboardInterrupt:
    loop.close()

Voici un exemple de ce que ce code peut afficher

[client-1] 00:52:51 connected
[client-2] 00:52:51 connected
[client-2] 00:52:53 0
[client-1] 00:52:53 Hello World!
[client-3] 00:52:53 connected
[client-3] 00:52:53 connected
[client-3] 00:52:55 :-)
[client-3] 00:52:55 :-)

On vient de faire une synchronisation de type 1 vers 1 ou plusieurs pour faire l'inverse, on va utiliser asyncio.gather. Cette fonction prend plusieurs Future pour en retourner une seule qui sera terminée lorsque l'ensemble des futurs passés sont terminés. Le résultat est une liste avec tous les résultats de future passée en paramétré. Dans cet exemple, on fait deux requêtes vers random et quand les requêtes sont fini, on fait la moyenne des résultat pour demander un smile.

from statistics import mean

def give_me_a_smile(future):
    # Conversion str vers entier.
    num_list = [int(num) for num in future.result()]
    num = int(mean(num_list))
    url = '/smile?n={}'.format(num)
    asyncio.async(http_client('127.0.0.1', 8080, url, 'client-3'))

loop = asyncio.get_event_loop()
future_1 = asyncio.async(http_client('127.0.0.1', 8080, '/random', 'client-2'))
future_2 = asyncio.async(http_client('127.0.0.1', 8080, '/random', 'client-3'))

# Combine les futures 1 et 2
future_1_and_2 = asyncio.gather(future_1, future_2)
future_1_and_2.add_done_callback(give_me_a_smile)

résultat:

[client-3] 00:58:29 connected
[client-2] 00:58:29 connected
[client-3] 00:58:31 1
[client-2] 00:58:31 2
[client-3] 00:58:31 connected
[client-3] 00:58:33 ;-)

asyncio.gather peut prendre directement des coroutine, on peut donc écrire directement ceci

coroutine_1 = http_client('127.0.0.1', 8080, '/random', 'client-2')
coroutine_2 = http_client('127.0.0.1', 8080, '/random', 'client-3')
future_1_and_2 = asyncio.gather(coroutine_1, coroutine_2)
future_1_and_2.add_done_callback(give_me_a_smile)

Voilou j'espère que cette mise en bouche d'asyncio vous a plu, d'ici le prochain article, à vos IDE et bon code laughing