Saltar al contenido

Cómo Usar Hilos (Threads) en Python

Cómo usar hilos o threads en Python (cabecera)

Los hilos nos permiten aprovechar las capacidades multiprocesador de nuestras máquinas para ejecutar varias instrucciones a la vez, como subprocesos independientes. Te cuento en este artículo cómo crear, usar, detener y sincronizar hilos (también llamados threads) en Python para crear programas multihilo.

Los hilos en Python se crean instanciando la clase Thread. Se inicia su ejecución con el método start. Para esperar por otro hilo se invoca a su método join. Se pueden sincronizar hilos mediante exclusión mutua, semáforos o barreras usando las clases Lock, Semaphore y Barrier, respectivamente.

Este artículo pretende ser una miniguía práctica para el uso de hilos en Python. Voy a tratar todos los conceptos básicos de una manera comprensible para que aprendas todo lo necesario para usar los hilos con solvencia. Para entrar más en profundidad te recomiendo que leas la documentación oficial sobre hilos en Python.

La librería threading

Para trabajar con hilos haremos uso de la librería threading. Esta librería contiene una clase principal Thread que representa a los hilos.

Además, nos proporciona las clases Lock, RLock, Semaphore, BoundedSemaphore y Barrier que nos permiten sincronizar los diferentes hilos de nuestro programa.

Por otro lado, las clase Event establece un mecanismo sencillo de comunicación entre hilos.

La clase Timer nos permite establecer temporizadores para acciones que deban ejecutarse cuando pasa una cierta cantidad de tiempo.

Finalmente, la clase Condition proporciona un mecanismo para que los hilos queden en espera hasta que se cumpla una determinada condición.

Al margen de todas estas clases, la librería threading posee otras funciones que ofrecen información de los hilos, como active_count o current_thread.

De todo esto vamos a ver lo principal, para que esta guía sea realmente práctica y no abrume con demasiada información.

Cómo crear hilos en Python

Existen dos maneras principales de crear un hilo en Python. La primera consiste en extender la clase Thread sobreescribiendo su método run en donde se especifica el código que se ejecutará en el hilo. La segunda se basa en instanciar directamente a Thread indicándole el código a ejecutar.

Vamos a centrarnos en esta segunda opción, ya que puede resultar un poco más sencilla para empezar y no requiere, en principio, hacer clases extras.

Para crear un hilo instanciaremos la clase Thread para obtener un objeto. Al constructor de la clase podremos pasarle varios argumentos. Son los siguientes:

  • target. Es el argumento principal. Debe ser una referencia a un objeto invocable, como una función.
  • args. Es una tupla con todos los argumentos que se le quieran proporcionar al objeto invocable indicado en target.
  • kwargs. Es un diccionario con todos los argumentos con nombre que se le quieran proporcionar a target.
  • name. Es una cadena de texto con el nombre que se le quiera dar al hilo para identificarlo. Por defecto se creará un nombre con el formato Thread-N.

En realidad, el constructor admite más parámetros, pero alguno no es relevante y otros los veremos más adelante.

Así, para crear un hilo, basta con llamar al constructor de Thread indicándole un nombre, un invocable, que puede ser una función, y los parámetros para dicho invocable.

Así, por ejemplo, y suponiendo que tenemos la función ejecutar que será el invocable que contiene las instrucciones a ejecutar por cada hilo, vamos a ver formas posibles de crear hilos:

15 conceptos fundamentales que necesitas conocer para aprender y dominar Python

Te voy a hacer dos regalos (no uno, sino dos) que hablan de estos 15 conceptos fundamentales de Python: mi Tutorial Básico Interactivo de Python y una cheat sheet de Python en español: La Hoja de Referencia de Python.

Estos regalos son exclusivos para los suscriptores de Código Pitón.

👍 Quiero mis dos regalos

import threading


def ejecutar(parametro1=10, parametro2=20):
    ...


hilo1 = threading.Thread(target=ejecutar)
hilo2 = threading.Thread(target=ejecutar, args=(100, 200))
hilo3 = threading.Thread(target=ejecutar, kwargs={'parametro1': 1000, 'parametro2': 2000})
hilo4 = threading.Thread(target=ejecutar, args=(300, 400), name='hilo4')

Ya ves que es muy sencillo. En principio no deberíamos tener miedo a crear hilos.

En realidad, todos los parámetros que se le pueden proporcionar al constructor de Thread son opcionales, aunque pueda parecer que no tiene mucho sentido dejarlos en blanco.

Así, en este ejemplo, hemos creado cuatro hilos diferentes, todos preparados para ejecutar el código de la función ejecutar y con los parámetros indicados.

Cómo ejecutar hilos en Python

Una vez tenemos los hilos creados ya podemos pasar a ejecutarlos.

Es importante entender que cuando se lanzan los hilos, estos se ejecutarán de manera paralela en nuestro equipo, es decir, de manera concurrente, según estime el sistema operativo. Podría ejecutarse cada hilo en un procesador diferente, por ejemplo.

Pues bien, nada más fácil que lanzar un hilo para que este comience a ejecutarse. Basta con invocar al método start (sin ningún parámetro) de nuestros objetos Thread.

Cuando un hilo es ejecutado con start se cambia su estado a alive (vivo).

Hay que entender que cuando se están ejecutando los diferentes hilos, cada uno irá a su ritmo y unos terminarán antes que otros. Ya no tenemos una ejecución de código secuencial.

Veamos algún ejemplo de cómo ejecutar hilos. En esta ocasión, crearé tres hilos que ejecuten una función que lo único que haga es mostrar por pantalla el identificador del hilo. Lo vemos:

import threading


def info():
    print(f'{threading.current_thread().name} {threading.get_native_id()}')


# creamos los hilos
hilo1 = threading.Thread(target=info)
hilo2 = threading.Thread(target=info)
hilo3 = threading.Thread(target=info)

# ejecutamos los hilos
hilo1.start()
hilo2.start()
hilo3.start()


# el programa principal sigue ejecutándose aunque los hilos no hayan terminado
print(f'{threading.current_thread().name} {threading.get_native_id()}')

Resultado:

Thread-8 (info) 278516
Thread-9 (info) 278517
Thread-10 (info) 278518
MainThread 278502

Otro aspecto importante es el hecho de que el programa principal es un hilo a su vez y sigue ejecutando su propio código cuando se lanzan otros hilos. Por eso, al llamar a start de hilo2, hilo1 ha empezado su ejecución pero no necesariamente ha terminado (lo normal es que no lo haga, puede ser un proceso muy largo).

Así, en este ejemplo tenemos cuatro hilos en realidad, y no tres: los tres creados más el hilo principal.

En el código de arriba puedes ver cómo se invoca a la función current_thread(). Nos permite obtener el hilo actual que se está ejecutando. De ahí obtenemos su nombre, a través de la propiedad name (como no le hemos indicado nosotros un nombre, Thread genera uno por nosotros como hemos visto antes).

También podemos obtener el identificador nativo, es decir, el número de proceso que el sistema le ha asignado al hilo, y que es único. Podemos hacerlo invocando a la función get_native_id de la librería.

El hilo principal espera a que el resto de hilos terminen su ejecución para dar por finalizado el programa.

Si queremos que el programa termine sin esperar a que el resto de hilos lo hagan, tenemos que convertirlos en hilos demonio. Para esto pasaremos el parámetro daemon a la hora de crear el hilo poniendo su valor a True. De esta manera, cuando el programa principal termina no espera a ese hilo (a ninguno que sea un hilo demonio) y este termina abruptamente.

Ten en cuenta que es posible que los recursos que haya reservado ese hilo demonio no se hayan liberado correctamente.

Crear y ejecutar hilos con temporizador

A veces sucede que necesitamos crear un hilo que no comience a ejecutarse hasta que transcurre un determinado tiempo.

Para lograr esto podemos hacer uso de la clase Timer. Crearemos una instancia e indicaremos en el constructor el tiempo de espera inicial, la función que ejecutara el hilo, parámetro function, y los parámetros a dicha función (args o kwargs).

Veamos un ejemplo sencillo:

import threading

def ejecutar():
    print(f'{threading.current_thread().name} te saluda')

# creamos un temporizador
temporizador = threading.Timer(5, function=ejecutar)  # creamos el hilo con temporizador
temporizador.start()  # el hilo empezará cuando pasen 5 segundos

Esto creará el hilo que empezará su ejecución pasados 5 segundos.

Ten en cuenta que, antes de que comience la ejecución, podemos llamar al método cancel del temporizador que lo detiene y evita su ejecución. Eso sí, solo se puede llamar si todavía se encuentra en la etapa de espera.

Cómo sincronizar hilos en Python

Ahora que ya sabes como crear y lanzar hilos, pasemos al siguiente concepto, que es el de la sincronización.

Normalmente los hilos no son totalmente independientes como si fueran procesos que no se conocieran entre sí. No, lo habitual es que compartan información y resultados. Y para logar esto de manera efectiva, necesitaremos una manera de sincronizarlos.

Sincronizar hilos consiste en que unos esperen por otros cuando es necesario, por ejemplo, esperar a que todos terminen sus tareas para combinar los resultados (esto es muy habitual) antes de poder continuar.

También suele suceder que varios hilos tengan que acceder al mismo recurso (por ejemplo, a la misma estructura de datos) y sea necesario regular el acceso al mismo para evitar incoherencias o modificaciones simultáneas.

La manera más habitual de sincronización es hacer que el programa o hilo principal espere a que el resto de hilos termine para proseguir su ejecución. Esto se logra con la llamada al método join de los objetos Thread.

Cuando se llama a join, el hilo que hace la llamada se queda en espera, es decir, se bloquea, hasta que el hilo en cuestión, sobre el que se ha invocado el método, termina.

Para comprobar el funcionamiento de join, vamos a crear dos hilos que simulen estar ocupados un tiempo con una llamada a time.sleep y, finalmente, cuando todos terminen, el hilo principal mostrará un mensaje por pantalla.

Vamos a ver el código y lo que sucedería si el hilo principal no llamase a join:

import threading
from time import sleep
import random


def ejecutar():
    print(f'Comienza {threading.current_thread().name}')
    sleep(random.random())  # esperamos un tiempo aleatorio entre 0 y 1 segundos
    print(f'Termina {threading.current_thread().name}')


# creamos los hilos
hilo1 = threading.Thread(target=ejecutar, name='Hilo 1')
hilo2 = threading.Thread(target=ejecutar, name='Hilo 2')

# ejecutamos los hilos
hilo1.start()
hilo2.start()

print('El hilo principal no espera por el resto de hilos.')

El resultado por pantalla es el siguiente, en el que se ve que el mensaje del hilo principal se muestra antes de que terminen los hilos 1 y 2.

Comienza Hilo 1
Comienza Hilo 2
El hilo principal no espera por el resto de hilos.
Termina Hilo 2
Termina Hilo 1

Veamos ahora qué pasa cuando hacemos que el hilo principal espere por el resto llamando a su método join:

import threading
from time import sleep
import random


def ejecutar():
    print(f'Comienza {threading.current_thread().name}')
    sleep(random.random())  # esperamos un tiempo aleatorio entre 0 y 1 segundos
    print(f'Termina {threading.current_thread().name}')


# creamos los hilos
hilo1 = threading.Thread(target=ejecutar, name='Hilo 1')
hilo2 = threading.Thread(target=ejecutar, name='Hilo 2')

# ejecutamos los hilos
hilo1.start()
hilo2.start()

# esperamos a que terminen los hilos
hilo1.join()
hilo2.join()

print('El hilo principal sí espera por el resto de hilos.')

Y tendremos el resultado esperado:

Comienza Hilo 1
Comienza Hilo 2
Termina Hilo 2
Termina Hilo 1
El hilo principal sí espera por el resto de hilos.

Veamos a continuación otros casos particulares y un poco más complejos que esto que hemos visto. Para resolver estos casos haremos uso de otras clases y características de la librería threading.

Bloqueos

La clase Lock permite establecer un bloqueo o candado para regular el acceso a un recurso compartido.

Si creamos un objeto de clase Lock los hilos podrán invocar a sus métodos acquire y release. Podemos ver el lock como una puerta que inicialmente está abierta. Si un hilo llega a la puerta debe invocar al primer método, acquire. Esto hace el que el hilo pase por la puerta, que esta abierta, y la cierre. En ese momento, tiene acceso al recurso que se está regulando, y que está detrás de la puerta.

Recibe contenido exclusivo y obtén gratis la Hoja de Referencia de Python.

Antes de suscribirte consulta aquí la 
Información Básica sobre Protección de Datos. Responsable de datos: Juan Monroy Camafreita. Finalidad de recogida y tratamiento de datos personales: enviarte boletín informativo de Python y comunicaciones comerciales. Legitimación: tu consentimiento. Destinatarios: no se ceden a terceros. Los datos se almacenan en los servidores de marketing (MailRelay). Derechos: podrás ejercer tus derechos de acceso, rectificación, limitación y supresión de datos en info @ codigopiton.com así como presentar una reclamación ante una autoridad de control. Más información: política de privacidad, encontrarás información adicional sobre la recopilación y el uso de tu información personal.

Si otro hilo llega a la puerta y hace acquire para pasar, pero esta estaba cerrada por otro hilo, se queda esperando en la puerta.

En el momento en el que el primer hilo deje de usar el recurso compartido, antes de irse debe abrir la puerta. Eso se hace invocando al segundo de los métodos, release. Esto abrirá la puerta y uno de los hilos que estaba esperando podrá pasar y cerrar la puerta.

Este proceso también se conoce como exclusión mutua o mutex.

Veamos un ejemplo sencillo. Vamos a imaginar hilos que hacen un calculo y escriben el resultado de ese cálculo al final de un fichero. Como un fichero no puede ser abierto para escritura por varios procesos a la vez, tenemos que regular su acceso con Lock.

Vamos a verlo:

import threading
import random


def escribir_valor(autor, valor):
    lock_acceso_fichero.acquire()  # pedimos acceso al recurso
    with open('resultados.txt', 'a') as fichero:  # abrimos el fichero para añadir contenido al final
        fichero.write(f'{autor} - {valor}\n')
    lock_acceso_fichero.release()  # liberamos el recurso en cuanto terminamos con él


def ejecutar():
    valor = sum([random.random() for i in range(0, 100)])  # generamos 100 números aleatorios entre 0 y 1 y los sumamos
    escribir_valor(threading.current_thread().name, valor)


# creamos los hilos
hilo1 = threading.Thread(target=ejecutar, name='Hilo 1')
hilo2 = threading.Thread(target=ejecutar, name='Hilo 2')
hilo3 = threading.Thread(target=ejecutar, name='Hilo 3')

# creamos el Lock
lock_acceso_fichero = threading.Lock()

# ejecutamos los hilos
hilo1.start()
hilo2.start()
hilo3.start()

En el código de arriba vemos que la función que se ejecutará de manera concurrente, ejecutar, hace una operación matemática (no es relevante, pero en este ejemplo calculo 100 números aleatorios entre 0 y 1 y los sumo todos) y después escribe su valor a un fichero, para lo cual hace una llamada a la función escribir_valor.

Si te fijas en el código de esa función, verás que antes de abrir el fichero se solicita paso al lock llamando a acquire. Así nos aseguramos de que un solo hilo estará manipulando el fichero a la vez.

El contenido del fichero resultados.txt será el siguiente:

Hilo 1 - 47.86632356293537
Hilo 2 - 54.8455667790973
Hilo 3 - 45.89074933195078

Cabe destacar aquí que Lock cumple con el protocolo de gestión de contexto, de forma que, como sucede con open, podemos hacer uso de with. Puedes leer más sobre esto en PEP 343.

Así, cuando hacemos with con un lock no tenemos que invocar a acquire ni a release, pues ya se hace automáticamente. Así, podemos reescribir la función escribir_valor de la siguiente manera:

def escribir_valor(autor, valor):
    with lock_acceso_fichero:
        with open('resultados.txt', 'a') as fichero:  # abrimos el fichero para añadir contenido al final
            fichero.write(f'{autor} - {valor}\n')

Cuidado, pues este mecanismo es muy sencillo y no controla quién hace release, de forma que cualquier otro hilo podría hacerlo. Si necesitas controlar esto, puedes hacer uso de la clase RLock, que funciona parecido a lo que ya te he contado pero con ese control a mayores. No pongo aquí un ejemplo de uso por no alargar demasiado el artículo. Puedes leer más sobre RLock en la documentación oficial.

Semáforos

Otro mecanismo para sincronización de hilos son los llamados semáforos. Su funcionamiento es similar al de Lock (ver sección anterior) pero con una diferencia fundamental. Mientras que Lock solo deja pasar a un hilo para que use el recurso compartido, los semáforos dejan pasar a un número de hilos preestablecido.

Se suele utilizar cuando tenemos un recurso con capacidad limitada, por ejemplo, una base de datos, en donde podríamos tener a varios hilos leyendo a la vez (y no solo a uno), pero no a demasiados.

Para utilizar semáforos con un recurso compartido utilizaremos la clase Semaphore. Crearemos una instancia e indicaremos en el constructor el número de hilos permitidos al recurso.

Para usar el recurso compartido lo hilos deben llamar a acquire y cuando terminen con él, a release. De la misma manera que se hacía con Lock.

El semáforo mantiene un contador interno que se disminuye en 1 cuando un hilo llama a acquire y se incrementa en 1 cuando se llama a release. Así, si el contador está a 0 y un hilo llama a acquire se queda en espera.

Podemos considerar a Lock como un caso particular de Semaphore en donde solo podemos tener un hilo consumiendo el recurso.

La clase Semaphore también cumple con el protocolo de gestión de contexto, con lo que podemos usarlos con with.

Veamos aquí un ejemplo de cómo crear un semáforo y de cómo controlar el acceso a un recurso:

import threading


def acceso_bbdd():
    semaforo_acceso_bbdd.acquire()  # pedimos paso para usar la base de datos
    ... # accedemos a la base de datos
    semaforo_acceso_bbdd.release()  # liberamos la base de datos para que pueda entrar otro hilo
    
    
def acceso_bbdd_with():  # versión con with
    with semaforo_acceso_bbdd:
        ... # accedemos a la base de datos
    
...

# creamos el semáforo dando acceso a 5 hilos
semaforo_acceso_bbdd = threading.Semaphore(5)

...

En el ejemplo puedes ver dos maneras de utilizar el semáforo: una con with y otra llamando directamente a acquire y release.

Barreras

Las barreras se utilizan cuando un grupo de hilos deben esperar los unos por los otros. Se suele usar cuando tenemos que esperar a que todos los hilos hayan terminado una tarea concreta antes de que todos puedan pasar a la siguiente.

También es útil cuando hilos con funcionalidades diferentes deben esperarse mutuamente hasta que estén preparados.

Para utilizar una barrera crearemos una instancia de la clase Barrier a la que indicaremos por parámetro en su constructor el número de hilos que se quedarán en espera en la barrera.

Cuando un hilo llega a la barrera debe llamar al método wait. Esto hará que el hilo se quede esperando hasta que el resto hayan hecho sus respectivas llamadas a wait. En ese momento, se abre la barrera y todos los hilos continúan con su ejecución.

Vamos a comprobar su funcionamiento con un ejemplo. En este caso, lanzaré varios hilos que esperen un tiempo aleatorio entre 0 y 10 segundos, escriban un mensaje de saludo, esperen otro tiempo aleatorio y, finalmente, muestren un mensaje de despedida.

Si no ejerzo ningún control, es posible que algún hilo muestre su mensaje de despedida antes de que otros hayan mostrado el de saludo. Vamos a verlo:

import threading
import random
from time import sleep


def ejecutar():
    sleep(random.random() * 10)  # esperamos entre 0 y 10 segundos
    print(f'{threading.current_thread().name} te saluda')

    sleep(random.random() * 10)  # esperamos entre 0 y 10 segundos
    print(f'{threading.current_thread().name} se despide')


# creamos los hilos
hilo1 = threading.Thread(target=ejecutar, name='Hilo 1')
hilo2 = threading.Thread(target=ejecutar, name='Hilo 2')
hilo3 = threading.Thread(target=ejecutar, name='Hilo 3')

# ejecutamos los hilos
hilo1.start()
hilo2.start()
hilo3.start()
Hilo 1 te saluda
Hilo 3 te saluda
Hilo 3 se despide
Hilo 2 te saluda
Hilo 2 se despide
Hilo 1 se despide

Como puedes ver, en esta ejecución concreta, Hilo 3 se ha despedido antes de que Hilo2 saludara. Esto es lo que queremos evitar. Para ello vamos a establecer una barrera de forma que nadie pueda despedirse si no ha saludado todo el mundo (es decir, los hilos).

Crearemos la barrera y la estableceremos justo después de saludar o justo antes de despedirse (tenemos esas dos opciones):

import threading
import random
from time import sleep


def ejecutar():
    sleep(random.random() * 10)  # esperamos entre 0 y 10 segundos
    print(f'{threading.current_thread().name} te saluda')

    barrera.wait()

    sleep(random.random() * 10)  # esperamos entre 0 y 10 segundos
    print(f'{threading.current_thread().name} se despide')


# creamos los hilos
hilo1 = threading.Thread(target=ejecutar, name='Hilo 1')
hilo2 = threading.Thread(target=ejecutar, name='Hilo 2')
hilo3 = threading.Thread(target=ejecutar, name='Hilo 3')

# creamos la barrera para 3 hilos
barrera = threading.Barrier(3)

# ejecutamos los hilos
hilo1.start()
hilo2.start()
hilo3.start()

Y con esto logramos el efecto deseado.

Consideraciones finales

En este artículo hemos visto cómo trabajar con hilos en Python. Desde crearlos y lanzarlos, hasta sincronizarlos para hacer acceso regulado a recursos compartidos.

Ten en cuenta que el artículo es muy introductorio y, aunque hemos visto suficiente como para poder trabajar con comodidad con hilos, el tema puede extenderse bastante. Esto es debido a todos los problemas que surgen en la concurrencia de procesos o procesos paralelos.

Aunque no lo he contado en texto, todas las funciones que generan un bloqueo de hilos tienen un parámetro llamado timeout con el que podemos indicar el tiempo en segundos que el hilo estará en espera. Es un sistema de seguridad por si surge algún problema que evite terminar a un hilo.

Si quieres profundizar algo más te sugiero que continúes tu investigación con los conceptos de eventos, que es un sistema básico de comunicación entre hilos, y el de condiciones, que nos permite sincronizar y bloquear hilos basándose en condiciones.

Cuando domines los hilos, puedes pasar a estudiar el paralelismo basado en procesos e ir un poco más allá.

Y nada más. Como siempre, muchas gracias por leerme. Suscríbete a Código Pitón y recibirás contenido exclusivo así como algún regalito que otro.

¡Feliz programación!

 🎁 Tutorial Básico Interactivo y La Hoja de Referencia de Python – ¡Gratis!

La Hoja de Referencia de Python - Código Pitón
¿Quieres mejorar tu Python? Recibe totalmente gratis el Tutorial Básico Interactivo de Python, la Hoja de Referencia de Python y contenido exclusivo como trucos y consejos.



Antes de suscribirte consulta aquí la Información Básica sobre Protección de Datos. Responsable de los datos: Juan Monroy Camafreita. Finalidad de la recogida y tratamiento de los datos personales: enviarte boletín informativo de Python y comunicaciones comerciales. Legitimación: tu consentimiento. Destinatarios: no se ceden a terceros. Los datos se almacenan en los servidores de marketing (MailRelay). Derechos: podrás ejercer tus derechos de acceso, rectificación, limitación y supresión de datos en info @ codigopiton.com así como presentar una reclamación ante una autoridad de control. Más información en nuestra política de privacidad, encontrarás información adicional sobre la recopilación y el uso de tu información personal, incluida información sobre acceso, conservación, rectificación, eliminación, seguridad y otros temas.