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 entarget
.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 formatoThread-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:
Te envío todos los días un consejo para que cada día seas mejor en Python.
Siempre sobre Python y programación.
Más de 2500 personas como tú los reciben cada día.
Día que estás fuera, consejo sobre Python que te pierdes.
Antes de suscribirte consulta aquí la
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 (GetResponse).
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.
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.
15 conceptos fundamentales que necesitas conocer para aprender y dominar Python
Te voy a hacer cuatro regalos (no uno, no dos, no tres, cuatro) que hablan de estos 15 conceptos fundamentales de Python: mi Tutorial Básico Interactivo de Python, una cheat sheet de Python en español: La Hoja de Referencia de Python, una guía de ChatGPT y Python y 30 ejercicios de Python (es un reto para ti).
Estos regalos son exclusivos para los suscriptores de Código Pitón.
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.
Día que estás fuera, consejo sobre Python que te pierdes.
Antes de suscribirte consulta aquí la
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 (GetResponse).
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!
Te envío todos los días un consejo para que cada día seas mejor en Python.
Siempre sobre Python y programación.
Más de 2500 personas como tú los reciben cada día.
Día que estás fuera, consejo sobre Python que te pierdes.
Antes de suscribirte consulta aquí la
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 (GetResponse).
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.