Saltar al contenido

Multithreading en Python con Global Interpreter Lock (GIL) Ejemplo

diciembre 8, 2019

El lenguaje de programación python te permite usar multiprocesamiento o multihilo, en este tutorial aprenderás a escribir aplicaciones multihilo en Python.

¿Qué es un Thread?

Una rosca es una unidad de exección en la programación concurrente. El multihilo es una técnica que permite a una CPU ejecutar muchas tareas de un proceso al mismo tiempo. Estos hilos pueden ejecutarse individualmente mientras comparten sus recursos de proceso.

¿Qué es un proceso?

Un proceso es básicamente el programa en ejecución. Al iniciar una aplicación en su ordenador (como un navegador o un editor de texto), el sistema operativo crea un proceso .

¿Qué es el Multithreading?

El multihilo es una técnica que permite a una CPU ejecutar múltiples hilos al mismo tiempo. Estos hilos pueden ejecutarse individualmente mientras comparten sus recursos de proceso.

¿Qué es el multiprocesamiento?

El multiprocesamiento le permite ejecutar múltiples procesos no relacionados simultáneamente. Estos procesos no comparten sus recursos y se comunican a través de la CIP.

Python Multithreading vs Multiprocessing

Para entender los procesos e hilos, considere este escenario: Un archivo.exe en su computadora es un programa. Al abrirlo, el sistema operativo lo carga en la memoria y la CPU lo ejecuta. La instancia del programa que se está ejecutando ahora se llama proceso.

Cada proceso tendrá dos componentes fundamentales:

  • El Código
  • Los datos

Ahora, un proceso puede contener una o más sub-partes llamadas threads. Esto depende de la arquitectura del sistema operativo, puede pensar en un hilo como una sección del proceso que puede ser ejecutado por separado por el sistema operativo.

En otras palabras, es un flujo de instrucciones que puede ser ejecutado independientemente por el sistema operativo. Los hilos dentro de un mismo proceso comparten los datos de ese proceso y están diseñados para trabajar juntos para facilitar el paralelismo.

En este tutorial, aprenderá,

  • ¿Qué es un Thread?
  • ¿Qué es un proceso?
  • ¿Qué es el Multithreading?
  • ¿Qué es el multiprocesamiento?
  • Python Multithreading vs Multiprocessing
  • ¿Por qué usar Multithreading?
  • Python MultiThreading
  • Los módulos Thread y Threading
  • El módulo de rosca
  • El módulo de enhebrado
  • Bloqueos y condiciones de carrera
  • Sincronización de roscas
  • ¿Qué es GIL?
  • ¿Por qué se necesitaba GIL?

¿Por qué usar Multithreading?

El multihilo le permite dividir una aplicación en múltiples subtareas y ejecutar estas tareas simultáneamente. Si utiliza el multithreading correctamente, la velocidad, el rendimiento y el rendering de su aplicación pueden mejorarse.

Python MultiThreading

Python soporta construcciones tanto para multiprocesamiento como para multithreading. En este tutorial, se centrará principalmente en la implementación de aplicaciones multihilo con python. Hay dos módulos principales que se pueden utilizar para manejar roscas en Python:

  1. El módulo thread , y
  2. El módulo de roscado

Sin embargo, en python, también hay algo llamado bloqueo global del intérprete (GIL). No permite mucha ganancia de rendimiento e incluso puede reducir el rendimiento de algunas aplicaciones multihilo. Usted aprenderá todo sobre esto en las próximas secciones de este tutorial.

Los módulos Thread y Threading

Los dos módulos de los que aprenderá en este tutorial son el módulo de roscado y el módulo de roscado .

Sin embargo, el módulo de hilos ha sido obsoleto desde hace mucho tiempo. Comenzando con Python 3, ha sido designado como obsoleto y sólo es accesible como __thread para compatibilidad hacia atrás.

Debería utilizar el módulo de roscado de nivel superior para las aplicaciones que desee implementar. El módulo de rosca sólo se ha tratado aquí con fines educativos.

El módulo de rosca

La sintaxis para crear un nuevo hilo usando este módulo es la siguiente:

thread.start_new_thread(nombre_de_función, argumentos)

Muy bien, ahora has cubierto la teoría básica para empezar a codificar. Por lo tanto, abra su IDLE o un bloc de notas y escriba lo siguiente:

>

Guarde el archivo y pulse F5 para ejecutar el programa. Si todo se hizo correctamente, esta es la salida que debería ver:

Aprenderá más sobre las condiciones de carrera y cómo manejarlas en las siguientes secciones

CODE EXPLANATION

  1. Estas sentencias importan el módulo de tiempo y rosca que se utilizan para manejar la ejecución y el retardo de las roscas de Python.
  2. Aquí, ha definido una función llamada thread_test, que será llamada por el método start_new_thread . La función ejecuta un bucle durante cuatro iteraciones e imprime el nombre del hilo que lo llamó. Una vez finalizada la iteración, se imprime un mensaje que indica que el hilo ha finalizado su ejecución.
  3. Esta es la sección principal de su programa. Aquí, simplemente se llama al método start_new_thread con la función thread_test como argumento.

    Esto creará un nuevo hilo de discusión para la función que usted pasa como argumento y comienza a ejecutarlo. Tenga en cuenta que puede reemplazar esta prueba (thread (thread _ ) por cualquier otra función que desee ejecutar como thread.

El módulo de enhebrado

Este módulo es la implementación de alto nivel del threading en python y el estándar de facto para la gestión de aplicaciones multihilo. Proporciona una amplia gama de características en comparación con el módulo de rosca.

Estructura del módulo Threading

He aquí una lista de algunas funciones útiles definidas en este módulo:

Nombre de la función Descripción activeCount() Devuelve el número de objetos Thread que aún están vivos currentThread() Devuelve el objeto actual de la clase Thread. enumerate() Enumera todos los objetos Thread activos. isDaemon() Devuelve true si el hilo es un demonio. esAlive() Devuelve true si el hilo sigue vivo. Thread Class methods start() Inicia la actividad de un hilo. Debe ser llamado sólo una vez para cada hilo porque lanzará un error de ejecución si se llama varias veces. run() Este método denota la actividad de un hilo y puede ser anulado por una clase que extiende la clase Thread. join() Bloquea la ejecución de otro código hasta que la hebra en la que se llamó al método join() termina.

Historia de fondo: La clase Thread

Antes de empezar a codificar programas multihilo utilizando el módulo de enhebrado, es crucial entender la clase Thread, que es la clase primaria que define la plantilla y las operaciones de un hilo en python.

La forma más común de crear una aplicación python multihilo es declarar una clase que extiende la clase Thread y anula su método run().

La clase Thread, en resumen, significa una secuencia de código que se ejecuta en un hilo de control separado .

Por lo tanto, al escribir una aplicación multihilo, harás lo siguiente:

  1. definir una clase que extienda la clase Thread
  2. Anular el constructor __init__
  3. Anular el método run()

Una vez que se ha hecho un objeto thread, se puede utilizar el método start() para iniciar la ejecución de esta actividad y el método join() para bloquear el resto del código hasta que la actividad actual termine.

Ahora, intentemos usar el módulo de enhebrado para implementar su ejemplo anterior. Una vez más, encienda su IDLE y escriba lo siguiente:

>

Esta será la salida cuando ejecute el código anterior:

CODE EXPLANATION

  1. Esta parte es la misma que la de nuestro ejemplo anterior. Aquí se importa el módulo de tiempo y de hilos que se utiliza para manejar la ejecución y los retardos de los hilos de Python.
  2. En este bit, está creando una clase llamada threadtester, que hereda o extiende la clase Thread del módulo de enhebrado. Esta es una de las formas más comunes de crear hilos en python. Sin embargo, sólo debe redefinir el constructor y el método run() en su aplicación. Como puede ver en el ejemplo de código anterior, el método __init__ (constructor) ha sido anulado.

    De forma similar, también ha anulado el método run() . Contiene el código que se desea ejecutar dentro de un hilo. En este ejemplo, ha llamado a la función thread_test().

  3. Este es el método thread_test() que toma el valor de i como argumento, lo disminuye en 1 en cada iteración y lo recorre por el resto del código hasta que se convierte en 0. En cada iteración, imprime el nombre del hilo que se está ejecutando en ese momento y duerme durante unos segundos (lo que también se toma como argumento).
  4. thread1 = probador de hilo(1, “Primer hilo”, 1)

    Aquí estamos creando un hilo y pasando los tres parámetros que declaramos en __init__. El primer parámetro es el id del hilo, el segundo es el nombre del hilo y el tercero es el contador, que determina cuántas veces debe ejecutarse el bucle while.

  5. thread2.start()

    El método start se utiliza para iniciar la ejecución de un hilo. Internamente, la función start() llama al método run() de su clase.

  6. rosca3.join()

    El método join() bloquea la ejecución de otro código y espera a que termine la rosca en la que se llamó.

Como ya sabes, los hilos que están en el mismo proceso tienen acceso a la memoria y a los datos de ese proceso. Como resultado, si más de un hilo intenta cambiar o acceder a los datos simultáneamente, pueden aparecer errores.

En la siguiente sección, verá los diferentes tipos de complicaciones que pueden aparecer cuando los hilos acceden a los datos y a la sección crítica sin comprobar las transacciones de acceso existentes.

Bloqueos y condiciones de carrera

Antes de aprender acerca de los callejones sin salida y las condiciones de carrera, será útil entender algunas definiciones básicas relacionadas con la programación concurrente:

  • Sección Crítica

    Es un fragmento de código que accede o modifica variables compartidas y debe ser realizado como una transacción atómica.

  • Conmutador de contexto

    Es el proceso que sigue una CPU para almacenar el estado de un hilo antes de cambiar de una tarea a otra, de modo que se pueda reanudar desde el mismo punto más tarde.

Bloqueos

Los bloqueos son el problema más temido al que se enfrentan los desarrolladores cuando escriben aplicaciones concurrentes/multihilos en python. La mejor manera de entender los callejones sin salida es usar el clásico problema de ejemplo de la informática conocido como el Problema de los Filósofos de la Comida .

La declaración del problema para los filósofos de los restaurantes es la siguiente:

Cinco filósofos están sentados en una mesa redonda con cinco platos de espaguetis (un tipo de pasta) y cinco tenedores, como se muestra en el diagrama.

Problema de los Filósofos de la Comida

En un momento dado, un filósofo debe estar comiendo o pensando.

Además, un filósofo debe tomar los dos tenedores adyacentes a él (es decir, los tenedores izquierdo y derecho) antes de que pueda comerse los espaguetis. El problema del punto muerto se produce cuando los cinco filósofos recogen sus horquillas derechas simultáneamente.

Como cada uno de los filósofos tiene un tenedor, todos esperarán a que los demás dejen su tenedor. Como resultado, ninguno de ellos podrá comer espaguetis.

Del mismo modo, en un sistema concurrente, se produce un punto muerto cuando diferentes hilos o procesos (filósofos) intentan adquirir los recursos compartidos del sistema (tenedores) al mismo tiempo. Como resultado, ninguno de los procesos tiene la oportunidad de ejecutarse, ya que están esperando otro recurso en poder de otro proceso.

Condiciones de carrera

Una condición de carrera es un estado no deseado de un programa que ocurre cuando un sistema realiza dos o más operaciones simultáneamente. Por ejemplo, considere esto simple para el bucle:

i=0; # una variable global
para x en el rango(100):
imprimir(i)
i+=1;
>

Si crea n número de hilos que ejecutan este código a la vez, no puede determinar el valor de i (que es compartido por los hilos) cuando el programa termina su ejecución. Esto se debe a que en un entorno multihilo real, los hilos pueden solaparse, y el valor de i que fue recuperado y modificado por un hilo puede cambiar cuando otro hilo accede a él.

Estas son las dos clases principales de problemas que pueden ocurrir en una aplicación python multihilo o distribuida. En la siguiente sección, aprenderá a superar este problema mediante la sincronización de los hilos.

Sincronización de roscas

Para tratar con condiciones de carrera, bloqueos y otros problemas basados en hilos, el módulo de enhebrado proporciona el objeto Lock . La idea es que cuando un hilo quiere acceder a un recurso específico, adquiere un bloqueo para ese recurso. Una vez que un hilo bloquea un recurso en particular, ningún otro hilo puede acceder a él hasta que se libera el bloqueo. Como resultado, los cambios en el recurso serán atómicos y se evitarán las condiciones de la raza.

Un bloqueo es una primitiva de sincronización de bajo nivel implementada por el módulo __thread . En cualquier momento, una cerradura puede estar en uno de los dos estados: bloqueado o desbloqueado. Soporta dos métodos:

  1. acquire()

    Cuando se desbloquea el estado de bloqueo, la llamada al método acquire() cambiará el estado a bloqueado y volverá. Sin embargo, si el estado está bloqueado, la llamada a acquire() se bloquea hasta que el método release() es llamado por algún otro hilo.

  2. release()

    El método release() se utiliza para ajustar el estado a desbloqueado, es decir, para liberar un bloqueo. Se puede llamar por cualquier hilo, no necesariamente el que adquirió la cerradura.

He aquí un ejemplo de uso de bloqueos en sus aplicaciones. Encienda su IDLE y escriba lo siguiente:

importación de hilos
lock = roscado.lock()
def first_function():
    para i in range(5):
        lock.acquire()
        print ($0027candado adquirido$0027)
        print ($0027Ejecutar la primera función$0027)
        lock.release()
def segunda función():
    para i in range(5):
        lock.acquire()
        print ($0027candado adquirido$0027)
        print ($0027Ejecutar la segunda función$0027)
        lock.release()
si __nombre__=="__main__":
    thread_one = threading.thread(target=función_primera)
    thread_two = threading.thread(target=second_function)
    thread_one.start()
    thread_two.start()
    thread_one.join()
    thread_two.join()

>

Ahora, presiona F5. Debería ver una salida como ésta:

CODE EXPLANATION

  1. Aquí, simplemente está creando un nuevo candado llamando a la función de fábrica threading.lock() . Internamente, Lock() devuelve una instancia de la clase de bloqueo de hormigón más efectiva que mantiene la plataforma.
  2. En la primera sentencia, se adquiere el bloqueo llamando al método acquire(). Cuando se ha concedido el bloqueo, se imprime “lock acquired” en la consola. Una vez que todo el código que quiere que el hilo se ejecute ha terminado, suelte el bloqueo llamando al método release().

La teoría está bien, pero ¿cómo sabes que la cerradura realmente funcionó? Si observa la salida, verá que cada una de las expresiones de impresión está imprimiendo exactamente una línea a la vez. Recordemos que, en un ejemplo anterior, los resultados de la impresión eran irregulares porque múltiples hilos estaban accediendo al método print() al mismo tiempo. En este caso, la función de impresión sólo se llama después de adquirir el cierre. Por lo tanto, las salidas se muestran una a una y línea por línea.

Aparte de los bloqueos, python también soporta otros mecanismos para manejar la sincronización de roscas como se indica a continuación:

  1. RLocks
  2. Semáforos
  3. Condiciones
  4. Eventos, y
  5. Barreras

Global Interpreter Lock (y cómo manejarlo)

Antes de entrar en los detalles de la GIL de python, definamos algunos términos que serán útiles para entender la próxima sección:

  1. Código vinculado a la CPU: se refiere a cualquier pieza de código que será ejecutada directamente por la CPU.
  2. Código de enlace I/O: puede ser cualquier código que acceda al sistema de archivos a través del sistema operativo
  3. .

  4. CPython: es la referencia de implementación de Python y puede ser descrito como el intérprete escrito en C y Python (lenguaje de programación).

¿Qué es GIL?

Se puede usar un bloqueo para asegurarse de que sólo un hilo tenga acceso a un recurso en particular en un momento dado.

Una de las características de Python es que utiliza un bloqueo global en cada proceso de interpretación, lo que significa que cada proceso trata al propio intérprete de python como un recurso.

Por ejemplo, supongamos que ha escrito un programa python que utiliza dos subprocesos para realizar tanto operaciones de CPU como de E/S. Cuando se ejecuta este programa, esto es lo que sucede:

  1. El intérprete de python crea un nuevo proceso y genera los hilos
  2. Cuando el hilo-1 comience a funcionar, primero adquirirá el GIL y lo bloqueará.
  3. Si el hilo-2 quiere ejecutarse ahora, tendrá que esperar a que se libere el GIL aunque haya otro procesador libre.
  4. Ahora, supongamos que la rosca 1 está esperando una operación de E/S. En este momento, liberará el GIL, e hilo-2 lo adquirirá.
  5. Después de completar las operaciones de E/S, si el hilo-1 quiere ejecutarse ahora, tendrá que esperar de nuevo a que el GIL sea liberado por el hilo-2.

Debido a esto, sólo un hilo puede acceder al intérprete en cualquier momento, lo que significa que sólo habrá un hilo ejecutando código python en un momento dado.

Esto está bien en un procesador de un solo núcleo porque usaría el tiempo de rebanar (ver la primera sección de este tutorial) para manejar los hilos. Sin embargo, en el caso de procesadores multinúcleo, una función vinculada a la CPU que se ejecute en múltiples subprocesos tendrá un impacto considerable en la eficiencia del programa, ya que en realidad no utilizará todos los núcleos disponibles al mismo tiempo.

¿Por qué se necesitaba GIL?

El colector de basura CPython utiliza una eficiente técnica de gestión de memoria conocida como conteo de referencia. Así es como funciona: Cada objeto en python tiene una cuenta de referencia, que se incrementa cuando se asigna a un nuevo nombre de variable o se añade a un contenedor (como tuplas, listas, etc.). Del mismo modo, el conteo de referencia se reduce cuando la referencia se sale del ámbito de aplicación o cuando se llama la sentencia del. Cuando el conteo de referencia de un objeto llega a 0, se recoge la basura y se libera la memoria asignada.

Pero el problema es que la variable de conteo de referencia es propensa a condiciones de raza como cualquier otra variable global. Para resolver este problema, los desarrolladores de python decidieron utilizar el bloqueo global del intérprete. La otra opción era añadir un bloqueo a cada objeto, lo que habría provocado bloqueos y un aumento de los gastos generales de las llamadas de adquisición y liberación.

Por lo tanto, GIL es una restricción significativa para los programas python multihilo que ejecutan operaciones pesadas vinculadas a la CPU (convirtiéndolos efectivamente en single-threaded). Si desea utilizar varios núcleos de CPU en su aplicación, utilice en su lugar el módulo de multiprocesamiento .

Resumen

  • Python soporta 2 módulos para multihilo:

    1. __thread : Proporciona una implementación de bajo nivel para el threading y es obsoleto.
    2. módulo de roscado : Proporciona una implementación de alto nivel para el roscado múltiple y es el estándar actual.
  • Para crear una rosca utilizando el módulo de roscado, debe hacer lo siguiente:

    1. Cree una clase que extienda la clase Thread .
    2. Anular su constructor (__init__).
    3. Sustituya su método run() .
    4. Cree un objeto de esta clase.
  • Se puede ejecutar una rosca llamando al método start() .
  • El método join() permite bloquear otras roscas hasta que esta rosca (en la que se llamó join) finalice la ejecución.
  • Una condición de carrera ocurre cuando múltiples hilos acceden o modifican un recurso compartido al mismo tiempo.
  • Puede evitarse mediante la sincronización de las roscas.
  • Python soporta 6 formas de sincronización de hilos:

    1. Cerraduras
    2. RLocks
    3. Semáforos
    4. Condiciones
    5. Eventos, y
    6. Barreras
  • Los cierres sólo permiten que entre en la sección crítica una rosca determinada que haya adquirido el cierre.
  • Una cerradura tiene dos métodos principales:

    1. acquire() : Establece el estado de bloqueo en locked. Si se llama a un objeto bloqueado, se bloquea hasta que el recurso está libre.
    2. release() : Establece el estado de bloqueo en unlocked y devuelve. Si se llama a un objeto desbloqueado, devuelve false.
  • El bloqueo global del intérprete es un mecanismo a través del cual sólo se puede ejecutar un proceso de interpretación CPython a la vez.
  • Se utilizó para facilitar la funcionalidad de conteo de referencia del recolector de basura de CPythons.
  • Para hacer aplicaciones de Python con operaciones pesadas de CPU, debe utilizar el módulo de multiprocesamiento.