Paralelismo en Python: Threads vs. Procesos - Parte 1

Paralelismo en Python: Threads vs. Procesos - Parte 1

En el desarrollo de software, el paralelismo juega un papel fundamental para mejorar el rendimiento y la eficiencia de las aplicaciones. La capacidad de ejecutar múltiples tareas simultáneamente mediante el uso de threads (o hilos en español) se convierte en una estrategia clave para reducir los tiempos de procesamiento y optimizar el funcionamiento de los programas.

Otra herramienta muy utilizada es la programación multiproceso, que requiere de otro tipo de enfoque al programar las aplicaciones.

En esta primera parte, nos enfocaremos en explorar el uso de threads en Python, un lenguaje de programación ampliamente utilizado hoy en día. Para comprender mejor el funcionamiento de los threads, proporcionaremos ejemplos de código que ilustran su implementación en situaciones prácticas con el objetivo de ayudar a familiarizarse con los conceptos y a aprovechar al máximo esta potente herramienta de programación paralela.

¿Qué son los threads?

En el contexto de la programación, un thread se refiere a una secuencia de instrucciones que puede ejecutarse de forma independiente dentro de un proceso. Los threads permiten realizar tareas de manera concurrente, lo que significa que varias tareas pueden ejecutarse simultáneamente, mejorando así el rendimiento de las aplicaciones.

En Python, los threads se implementan a través del módulo threading, que proporciona una interfaz para crear y controlar los threads de manera sencilla.


Threads para tareas de E/S

Los threads son especialmente útiles para manejar operaciones de E/S. Algunos ejemplos de este tipo de operaciones son:

  • Leer y escribir datos en un archivo.
  • Imprimir los resultados de un programa por pantalla.
  • Conectarse a una base de datos e intercambiar información.
  • Enviar datos a través de la red, por ejemplo utilizando protocolos como HTTP, TCP/IP, etc.
  • Leer y escribir datos de un dispositivo de almacenamiento externo como un USB.
  • Interactuar con el sistema operativo para realizar operaciones.

Más adelante ampliaremos las razones de por qué esto es así. Primero veamos un ejemplo.

Los siguientes scripts muestran dos implementaciones de un simple programa que ejecuta tareas de E/S. En este caso, la tarea es hacer un request a la página oficial de Python. El programa debe ejecutar la tarea 4 veces.

Implementación sin uso de threads


Esta implementación no usa threads. Simplemente itera 4 veces y ejecuta la función task_io. La función se encarga de hacer el request a python.org y luego imprime por consola el status code. El tiempo total en ejecutar este script es de 567 milisegundos.

💡
Nota: si ejecutas el script obtendrás otro tiempo total. Esto se debe a que depende de múltiples variables como tu velocidad de internet, la congestión de la red y del servidor al que se está llamando. 


Implementación con uso de threads


Dijimos que los threads son muy buenos para ejecutar tareas de E/S. En este ejemplo, realizamos requests a un servidor externo, lo cual es una tarea de E/S. Reescribamos el código anterior pero ahora utilizando threads con la librería oficial de Python.

De nuevo, los tiempos pueden variar pero, en promedio, este script tarda 195 milisegundos en ejecutarse. ¡Esto es una reducción del tiempo de ejecución en un 65 %! El resultado es asombroso, pero, ¿a qué se debe? En esta implementación se crean 4 threads para correr la tarea en paralelo. Esto quiere decir que en vez de hacer un request y esperar a que termine para luego ejecutar el siguiente, cada thread se encarga (en simultáneo) de llamar al servidor y no necesita esperar a que los demás terminen.

Veamos ahora cómo pueden utilizarse los threads para otro tipo de tareas.

Threads para tareas intensivas de CPU


Las tareas intensivas de CPU son aquellas que demandan un alto nivel de capacidad de procesamiento de la Unidad Central de Procesamiento (CPU en inglés).

Estas tareas involucran un uso extensivo de la potencia de cálculo de la CPU para llevar a cabo operaciones complejas y exigentes desde el punto de vista computacional. Para abordar estas tareas de manera eficiente, es necesario prestar especial atención a la optimización y eficiencia del código, dado que requieren una cantidad significativa de recursos de la CPU y pueden impactar directamente en el rendimiento general del sistema.

Algunos ejemplos de tareas intensivas de CPU son:

  • Compresión y descompresión de archivos.
  • Procesamiento de imágenes.
  • Criptografía.
  • Compilación de código fuente.
  • Cálculos matemáticos complejos.
  • Algoritmos de Inteligencia Artificial.

Los threads nos ayudaron a optimizar nuestra tarea de E/S. ¿Por qué no utilizarlos para tareas intensivas de CPU? ¡Hagámoslo!

Implementación sin uso de threads

El siguiente programa puede parecer complejo, pero no lo es. Se define la función Fibonacci, parte de nuestra tarea intensiva de CPU, ya que es un cálculo matemático que consume tiempo de CPU.

La tarea intensiva consiste en obtener el Fibonacci del número 5 unas 100 mil veces. Si bien la tarea no tiene ningún fin real, es suficiente para simular un cálculo matemático muy complejo.


Al igual que el ejemplo anterior, la tarea se ejecuta 4 veces en un ciclo. Una vez que termina la primera, se ejecuta la segunda y así sucesivamente. El tiempo total de ejecución del programa es de 688 milisegundos.

💡
Nota: nuevamente, el tiempo de ejecución puede variar dependiendo de la velocidad de tu PC y de la disponibilidad de los recursos del sistema al momento de ejecutar el script.

Implementación con uso de threads


Veamos ahora el ejemplo implementado con threads. Nuevamente, creamos 4 threads para ejecutar la tarea 4 veces en simultáneo, 1 vez por cada thread. En el ejemplo anterior obtuvimos una gran reducción en el tiempo de procesamiento. Esperamos un resultado similar, ¿verdad?


Al correr este programa, obtenemos un tiempo total de procesamiento de 695 ms. ¿Cómo es esto posible? No ha mejorado nada. De hecho, hasta ha sido un poco peor que la versión que no utiliza threads. Esto se debe a un mecanismo exclusivo de Python llamado GIL.

¿Qué es el GIL en Python?

El GIL (Global Interpreter Lock) es una característica de Python que garantiza la coherencia de los datos internos del intérprete. Funciona como un mecanismo de bloqueo que permite que solo un thread pueda ejecutar código Python a la vez, incluso en servidores con múltiples núcleos de CPU. Esto limita el rendimiento de las aplicaciones Python que intentan utilizar varios threads para realizar tareas intensivas de CPU en paralelo.

Cuando se realizan tareas intensivas de E/S, gran parte del tiempo del programa se dedica a esperar las respuestas del sistema o dispositivo al que se está accediendo. En otras palabras, no se requiere una gran capacidad de procesamiento de la CPU para hacer una solicitud a una página web u otro tipo de operación de E/S. Debido a esto, los threads funcionan de manera óptima para este tipo de tareas. El GIL tiene una intervención mínima y se puede aprovechar al máximo el paralelismo disponible.

Aunque los threads no son eficientes para programas que realizan tareas intensivas de CPU, existen otras herramientas en el campo de la ingeniería de software que sí pueden ser útiles. En la siguiente parte, exploramos una de ellas: la programación multiproceso, semejante a los threads al buscar optimizar un programa mediante el paralelismo. Sin embargo, su implementación difiere y tiene limitaciones que los threads no tienen. Por lo tanto, es fundamental comprender su funcionamiento para aprovecharla adecuadamente.


Conclusión

Los threads en Python son una herramienta poderosa para lograr la concurrencia y mejorar el rendimiento de las aplicaciones, especialmente en operaciones de E/S intensivas. Permiten que múltiples tareas se ejecuten de manera concurrente y aprovechen eficientemente los recursos disponibles.

Sin embargo, es importante tener en cuenta las limitaciones del GIL de Python y comprender que los threads no son la solución óptima para tareas intensivas en CPU. En esos casos, se pueden explorar otras herramientas como la programación multiproceso, algo que veremos en la segunda parte.

Al dominar el uso de threads en Python y comprender cuándo y cómo aplicarlos adecuadamente, los desarrolladores pueden crear aplicaciones más eficientes y responsivas, mejorando así la experiencia del usuario y el rendimiento general del software.

¡Nos vemos en la próxima entrega! ¡Saludos!

💡
Las opiniones y comentarios emitidos en este artículo son propiedad única de su autor y no necesariamente representan el punto de vista de Listopro.

Listopro Community da la bienvenida a todas las razas, etnias, nacionalidades, credos, géneros, orientaciones, puntos de vista e ideologías, siempre y cuando promuevan la diversidad, la equidad, la inclusión y el crecimiento profesional de los profesionales en tecnología.