
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 ThreadExplicar 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.
def una_funcion: print “¡Hola Genbeta Dev!”
thread1 = Thread(target=una_funcion)
thread1.start()
thread1.join()
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
Comentarios
Estoy impaciente por leer el próximo post de este topic
Gracias Francisco :)
También espero el próximo con ganas.
Me gustan este tipo de artículos y si son de python más.
-- editado por última vez a las 16:15
Impecable, da gusto "oírte". Gracias por el post.
Por otra parte, nunca me han gustado los lenguajes al estilo Python (Perl, PHP, JavaScript, ...); siempre me parece (y al final es así) que no controlas lo que pasa.
El problema (gran problema, me parece a mí) que comentas es sólo uno, otros (no se si a Python le pasa) es que (asombrosamente) son ambiguos (leer la doc de Perl, por ejemplo, ¡y es el que menos me disgusta!).
Sin ningún genero de duda (subjetivamente, claro) el futuro está en los lenguajes funcionales (ojo, Python no lo es). Me encanta Haskell, todo parece rígido como una tabla, pero te permite una flexibilidad tremenda y la paralelización es trivial (de programar también, pero me refiero al compilar y ejecutar), entre otros. Pena que en él aún soy un bebé (y por mucho tiempo me parece...).
-- editado por última vez a las 18:37
Hola Jose Juan.
Me he perdido en el segundo párrafo no he llegado a entender muy bien a lo que te estabas refiriendo.
En cuanto a gustos, pues colores. A mi me gusta Python, tb me gusta Haskell.
Una cosa importante es saber separar el lenguaje de la implementación. El lenguaje es Python y Python como especificación no tiene ningún problema con el multi-threading y el procesamiento en paralelo, es la implementación en C de Python la que tiene el problema.
Un saludo.
Con el segundo párrafo me refiero a cómo se procesan las cosas, con éstos lenguajes "nunca" estás seguro cómo se va a procesar una sentencia (suena exagerado, ya) y el hecho de no ser estrictos en los tipos da bastantes dolores de cabeza.
En fin, que hay muchos bugs debidos a su "flexibilidad" que podrían ser evitados fácilmente poniendo un poco de orden (ej. strong static typing).
Sí, son las mismas cosas que los hacen débiles las que los hacen populares, pero eso no quiere decir que sea bueno.
En cuanto a gustos... pues te puede gustar un coche que no pasa de 50 Km/h y yo lo respetaré mucho, pero no me podrás negar que es mejor otro que corre a 100 Km/h (hablo en general, ya se que entrar a comparar lenguajes puede ser "doloroso").
"...Python como especificación no tiene ningún problema con el multi-threading..."
Lo se, es lo que explicas, pero precisamente es a eso a lo que me refiero. Por el propio diseño de Haskell, es "trivial" paralelizar los procesos involucrados ¡pues no hay efectos colaterales! (en la parte funcional pura, claro) y ésto es conocido, además, en tiempo de compilación.
Sin embargo en otros lenguajes, se hace muy complicado (cuando no imposible) realizar una compilación (o interpretación) eficiente.
Por resumir (menudo ladrillo), hoy en día uno no puede andar creando el código perfecto en ensamblador (ni C, ...). Éstos lenguajes tienen un hueco evidente al dar una productividad enorme, pero ésto lo hace Haskell de maravilla, ¿inconveniente?, que hay que darle al coco...
No estoy del todo de acuerdo en lo que comentas. Los lenguajes dinámicos como Python permiten escribir menos código repetitivo con respecto a lenguajes estáticos como Java. Debido a eso hay quien argumenta que la productividad es mayor. La contrapartida es que con en los lenguajes dinámicos es más sencillo cometer errores que sólo pueden ser detectados durante la ejecución. Eso obliga a escribir tests para estar razonablemente seguros de que el programa funciona.
Sin embargo hace tiempo que leí (no recuerdo donde) un argumento con el que estoy de acuerdo. Y es que para los lenguajes estáticos tampoco te libras de escribir esos mismos tests porque siempre es necesario verificar la lógica del programa. Así que, dado que estás en las mismas, casi mejor utilizar un lenguaje que te permita escribir el programa más rápido.
Todo caso, como bien dices, para gustos colores.
Precisamente es lo que digo, Python y otros tienen más productividad (al menos potencial) que otros más "duros" como Java, pero su ambigüedad en múltiples aspectos hacen que sea difícil no cometer errores (el lenguaje no te ayuda a no cometerlos).
Lenguajes como Haskell tiene tanta o más expresividad que los populares Python, Perl, PHP, ... y a la vez aportan fuertes herramientas (al lenguaje me refiero) para no cometer y detectar errores.
Pero a lo que yo iva inicialmente, es que la formalización de los lenguajes funcionales abren la puerta a una implementación (a la compilación/interpretación me refiero) eficiente con independencia del sistema subyacente.
Por poner un ejemplo, avances en el ATP (demostración de teoremas automática) permitirá a los compiladores optimizar (en una especie de "redución de teoremas") optimizar en conjunto la intención del programador y no un conjunto limitado de instrucciones. Pero ésto sólo se puede lograr en tanto en cuanto el algoritmo escrito exprese la intención del programador (lenguajes de bajo nivel vs. de alto nivel).
La llegada de múltiples cores (los que se venden ahora llevan 8) dará ventaja a aquellos lenguajes que permitan usar de forma cómoda la paralelización (si es automática mejor).
A la larga, creo que se impondrán los funcionales.
Yo creo que el que se impongan los funcionales va a costar simplemente porque requieren una forma de pensar diferente. Supongo que tienes y al final se impondrán, pero va a costa muchísimo.
Una cosa por que siempre he tenido curiosidad es por los costes reales de utilizar multi-proceso en lugar de multi-hilo. A fin de cuentas, gracias al CoW cada nuevo proceso sólo necesita de memoria las páginas que realmente ensucié respecto al proceso original. Eso significa que todo el código de intérprete y las librerías se compartirán. Y, según el caso, buena parte de las páginas de de datos.
Así que es muy posible que con la cantidad de memoria que tienen los ordenadores modernos, ese coste sea casi despreciable.
Muy buen artículo. Vale decir que el GIL es algo común a muchas implementaciones de lenguajes interpretados, como ser varias de Ruby y Python. Otros lenguajes de la misma índole, como Php y Perl, siquiera soportan threads (sólo procesos), mientras que los hay como Lua o versiones anteriores de Ruby, que sólo tienen user threads, es decir, hilos cuyo scheduling lo realiza la virtual machine, y por tanto solo se pueden ejecutar en el mismo CPU que en un momento dado procesa a la misma. Saludos.
Interesante. Sólo añadir en Python se pueden tener user threads mediante Stackless Python. Permite resolver problemas que requieren de una alta concurrencia (como juegos online). Eso sí, no pueden explotar el potencial de las máquinas multiprocesador.
¿Te refieres a la versión de PyPy verdad? Nunca la usé pero estuve leyendo algo, es muy interesante. Sobre el multi-core, imagino que usando un pool de user threads por proceso python, y manejando estos últimos mediante el módulo multiprocess, se puede aprovechar el uso de varios núcleos. Algo de eso comenté en #9. Otra cosa interesante del módulo multiprocess es que incluye colecciones de memoria compartida que además son thread y process safe. Esto permite comunicar varios procesos con facilidad, sin preocuparte de los problemas básicos de concurrencia. Muy útil me resulto la JoinableQueue, hay muchos ejemplos de uso en la red. No sé en PyPy, pero CPython incluye el módulo antedicho desde la versión 2.6.2 en adelante, aunque también hay un backport para la v2.5, disponible desde easy install. Saludos.
No, aunque lo comentas es muy interesante. Me refería a esto:
http://www.stackless.com/ http://en.wikipedia.org/wiki/Stackless_Python
Agrego, ya que es interesante el mecanismo que algunos han propuesto (no hay nada real, estable aún) para sortear el problema del GIL en distintos lenguajes. Se trata de usar un conjunto de virtual machines corriendo cada una un pool user threads. Estos últimos no requieren, a diferencia de los kernel threads, de una llamada al sistema (no hay cambio de contexto) para ser creados, y por tanto son más económicos. La desventaja de los user threads, es decir, que el desmultiplexado del tiempo de CPU lo hace el proceso y no el scheduler del kernel (solo un user thread es ejecutado a la vez), aquí no es tan importante ya que se ejecutan varias maquinas virtuales (procesos con al menos un kernel thread) que el scheduler puede repartir entre los distintos CPU. Todo lo anterior se conoce como Multiple Virtual Machine, y es el modelo que usa Earlang, y algunos web servers. Saludos.
Escribir un comentario
Para hacer un comentario es necesario que te identifiques: ENTRA o conéctate con FacebookConnect