La función lock()
en Python es crucial para la sincronización cuando trabajamos con hilos (threads). Su propósito principal es evitar las «condiciones de carrera» o «race conditions», que ocurren cuando varios hilos intentan acceder o modificar una misma variable o recurso compartido al mismo tiempo, lo cual puede llevar a resultados inesperados o errores.
Un lock
(o bloqueo) es una estructura de sincronización que asegura que solo un hilo pueda ejecutar un bloque de código a la vez, impidiendo que otros hilos accedan a ese bloque hasta que el lock
se libere. Esto es útil cuando varios hilos requieren acceso exclusivo a un recurso compartido.
lock
Imaginemos que tenemos un programa donde varios hilos suman un valor a una variable compartida. Sin lock
, la variable compartida puede actualizarse incorrectamente debido al acceso simultáneo de múltiples hilos. A continuación, se muestra cómo se usa lock
para proteger el acceso a la variable compartida:
import threading
# Variable compartida
contador = 0
# Creamos un objeto Lock
lock = threading.Lock()
# Función que incrementa el contador
def incrementar():
global contador
for _ in range(1000):
# Adquirimos el lock antes de modificar la variable compartida
lock.acquire()
contador += 1
# Liberamos el lock después de la modificación
lock.release()
# Creamos múltiples hilos que ejecutan la función incrementar
hilos = []
for _ in range(10): # Crearemos 10 hilos
hilo = threading.Thread(target=incrementar)
hilos.append(hilo)
hilo.start()
# Esperamos a que todos los hilos terminen
for hilo in hilos:
hilo.join()
print("Valor final del contador:", contador)
contador
es una variable global que será incrementada por varios hilos.lock
que usaremos para sincronizar el acceso a contador
.incrementar
: Esta función es la que cada hilo ejecuta. En ella:
lock
con lock.acquire()
antes de incrementar contador
. Esto asegura que solo un hilo puede acceder y modificar contador
en cada momento.lock
con lock.release()
para que otro hilo pueda adquirirlo y hacer su modificación.incrementar
.hilo.join()
, esperamos que todos los hilos terminen su ejecución antes de imprimir el valor final de contador
.lock
Si ejecutamos este mismo código sin lock
, el valor final de contador
sería indeterminado, ya que múltiples hilos pueden acceder y modificar el valor al mismo tiempo, generando resultados inconsistentes.
Explicación antigua.
Veamos como poner un candado (lock) a parte de nuestro código, las condiciones para poder poner un candado serán:
El funcionamiento es muy similar a las dobles puertas de los bancos, se tiene que poder cerrar la segunda puerta para que se abra la primera puerta.
class threading.Lock
La clase que implemente los objetos de la primitiva lock. Una vez que un hilo ha adquirido un lock, intentos subsecuentes por adquirirlo bloquearán, hasta que sea liberado; cualquier hilo puede liberarlo.
Nótese que Lock es una función de fábrica que retorna una instancia de la versión más eficiente de la clase Lock concreta soportada por la plataforma.
acquire(blocking=True, timeout=-1)
Adquirir un lock, bloqueante o no bloqueante.
Cuando se invoca con el argumento blocking establecido como True (el valor por defecto), bloquea hasta que el lock se abra, luego lo establece como cerrado y retorna True.
Cuando es invocado con el argumento blocking como False, no bloquea. Si una llamada con blocking establecido como True bloqueara, retorna Falso inmediatamente; de otro modo, cierra el lock y retorna True.
When invoked with the floating-point timeout argument set to a positive value, block for at most the number of seconds specified by timeout and as long as the lock cannot be acquired. A timeout argument of -1 specifies an unbounded wait. It is forbidden to specify a timeout when blocking is False.
El valor de retorno es True si el lock es adquirido con éxito, Falso si no (por ejemplo si timeout expiró).
Distinto en la versión 3.2: El parámetro timeout es nuevo.
Distinto en la versión 3.2: La adquisición de un lock ahora puede ser interrumpida por señales en POSIX si la implementación de hilado subyacente lo soporta.
release()
Libera un lock. Puede ser llamado desde cualquier hilo, no solo el hilo que ha adquirido el lock.
Cuando el lock está cerrado, lo restablece a abierto, y retorna. Si cualquier otro hilo está bloqueado esperando que el lock se abra, permite que exactamente uno de ellos proceda.
Cuando se invoca en un lock abierto, se lanza un RuntimeError.
No hay valor de retorno.
locked()
Return True if the lock is acquired.
from threading import Lock, Thread
import time
def suma_uno():
global g
lock.acquire()
a = g
time.sleep(0.001)
g = a+1
lock.release()
def suma_tres():
global g
with lock:
a = g
time.sleep(0.001)
g =a+3
lock = Lock()
g = 0
threads = []
for func in [suma_uno,suma_tres]:
threads.append(Thread(target=func))
threads[-1].start()
for thread in threads:
thread.join()
print(g)
Este código siempre dará 4
lock.acquire(): bloquea las variables del código
lock.release(): desbloquea las variables del código
Otro métodos de lock:
locked(): nos devuelve true o false en función de que el actual lock esté o no en uso.
with lock: es equivalente a hacer
lock.acquire()
lock.release()
Un objeto lock de la clase Lock() es un conjunto de cerrojos, que colocamos por distintos puntos del código de forma que si la ejecucíon de un hilo entra, se cierran todos los demás, cuando termine la ejecución volverá a abrir todos esos cerrojos asociado a ese lock
Veamos un nuevo ejemplo.
import threading
from time import time
def f_hilo(lock):
global x
for _ in range(1000000):
#lock.acquire()
for _ in range(100):
a=x
x=a+1
#lock.release()
x = 0
#tiempo_inicial = time()
lock = threading.Lock()
t1 = threading.Thread(target=f_hilo, args=(lock,))
t2 = threading.Thread(target=f_hilo, args=(lock,))
t1.start()
t2.start()
t1.join()
t2.join()
#print (time()-tiempo_inicial)
print (x)
1994937
En teoría se podía esperar que el resultado fuera de 2.000.000 ya que cada hilo debería incrementar hasta el 1.000.000
Si quitamos ahora los comentarios e introducimos una regíón crítica:
import threading
from time import time
def f_hilo(lock):
global x
for _ in range(1000000):
lock.acquire()
for _ in range(100):
a=x
x=a+1
lock.release()
x = 0
tiempo_inicial = time()
lock = threading.Lock()
t1 = threading.Thread(target=f_hilo, args=(lock,))
t2 = threading.Thread(target=f_hilo, args=(lock,))
t1.start()
t2.start()
t1.join()
t2.join()
print (time()-tiempo_inicial)
print (x)
44.83898711204529 2000000
Lógicamente cuanto más grande sea el conjunto de instrucciones de bloqueo de forma innecesaria menos espacio se deja a la concurrencia.
Veamos un ejemplo de lock dentro de una clase:
import threading
import time
class CuentaBancaria:
def __init__(self, nombre, saldo):
self.nombre = nombre
self.saldo = saldo
def __str__(self):
return self.nombre+' '+str(self.saldo)
class BankTransferThread(threading.Thread):
def __init__(self, ordenante, receptor, cantidad):
threading.Thread.__init__(self)
self.ordenante = ordenante
self.receptor = receptor
self.cantidad = cantidad
def run(self):
lock.acquire()
saldo_ordenante= self.ordenante.saldo
saldo_ordenante-= self.cantidad
# retraso para permitir ejecutar saltar entre hilos
time.sleep(0.001)
self.ordenante.saldo = saldo_ordenante
saldo_receptor = self.receptor.saldo
saldo_receptor += self.cantidad
# retraso para permitir ejecutar saltar entre hilos
time.sleep(0.001)
self.receptor.saldo = saldo_receptor
lock.release()
# Las cuentas son recursos compartidos
cuenta1 = CuentaBancaria("cuentaOrigen", 100)
cuenta2 = CuentaBancaria("cuentaDestino", 0)
lock = threading.Lock()
threads = []
for i in range(100):
threads.append(BankTransferThread(cuenta1, cuenta2, 1))
for thread in threads:
thread.start()
for thread in threads:
thread.join()
print(cuenta1)
print(cuenta2)
cuentaOrigen 0 cuentaDestino 100
Es una labor del programador establecer la región crítica y establecer el ámbito de exclusión.
Nota: Uso de listas o tuplas
from threading import Thread
t = Thread(target=print, args=[1])
t.run()
1
t = Thread(target=print, args=(1,))
t.run()
1
Ejercicio: Imagina que tienes un sistema de cajeros automáticos con un saldo inicial de 1000 euros. Varios usuarios pueden acceder al cajero simultáneamente para retirar dinero. Tu tarea es asegurarte de que, cuando un usuario retire dinero, el saldo se actualice correctamente sin ser afectado por las operaciones de otros usuarios. Para ello, puedes utilizar la instrucción lock.
Ejemplo de salida.
Retirada exitosa de 200 euros. Retirada exitosa de 200 euros. Retirada exitosa de 200 euros. Retirada exitosa de 200 euros. Retirada exitosa de 200 euros. Saldo insuficiente. Saldo insuficiente. Saldo insuficiente. Saldo insuficiente. Saldo insuficiente. Saldo final: 0 euros.