Concurrencia en Python: Mutex, Hilos, GIL, Semáforos, Eventos, Pools y Corrutinas

  1. Bloqueo Mutuo (Mutex) – Implementación Práctica

Un mutex (bloqueo mutuo) garantiza que solo un hilo acceda a los datos compartidos en un momento dado. Los hilos comptien por el bloqueo; el que lo obtiene ejecuta el código crítico, y los demás esperan hasta que se libere.


from multiprocessing import Process, Lock
import time
import json
import random

def consultar(nombre):
    with open('datos.json', 'r', encoding='utf8') as f:
        info = json.load(f)
    print(f'{nombre} consulta: {info.get("ticket_num")} restantes')

def comprar(nombre):
    with open('datos.json', 'r', encoding='utf8') as f:
        info = json.load(f)
    time.sleep(random.randint(1, 3))
    if info.get('ticket_num') > 0:
        with open('datos.json', 'w', encoding='utf8') as f:
            info['ticket_num'] -= 1
            json.dump(info, f)
        print(f'{nombre} compró exitosamente')
    else:
        print(f'{nombre} falló, no hay boletos')

def ejecutar(nombre, candado):
    consultar(nombre)
    candado.acquire()
    comprar(nombre)
    candado.release()

if __name__ == '__main__':
    candado = Lock()
    for i in range(10):
        p = Process(target=ejecutar, args=(f'Usuario {i}', candado))
        p.start()

Punto muerto (deadlock)

Ocurre cuando un hilo retiene el bloqueo A y espera el B, mientras otro retiene B y espera A. Ambos quedan bloqueados indefinidamente.


from threading import Thread, Lock
import time

candadoA = Lock()
candadoB = Lock()

class MiHilo(Thread):
    def run(self):
        self.func1()
        self.func2()

    def func1(self):
        candadoA.acquire()
        print(f'{self.name} tomó A')
        candadoB.acquire()
        print(f'{self.name} tomó B')
        candadoB.release()
        candadoA.release()

    def func2(self):
        candadoB.acquire()
        print(f'{self.name} tomó B')
        time.sleep(1)
        candadoA.acquire()
        print(f'{self.name} tomó A')
        candadoA.release()
        candadoB.release()

for i in range(10):
    h = MiHilo()
    h.start()

  1. Teoría de Hilos

Proceso: unidad de recursos (espacio de memoria). Un archivo Python en ejecución es un proceso.

Hilo: unidad de ejecución. Dentro de cada proceso hay al menos un hilo que ejecuta las instrucciones.

  • Los hilos comparten memoria y recursos del proceso padre.
  • La creación de hilos es más liviana que la de procesos.
  • Permiten concurrencia y comunicación sencilla mediante variables globales.
  1. Creación de Hilos

Similar a procesos: mediante función o clase.


from threading import Thread
import time

def tarea(nombre):
    print(f'{nombre} ejecutándose')
    time.sleep(0.1)
    print(f'{nombre} terminó')

if __name__ == '__main__':
    lista_hilos = []
    for i in range(100):
        t = Thread(target=tarea, args=(f'Usuario {i}',))
        t.start()
        lista_hilos.append(t)
    for t in lista_hilos:
        t.join()
    print('Hilo principal finalizó')

class MiHilo(Thread):
    def run(self):
        print('Ejecutando run')
        time.sleep(1)
        print('Run terminó')

obj = MiHilo()
obj.start()
print('Hilo principal')

  1. GIL (Global Interpreter Lock)

En CPython, el GIL es un mutex que impide que múltiples hilos ejecuten bytecode al mismo tiempo. Existe porque la gestión de memoria de CPython no es segura para hilos (recolección de basura).

  • El GIL hace que los hilos se ejecuten de forma cuasi‑secuencial (solo uno activo a la vez).
  • Para tareas intensivas en CPU, un solo proceso con muchos hilos puede ser más lento que múltiples procesos.
  • Para tareas intensivas en E/S, los hilos siguen siendo ventajosos porque ceden el GIL durante las esperas.

import time
from threading import Thread, Lock

contador = 100
candado = Lock()

def restar(candado):
    global contador
    candado.acquire()
    temp = contador
    time.sleep(0.1)
    contador = temp - 1
    candado.release()

hilos = []
for _ in range(100):
    t = Thread(target=restar, args=(candado,))
    t.start()
    hilos.append(t)
for t in hilos:
    t.join()
print(contador)  # Debe ser 0

  1. Semáforo

Un semáforo permite que un número fijo de hilos accedan a un recurso simultáneamente. Funciona como un conjunto de múltiples mutex.


from threading import Thread, Semaphore
import time
import random

semaforo = Semaphore(5)

class Tarea(Thread):
    def run(self):
        semaforo.acquire()
        print(f'{self.name} adquirió semáforo')
        time.sleep(random.randint(1, 3))
        semaforo.release()

for i in range(20):
    t = Tarea()
    t.start()

  1. Eventos

Un objeto Event permite sincronización: los hilos esperan hasta que se active el evento, luego continúan.


from threading import Thread, Event
import time

evento = Event()

def semaforo_luz():
    print('Rojo: todos detenidos')
    time.sleep(3)
    print('Verde: ¡aceleren!')
    evento.set()

def auto(nombre):
    print(f'{nombre} esperando luz verde')
    evento.wait()
    print(f'{nombre} arranca')

h = Thread(target=semaforo_luz)
h.start()
for i in range(20):
    t = Thread(target=auto, args=(f'Auto {i}',))
    t.start()

  1. Pool de Procesos y Pool de Hilos

Crear procesos/hilos sin límite puede saturar el sistema. Los pools mantienen un número fijo de workers y reutilizan los ya creados.


from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
import time
import random

def trabajo(n):
    print(f'Trabajando en {n}')
    time.sleep(random.randint(1, 2))
    return n ** 2

if __name__ == '__main__':
    # Pool de procesos (5 workers)
    with ProcessPoolExecutor(max_workers=5) as pool:
        futures = [pool.submit(trabajo, i) for i in range(10)]
        for f in futures:
            print('Resultado:', f.result())
    
    # Pool de hilos (10 workers)
    with ThreadPoolExecutor(max_workers=10) as pool:
        futures = [pool.submit(trabajo, i) for i in range(10)]
        for f in futures:
            print('Resultado:', f.result())

Se pueden definir callbacks con add_done_callback:


def callback(fut):
    print('Callback:', fut.result())

with ThreadPoolExecutor() as pool:
    fut = pool.submit(lambda x: x*2, 21)
    fut.add_done_callback(callback)

  1. Corrutinas (Concurrencia en un solo hilo)

Las corrutinas permiten concurrencia dentro de un solo hilo mediante la detección de operaciones de E/S y el cambio de contexto manual. La biblioteca gevent implementa esto.


import time
from gevent import monkey; monkey.patch_all()
from gevent import spawn

def funcion1():
    print('Inicio f1')
    time.sleep(3)
    print('Fin f1')

def funcion2():
    print('Inicio f2')
    time.sleep(5)
    print('Fin f2')

start = time.time()
t1 = spawn(funcion1)
t2 = spawn(funcion2)
t1.join()
t2.join()
print('Tiempo:', time.time() - start)  # Aprox 5s (no 8s)

  1. Corrutinas para concurrencia real (servidor)


import socket
from gevent import monkey; monkey.patch_all()
from gevent import spawn

def atender(conn):
    while True:
        data = conn.recv(1024)
        if not data:
            break
        print('Recibido:', data.decode())
        conn.send(data.upper())
    conn.close()

def servidor():
    s = socket.socket()
    s.bind(('127.0.0.1', 8080))
    s.listen(5)
    while True:
        conn, addr = s.accept()
        spawn(atender, conn)

if __name__ == '__main__':
    spawn(servidor).join()

Combniación óptima: usar múltiples procesos, dentro de cada proceso múltiples hilos, y dentro de cada hilo corrutinas para máxima eficiencia.

Etiquetas: Python mutex threading GIL Semaphore

Publicado el 7-11 01:00