Concurrencia Indolora: el módulo multiprocessing

../processing/yo-small.gif

Autor: Roberto Alsina

El autor lleva mucho tiempo con Python, y ya casi casi le está tomando la mano.

Blog: http://lateral.netmanagers.com.ar

twitter: @ralsina

identi.ca: @ralsina

A veces uno está trabajando en un programa y se encuentra con uno de los problemas clásicos: la interfaz del usuario se bloquea. Estamos haciendo una operación larga y la ventana se "congela", se traba, no se actualiza hasta que esa operación termina.

A veces eso es tolerable, pero en general da una imagen de aplicación amateur, o mal escrita, y molesta.

La solución tradicional para ese problema es hacer que tu programa use multithreading, o sea, que tenga más de un hilo de ejecución. Descargás la tarea costosa a un hilo secundario, hacés lo que tenés que hacer para que la aplicación se vea "viva", esperás que termine el hilo y seguís adelante.

Un ejemplo de juguete es este:

# -*- coding: utf-8 -*-
import threading
import time

def trabajador():
    print "Empiezo a trabajar"
    time.sleep(2)
    print "Termino de trabajar"

def main():
    print "Empieza el programa principal"
    hilo = threading.Thread(target=trabajador)
    print "Lanzo el hilo"
    hilo.start()
    print "El hilo está lanzado"
    
    # isAlive() da false cuando el hilo termina.
    while hilo.isAlive():
        # Acá iría el código para que la aplicación
        # se vea "viva", una barra de progreso, o simplemente
        # seguir trabajando normalmente.
        print "El hilo sigue corriendo"
        # Esperamos un ratito, o hasta que termine el hilo,
        # lo que sea más corto.
        hilo.join(.3)
    print "Termina el programa"

# Importante: los módulos no deberían ejecutar
# código al ser importados
if __name__ == '__main__':
    main()

Que produce este resultado:

$ python demo_threading_1.py
Empieza el programa principal
Lanzo el hilo
El hilo está lanzado
El hilo sigue corriendo
Empiezo a trabajar
El hilo sigue corriendo
El hilo sigue corriendo
El hilo sigue corriendo
El hilo sigue corriendo
El hilo sigue corriendo
El hilo sigue corriendo
Termino de trabajar
Termina el programa

Ahí capaz que decís "¡que lindo es threading!" pero... acuérdense que este es un ejemplo de juguete. Resulta que usar hilos en Python tiene algunos problemas.

Entonces, ¿cual es la solución para esto? No usar hilos, sino procesos. Veamos un ejemplito sospechosamente parecido al anterior:

# -*- coding: utf-8 -*-
import multiprocessing
import time

def trabajador():
    print "Empiezo a trabajar"
    time.sleep(2)
    print "Termino de trabajar"

def main():
    print "Empieza el programa principal"
    hilo = processing.Process(target=trabajador)
    print "Lanzo el hilo"
    hilo.start()
    print "El hilo está lanzado"
    
    # isAlive() da false cuando el hilo termina.
    while hilo.isAlive():
        # Acá iría el código para que la aplicación
        # se vea "viva", una barra de progreso, o simplemente
        # seguir trabajando normalmente.
        print "El hilo sigue corriendo"
        # Esperamos un ratito, o hasta que termine el hilo,
        # lo que sea más corto.
        hilo.join(.3)
    print "Termina el programa"

# Importante: los módulos no deberían ejecutar
# código al ser importados
if __name__ == '__main__':
    main()

Sí, lo único que cambia es import threading por import multiprocessing y Process en vez de Thread. Ahora la función trabajador se ejecuta en un intérprete python separado. Como son procesos separados, usa tantos cores como procesos tengas, haciendo que el programa pueda ser mucho más rápido en una máquina moderna.

Antes mencioné deadlocks. Tal vez creas que tener un poco de cuidado y poner locks alrededor de variables te evita esos problemas. Bueno, no. Veamos dos funciones f1 y f2 que necesitan usar dos variables x y y protegidas por locks lockx y locky:

# -*- coding: utf-8 -*-
import threading
import time

x = 4
y = 6
lock_x = threading.Lock()
lock_y = threading.Lock()

def f1():
    lock_x.acquire()
    time.sleep(2)
    lock_y.acquire()
    time.sleep(2)
    lock_x.release()
    lock_y.release()
    
def f2():
    lock_y.acquire()
    time.sleep(2)
    lock_x.acquire()
    time.sleep(2)
    lock_y.release()
    lock_x.release()

def main():
    print "Empieza el programa principal"
    hilo1 = threading.Thread(target=f1)
    hilo2 = threading.Thread(target=f2)
    print "Lanzo los hilos"
    hilo1.start()
    hilo2.start()
    hilo1.join()
    hilo2.join()
    print "Terminaron los dos hilos"
    print "Termina el programa"

# Importante: los módulos no deberían ejecutar
# código al ser importados
if __name__ == '__main__':
    main()

Si corrés ese programa, se traba. ¡Todas las variables están protegidas con locks y de todas formas tenés deadlocks! Lo que pasa es que mientras f1 adquiere x y espera a y, f2 adquirió y y espera x y como ninguna va a liberar lo que la otra necesita, se quedan las dos trabadas. Tratar de depurar esta clase de cosas en programas no triviales es horrible, porque sólo suceden cuando coincide un determinado orden y momento de ejecución de las funciones.

Sumale que por ejemplo los diccionarios de python no son reentrantes y resulta que tenés que ponerle locks a muchísimas variables, y estos escenarios se hacen aún más comunes.

Y cómo funcionaría esto con multiprocessing... bueno, dado que no compartís recursos porque son procesos separados, no hay problemas de contención de recursos, y no hay problemas de deadlock.

Al usar múltiples procesos una manera de hacerlo es pasando los valores que necesites. Tu función de esa forma no tiene "efectos secundarios", acercándonos más al estilo de la programación funcional, como LISP o erlang. Ejemplo:

# -*- coding: utf-8 -*-
import multiprocessing
import time

x = 4
y = 6

def f1(x,y):
    x = x+y
    print 'F1:', x
    
def f2(x,y):
    y = x-y
    print 'F2:', y

def main():
    print "Empieza el programa principal"
    hilo1 = processing.Process(target=f1, args=(x,y))
    hilo2 = processing.Process(target=f2, args=(x,y))
    print "Lanzo los hilos"
    hilo1.start()
    hilo2.start()
    hilo1.join()
    hilo2.join()
    print "Terminaron los dos hilos"
    print "Termina el programa"
    print "X:",x,"Y:",y

# Importante: los módulos no deberían ejecutar
# código al ser importados
if __name__ == '__main__':
    main()

¿Porqué no hago locking? Porque el x y el y de f1 y f2 no son el mismo del programa principal, sino una copia. ¿Para qué serviría lockear una copia?

Si tenemos el caso de recursos que sí necesitan ser accedidos secuencialmente, entonces multiprocessing incluye locks, semáforos, etcétera con la misma semántica que threading.

O podés crear un proceso que administre el recurso y pasarle los datos mediante una cola (clases Queue o Pipe) y listo, el acceso es secuencial.

En general, con un poco de cuidado en el diseño de tu programa, multiprocessing tiene todos los beneficios del multithreading, con el agregado de aprovechar mejor el hardware y evitar algunos de sus dolores de cabeza.

Nota:
El módulo multiprocessing está disponible como parte de la biblioteca standard en python 2.6 o superior. Para otras versiones de python, es posible instalar el módulo processing vía PyPI.

Versión en PDF.

Help PET: Donate

blog comments powered by Disqus

Último cambio: Thu Aug 12 18:37:18 2010.  -  Esta revista está bajo una licencia Creative Commons.