Python: Cómo sincronizar procesos y bloquear acceso a recursos

Una situación relativamente habitual cuando trabajamos con procesos es que dos o más tienen que acceder simultáneamente a un recurso que sólo puede usar uno a la vez. Un ejemplo podría ser el de una cámara de seguridad. Imaginemos dos procesos: uno de ellos, cuando se detecta movimiento, toma un vídeo, el otro proceso saca una foto de manera automática cada cinco minutos. Si el primer proceso está grabando vídeo cuando el segundo quiere sacar la foto no podrá hacerlo y tendremos un problema.

¿Cómo evitamos que dos procesos independientes accedan al mismo recurso a la vez en Python?

Para solucionar este problema podemos usar bloqueos de ficheros. Os dejo un ejemplo para que lo veáis de manera sencilla. En este caso vamos a tener dos procesos (prueba_bloqueo_A.py y prueba_bloqueo_B.py) que intentan acceder simultáneamente a un recurso limitado.

La “magia” se consigue gracias a fcntl.

Código de prueba_bloqueo_A.py:

import fcntl
import time
 
filename = "/tmp/prueba.tmp"
 
handle = open(filename, 'w')
 
for n in range(1,3):
	# bloqueo durante 15 segundos
	print("El proceso A solicita acceso al recurso compartido")
	fcntl.flock(handle, fcntl.LOCK_EX)
	# Aquí comenzaria el codigo que accede al recurso compartido.
	# En su lugar pongo un codigo de ejemplo.
	for i in range(1,10):
		print ("Proceso A: " + str(i))
		time.sleep(1)
	# Fin del codigo que accede al recurso compartido.
	fcntl.flock(handle, fcntl.LOCK_UN)
	print("Recurso liberado por el proceso A")
	time.sleep(1)
handle.close()

Código de prueba_bloqueo_B.py:

import fcntl
import time
 
filename = "/tmp/prueba.tmp"
 
handle = open(filename, 'w')
 
for n in range(1,3):
	# bloqueo durante 15 segundos
	print("El proceso B solicita acceso al recurso compartido")
	fcntl.flock(handle, fcntl.LOCK_EX)
	# Aquí comenzaria el codigo que accede al recurso compartido.
	# En su lugar pongo un codigo de ejemplo.
	for i in range(1,10):
		print ("Proceso B: " + str(i))
		time.sleep(1)
	# Fin del codigo que accede al recurso compartido.
	fcntl.flock(handle, fcntl.LOCK_UN)
	print("Recurso liberado por el proceso B")
	time.sleep(1)
handle.close()

Para verlos en acción puedes teclear en un terminal lo siguiente:

python prueba_bloqueo_A.py & python prueba_bloqueo_B.py

Los dos procesos se ejecutarán de manera concurrente. La salida puede ser similar a ésta:

El proceso B solicita acceso al recurso compartido
Proceso B: 1
El proceso A solicita acceso al recurso compartido
Proceso B: 2
Proceso B: 3
Proceso B: 4
Proceso B: 5
Proceso B: 6
Proceso B: 7
Proceso B: 8
Proceso B: 9
Recurso liberado por el proceso B
Proceso A: 1
El proceso B solicita acceso al recurso compartido
Proceso A: 2
Proceso A: 3
Proceso A: 4
Proceso A: 5
Proceso A: 6
Proceso A: 7
Proceso A: 8
Proceso A: 9
Recurso liberado por el proceso A
...

¿Por qué digo que la salida puede ser “similar a ésta” y no “igual a ésta”? Porque, dado que trabajamos en un sistema multiproceso no tenemos garantía de que el proceso A sea más rápido o se ejecute antes que el proceso B.

Con este resultado vemos que ambos procesos solicitan acceso al recurso compartido, pero sólo uno de ellos lo conseguirá (el que antes lo solicite). El otro se queda esperando a que el primero termine. Luego será el primero el que espere a que termine el segundo.

La clase bloqueo (lock)

En este post (en inglés) Chris, además de dar un completo repaso a los bloqueos de procesos en Python, nos muestra una sencilla clase para controlas los bloqueos. Es lo mismo que hemos hecho más arriba pero de un modo más elegante:

Contenido del fichero bloqueos.py:

import fcntl
 
class Bloqueo:
 
	def __init__(self, fichero):
		self.handle = open(fichero, 'w')
 
	def solicitar(self):
		fcntl.flock(self.handle, fcntl.LOCK_EX)
 
	def liberar(self):
		fcntl.flock(self.handle, fcntl.LOCK_UN)
 
	def __del__(self):
		self.handle.close()

Y os dejo los anteriores scripts modificados para usar esta clase:

Contenido del fichero bloqueo_A.py:

import time
from bloqueos import Bloqueo
 
bloqueo = Bloqueo("/tmp/prueba.tmp")
 
for n in range(1,3):
	# bloqueo durante 15 segundos
	print("El proceso A solicita acceso al recurso compartido")
	bloqueo.solicitar()
	for i in range(1,10):
		print ("Proceso A: " + str(i))
		time.sleep(1)
	print("Recurso liberado por el proceso A")
	bloqueo.liberar()
	time.sleep(1)

Contenido de bloqueo_B.py:

import time
from bloqueos import Bloqueo
 
bloqueo = Bloqueo("/tmp/prueba.tmp")
 
for n in range(1,3):
	# bloqueo durante 15 segundos
	print("El proceso B solicita acceso al recurso compartido")
	bloqueo.solicitar()
	for i in range(1,10):
		print ("Proceso B: " + str(i))
		time.sleep(1)
	print("Recurso liberado por el proceso B")
	bloqueo.liberar()
	time.sleep(1)

Para ver el resultado teclear en un terminal:

python bloqueo_A.py & python bloqueo_B.py

Por último queda avisar que debemos tener cuidado y evitar que un proceso se quede colgado bloqueando un recurso de manera indefinida. No debemos olvidar los habituales try-except.