Concurrencia en Python

La informática ha evolucionado con el tiempo y han surgido más y más formas de hacer que las computadoras funcionen aún más rápido. ¿Qué pasaría si en lugar de ejecutar una sola instrucción en un t...

Introducción

La informática ha evolucionado con el tiempo y han surgido más y más formas de hacer que las computadoras funcionen aún más rápido. ¿Y si en lugar de ejecutar una sola instrucción a la vez, también podemos ejecutar varias instrucciones al mismo tiempo? Esto significaría un aumento significativo en el rendimiento de un sistema.

A través de la simultaneidad, podemos lograr esto y nuestros programas de Python podrán manejar incluso más solicitudes a la vez y, con el tiempo, generar mejoras de rendimiento impresionantes.

En este artículo, discutiremos la concurrencia en el contexto de la programación de Python, las diversas formas en las que se presenta y aceleraremos un programa simple para ver las ganancias de rendimiento en la práctica.

¿Qué es la concurrencia?

Cuando dos o más eventos son concurrentes significa que están ocurriendo al mismo tiempo. En la vida real, la concurrencia es común ya que suceden muchas cosas al mismo tiempo todo el tiempo. En informática, las cosas son un poco diferentes cuando se trata de concurrencia.

En informática, la concurrencia es la ejecución de piezas de trabajo o tareas por una computadora al mismo tiempo. Normalmente, una computadora ejecuta un trabajo mientras otros esperan su turno, una vez que se completa, los recursos se liberan y comienza la ejecución del siguiente trabajo. Este no es el caso cuando se implementa la concurrencia, ya que las piezas de trabajo que se ejecutarán no siempre tienen que esperar a que otras se completen. Se ejecutan al mismo tiempo.

Concurrencia frente a paralelismo

Hemos definido la concurrencia como la ejecución de tareas al mismo tiempo, pero ¿cómo se compara con paralelismo, y qué es?

El paralelismo se logra cuando se realizan múltiples cálculos u operaciones al mismo tiempo o en paralelo con el objetivo de acelerar el proceso de cálculo.

Tanto la simultaneidad como el paralelismo están involucrados en la realización de múltiples tareas simultáneamente, pero lo que los diferencia es el hecho de que, mientras que la simultaneidad solo tiene lugar en un procesador, el paralelismo se logra mediante la utilización de múltiples CPU para realizar tareas en paralelo.

Subproceso vs Proceso vs Tarea

Si bien en términos generales, los subprocesos, los procesos y las tareas pueden referirse a piezas o unidades de trabajo. Sin embargo, en detalle no son tan similares.

Un hilo es la unidad de ejecución más pequeña que se puede realizar en una computadora. Los subprocesos existen como partes de un proceso y, por lo general, no son independientes entre sí, lo que significa que comparten datos y memoria con otros subprocesos dentro del mismo proceso. Los subprocesos también se denominan a veces procesos ligeros.

Por ejemplo, en una aplicación de procesamiento de documentos, un subproceso podría ser responsable de formatear el texto y otro maneja el guardado automático, mientras que otro realiza la revisión ortográfica.

Un proceso es un trabajo o una instancia de un programa computado que se puede ejecutar. Cuando escribimos y ejecutamos código, se crea un proceso para ejecutar todas las tareas que le hemos indicado a la computadora que haga a través de nuestro código. Un proceso puede tener un solo subproceso primario o varios subprocesos dentro de él, cada uno con su propia pila, registros y contador de programa. Pero todos comparten el código, los datos y la memoria.

Algunas de las diferencias comunes entre procesos e hilos son:

  • Los procesos funcionan de forma aislada, mientras que los subprocesos pueden acceder a los datos de otros subprocesos.
  • Si un subproceso dentro de un proceso está bloqueado, otros subprocesos pueden continuar ejecutándose, mientras que un proceso bloqueado pondrá en espera la ejecución de los otros procesos en la cola.
  • Mientras que los subprocesos comparten memoria con otros subprocesos, los procesos no lo hacen y cada proceso tiene su propia asignación de memoria.

Una tarea es simplemente un conjunto de instrucciones de programa que se cargan en la memoria.

Multihilo frente a multiprocesamiento frente a Asyncio {#multihilo frente a multiprocesamiento frente a asincio}

Habiendo explorado hilos y procesos, profundicemos ahora en las diversas formas en que una computadora se ejecuta simultáneamente.

El subproceso múltiple se refiere a la capacidad de una CPU para ejecutar varios subprocesos al mismo tiempo. La idea aquí es dividir un proceso en varios hilos que se pueden ejecutar de manera paralela o al mismo tiempo. Esta división de tareas mejora la velocidad de ejecución de todo el proceso. Por ejemplo, en un procesador de texto como MS Word, suceden muchas cosas cuando está en uso.

Los subprocesos múltiples permitirán que el programa guarde automáticamente el contenido que se está escribiendo, realice revisiones ortográficas del contenido y también formatee el contenido. A través de subprocesos múltiples, todo esto puede ocurrir simultáneamente y el usuario no tiene que completar el documento primero para que se guarde o se realicen las revisiones ortográficas.

Solo un procesador está involucrado durante el subprocesamiento múltiple y el sistema operativo decide cuándo cambiar las tareas en el procesador actual, estas tareas pueden ser externas al proceso o programa actual que se está ejecutando en nuestro procesador.

El multiprocesamiento, por otro lado, implica utilizar dos o más unidades de procesador en una computadora para lograr el paralelismo. Python implementa el multiprocesamiento mediante la creación de diferentes procesos para diferentes programas, cada uno con su propia instancia del intérprete de Python para ejecutar y asignación de memoria para utilizar durante la ejecución.

E/S asíncrono o asynchronous IO es un nuevo paradigma introducido en Python 3 con el propósito de escribir código concurrente usando el asíncrono/espera sintaxis. Es mejor para propósitos de redes de alto nivel y vinculados a IO.

Cuándo usar la simultaneidad

Las ventajas de la simultaneidad se aprovechan mejor cuando se resuelven problemas relacionados con la CPU o la E/S.

Los problemas vinculados a la CPU involucran programas que realizan muchos cálculos sin necesidad de redes o instalaciones de almacenamiento y solo están limitados por las capacidades de la CPU.

Los problemas vinculados a E/S involucran programas que dependen de recursos de entrada/salida que a veces pueden ser más lentos que la CPU y generalmente están en uso, por lo tanto, el programa tiene que esperar a que la tarea actual libere los recursos de E/S.

Es mejor escribir código concurrente cuando los recursos de E/S o CPU son limitados y desea acelerar su programa.

Cómo usar la concurrencia

En nuestro ejemplo de demostración, resolveremos un problema común de enlace de E/S, que es la descarga de archivos a través de una red. Escribiremos código no concurrente y código concurrente y compararemos el tiempo que tarda cada programa en completarse.

Descargaremos imágenes de Imgur a través de su API. Primero, necesitamos crear una cuenta y luego Registrarse nuestra aplicación de demostración para acceder a la API y descargar algunas imágenes.

Una vez que nuestra aplicación esté configurada en Imgur, recibiremos un identificador de cliente y un secreto de cliente que usaremos para acceder a la API. Guardaremos las credenciales en un archivo .env ya que Pipenv carga automáticamente las variables del archivo .env.

Script síncrono

Con esos detalles, podemos crear nuestro primer script que simplemente descargará un montón de imágenes a una carpeta de descargas:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import os
from urllib import request
from imgurpython import ImgurClient
import timeit

client_secret = os.getenv("CLIENT_SECRET")
client_id = os.getenv("CLIENT_ID")

client = ImgurClient(client_id, client_secret)

def download_image(link):
    filename = link.split('/')[3].split('.')[0]
    fileformat = link.split('/')[3].split('.')[1]
    request.urlretrieve(link, "downloads/{}.{}".format(filename, fileformat))
    print("{}.{} downloaded into downloads/ folder".format(filename, fileformat))

def main():
    images = client.get_album_images('PdA9Amq')
    for image in images:
        download_image(image.link)

if __name__ == "__main__":
    print("Time taken to download images synchronously: {}".format(timeit.Timer(main).timeit(number=1)))

En este script, pasamos un identificador de álbum de Imgur y luego descargamos todas las imágenes de ese álbum usando la función get_album_images(). Esto nos da una lista de las imágenes y luego usamos nuestra función para descargar las imágenes y guardarlas en una carpeta localmente.

Este simple ejemplo hace el trabajo. Podemos descargar imágenes de Imgur pero no funciona al mismo tiempo. Solo descarga una imagen a la vez antes de pasar a la siguiente imagen. En mi máquina, el script tardó 48 segundos en descargar las imágenes.

Optimización con subprocesos múltiples

Hagamos ahora nuestro código concurrente usando Multithreading y veamos cómo funciona:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# previous imports from synchronous version are maintained
import threading
from concurrent.futures import ThreadPoolExecutor

# Imgur client setup remains the same as in the synchronous version

# download_image() function remains the same as in the synchronous

def download_album(album_id):
    images = client.get_album_images(album_id)
    with ThreadPoolExecutor(max_workers=5) as executor:
        executor.map(download_image, images)

def main():
    download_album('PdA9Amq')

if __name__ == "__main__":
    print("Time taken to download images using multithreading: {}".format(timeit.Timer(main).timeit(number=1)))

En el ejemplo anterior, creamos un Threadpool y configuramos 5 subprocesos diferentes para descargar imágenes de nuestra galería. Recuerde que los hilos se ejecutan en un solo procesador.

Esta versión de nuestro código tarda 19 segundos. Eso es casi tres veces más rápido que la versión síncrona del script.

Optimización con multiprocesamiento

Ahora implementaremos Multiprocesamiento en varias CPU para el mismo script para ver cómo funciona:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# previous imports from synchronous version remain
import multiprocessing

# Imgur client setup remains the same as in the synchronous version

# download_image() function remains the same as in the synchronous

def main():
    images = client.get_album_images('PdA9Amq')

    pool = multiprocessing.Pool(multiprocessing.cpu_count())
    result = pool.map(download_image, [image.link for image in images])

if __name__ == "__main__":
    print("Time taken to download images using multiprocessing: {}".format(timeit.Timer(main).timeit(number=1)))

En esta versión, creamos un grupo que contiene la cantidad de núcleos de CPU en nuestra máquina y luego asignamos nuestra función para descargar las imágenes en el grupo. Esto hace que nuestro código se ejecute de manera paralela en nuestra CPU y esta versión de multiprocesamiento de nuestro código tarda un promedio de 14 segundos después de varias ejecuciones.

Esto es un poco más rápido que nuestra versión que utiliza subprocesos y significativamente más rápido que nuestra versión no concurrente.

Optimización con AsyncIO

Implementemos el mismo script usando AsyncIO para ver cómo funciona:

 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
# previous imports from synchronous version remain
import asyncio
import aiohttp

# Imgur client setup remains the same as in the synchronous version

async def download_image(link, session):
    """
    Function to download an image from a link provided.
    """
    filename = link.split('/')[3].split('.')[0]
    fileformat = link.split('/')[3].split('.')[1]

    async with session.get(link) as response:
        with open("downloads/{}.{}".format(filename, fileformat), 'wb') as fd:
            async for data in response.content.iter_chunked(1024):
                fd.write(data)

    print("{}.{} downloaded into downloads/ folder".format(filename, fileformat))

async def main():
    images = client.get_album_images('PdA9Amq')

    async with aiohttp.ClientSession() as session:
        tasks = [download_image(image.link, session) for image in images]

        return await asyncio.gather(*tasks)

if __name__ == "__main__":
    start_time = timeit.default_timer()

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

    time_taken = timeit.default_timer() - start_time

    print("Time taken to download images using AsyncIO: {}".format(time_taken))

Hay pocos cambios que se destacan en nuestro nuevo guión. Primero, ya no usamos el módulo requests normal para descargar nuestras imágenes, sino que usamos aiohttp. La razón de esto es que requests es incompatible con AsyncIO ya que utiliza el módulo http y sockets de Python.

Los sockets se bloquean por naturaleza, es decir, no se pueden pausar y continuar la ejecución más adelante. aiohttp resuelve esto y nos ayuda a lograr un código realmente asíncrono.

La palabra clave async indica que nuestra función es una co-rutina (Rutina Cooperativa), que es un fragmento de código que se puede pausar y reanudar. Las corrutinas realizan múltiples tareas de manera cooperativa, lo que significa que eligen cuándo pausar y dejar que otros ejecuten.

Creamos un pool donde hacemos una cola de todos los enlaces a las imágenes que deseamos descargar. Nuestra corrutina se inicia colocándola en el ciclo de eventos y ejecutándola hasta que se complete.

Después de varias ejecuciones de este script, la versión AsyncIO tarda en promedio 14 segundos en descargar las imágenes del álbum. Esto es significativamente más rápido que las versiones multiproceso y sincrónicas del código, y bastante similar a la versión de multiprocesamiento.

Comparación de rendimiento {#comparación de rendimiento}

Síncrono Multihilo Multiprocesamiento Asyncio


48s 19s 14s 14s

Conclusión

En esta publicación, hemos cubierto la concurrencia y cómo se compara con el paralelismo. También exploramos los diversos métodos que podemos usar para implementar la concurrencia en nuestro código de Python, incluidos los subprocesos múltiples y el procesamiento múltiple, y también discutimos sus diferencias.

A partir de los ejemplos anteriores, podemos ver cómo la concurrencia ayuda a que nuestro código se ejecute más rápido de lo que lo haría de manera síncrona. Como regla general, el multiprocesamiento es más adecuado para las tareas vinculadas a la CPU, mientras que el multiproceso es mejor para las tareas vinculadas a E/S.

El código fuente de esta publicación está disponible en GitHub como referencia.