In questo approfondimento, esploreremo come sfruttare il modulo di threading integrato di Python, scoprendo le potenzialità del multithreading in questo linguaggio di programmazione.
Partendo dalle nozioni fondamentali di processi e thread, comprenderemo il funzionamento del multithreading in Python, analizzando i concetti di concorrenza e parallelismo. Successivamente, impareremo come avviare ed eseguire uno o più thread in Python, servendoci del modulo di threading presente nella libreria standard.
Iniziamo subito questo percorso.
Differenze tra processi e thread
Cos’è un processo?
Un processo rappresenta ogni singola istanza di un programma in esecuzione.
Questo può includere uno script Python, un browser web come Chrome o un’applicazione per videoconferenze. Se apri il Task Manager del tuo computer e vai alla sezione Prestazioni -> CPU, potrai visualizzare i processi e i thread attualmente in esecuzione sui core della tua CPU.
Comprendere processi e thread
Internamente, un processo dispone di uno spazio di memoria dedicato, dove sono archiviati il codice e i dati specifici di quel processo.
Un processo è composto da uno o più thread. Un thread è la più piccola sequenza di istruzioni che il sistema operativo è in grado di eseguire, rappresentando quindi il flusso di esecuzione.
Ogni thread possiede il proprio stack e i propri registri, ma non ha una memoria dedicata. Tutti i thread appartenenti allo stesso processo possono accedere ai dati di quest’ultimo. Di conseguenza, dati e memoria sono condivisi tra tutti i thread di un processo.
In una CPU dotata di N core, N processi possono essere eseguiti in parallelo nello stesso istante. Tuttavia, due thread dello stesso processo non possono mai essere eseguiti in parallelo, ma possono essere eseguiti contemporaneamente. Approfondiremo il concetto di concorrenza e parallelismo nella sezione successiva.
Sulla base di quanto appreso finora, riepiloghiamo le differenze principali tra un processo e un thread.
Caratteristica | Processo | Thread |
Memoria | Memoria dedicata | Memoria condivisa |
Modalità di esecuzione | Parallelo, simultaneo | Concorrente; ma non parallelo |
Gestione dell’esecuzione | Sistema Operativo | Interprete CPython |
Multithreading in Python
In Python, il Global Interpreter Lock (GIL) assicura che solo un thread alla volta possa acquisire il blocco ed essere eseguito. Tutti i thread devono acquisire questo blocco per poter essere eseguiti. Questo meccanismo garantisce che solo un thread sia attivo in un dato momento, prevenendo il multithreading simultaneo.
Per esempio, consideriamo due thread, t1 e t2, appartenenti allo stesso processo. Poiché i thread condividono gli stessi dati, se t1 legge un valore specifico k, t2 potrebbe modificare lo stesso valore k. Questo potrebbe causare deadlock e risultati indesiderati. Tuttavia, solo uno dei thread può acquisire il blocco ed essere eseguito in un certo istante. Di conseguenza, il GIL garantisce anche la sicurezza del thread.
Quindi, come possiamo ottenere effettive capacità di multithreading in Python? Per rispondere a questa domanda, dobbiamo chiarire i concetti di concorrenza e parallelismo.
Concorrenza vs. Parallelismo: una panoramica
Consideriamo una CPU con più core. Nell’immagine seguente, la CPU ha quattro core. Ciò significa che possiamo eseguire quattro diverse operazioni in parallelo in un dato istante.
Se ci sono quattro processi, ognuno di essi può essere eseguito in modo indipendente e simultaneo su ciascuno dei quattro core. Supponiamo che ogni processo abbia due thread.
Per capire come funziona il threading, passiamo da un’architettura con processore multi-core a una single-core. Come accennato, solo un singolo thread può essere attivo in un certo istante di esecuzione; tuttavia, il core del processore può passare da un thread all’altro.
Ad esempio, i thread che gestiscono operazioni di I/O spesso devono attendere: input dell’utente, letture da database e operazioni sui file. Durante questo periodo di attesa, il thread può rilasciare il blocco, consentendo ad un altro thread di essere eseguito. Il tempo di attesa può essere anche una semplice operazione come “dormire” per n secondi.
In sintesi: durante le operazioni di attesa, il thread rilascia il blocco, consentendo al core del processore di passare ad un altro thread. Il thread precedente riprende l’esecuzione al termine del periodo di attesa. Questo processo, in cui il core del processore passa da un thread all’altro in modo simultaneo, facilita il multithreading. ✅
Se si desidera implementare il parallelismo a livello di processo all’interno di un’applicazione, si dovrebbe considerare l’utilizzo del multiprocessing.
Modulo Threading di Python: primi passi
Python include un modulo di threading che può essere importato all’interno di uno script Python.
import threading
Per creare un oggetto thread in Python, si utilizza il costruttore Thread: threading.Thread(…). Questa è la sintassi generale adatta alla maggior parte delle implementazioni di threading:
threading.Thread(target=...,args=...)
Dove:
- target è l’argomento che specifica una funzione Python da eseguire
- args è la tupla degli argomenti da passare alla funzione target.
Avrai bisogno di Python 3.x per eseguire gli esempi di codice presenti in questo tutorial. Scarica il codice e seguimi.
Come definire ed eseguire thread in Python
Definiamo un thread che esegue una funzione.
La funzione target è some_func.
import threading import time def some_func(): print("Esecuzione di some_func...") time.sleep(2) print("Esecuzione di some_func completata.") thread1 = threading.Thread(target=some_func) thread1.start() print(threading.active_count())
Analizziamo cosa fa lo snippet di codice precedente:
- Importa i moduli threading e time.
- La funzione some_func ha istruzioni print() descrittive e include un’operazione di sospensione per due secondi: time.sleep(n) fa sì che la funzione vada in pausa per n secondi.
- Successivamente, definiamo un thread thread_1 con l’obiettivo some_func. threading.Thread(target=…) crea un oggetto thread.
- Nota: bisogna specificare il nome della funzione e non una chiamata; usa some_func e non some_func().
- La creazione di un oggetto thread non avvia automaticamente un thread; per farlo, si deve chiamare il metodo start() sull’oggetto thread.
- Per ottenere il numero di thread attivi, si utilizza la funzione active_count().
Lo script Python viene eseguito sul thread principale e stiamo creando un altro thread (thread1) per eseguire la funzione some_func, quindi il conteggio dei thread attivi è due, come si vede nell’output:
# Output Esecuzione di some_func... 2 Esecuzione di some_func completata.
Analizzando l’output, si può osservare che all’avvio di thread1, viene eseguita la prima istruzione print. Tuttavia, durante l’operazione di sospensione, il processore passa al thread principale e stampa il numero di thread attivi, senza attendere il completamento dell’esecuzione di thread1.
Attendere che i thread finiscano l’esecuzione
Se desideri che thread1 termini la sua esecuzione, puoi chiamare il metodo join() su di esso dopo aver avviato il thread. Questo farà in modo di attendere che thread1 termini la sua esecuzione senza passare al thread principale.
import threading import time def some_func(): print("Esecuzione di some_func...") time.sleep(2) print("Esecuzione di some_func completata.") thread1 = threading.Thread(target=some_func) thread1.start() thread1.join() print(threading.active_count())
Ora, thread1 ha terminato l’esecuzione prima di stampare il conteggio dei thread attivi. Quindi è attivo solo il thread principale, il che significa che il conteggio dei thread attivi è uno. ✅
# Output Esecuzione di some_func... Esecuzione di some_func completata. 1
Come eseguire più thread in Python
Ora, creiamo due thread per eseguire due funzioni diverse.
Qui, count_down è una funzione che accetta un numero come argomento ed esegue un conteggio alla rovescia da quel numero fino a zero.
def count_down(n): for i in range(n,-1,-1): print(i)
Definiamo count_up, un’altra funzione Python che conta da zero fino a un certo numero.
def count_up(n): for i in range(n+1): print(i)
📑 Quando si utilizza la funzione range() con la sintassi range(start, stop, step), l’arresto del punto finale è escluso per impostazione predefinita.
– Per eseguire un conteggio alla rovescia da un numero specifico a zero, si può utilizzare un valore di incremento negativo pari a -1 e impostare il valore di arresto su -1 in modo da includere zero.
– Allo stesso modo, per contare fino a n, è necessario impostare il valore di stop su n + 1. Poiché i valori predefiniti di start e step sono rispettivamente 0 e 1, si può usare range(n + 1) per ottenere la sequenza da 0 fino a n.
Ora definiamo due thread, thread1 e thread2, per eseguire rispettivamente le funzioni count_down e count_up. Aggiungiamo delle istruzioni di stampa e operazioni di sospensione per entrambe le funzioni.
Quando si creano gli oggetti thread, si noti che gli argomenti della funzione target devono essere specificati come tupla, nel parametro args. Poiché entrambe le funzioni (count_down e count_up) accettano un argomento, bisognerà inserire una virgola esplicitamente dopo il valore. Questo assicura che l’argomento venga comunque passato come una tupla, dato che gli elementi successivi sono considerati come None.
import threading import time def count_down(n): for i in range(n,-1,-1): print("Esecuzione di thread1....") print(i) time.sleep(1) def count_up(n): for i in range(n+1): print("Esecuzione di thread2...") print(i) time.sleep(1) thread1 = threading.Thread(target=count_down,args=(10,)) thread2 = threading.Thread(target=count_up,args=(5,)) thread1.start() thread2.start()
In output:
- La funzione count_up viene eseguita su thread2 e conta fino a 5 partendo da 0.
- La funzione count_down viene eseguita su thread1 con un conteggio alla rovescia da 10 a 0.
# Output Esecuzione di thread1.... 10 Esecuzione di thread2... 0 Esecuzione di thread1.... 9 Esecuzione di thread2... 1 Esecuzione di thread1.... 8 Esecuzione di thread2... 2 Esecuzione di thread1.... 7 Esecuzione di thread2... 3 Esecuzione di thread1.... 6 Esecuzione di thread2... 4 Esecuzione di thread1.... 5 Esecuzione di thread2... 5 Esecuzione di thread1.... 4 Esecuzione di thread1.... 3 Esecuzione di thread1.... 2 Esecuzione di thread1.... 1 Esecuzione di thread1.... 0
Si può vedere come thread1 e thread2 vengono eseguiti in modo alternato, poiché entrambi prevedono un’operazione di attesa (sleep). Una volta che la funzione count_up ha finito di contare fino a 5, thread2 non è più attivo. Quindi otteniamo l’output corrispondente al solo thread1.
Riassumendo
In questo approfondimento, abbiamo esplorato come utilizzare il modulo di threading integrato in Python per implementare il multithreading. Ecco un riepilogo dei punti chiave:
- Il costruttore Thread può essere utilizzato per creare un oggetto thread. Usando threading.Thread(target=<funzione>, args=(<tupla di argomenti>)) si crea un thread che esegue la funzione target con gli argomenti specificati in args.
- Il programma Python viene eseguito su un thread principale, quindi gli oggetti thread che creiamo sono thread aggiuntivi. La funzione active_count() restituisce il numero di thread attivi in un dato momento.
- Si può avviare un thread utilizzando il metodo start() sull’oggetto thread e attendere che termini l’esecuzione utilizzando il metodo join().
Prova altri esempi modificando i tempi di attesa, sperimentando diverse operazioni di I/O. Assicurati di integrare il multithreading nei tuoi prossimi progetti Python. Buona programmazione!🎉