Programación concurrente con hilos

← Fundamentos de Python ⌂ Home

Objetivos

Desarrollo

Un hilo o subproceso se define como la unidad más pequeña que se puede ejecutar en un sistema operativo, es decir, en el CPU. Los hilos son normalmente creados por procesos (se llama proceso a un programa en ejecución) de una computadora. Puede existir más de un hilo por proceso. Cada proceso tiene al menos un hilo, el mismo proceso.

El sistema operativo ejecuta los hilos en “paralelo”, en un CPU, este paralelismo se logra mediante la programación (scheduling) de hilos. Gracias a esto podemos realizar más de una tarea en la computadora.

Lo que ocurre es que en realidad no estamos ejecutando varios procesos a la vez, sino que los procesos se van turnando y, dada la velocidad a la que ejecutan las instrucciones, nosotros tenemos la impresión de que las tareas se ejecutan de forma paralela.

Ventajas

Módulo threading

En Python 3 podemos manejar hilos con el módulo threading.

# ejemplo_1.py
import threading

def tarea(hilo):
    print("Hilo %d en ejecución" % hilo)

print("Hilo del proceso principal")

for i in range(0, 10):
    hilo = threading.Thread(target=tarea, args=(i,))
    hilo.start()

Es posible crear un hilo con la clase Thread, indicando en el constructor un método a ejecutar y los argumentos en una tupla (parámetro args) o un diccionario (parámetro kwargs). Y posterior iniciar la ejecución de cada uno de los hilos con el método start.

El procesador decide que hilo entra primero y cual después, hasta este momento aun no podemos controlar su orden, pero que logramos observar, los hilos se ejecutan tan rápido que todos logran terminar su tarea designada (imprimir) en un tiempo de procesador y la impresión de cada uno de los hilos salen en orden, como si cada uno entrara en el orden que fue creado.

Vamos a agregar lo siguiente al código:

# ejemplo_1.py
import threading
import time

def tarea(hilo):
    print("Hilo %d se pondra en espera 3 segundos" % hilo)
    time.sleep(3)
    print("Hilo %d ha terminado su espera" % hilo)

print("Hilo del proceso principal")

for i in range(0, 10):
    hilo = threading.Thread(target=tarea, args=(i,))
    hilo.start()

¿Qué logramos observar? Al poner en espera a los hilos tardan 3 segundos más en realizar su tarea designada, y posiblemente su ejecución no termine en un tiempo de procesador, y se tengan que volver a formar a lo cola de hilos por ejecutar.

Muy bien, ya que se pudo comprobar que el procesador es quien decide que hilo ejecutar, agreguemos lo siguiente al código, el método join y veamos su funcionamiento:

# ejemplo_1.py
import threading
import time

def tarea(hilo):
    print("Hilo %d se pondra en espera 3 segundos" % hilo)
    time.sleep(3)
    print("Hilo %d ha terminado su espera" % hilo)

print("Hilo del proceso principal")

for i in range(0, 10):
    hilo = threading.Thread(target=tarea, args=(i,))
    hilo.start()
    hilo.join()

El método join es utilizado para que el hilo que ejecuta la llamada se bloquee hasta que finalice el su ejecución.

Existen otros dos métodos útiles: isAlive y getName(), para ver si el hilo sigue en ejecución y traer el nombre del hilo que le asignó el programa, respectivamente.

# ejemplo_1.py
import threading
import time

def tarea(hilo):
    print("Hilo %d se pondra en espera 3 segundos" % hilo )
    time.sleep(3)
    print("Hilo %d ha terminado su espera" % hilo)
    print("Hilo %d sigue en ejecución: %s" % (hilo, threading.currentThread().isAlive()))

print("Hilo del proceso principal: %s" % threading.currentThread().getName())

for i in range(0, 10):
    hilo = threading.Thread(target=tarea, args=(i,))
    hilo.start()
    hilo.join()

Existe otra forma de crear hilos, el comportamiento es similar y se logra heredando de la clase Thread de la siguiente manera:

# ejemplo_2.py
import threading
import time

class MiHilo(threading.Thread):
    def __init__(self, hilo):
        threading.Thread.__init__(self)
        self.hilo = hilo

    def run(self):
        print("Hilo %d se pondra en espera 3 segundos" % self.hilo)
        time.sleep(3)
        print("Hilo %d ha terminado su espera" % self.hilo)
        print("Hilo %d sigue en ejecución: %s" % (self.hilo, threading.currentThread().isAlive()))

print("Hilo del proceso principal: %s" % threading.currentThread().getName())

for i in range(0, 10):
    hilo = MiHilo(i)
    hilo.start()
    hilo.join()

La funcionalidad del hilo se coloca dentro del método run(), el cual se ejecuta automaticamente al iniciar el hilo.

Sincronización

El módulo de threading incluye un mecanismo de bloqueo que permite sincronizar hilos. Se crea un nuevo bloqueo llamando al método Lock(), que devuelve el nuevo bloqueo. El método acquire() del objeto de bloqueo se usa para obligar a los hilos a ejecutarse de forma síncrona. El método release() del objeto de bloqueo se usa para liberar el bloqueo cuando ya no es necesario.

Cuando un hilo acquire el bloqueo, los demás hilos que lleguen a ese punto e intenten acceder se bloquearán hasta que el hilo con bloqueo libere el bloqueo, momento en el cuál podrá entrar otro hilo.

# ejemplo_3.py
import threading

class CuentaFamiliar:
    def __init__(self, saldo):
        self.saldo = saldo

    def retirar(self, nombre, cantidad):
        if self.saldo >= cantidad:
            print(nombre + " realizo un retiro")
            self.saldo -= cantidad
            print("Saldo actual: ", self.saldo)

c = CuentaFamiliar(3000)

hilos = []

for i in range(30):
    hilos.append(threading.Thread(target=c.retirar, args=("Alejandro", 200)))
    hilos.append(threading.Thread(target=c.retirar, args=("Luis", 200)))
    hilos.append(threading.Thread(target=c.retirar, args=("Alex", 200)))
    hilos.append(threading.Thread(target=c.retirar, args=("Jenny", 200)))

for hilo in hilos:
    hilo.start()

Al ejecutar el código anterior podemos observar que varios hilos retiran dinero de la misma cuenta y llega un momento en que dos hilos logran entrar a la condición if y realizan el retiro pero el saldo ya no es suficiente, logrando números negativos para la cuenta. Para poder evitarlo utilizamos los bloqueos en el código que queremos sincronizar.

# ejemplo_3.py
import threading

class CuentaFamiliar:
    def __init__(self, saldo):
        self.saldo = saldo
        self.candado = threading.Lock()

    def retirar(self, nombre, cantidad):
        self.candado.acquire()
        if self.saldo >= cantidad:
            print(nombre + " realizo un retiro")
            self.saldo -= cantidad
            print("Saldo actual: ", self.saldo)
        self.candado.release()

c = CuentaFamiliar(3000)

hilos = []

for i in range(30):
    hilos.append(threading.Thread(target=c.retirar, args=("Alejandro", 200)))
    hilos.append(threading.Thread(target=c.retirar, args=("Luis", 200)))
    hilos.append(threading.Thread(target=c.retirar, args=("Alex", 200)))
    hilos.append(threading.Thread(target=c.retirar, args=("Jenny", 200)))

for hilo in hilos:
    hilo.start()

Fuentes