Programación concurrente usando múltiples threads en Python

Programación concurrente usando múltiples threads en Python

Dos conceptos importantes que todo programador debe conocer son: concurrencia y paralelismo. Estos brindan la capacidad de aumentar la velocidad a la que se ejecutan los programas cuando se ejecuta más de una tarea al mismo tiempo.

En este artículo, hablaremos un poco sobre esto y luego aplicaremos la concurrencia en la práctica usando el módulo de threading en Python.

Para empezar, ¿qué es concurrencia?


La programación concurrente consiste en usar múltiples threads (unidades de procesamiento mínimas que ejecutará un procesador) para ejecutar diferentes tareas de un programa al mismo tiempo, pero aún dentro del mismo proceso.

¿Qué es paralelismo?


En el paralelismo se utilizan múltiples procesos para ejecutar programas en paralelo, es decir, pudiendo realizar simultáneamente distintas tareas del programa.

La diferencia entre ambos es, básicamente, que mientras en concurrencia las tareas tienen un tiempo determinado para ejecutarse y son alternadas por el llamado scheduler del sistema operativo, en paralelismo las dos tareas se ejecutarán exactamente al mismo tiempo. Lo que determinará el mejor enfoque para tu programa será el contexto, ya que cada uno tiene sus ventajas y desventajas.

Aunque varios procesos se ejecutan más rápido, el costo de iniciar uno nuevo es mayor en comparación con iniciar un nuevo thread.

¿Programación concurrente o secuencial?


Para comprender mejor el beneficio de los threads sobre la programación secuencial, un ejemplo clásico es un programa que lee información de un archivo y la envía a una impresora. En la programación secuencial, el archivo se lee, se almacena en la memoria y solo entonces enciende la impresora y comienza a imprimir. De esa forma, mientras se usa el disco para leer el archivo, la impresora se detiene.

Ahora imagina poner a trabajar el disco y la impresora al mismo tiempo: el disco empieza a leer, mete en memoria lo que ya se ha leído y después de cierto tiempo el contexto cambia y ahora la impresora empieza a funcionar, imprimiendo lo que está en memoria. Este ciclo se repite hasta que se ha impreso todo el archivo. Al hacer que ambos funcionen al mismo tiempo, el tiempo total dedicado a ejecutar el programa fue menor.

Sin embargo, hay un costo-beneficio, porque para crear un nuevo thread se requiere más memoria y para cambiar el contexto de un thread a otro hay un costo de procesamiento. Entonces, la cantidad de threads dependerá de la capacidad de tu equipo.

Si estás en Windows, puedes abrir el Administrador de Tareas y ver cuántos threads y procesos se ejecutan actualmente:

Este procesador es un I5-8300H, que tiene 4 núcleos y 8 threads, por lo que estos 254 procesos y 2816 threads en el Administrador de Tareas están todos en la memoria ejecutándose simultáneamente, es decir, el contexto cambia para que se ejecuten todos los threads.

Aunque hay muchos más hilos para ejecutar de los que hay físicamente, las computadoras de hoy pueden ejecutar miles de millones de operaciones por segundo, por lo que ni siquiera se nota que no se están ejecutando al mismo tiempo (hasta cierto punto) y esto es lo que nos permite ejecutar múltiples programas simultáneamente.

Manipulando threads


Para comenzar, creamos un archivo llamado main.py e importamos las bibliotecas nativas de time y threading:

Ahora crearemos una función simple para contar hasta 10 y pasarla como parámetro a una variable que será un objeto de un thread:

Ten en cuenta que al pasar la función al parámetro target del objeto no se está llamando, simplemente pasamos el nombre de la función que será el destino sin los paréntesis al frente.

Para que el thread comience a ejecutarse, debemos llamar a la función start del objeto, pero primero ejecutaremos la función active_count del módulo threading para averiguar cuántos threads se están ejecutando y, una vez que los hayamos ejecutado, llamaré a esa misma función de nuevo:

Al ejecutar el programa, verás al principio que la función active_count devolverá 1, lo que significa que solo se está ejecutando un thread , es decir, el principal que todos tus programas de Python ya crean normalmente. Entonces así verás en la consola el thread en ejecución por lo que luego el resultado de active_count será 2, lo que significa que ahora tenemos 2 threads en ejecución, el principal y el que creamos.

Cuando comenzamos un nuevo thread, no bloquea el principal a menos que necesiten usar el mismo recurso, por lo que a diferencia de la programación secuencial donde tu código se ejecuta línea por línea de arriba a abajo, en el concurrente el hilo se iniciará, comenzará a hacer su trabajo y los comandos debajo de la inicialización del thread comenzaron a ejecutarse.

Entonces, incluso en este ejemplo simple que hicimos, podría ser que la función active_count se ejecute antes de que el thread termine el trabajo. Esto se vuelve más evidente si lo configuramos para contar hasta 100.

También es posible pasar argumentos a funciones en threads. Para esto usamos el parámetro args. Ahora vamos a crear una función que reciba un nombre como parámetro y lo muestre en la terminal cuando está contando, y luego dos threads en diferentes variables y con diferentes nombres:

Al analizar el resultado, verás que la ejecución del thread 1 ocurrirá al mismo tiempo (simultáneamente) con el 2, por lo que la salida en la terminal será una mezcla de ambos. Pero puedes vincular un thread al principal usando el comando join:

Si llamas a esta función thread 1 antes de iniciar el thread 2, el programa esperará a que finalice la ejecución del primero antes de iniciar el segundo.

Sincronizando threads


Ahora comprendamos cómo sincronizar threads. Este es un paso importante, porque cuando un thread trata de acceder a una variable o archivo en la memoria y otro thread lo intenta al mismo tiempo, puede dar un error.

Por ejemplo, imagina que un thread hace un cálculo matemático y genera números para una determinada variable, mientras que otro thread lee el valor de esa variable para usarlo en algún sistema.

Puede ocurrir, dependiendo del escenario, que el primero termine los cálculos más rápido de lo que el segundo puede leer, por lo que se perderá un número. Pero si ocurre lo contrario y el thread que lo lee logra ser más rápido, un número puede leerse dos veces.

Lock


Primero crearemos una variable global con un número y un objeto de la clase lock del módulo threading:

Ahora crearemos dos funciones, una para duplicar el valor de la variable y otra para reducirlo a la mitad hasta cierto límite:

Al comienzo de cada función, definimos las variables globales x y lock, por lo que antes del loop llamaremos a la función lock.aquire() y después del ciclo llamaremos lock.release(), estas dos funciones bloquearán la variable para que que las operaciones se realicen y otro thread no empiece a manipular la misma variable y finalmente libere el acceso.

Ahora crearemos los threads apuntando a las funciones y luego comenzaremos con la función start:

Al ejecutar, la función de reducción a la mitad se iniciará y bloqueará la variable x, luego, una vez finalizada, se liberará la variable x y se iniciará la función doble. Si no hubiera bloqueo, los dos threads estarían en un loop infinito, ya que uno estaría dividiendo por la mitad mientras que el otro estaría multiplicando por 2.

Semáforo


Otra forma de sincronizar threads es mediante el uso de semáforos. A diferencia del bloqueo de que solo un thread puede acceder a un recurso a la vez, en el semáforo es posible definir un número máximo de threads.

Primero, vamos a crear un objeto de la clase BoundedSemaphore pasando el valor 5, que será el número máximo de threads que pueden acceder a un recurso determinado.

Ahora crearemos una función que recibirá el número del threads y nos avisará cuando intente acceder a él. Luego llamaremos a la función aquire del semáforo, nos avisará cuando el thread tenga acceso, programa un delay de 10 segundos y finalmente se liberará el acceso:

Por último, crearemos un loop para generar 10 threads pasando la variable access como target y el número de thread como argumento:

Al ejecutar, cuando llegue a la thread 5 que es el máximo, el thread 6 y los demás van a tener que esperar que los primeros liberen el acceso para continuar.

Daemon threads


Otro concepto importante es el de los daemon threads. Cuando el thread principal está por terminar, los demás seguirán trabajando hasta que hayan concluido todo lo que deben hacer, pero si definimos un thread como daemon, significa que estará enlazado con el thread principal, es decir, cuando finaliza el principal, si solo quedan hilos de tipo daemon, finaliza todo el programa.

Ve el ejemplo a continuación, donde tenemos la función readFile que leerá un archivo de texto y otro, printLoop, que mostrará lo que está escrito en él:

La primera función, como es un loop infinito que solo va a verificar si el archivo fue alterado, será definida como una thread daemon y la segunda que no es un loop infinito es un thread normal:

Al ejecutar, cuando el segundo thread termina su trabajo, todo el programa será finalizado, independientemente de que el primer thread sea un loop infinito porque es de tipo daemon.

Eventos


Un problema que tienen los threads daemon es que, sin importar lo que hagan, serán eliminado cuando finalice el proceso principal. Esto puede causar un error si el thread accede a una API o una base de datos. En este caso, es mejor usar un thread normal y usar otra función del módulo de threading, que son los events.

Usando el mismo ejemplo anterior, si antes de declarar las funciones creamos un event y en el loop de la función readFile ponemos un check del estado del evento, podemos terminar el loop infinito si el event ha sido definido:

Ahora el thread que leerá el archivo puede ser normal y el principal puede terminar el loop infinito al definir el event:

De esta manera, después de 5 segundos, el thread que lee el archivo finalizará y no se detectarán cambios. Esta característica se puede usar cuando el proceso principal tiene que terminar y la ejecución de los otros threads no puede terminar repentinamente, como en el caso de la base de datos, que primero tendría que cerrar las conexiones. Por lo tanto, la elección de utilizar threads o events daemon dependerá de cada caso.

Conclusión


La programación usando threads es más difícil porque, a diferencia de los ejemplos básicos de recetas de pasteles que aprendimos cuando comenzamos a estudiar la lógica de programación, ahora estamos tratando con múltiples sistemas que se ejecutan al mismo tiempo. Sin embargo, dominar la concurrencia y el paralelismo es una habilidad esencial que hará posible programar sistemas más complejos, como un juego, por ejemplo, en el que debes tomar las entradas del usuario desde el teclado y el mouse, renderizar los gráficos y reproducir el audio al mismo tiempo.

Para acceder al código fuente completo de este proyecto, consulta el repositorio en GitHub y, si no quieres perderte más proyectos como éste, consejos profesionales y noticias tecnológicas, sigue al pendiente de mis publicaciones.

¡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 Revelo.

Revelo Content Network 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.