Concurrencia en Python

Introducción

La computación ha evolucionado a lo largo del tiempo y se han desarrollado más y más formas para hacer que las computadoras funcionen aún más rápido. ¿Qué pasa 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 concurrencia, podemos lograr esto y nuestros programas de Python podrán manejar incluso más solicitudes a la vez y, con el tiempo, conducir a impresionantes ganancias de rendimiento.
En este artículo, discutiremos la concurrencia en el contexto de la programación de Python, las diversas formas en que se presenta y aceleraremos un programa simple para ver las mejoras de rendimiento en la práctica.

¿Qué es la concurrencia?

Cuando dos o más eventos son concurrentes significa que están sucediendo al mismo tiempo. En la vida real, la concurrencia es común ya que muchas cosas suceden al mismo tiempo todo el tiempo. En informática, las cosas son un poco diferentes cuando se trata de concurrencia.
En computación, la concurrencia es la ejecución de trabajos 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 el siguiente trabajo comienza a ejecutarse. Este no es el caso cuando se implementa la concurrencia, ya que los trabajos a ejecutar no siempre tienen que esperar a que se completen otros. Se ejecutan al mismo tiempo.

Concurrencia vs Paralelismo

Hemos definido la concurrencia como la ejecución de tareas al mismo tiempo, pero ¿cómo se compara con el 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 concurrencia como el paralelismo implican realizar múltiples tareas simultáneamente, pero lo que las distingue es el hecho de que, si bien la concurrencia solo tiene lugar en un procesador, el paralelismo se logra mediante la utilización de múltiples CPU para realizar tareas en paralelo.

Hilo vs Proceso vs Tarea

En términos generales, los hilos, procesos y 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 generalmente no son independientes entre sí, lo que significa que comparten datos y memoria con otros subprocesos dentro del mismo proceso. Los hilos también se conocen a veces como procesos ligeros.
Por ejemplo, en una aplicación de procesamiento de documentos, un subproceso puede ser responsable de formatear el texto y otro controla el guardado automático, mientras que otro está haciendo correcciones ortográficas.
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 ordenado a la computadora que realice a través de nuestro código. Un proceso puede tener un solo hilo primario o tener varios hilos dentro de él, cada uno con su propia pila, registros y contador de programas. Pero todos ellos comparten el código, los datos y la memoria.
Algunas de las diferencias comunes entre procesos y subprocesos son:
  • Los procesos funcionan de forma aislada, mientras que los subprocesos pueden acceder a los datos de otros subprocesos
  • Si se bloquea un subproceso dentro de un proceso, 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 vs multiproceso vs asyncio

Habiendo explorado hilos y procesos, ahora profundicemos en las diversas formas en que una computadora se ejecuta simultáneamente.
El subprocesamiento múltiple se refiere a la capacidad de una CPU para ejecutar varios subprocesos a la vez. La idea aquí es dividir un proceso en varios hilos que se pueden ejecutar de forma 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 textos como MS Word, suceden muchas cosas cuando están en uso.
El subprocesamiento múltiple permitirá al programa guardar automáticamente el contenido que se está escribiendo, realizar correcciones ortográficas del contenido y también dar formato al contenido. A través de los subprocesos múltiples, todo esto puede ocurrir simultáneamente y el usuario no tiene que completar el documento primero para que se realice el guardado o para que se realicen las correcciones 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 procesos diferentes para diferentes programas, cada uno con su propia instancia del intérprete de Python para ejecutar y la asignación de memoria para utilizar durante la ejecución.
AsyncIO o asynchronous IO es un nuevo paradigma introducido en Python 3 con el propósito de escribir código concurrente mediante el uso de la sintaxis async / await . Es mejor para fines de red de alto nivel y enlazados a IO.

Cuándo usar la concurrencia

Las ventajas de la concurrencia se aprovechan mejor cuando se resuelven problemas relacionados con CPU o IO.
Los problemas vinculados a la CPU involucran programas que realizan una gran cantidad de cómputo sin requerir redes o instalaciones de almacenamiento y solo están limitados por las capacidades de la CPU.
Los problemas vinculados a IO implican 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 la CPU o los recursos de E / S están 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 un código no concurrente y un código concurrente y compararemos el tiempo necesario para completar cada programa.
Descargaremos imágenes de Imgur a través de su API. Primero, debemos crear una cuenta y luego registrar 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 utilizaremos para acceder a la API. Guardaremos las credenciales en un .envarchivo ya que Pipenv carga automáticamente las variables del .envarchivo.

Script Sincrónico

Con esos detalles, podemos crear nuestro primer script que simplemente descargará un montón de imágenes a una downloadscarpeta:
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 Imgur y luego descargamos todas las imágenes en 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 desde 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.

Optimizando con multiprocesamiento

Permítanos ahora hacer que nuestro código sea simultáneo utilizando Multithreading y veamos cómo se realiza:
# 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 Threadpooly 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 el multiprocesamiento en varias CPU para el mismo script para ver cómo se realiza:
# 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 a través del 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 toma un promedio de 14 segundos después de varias ejecuciones.
Esto es ligeramente más rápido que nuestra versión que utiliza subprocesos y significativamente más rápido que nuestra versión no concurrente.

Optimizando con AsyncIO

Permítanos implementar el mismo script usando AsyncIO para ver cómo se realiza:
# 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 script. Primero, ya no usamos el requestsmódulo normal para descargar nuestras imágenes, sino que usamos aiohttpLa razón de esto es que requestses incompatible con AsyncIO, ya que utiliza Python's httpy el socketsmódulo.
Los sockets están bloqueados por naturaleza, es decir, no pueden pausarse y la ejecución continúa más adelante. aiohttpResuelve esto y nos ayuda a lograr un código verdaderamente asíncrono.
La palabra clave asyncindica que nuestra función es una rutina (Cooperativa Rutinaria) , que es un fragmento de código que se puede pausar y reanudar. Coroutines realizan múltiples tareas de forma cooperativa, lo que significa que eligen cuándo hacer una pausa y dejar que otros ejecuten.
Creamos un grupo donde hacemos una cola de todos los enlaces a las imágenes que deseamos descargar. Nuestra rutina se inicia colocándola en el bucle de eventos y ejecutándola hasta su finalización.
Después de varias ejecuciones de este script, la versión de AsyncIO tarda 14 segundos en promedio en descargar las imágenes del álbum. Esto es significativamente más rápido que las versiones de código múltiple y síncronas del código, y bastante similar a la versión de multiprocesamiento.

Comparación de rendimiento

SincrónicoMultihiloMultiprocesamientoAsyncio
48s19s14s14s

Conclusión

En este post, hemos cubierto la concurrencia y cómo se compara con el paralelismo. También hemos explorado los diversos métodos que podemos utilizar para implementar la concurrencia en nuestro código Python, incluidos los subprocesos múltiples y el multiprocesamiento, y también hemos analizado 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 sincrónica. Como regla general, el multiprocesamiento es el más adecuado para las tareas vinculadas a la CPU, mientras que el multiproceso es el mejor para las tareas vinculadas a la E / S.
El código fuente de esta publicación está disponible en GitHub como referencia.

Acerca de: Programator

Somos Instinto Programador

0 comentarios:

Publicar un comentario

Dejanos tu comentario para seguir mejorando!

Con tecnología de Blogger.