Multiprocesamiento en Python: Global Interpreter Lock (GIL)



Python, como la práctica totalidad de los lenguajes modernos, soporta el uso de múltiples hilos de ejecución. La implementación más extendida y utilizada del lenguaje es sin duda CPython muy por delante de Jython y de IronPython, implementaciones en Java y C# para plataforma .NET respectivamente.

La implementación del intérprete en C siempre ha sido bastante más rápida que las implementaciones en Java y .NET en la mayoría de los benchmarks exceptuando aquellos en los que se usan múltiples hilos en sistemas que disponen más de un procesador o procesadores con más de un núcleo.

Esto es debido a que la implementación de CPython solo permite que un único thread ejecute bytecode a la vez por lo que se pierde la potencia de los sistemas SMP. En este artículo de multiprocesamiento en Python vamos a hacer una introducción al Global Interpreter Lock (GIL).

El Global interpreter Lock

El mecanismo que impide a la implementación en C de Python (a la que nos referiremos siempre como CPython a partir de ahora) la ejecución de bytecode por varios hilos a la vez se llama Global Interpreter Lock o GIL para abreviar y ha sido y es fuente de discusión y debate en el las listas de correo de los developers de Python desde hace mucho tiempo.

De hecho, Guido van Rossum creador del lenguaje también conocido como el BDFL (Benevolent Dictator For Life) ha especificado en varios correos en la lista de desarrolladores de Python que el GIL está aquí para quedarse. Pueden leerse sus argumentaciones en este hilo y en este otro donde explica que eliminar el GIL no es tan sencillo y anima a la Comunidad de desarrolladores a mantener un fork sin GIL si alguien se anima.

Dejando a un lado que efectivamente y como has podido imaginar, Guido es un cachondo, vamos a intentar explicar que es el GIL y por qué es necesario en la implementación de CPython.

¿Qué es el GIL?

A parte de ser un partido político neoliberal nacido en Marbella, es el mecanismo utilizado en CPython para impedir que múltiples threads modifiquen los objetos de Python a la vez en una aplicación multi hilo. Esto no evita que tengamos que utilizar primitivas de sincronización en nuestras aplicaciones en Python, no van por ahí los tiros.

Si en nuestras aplicaciones tenemos varios threads accediendo a una sección de código con datos mutables, tendremos un problema si no utilizamos primitivas de sincronización. Crear un thread en Python es la cosa más sencilla del mundo, tan solo tenemos que utilizar el módulo threading:

from threading import Thread
def una_funcion: print “¡Hola Genbeta Dev!”
thread1 = Thread(target=una_funcion)
thread1.start()
thread1.join()
Explicar en profundidad la librería de soporte de threads en Python no es el objetivo principal de este primer artículo de Multiprocesamiento en Python, así que no voy a perderme en detalles sobre el código anterior.

Tan solo comentar que importamos la clase Thread del módulo threading e instanciamos un nuevo objeto de tipo Thread al que le pasamos como sección crítica la funcion una_funcion. Lo ejecutamos y bloqueamos el hilo de ejecución principal del script hasta que el thread1 regrese de la sección crítica.

Al igual que en otros lenguajes, si queremos que solo un hilo de ejecución haga cambios en los datos de la sección crítica, debemos hacer uso de la clase Lock que nos permite adquirir una sección crítica. Entonces… ¿para que sirve el GIL?

El GIL es un bloqueo a nivel de intérprete. Este bloqueo previene la ejecución de múltiples hilos a la vez en un mismo intérprete de Python. Cada hilo debe esperar a que el GIL sea liberado por otro hilo.

Aunque CPython utiliza el soporte nativo del sistema operativo donde se ejecute a la hora de manejar hilos, y la implementación nativa del sistema operativo permite la ejecución de múltiples hilos de forma simultánea, el intérprete CPython fuerza a los hilos a adquirir el GIL antes de permitirles acceder al intérprete, la pila y puedan así modificar los objetos Python en memoria.

En definitiva, el GIL protege la memoria del intérprete que ejecuta nuestras aplicaciones y no a las aplicaciones en sí. El GIL también mantiene el recolector de basura en un correcto y saneado funcionamiento.

¿Cómo funciona el recolector de basura en CPython?

El recolector de basura de Python, como todos los recolectores de basura de diferentes lenguajes de programación, se encarga de liberar la memoria cuando terminamos de usar un objeto. En Python, este mecanismo hace uso de un concepto denominado conteo de referencias.

Cada vez que se hace referencia a un objeto instanciado (un int, una cadena o cualquier otro tipo de objeto nativo o propio) el recolector de basura lo monitorea y suma uno al contador de referencias al objeto. Cuando este número llega al cero, significa que el objeto no está más en uso y el recolector de basura procede a su eliminación de la memoria.

De esta forma no debemos preocuparnos nosotros mismos por liberar la memoria y limpiar los objetos que van a dejar de ser usados como si tenemos que hacer por ejemplo en C o C++. El GIL impide que un thread decremente el valor del conteo de referencia de un objeto a cero mientras otro thread está haciendo uso del mismo. Solo un thread puede acceder a un objeto Python a la vez.

El GIL permite que la implementación de CPython sea extremadamente sencilla a la vez que incrementa la velocidad de ejecución de aplicaciones con un único hilo y la ejecución de aplicaciones multi hilo en sistemas que cuentan con un único procesador. Facilita el mantenimiento del intérprete así como la escritura de módulos y extensiones para el mismo.

Esto es genial, pero también impide la ejecución de múltiples hilos de procesamiento en paralelo en sistemas con múltiples procesadores. Según Guido los threads no son la única forma de conseguir procesamiento múltiple en Python y nos recuerda que pueden utilizarse múltiples procesos pero parece que esa idea no ha calado especialmente entre la Comunidad debido a su coste.

El GIL no es tan malo como parece a primera vista

El GIL no es tan malo como puede aparentar a primera vista. Los módulos que realizan tareas de computación intensiva como la compresión o la codificación liberan el GIL mientras operan. También es liberado en todas las operaciones de E/S.

¿Nadie ha intentado eliminar GIL de la ecuación?

Lo cierto es que si. En 1999 Greg Stein, director de la Apache Software Foundation, y mantenedor de Python y sus librerías desde 1999 al 2003, creó un parche para el intérprete que eliminaba completamente el GIL y añadía bloqueo granular alrededor de operaciones sensibles en el intérprete.

Este parche incrementaba la velocidad de ejecución de aplicaciones multi-hilo pero la decrementaba a la mitad en aplicaciones que ejecutaban un único hilo, lo cual, no era aceptable. Por supuesto esa rama de desarrollo de CPython no tiene ningún tipo de mantenimiento y es hoy inaccesible.

Conclusión

Hoy hemos hecho una introducción al multiprocesamiento en Python revisando por fuerza el Global Interpreter Lock (GIL) para entender el problema del multiprocesamiento con CPython en sistemas donde se dispone de más de un procesador o núcleo.

En siguientes entrada aprenderemos algunos trucos para saltarnos esta restricción, también aprenderemos a usar múltiples núcleos utilizando procesos en lugar de hilos y por último descubriremos las nuevas funcionalidades para ejecutar hilos en múltiples núcleos en CPython 3.2



Más Información | Python Wiki

Portada de Genbeta