Concurrencia en Python

C

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. ¿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 aún más solicitudes a la vez y, con el tiempo, lo que generará impresionantes ganancias de rendimiento.

En este artículo, discutiremos la concurrencia en el contexto de la programación 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 sucediendo 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 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 simultaneidad, ya que las piezas de trabajo a ejecutar no siempre tienen que esperar a que se completen otras. Se ejecutan al mismo tiempo.

Simultaneidad 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 están involucrados en la realización de 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

Aunque 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, 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 está haciendo correcciones ortográficas.

Un proceso es un trabajo o una instancia de un programa calculado 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 tener 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 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.

Multithreading vs multiprocesamiento vs Asyncio

Habiendo explorado los hilos y procesos, profundicemos ahora en las diversas formas en que una computadora se ejecuta al mismo tiempo.

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 subprocesos que se pueden ejecutar de forma paralela o al mismo tiempo. Esta división de funciones 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.

El subproceso múltiple permitirá al programa guardar automáticamente el contenido que se está escribiendo, realizar correcciones ortográficas del contenido y también formatear el contenido. A través del multiproceso, todo esto puede tener lugar 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 subproceso 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 la utilización de dos o más unidades de procesador en una computadora para lograr el paralelismo. Python implementa el multiprocesamiento creando diferentes procesos para diferentes programas, y cada uno tiene su propia instancia del intérprete de Python para ejecutar y la asignación de memoria para utilizar durante la ejecución.

AsyncIO o IO asincrónico es un nuevo paradigma introducido en Python 3 con el propósito de escribir código concurrente usando la sintaxis async / await. Es mejor para propósitos de redes de alto nivel y de E / S.

Cuándo usar la concurrencia

Las ventajas de la simultaneidad se aprovechan mejor al resolver problemas vinculados a CPU o IO.

Los problemas relacionados con la CPU involucran programas que realizan muchos cálculos sin requerir instalaciones de red o 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 la CPU o los recursos de E / S son limitados y desea acelerar su programa.

Cómo utilizar 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 .env archivo desde Pipenv carga automáticamente las variables del .env archivo.

Script sincrónico

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

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("https://Pharos.sh.com/")[3].split('.')[0]
    fileformat = link.split("https://Pharos.sh.com/")[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 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 sencillo 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. 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:

# 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 configure 5 hilos diferentes para descargar imágenes de nuestra galería. Recuerde que los subprocesos 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 sincrónica del script.

Optimización con multiprocesamiento

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

# 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 múltiples 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:

# 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("https://Pharos.sh.com/")[3].split('.')[0]
    fileformat = link.split("https://Pharos.sh.com/")[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 algunos cambios que se destacan en nuestro nuevo guión. Primero, ya no usamos lo normal requests módulo para descargar nuestras imágenes, pero en su lugar usamos aiohttp. La razón de esto es que requests es incompatible con AsyncIO ya que usa Python http y sockets módulo.

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

La palabra clave async indica que nuestra función es una corrutina (rutina cooperativa), que es un fragmento de código que se puede pausar y reanudar. Las corrutinas realizan múltiples tareas de forma cooperativa, lo que significa que eligen cuándo hacer una pausa y dejar que otros la 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 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 multiproceso y síncronas del código, y bastante similar a la versión multiprocesamiento.

Comparación de rendimiento

Asyncio multiproceso multiproceso síncrono

48 s19 años14 s14 s

Conclusión

En esta publicación, hemos cubierto la concurrencia y cómo se compara con el paralelismo. También hemos explorado los diversos métodos que podemos usar para implementar la simultaneidad en nuestro código Python, incluidos el multiproceso y el multiprocesamiento, y también discutimos sus diferencias.

En los ejemplos anteriores, podemos ver cómo la simultaneidad 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 tareas vinculadas a la CPU, mientras que el subproceso múltiple es mejor para tareas vinculadas a E / S.

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

 

About the author

Ramiro de la Vega

Bienvenido a Pharos.sh

Soy Ramiro de la Vega, Estadounidense con raíces Españolas. Empecé a programar hace casi 20 años cuando era muy jovencito.

Espero que en mi web encuentres la inspiración y ayuda que necesitas para adentrarte en el fantástico mundo de la programación y conseguir tus objetivos por difíciles que sean.

Add comment

Sobre mi

Últimos Post

Etiquetas

Esta web utiliza cookies propias para su correcto funcionamiento. Al hacer clic en el botón Aceptar, aceptas el uso de estas tecnologías y el procesamiento de tus datos para estos propósitos. Más información
Privacidad