Análisis de rendimiento de Python asíncrono vs síncrono

Este artículo es la segunda parte de una serie sobre el uso de Python para desarrollar aplicaciones web asincrónicas. La primera parte proporciona una cobertura más profunda de c...

Introducción

Este artículo es la segunda parte de una serie sobre el uso de Python para desarrollar aplicaciones web asincrónicas. La primera parte proporciona una cobertura más profunda de la concurrencia en Python y asyncio, así como aiohttp.

Si desea leer más sobre Python asíncrono para desarrollo web, lo tenemos cubierto.

Debido a la naturaleza sin bloqueo de las bibliotecas asíncronas como aiohttp, esperamos poder realizar y manejar más solicitudes en un período de tiempo determinado en comparación con el código síncrono análogo. Esto se debe al hecho de que el código asíncrono puede cambiar rápidamente entre contextos para minimizar el tiempo de espera de E/S.

Rendimiento del lado del cliente frente al rendimiento del lado del servidor {#rendimiento del lado del cliente frente al rendimiento del lado del servidor}

Probar el rendimiento del lado del cliente de una biblioteca asíncrona como aiohttp es relativamente sencillo. Elegimos algún sitio web como referencia y luego hacemos una cierta cantidad de solicitudes, cronometrando cuánto tiempo le toma a nuestro código completarlas. Veremos el rendimiento relativo de aiohttp y requests al realizar solicitudes a https://example.com.

Probar el rendimiento del lado del servidor es un poco más complicado. Las bibliotecas como aiohttp vienen con servidores de desarrollo incorporados, que están bien para probar rutas en una red local. Sin embargo, estos servidores de desarrollo no son adecuados para implementar aplicaciones en la web pública, ya que no pueden manejar la carga esperada de un sitio web disponible públicamente, y no son buenos para servir activos estáticos, como Javascript, CSS y archivos de imagen.

Para tener una mejor idea del rendimiento relativo de aiohttp y un marco web síncrono análogo, vamos a volver a implementar nuestra aplicación web usando [Matraz] (http://flask.pocoo.org/) y luego compararemos los servidores de desarrollo y producción para ambas implementaciones.

Para el servidor de producción, vamos a utilizar guinicornio.

Lado del cliente: aiohttp frente a solicitudes

Para un enfoque sincrónico tradicional, solo usamos un simple ciclo for. Eso sí, antes de ejecutar el código, asegúrate de instalar el módulo de solicitudes:

1
$ pip install --user requests

Con eso fuera del camino, sigamos adelante e implementémoslo de una manera más tradicional:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# multiple_sync_requests.py
import requests
def main():
    n_requests = 100
    url = "https://example.com"
    session = requests.Session()
    for i in range(n_requests):
        print(f"making request {i} to {url}")
        resp = session.get(url)
        if resp.status_code == 200:
            pass

main()

Sin embargo, el código asincrónico análogo es un poco más complicado. Realizar múltiples solicitudes con aiohttp aprovecha el método asyncio.gather para realizar solicitudes al mismo tiempo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# multiple_async_requests.py
import asyncio
import aiohttp

async def make_request(session, req_n):
    url = "https://example.com"
    print(f"making request {req_n} to {url}")
    async with session.get(url) as resp:
        if resp.status == 200:
            await resp.text()

async def main():
    n_requests = 100
    async with aiohttp.ClientSession() as session:
        await asyncio.gather(
            *[make_request(session, i) for i in range(n_requests)]
        )

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

Ejecutando código síncrono y asíncrono con la utilidad bash tiempo:

1
2
3
4
[correo electrónico protegido]local:~$ time python multiple_sync_requests.py
real    0m13.112s
user    0m1.212s
sys     0m0.053s
1
2
3
4
[correo electrónico protegido]local:~$ time python multiple_async_requests.py
real    0m1.277s
user    0m0.695s
sys     0m0.054s

El código concurrente/asincrónico es mucho más rápido. Pero, ¿qué sucede si hacemos varios subprocesos en el código síncrono? ¿Podría igualar la velocidad del código concurrente?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# multiple_sync_request_threaded.py
import threading
import argparse
import requests

def create_parser():
    parser = argparse.ArgumentParser(
        description="Specify the number of threads to use"
    )

    parser.add_argument("-nt", "--n_threads", default=1, type=int)

    return parser

def make_requests(session, n, url, name=""):
    for i in range(n):
        print(f"{name}: making request {i} to {url}")
        resp = session.get(url)
        if resp.status_code == 200:
            pass

def main():
    parsed = create_parser().parse_args()

    n_requests = 100
    n_requests_per_thread = n_requests // parsed.n_threads

    url = "https://example.com"
    session = requests.Session()

    threads = [
        threading.Thread(
            target=make_requests,
            args=(session, n_requests_per_thread, url, f"thread_{i}")
        ) for i in range(parsed.n_threads)
    ]
    for t in threads:
        t.start()
    for t in threads:
        t.join()

main()

Ejecutar este fragmento de código bastante detallado producirá:

1
2
3
4
[correo electrónico protegido]local:~$ time python multiple_sync_request_threaded.py -nt 10
real    0m2.170s
user    0m0.942s
sys     0m0.104s

Y podemos aumentar el rendimiento usando más subprocesos, pero los rendimientos disminuyen rápidamente:

1
2
3
4
[correo electrónico protegido]local:~$ time python multiple_sync_request_threaded.py -nt 20
real    0m1.714s
user    0m1.126s
sys     0m0.119s

Al introducir subprocesos, podemos acercarnos a igualar el rendimiento del código asíncrono, a costa de una mayor complejidad del código.

Si bien ofrece un tiempo de respuesta similar, no vale la pena por el precio de complicar el código que podría ser simple: la calidad del código no aumenta con la complejidad o la cantidad de líneas que usamos.

Lado del servidor: aiohttp vs Flask

Usaremos la herramienta Punto de referencia de Apache (ab) para probar el rendimiento de diferentes servidores.

Con ab podemos especificar el número total de solicitudes a realizar, además del número de solicitudes concurrentes a realizar.

Antes de que podamos comenzar a probar, tenemos que volver a implementar nuestra aplicación de seguimiento de planetas (del Artículo anterior) utilizando un marco síncrono. Usaremos Flask, ya que la API es similar a aiohttp (en realidad, la API de enrutamiento aiohttp se basa en Flask):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# flask_app.py
from flask import Flask, jsonify, render_template, request

from planet_tracker import PlanetTracker

__all__ = ["app"]

app = Flask(__name__, static_url_path="",
            static_folder="./client",
            template_folder="./client")

@app.route("/planets/<planet_name>", methods=["GET"])
def get_planet_ephmeris(planet_name):
    data = request.args
    try:
        geo_location_data = {
            "lon": str(data["lon"]),
            "lat": str(data["lat"]),
            "elevation": float(data["elevation"])
        }
    except KeyError as err:
        # default to Greenwich observatory
        geo_location_data = {
            "lon": "-0.0005",
            "lat": "51.4769",
            "elevation": 0.0,
        }
    print(f"get_planet_ephmeris: {planet_name}, {geo_location_data}")
    tracker = PlanetTracker()
    tracker.lon = geo_location_data["lon"]
    tracker.lat = geo_location_data["lat"]
    tracker.elevation = geo_location_data["elevation"]
    planet_data = tracker.calc_planet(planet_name)
    return jsonify(planet_data)

@app.route('/')
def hello():
    return render_template("index.html")

if __name__ == "__main__":
    app.run(
        host="localhost",
        port=8000,
        threaded=True
    )

Si está saltando sin leer el artículo anterior, tenemos que configurar nuestro proyecto un poco antes de probarlo. He puesto todo el código del servidor de Python en un directorio planettracker, en sí mismo un subdirectorio de mi carpeta de inicio.

1
2
3
4
[correo electrónico protegido]local:~/planettracker$ ls
planet_tracker.py
flask_app.py
aiohttp_app.py

Le sugiero encarecidamente que visite el Artículo anterior y se familiarice con la aplicación que ya hemos creado antes de continuar.

Servidores de desarrollo aiohttp y Flask {#aiohttp y servidores de desarrollo Flask}

Veamos cuánto tardan nuestros servidores en manejar 1000 solicitudes, hechas 20 a la vez.

Primero, abriré dos ventanas de terminal. En el primero, ejecuto el servidor:

1
2
# terminal window 1
[correo electrónico protegido]local:~/planettracker$ pipenv run python aiohttp_app.py

En el segundo, ejecutemos ab:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# terminal window 2
[correo electrónico protegido]local:~/planettracker$ ab -k -c 20 -n 1000 "localhost:8000/planets/mars?lon=145.051&lat=-39.754&elevation=0"
...
Concurrency Level:      20
Time taken for tests:   0.494 seconds
Complete requests:      1000
Failed requests:        0
Keep-Alive requests:    1000
Total transferred:      322000 bytes
HTML transferred:       140000 bytes
Requests per second:    2023.08 [\#/sec] (mean)
Time per request:       9.886 [ms] (mean)
Time per request:       0.494 [ms] (mean, across all concurrent requests)
Transfer rate:          636.16 [Kbytes/sec] received
...

ab genera mucha información, y solo he mostrado la parte más relevante. De este el número al que debemos prestar más atención es al campo "Solicitudes por segundo".

Ahora, saliendo del servidor en la primera ventana, iniciemos nuestra aplicación Flask:

1
2
# terminal window 1
[correo electrónico protegido]local:~/planettracker$ pipenv run python flask_app.py

Ejecutando el script de prueba de nuevo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# terminal window 2
[correo electrónico protegido]local:~/planettracker$ ab -k -c 20 -n 1000 "localhost:8000/planets/mars?lon=145.051&lat=-39.754&elevation=0"
...
Concurrency Level:      20
Time taken for tests:   1.385 seconds
Complete requests:      1000
Failed requests:        0
Keep-Alive requests:    0
Total transferred:      210000 bytes
HTML transferred:       64000 bytes
Requests per second:    721.92 [\#/sec] (mean)
Time per request:       27.704 [ms] (mean)
Time per request:       1.385 [ms] (mean, across all concurrent requests)
Transfer rate:          148.05 [Kbytes/sec] received
...

Parece que la aplicación aiohttp es de 2,5 a 3 veces más rápida que Flask ​​cuando se usa el servidor de desarrollo respectivo de cada biblioteca.

¿Qué sucede si usamos gunicorn para servir nuestras aplicaciones?

aiohttp y Flask servidos por gunicorn

Antes de que podamos probar nuestras aplicaciones en modo de producción, primero debemos instalar gunicorn y descubrir cómo ejecutar nuestras aplicaciones usando una clase de trabajador gunicorn adecuada. Para probar la aplicación Flask ​​podemos usar el trabajador gunicorn estándar, pero para aiohttp tenemos que usar el trabajador gunicorn incluido con aiohttp. Podemos instalar gunicorn con pipenv:

1
[correo electrónico protegido]local~/planettracker$ pipenv install gunicorn

Podemos ejecutar la aplicación aiohttp con el trabajador gunicorn apropiado:

1
2
# terminal window 1
[correo electrónico protegido]local:~/planettracker$ pipenv run gunicorn aiohttp_app:app --worker-class aiohttp.GunicornWebWorker

En el futuro, cuando muestre los resultados de la prueba ab, solo mostraré el campo "Solicitudes por segundo" en aras de la brevedad:

1
2
3
4
5
# terminal window 2
[correo electrónico protegido]local:~/planettracker$ ab -k -c 20 -n 1000 "localhost:8000/planets/mars?lon=145.051&lat=-39.754&elevation=0"
...
Requests per second:    2396.24 [\#/sec] (mean)
...

Ahora veamos cómo le va a la aplicación Flask:

1
2
# terminal window 1
[correo electrónico protegido]local:~/planettracker$ pipenv run gunicorn flask_app:app

Probando con ab:

1
2
3
4
5
# terminal window 2
[correo electrónico protegido]local:~/planettracker$ ab -k -c 20 -n 1000 "localhost:8000/planets/mars?lon=145.051&lat=-39.754&elevation=0"
...
Requests per second:    1041.30 [\#/sec] (mean)
...

El uso de gunicorn definitivamente resulta en un mayor rendimiento para las aplicaciones aiohttp y Flask. La aplicación aiohttp aún funciona mejor, aunque no por tanto margen como con el servidor de desarrollo.

gunicorn nos permite usar múltiples trabajadores para servir nuestras aplicaciones. Podemos usar el argumento de línea de comando -w para decirle a gunicorn que genere más procesos de trabajo. El uso de 4 trabajadores da como resultado un aumento significativo en el rendimiento de nuestras aplicaciones:

1
2
# terminal window 1
[correo electrónico protegido]local:~/planettracker$ pipenv run gunicorn aiohttp_app:app -w 4

Probando con ab:

1
2
3
4
5
# terminal window 2
[correo electrónico protegido]local:~/planettracker$ ab -k -c 20 -n 1000 "localhost:8000/planets/mars?lon=145.051&lat=-39.754&elevation=0"
...
Requests per second:    2541.97 [\#/sec] (mean)
...

Pasando a la versión Flask:

1
2
# terminal window 1
[correo electrónico protegido]local:~/planettracker$ pipenv run gunicorn flask_app:app -w 4

Probando con ab:

1
2
3
4
5
# terminal window 2
[correo electrónico protegido]local:~/planettracker$ ab -k -c 20 -n 1000 "localhost:8000/planets/mars?lon=145.051&lat=-39.754&elevation=0"
...
Requests per second:    1729.17 [\#/sec] (mean)
...

¡La aplicación ‘Flask’ vio un aumento más significativo en el rendimiento cuando se usaban varios trabajadores!

Resumen de resultados

Demos un paso atrás y veamos los resultados de las pruebas de los servidores de desarrollo y producción para las implementaciones aiohttp y Flask ​​de nuestra aplicación de seguimiento de planetas en una tabla:


                                    aiohttp   Flask     \% difference

Servidor de desarrollo (Solicitudes/seg) 2023,08 721,92 180,24 gunicorn (Solicitudes/seg) 2396.24 1041.30 130.12 % de aumento sobre el servidor de desarrollo 18,45 44,24 gunicorn -w 4 (Solicitudes/seg) 2541,97 1729,17 47,01 % de aumento sobre el servidor de desarrollo 25,65 139,52


Conclusión

En este artículo, comparamos el rendimiento de una aplicación web asíncrona con su contraparte síncrona y usamos varias herramientas para hacerlo.

El uso de bibliotecas Python asincrónicas y técnicas de programación tiene el potencial de acelerar una aplicación, ya sea que realice solicitudes a un servidor remoto o
manejo de solicitudes entrantes.