Esempio di multithreading in Python con Global Interpreter Lock (GIL)

Sommario:

Anonim

Il linguaggio di programmazione Python ti consente di utilizzare il multiprocessing o il multithreading. In questo tutorial imparerai come scrivere applicazioni multithread in Python.

Cos'è un thread?

Un thread è un'unità di esecuzione nella programmazione concorrente. Il multithreading è una tecnica che consente a una CPU di eseguire più attività di un processo contemporaneamente. Questi thread possono essere eseguiti individualmente condividendo le risorse di processo.

Cos'è un processo?

Un processo è fondamentalmente il programma in esecuzione. Quando avvii un'applicazione sul tuo computer (come un browser o un editor di testo), il sistema operativo crea un processo.

Cos'è il multithreading in Python?

Il multithreading nella programmazione Python è una tecnica ben nota in cui più thread in un processo condividono il proprio spazio dati con il thread principale, il che rende la condivisione delle informazioni e la comunicazione all'interno dei thread facile ed efficiente. I fili sono più leggeri dei processi. I multi thread possono essere eseguiti individualmente condividendo le risorse di processo. Lo scopo del multithreading è eseguire più attività e celle funzione contemporaneamente.

Cos'è il multiprocessing?

Il multiprocessing consente di eseguire più processi non correlati contemporaneamente. Questi processi non condividono le proprie risorse e comunicano tramite IPC.

Multithreading Python vs multiprocessing

Per comprendere processi e thread, considera questo scenario: un file .exe sul tuo computer è un programma. Quando lo apri, il sistema operativo lo carica in memoria e la CPU lo esegue. L'istanza del programma che è ora in esecuzione è chiamata processo.

Ogni processo avrà 2 componenti fondamentali:

  • Il codice
  • I dati

Ora, un processo può contenere una o più sottoparti chiamate thread. Questo dipende dall'architettura del sistema operativo. Puoi pensare a un thread come a una sezione del processo che può essere eseguita separatamente dal sistema operativo.

In altre parole, è un flusso di istruzioni che può essere eseguito indipendentemente dal sistema operativo. I thread all'interno di un singolo processo condividono i dati di quel processo e sono progettati per lavorare insieme per facilitare il parallelismo.

In questo tutorial imparerai,

  • Cos'è un thread?
  • Cos'è un processo?
  • Cos'è il multithreading?
  • Cos'è il multiprocessing?
  • Multithreading Python vs multiprocessing
  • Perché utilizzare il multithreading?
  • Python MultiThreading
  • I moduli Thread e Threading
  • Il modulo Thread
  • Il modulo di filettatura
  • Deadlock e condizioni di gara
  • Sincronizzazione dei thread
  • Cos'è GIL?
  • Perché era necessario GIL?

Perché utilizzare il multithreading?

Il multithreading consente di suddividere un'applicazione in più attività secondarie ed eseguire queste attività contemporaneamente. Se si utilizza correttamente il multithreading, è possibile migliorare la velocità, le prestazioni e il rendering dell'applicazione.

Python MultiThreading

Python supporta i costrutti sia per il multiprocessing che per il multithreading. In questo tutorial, ti concentrerai principalmente sull'implementazione di applicazioni multithread con python. Esistono due moduli principali che possono essere utilizzati per gestire i thread in Python:

  1. Il modulo thread e
  2. Il modulo di filettatura

Tuttavia, in python, esiste anche qualcosa chiamato global interpreter lock (GIL). Non consente un notevole aumento delle prestazioni e può persino ridurre le prestazioni di alcune applicazioni multithread. Imparerai tutto a riguardo nelle prossime sezioni di questo tutorial.

I moduli Thread e Threading

I due moduli che imparerai in questo tutorial sono il modulo thread e il modulo threading .

Tuttavia, il modulo thread è stato a lungo deprecato. A partire da Python 3, è stato designato come obsoleto ed è accessibile solo come __thread per compatibilità con le versioni precedenti.

È necessario utilizzare il modulo di threading di livello superiore per le applicazioni che si intende distribuire. Il modulo thread è stato trattato qui solo per scopi didattici.

Il modulo Thread

La sintassi per creare un nuovo thread utilizzando questo modulo è la seguente:

thread.start_new_thread(function_name, arguments)

Bene, ora hai coperto la teoria di base per iniziare a programmare. Quindi, apri il tuo IDLE o un blocco note e digita quanto segue:

import timeimport _threaddef thread_test(name, wait):i = 0while i <= 3:time.sleep(wait)print("Running %s\n" %name)i = i + 1print("%s has finished execution" %name)if __name__ == "__main__":_thread.start_new_thread(thread_test, ("First Thread", 1))_thread.start_new_thread(thread_test, ("Second Thread", 2))_thread.start_new_thread(thread_test, ("Third Thread", 3))

Salva il file e premi F5 per eseguire il programma. Se tutto è stato fatto correttamente, questo è l'output che dovresti vedere:

Imparerai di più sulle condizioni di gara e su come gestirle nelle prossime sezioni

SPIEGAZIONE DEL CODICE

  1. Queste istruzioni importano il modulo time e thread che vengono utilizzati per gestire l'esecuzione e il ritardo dei thread Python.
  2. Qui, hai definito una funzione chiamata thread_test, che verrà chiamata dal metodo start_new_thread . La funzione esegue un ciclo while per quattro iterazioni e stampa il nome del thread che l'ha chiamata. Una volta completata l'iterazione, stampa un messaggio che dice che il thread ha terminato l'esecuzione.
  3. Questa è la sezione principale del tuo programma. Qui, chiami semplicemente il metodo start_new_thread con la funzione thread_test come argomento.

    Questo creerà un nuovo thread per la funzione che passi come argomento e inizierà a eseguirlo. Nota che puoi sostituire questo (thread _ test) con qualsiasi altra funzione che desideri eseguire come thread.

Il modulo di filettatura

Questo modulo è l'implementazione di alto livello del threading in Python e lo standard de facto per la gestione delle applicazioni multithread. Fornisce un'ampia gamma di funzionalità rispetto al modulo thread.

Struttura del modulo Threading

Di seguito è riportato un elenco di alcune funzioni utili definite in questo modulo:

Nome funzione Descrizione
activeCount () Restituisce il conteggio degli oggetti Thread che sono ancora vivi
currentThread () Restituisce l'oggetto corrente della classe Thread.
enumerare() Elenca tutti gli oggetti Thread attivi.
isDaemon () Restituisce vero se il thread è un demone.
è vivo() Restituisce vero se il thread è ancora attivo.
Metodi di classe di thread
inizio() Avvia l'attività di un thread. Deve essere chiamato solo una volta per ogni thread perché genererà un errore di runtime se chiamato più volte.
correre() Questo metodo denota l'attività di un thread e può essere sovrascritto da una classe che estende la classe Thread.
aderire() Blocca l'esecuzione di altro codice fino a quando il thread su cui è stato chiamato il metodo join () non viene terminato.

Backstory: The Thread Class

Prima di iniziare a codificare programmi multithread utilizzando il modulo threading, è fondamentale comprendere la classe Thread, che è la classe primaria che definisce il modello e le operazioni di un thread in python.

Il modo più comune per creare un'applicazione python multithread è dichiarare una classe che estende la classe Thread e sovrascrive il suo metodo run ().

La classe Thread, in sintesi, indica una sequenza di codice che viene eseguita in un thread di controllo separato .

Quindi, quando scrivi un'app multithread, farai quanto segue:

  1. definire una classe che estende la classe Thread
  2. Sostituisci il costruttore __init__
  3. Sostituisci il metodo run ()

Una volta creato un oggetto thread, il metodo start () può essere utilizzato per iniziare l'esecuzione di questa attività e il metodo join () può essere utilizzato per bloccare tutto il resto del codice fino al termine dell'attività corrente.

Ora, proviamo a utilizzare il modulo threading per implementare il tuo esempio precedente. Ancora una volta, avvia il tuo IDLE e digita quanto segue:

import timeimport threadingclass threadtester (threading.Thread):def __init__(self, id, name, i):threading.Thread.__init__(self)self.id = idself.name = nameself.i = idef run(self):thread_test(self.name, self.i, 5)print ("%s has finished execution " %self.name)def thread_test(name, wait, i):while i:time.sleep(wait)print ("Running %s \n" %name)i = i - 1if __name__=="__main__":thread1 = threadtester(1, "First Thread", 1)thread2 = threadtester(2, "Second Thread", 2)thread3 = threadtester(3, "Third Thread", 3)thread1.start()thread2.start()thread3.start()thread1.join()thread2.join()thread3.join()

Questo sarà l'output quando esegui il codice sopra:

SPIEGAZIONE DEL CODICE

  1. Questa parte è la stessa del nostro esempio precedente. Qui, importi il ​​modulo time e thread che vengono utilizzati per gestire l'esecuzione e i ritardi dei thread Python.
  2. In questo bit, stai creando una classe chiamata threadtester, che eredita o estende la classe Thread del modulo di threading. Questo è uno dei modi più comuni per creare thread in Python. Tuttavia, dovresti solo sovrascrivere il costruttore e il metodo run () nella tua app. Come puoi vedere nell'esempio di codice sopra, il metodo __init__ (costruttore) è stato sovrascritto.

    Allo stesso modo, hai anche sovrascritto il metodo run () . Contiene il codice che vuoi eseguire all'interno di un thread. In questo esempio, hai chiamato la funzione thread_test ().

  3. Questoèil metodo thread_test () che prende il valore di i come argomento, lo diminuisce di 1 ad ogni iterazione e scorre il resto del codice fino a quando i diventa 0. In ogni iterazione, stampa il nome del thread attualmente in esecuzione e dorme per attendere secondi (che è anche preso come argomento).
  4. thread1 = threadtester (1, "Primo thread", 1)

    Qui stiamo creando un thread e passando i tre parametri che abbiamo dichiarato in __init__. Il primo parametro è l'id del thread, il secondo parametro è il nome del thread e il terzo parametro è il contatore, che determina quante volte deve essere eseguito il ciclo while.

  5. thread2.start ()

    Il metodo start viene utilizzato per avviare l'esecuzione di un thread. Internamente, la funzione start () chiama il metodo run () della tua classe.

  6. thread3.join ()

    Il metodo join () blocca l'esecuzione di altro codice e attende fino al termine del thread su cui è stato chiamato.

Come già sapete, i thread che si trovano nello stesso processo hanno accesso alla memoria e ai dati di quel processo. Di conseguenza, se più di un thread tenta di modificare o accedere ai dati contemporaneamente, potrebbero verificarsi degli errori.

Nella sezione successiva, vedrai i diversi tipi di complicazioni che possono presentarsi quando i thread accedono ai dati e alla sezione critica senza controllare le transazioni di accesso esistenti.

Deadlock e condizioni di gara

Prima di conoscere i deadlock e le race condition, sarà utile comprendere alcune definizioni di base relative alla programmazione concorrente:

  • Sezione critica

    È un frammento di codice che accede o modifica le variabili condivise e deve essere eseguito come una transazione atomica.

  • Cambio di contesto

    È il processo che una CPU segue per memorizzare lo stato di un thread prima di passare da un'attività all'altra in modo che possa essere ripreso dallo stesso punto in un secondo momento.

Deadlock

I deadlock sono il problema più temuto che gli sviluppatori devono affrontare durante la scrittura di applicazioni simultanee / multithread in Python. Il modo migliore per comprendere i deadlock è utilizzare il classico problema di esempio dell'informatica noto come problema dei filosofi a tavola.

L'affermazione del problema per i filosofi a tavola è la seguente:

Cinque filosofi sono seduti su una tavola rotonda con cinque piatti di spaghetti (un tipo di pasta) e cinque forchette, come mostrato nel diagramma.

Problema dei filosofi a pranzo

In un dato momento, un filosofo deve mangiare o pensare.

Inoltre, un filosofo deve prendere le due forchette adiacenti a lui (cioè la forchetta sinistra e quella destra) prima di poter mangiare gli spaghetti. Il problema del deadlock si verifica quando tutti e cinque i filosofi raccolgono le loro forchette giuste contemporaneamente.

Poiché ognuno dei filosofi ha una forchetta, tutti aspetteranno che gli altri posino la forchetta. Di conseguenza, nessuno di loro potrà mangiare gli spaghetti.

Allo stesso modo, in un sistema concorrente, si verifica un deadlock quando diversi thread o processi (filosofi) tentano di acquisire contemporaneamente le risorse di sistema condivise (fork). Di conseguenza, nessuno dei processi ha la possibilità di essere eseguito poiché è in attesa di un'altra risorsa detenuta da un altro processo.

Condizioni di gara

Una condizione di competizione è uno stato indesiderato di un programma che si verifica quando un sistema esegue due o più operazioni contemporaneamente. Ad esempio, considera questo semplice ciclo for:

i=0; # a global variablefor x in range(100):print(i)i+=1;

Se crei un numero n di thread che eseguono questo codice contemporaneamente, non puoi determinare il valore di i (che è condiviso dai thread) quando il programma termina l'esecuzione. Questo perché in un vero ambiente multithreading, i thread possono sovrapporsi e il valore di i che è stato recuperato e modificato da un thread può cambiare quando un altro thread vi accede.

Queste sono le due principali classi di problemi che possono verificarsi in un'applicazione Python multithread o distribuita. Nella sezione successiva imparerai come superare questo problema sincronizzando i thread.

Sincronizzazione dei thread

Per gestire condizioni di competizione, deadlock e altri problemi basati su thread, il modulo di threading fornisce l' oggetto Lock . L'idea è che quando un thread desidera accedere a una risorsa specifica, acquisisce un blocco per quella risorsa. Una volta che un thread blocca una determinata risorsa, nessun altro thread può accedervi finché il blocco non viene rilasciato. Di conseguenza, le modifiche alla risorsa saranno atomiche e le race condition verranno evitate.

Un lock è una primitiva di sincronizzazione di basso livello implementata dal modulo __thread . In qualsiasi momento, un lucchetto può trovarsi in uno dei 2 stati: bloccato o sbloccato. Supporta due metodi:

  1. acquisire()

    Quando lo stato di blocco è sbloccato, la chiamata al metodo acquis () cambierà lo stato in bloccato e verrà restituito. Tuttavia, se lo stato è bloccato, la chiamata ad acquis () viene bloccata fino a quando il metodo release () non viene chiamato da qualche altro thread.

  2. pubblicazione()

    Il metodo release () viene utilizzato per impostare lo stato su unlocked, ovvero per rilasciare un blocco. Può essere chiamato da qualsiasi thread, non necessariamente quello che ha acquisito il blocco.

Ecco un esempio di utilizzo dei lucchetti nelle tue app. Avvia il tuo IDLE e digita quanto segue:

import threadinglock = threading.Lock()def first_function():for i in range(5):lock.acquire()print ('lock acquired')print ('Executing the first funcion')lock.release()def second_function():for i in range(5):lock.acquire()print ('lock acquired')print ('Executing the second funcion')lock.release()if __name__=="__main__":thread_one = threading.Thread(target=first_function)thread_two = threading.Thread(target=second_function)thread_one.start()thread_two.start()thread_one.join()thread_two.join()

Ora premi F5. Dovresti vedere un output come questo:

SPIEGAZIONE DEL CODICE

  1. Qui, stai semplicemente creando un nuovo blocco chiamando la funzione di fabbrica threading.Lock () . Internamente, Lock () restituisce un'istanza della classe concreta Lock più efficace mantenuta dalla piattaforma.
  2. Nella prima istruzione, acquisisci il blocco chiamando il metodo acquis (). Quando il blocco è stato concesso, si stampa "blocco acquisito" sulla console. Una volta terminata l'esecuzione di tutto il codice che si desidera venga eseguito dal thread, si rilascia il blocco chiamando il metodo release ().

La teoria va bene, ma come fai a sapere che la serratura ha funzionato davvero? Se guardi l'output, vedrai che ciascuna delle istruzioni print sta stampando esattamente una riga alla volta. Ricorda che, in un esempio precedente, gli output di print erano casuali perché più thread stavano accedendo al metodo print () contemporaneamente. Qui, la funzione di stampa viene chiamata solo dopo l'acquisizione del blocco. Quindi, le uscite vengono visualizzate una alla volta e riga per riga.

Oltre ai blocchi, python supporta anche altri meccanismi per gestire la sincronizzazione dei thread come elencato di seguito:

  1. RLocks
  2. Semafori
  3. Condizioni
  4. Eventi e
  5. Barriere

Global Interpreter Lock (e come gestirlo)

Prima di entrare nei dettagli del GIL di python, definiamo alcuni termini che saranno utili per comprendere la prossima sezione:

  1. Codice legato alla CPU: si riferisce a qualsiasi parte di codice che verrà eseguita direttamente dalla CPU.
  2. Codice associato a I / O: può essere qualsiasi codice che accede al file system attraverso il sistema operativo
  3. CPython: è l' implementazione di riferimento di Python e può essere descritto come l'interprete scritto in C e Python (linguaggio di programmazione).

Cos'è GIL in Python?

Global Interpreter Lock (GIL) in python è un blocco di processo o un mutex utilizzato durante la gestione dei processi. Assicura che un thread possa accedere a una particolare risorsa alla volta e impedisce anche l'uso di oggetti e bytecode contemporaneamente. Ciò avvantaggia i programmi a thread singolo in un aumento delle prestazioni. GIL in Python è molto semplice e facile da implementare.

È possibile utilizzare un blocco per assicurarsi che un solo thread abbia accesso a una determinata risorsa in un dato momento.

Una delle caratteristiche di Python è che utilizza un blocco globale su ogni processo dell'interprete, il che significa che ogni processo tratta l'interprete Python stesso come una risorsa.

Ad esempio, supponiamo di aver scritto un programma python che utilizza due thread per eseguire operazioni sia della CPU che di "I / O". Quando esegui questo programma, ecco cosa succede:

  1. L'interprete Python crea un nuovo processo e genera i thread
  2. Quando il thread-1 inizia a funzionare, prima acquisirà il GIL e lo bloccherà.
  3. Se il thread-2 vuole essere eseguito ora, dovrà attendere il rilascio del GIL anche se un altro processore è libero.
  4. Supponiamo ora che il thread-1 stia aspettando un'operazione di I / O. A questo punto, rilascerà il GIL e il thread-2 lo acquisirà.
  5. Dopo aver completato le operazioni di I / O, se il thread-1 vuole essere eseguito ora, dovrà di nuovo attendere che il GIL venga rilasciato dal thread-2.

Per questo motivo, solo un thread può accedere all'interprete in qualsiasi momento, il che significa che ci sarà un solo thread che esegue il codice Python in un determinato momento.

Questo va bene in un processore single-core perché userebbe il time slicing (vedere la prima sezione di questo tutorial) per gestire i thread. Tuttavia, in caso di processori multi-core, una funzione associata alla CPU eseguita su più thread avrà un impatto considerevole sull'efficienza del programma poiché in realtà non utilizzerà tutti i core disponibili contemporaneamente.

Perché era necessario GIL?

Il garbage collector CPython utilizza una tecnica di gestione della memoria efficiente nota come conteggio dei riferimenti. Ecco come funziona: ogni oggetto in python ha un conteggio dei riferimenti, che viene aumentato quando viene assegnato a un nuovo nome di variabile o aggiunto a un contenitore (come tuple, elenchi, ecc.). Allo stesso modo, il conteggio dei riferimenti viene ridotto quando il riferimento esce dall'ambito o quando viene chiamata l'istruzione del. Quando il conteggio dei riferimenti di un oggetto raggiunge 0, viene sottoposto a garbage collection e la memoria assegnata viene liberata.

Ma il problema è che la variabile di conteggio dei riferimenti è soggetta a condizioni di gara come qualsiasi altra variabile globale. Per risolvere questo problema, gli sviluppatori di python hanno deciso di utilizzare il blocco dell'interprete globale. L'altra opzione era quella di aggiungere un blocco a ciascun oggetto che avrebbe provocato deadlock e un aumento dell'overhead dalle chiamate acquisite () e release ().

Pertanto, GIL è una restrizione significativa per i programmi Python multithread che eseguono operazioni pesanti legate alla CPU (rendendoli effettivamente a thread singolo). Se si desidera utilizzare più core della CPU nella propria applicazione, utilizzare invece il modulo multiprocessing .

Sommario

  • Python supporta 2 moduli per il multithreading:
    1. Modulo __thread : fornisce un'implementazione di basso livello per il threading ed è obsoleto.
    2. modulo di threading : fornisce un'implementazione di alto livello per il multithreading ed è lo standard corrente.
  • Per creare un thread utilizzando il modulo threading, è necessario eseguire le seguenti operazioni:
    1. Crea una classe che estende la classe Thread .
    2. Sostituisci il suo costruttore (__init__).
    3. Ignora il suo metodo run () .
    4. Crea un oggetto di questa classe.
  • Un thread può essere eseguito chiamando il metodo start () .
  • Il metodo join () può essere utilizzato per bloccare altri thread fino a quando questo thread (quello su cui è stato chiamato il join) termina l'esecuzione.
  • Una condizione di competizione si verifica quando più thread accedono o modificano contemporaneamente una risorsa condivisa.
  • Può essere evitato sincronizzando i thread.
  • Python supporta 6 modi per sincronizzare i thread:
    1. Serrature
    2. RLocks
    3. Semafori
    4. Condizioni
    5. Eventi e
    6. Barriere
  • I blocchi consentono solo a un particolare thread che ha acquisito il blocco di entrare nella sezione critica.
  • Un lucchetto ha 2 metodi principali:
    1. acquisisci () : imposta lo stato di blocco su bloccato. Se chiamato su un oggetto bloccato, si blocca finché la risorsa non è libera.
    2. release () : imposta lo stato di blocco su sbloccato e ritorna. Se chiamato su un oggetto sbloccato, restituisce false.
  • Il blocco dell'interprete globale è un meccanismo attraverso il quale può essere eseguito solo 1 processo interprete CPython alla volta.
  • È stato utilizzato per facilitare la funzionalità di conteggio dei riferimenti del garbage collector di CPythons.
  • Per creare app Python con pesanti operazioni legate alla CPU, dovresti usare il modulo multiprocessing.