Introduzione al Multithreading e al Multiprocessing in Python

Un'Introduzione al Multithreading e al Multiprocessing in Python

 

Questo tutorial discuterà sull’utilizzo della capacità di esecuzione di multithreading e multiprogrammazione di Python. Offrono un gateway per eseguire operazioni simultanee all’interno di un singolo processo o attraverso processi multipli. L’esecuzione parallela e simultanea aumenta la velocità ed efficienza dei sistemi. Dopo aver discusso le basi del multithreading e della multiprogrammazione, discuteremo anche della loro realizzazione pratica utilizzando le librerie di Python. Per prima cosa, discutiamo brevemente i vantaggi dei sistemi paralleli.

  1. Miglioramento delle prestazioni: Con la capacità di eseguire operazioni simultanee, possiamo ridurre il tempo di esecuzione e migliorare le prestazioni complessive del sistema.
  2. Scalabilità: Possiamo dividere un grande compito in vari sotto-compiti più piccoli e assegnare loro un core o thread separato per l’esecuzione indipendente. Può essere utile nei sistemi su larga scala.
  3. Efficienza delle operazioni I/O: Grazie alla concorrenza, la CPU non deve attendere che un processo completi le sue operazioni I/O. La CPU può immediatamente iniziare ad eseguire il processo successivo fino a quando il processo precedente è occupato con le sue operazioni I/O.
  4. Ottimizzazione delle risorse: Dividendo le risorse, possiamo evitare che un singolo processo occupi tutte le risorse. Ciò può evitare il problema della Fame per processi più piccoli.

  

Questi sono alcuni motivi comuni per cui è necessaria l’esecuzione simultanea o parallela. Ora, torniamo agli argomenti principali, ovvero il multithreading e la multiprogrammazione e ne discutiamo le principali differenze.

 

Cos’è il multithreading?

 

Il multithreading è uno dei modi per ottenere il parallelismo in un singolo processo, in grado di eseguire attività simultanee. Sono possibili la creazione di thread multipli all’interno di un singolo processo e l’esecuzione di attività più piccole simultaneamente in quel processo. 

I thread presenti all’interno di un singolo processo condividono uno spazio di memoria comune, ma le tracce dello stack e i registri sono separati. Sono meno dispendiosi in termini di calcolo grazie a questa memoria condivisa.

  

Il multithreading è principalmente utilizzato per eseguire operazioni I/O, ovvero se una parte del programma è occupata da operazioni I/O, il resto del programma può restare reattivo. Tuttavia, nell’implementazione di Python, il multithreading non può raggiungere un vero parallelismo a causa del Global Interpreter Lock (GIL).

In breve, il GIL è un blocco mutex che consente a un solo thread alla volta di interagire con il bytecode di Python, ovvero anche in modalità multithread, solo un thread può eseguire il bytecode alla volta.

Questo viene fatto per mantenere la sicurezza dei thread in CPython, ma limita i vantaggi delle prestazioni del multithreading. Per risolvere questo problema, Python ha una libreria di multiprocessing separata, di cui parleremo dopo.

Cos’è un thread daemon?

I thread che vengono eseguiti costantemente in background sono chiamati thread daemon. Il loro compito principale è supportare il thread principale o i thread non daemon. Il thread daemon non blocca l’esecuzione del thread principale e continua a funzionare anche se ha completato la sua esecuzione.

In Python, i thread daemon vengono principalmente utilizzati come garbage collector. Distruggeranno tutti gli oggetti inutili e libereranno la memoria di default in modo che il thread principale possa essere utilizzato ed eseguito correttamente.

 

Cos’è la multiprogrammazione?

 

La multiprogrammazione viene utilizzata per eseguire l’esecuzione parallela di più processi. Ci aiuta a ottenere un vero parallelismo, poiché eseguiamo processi separati contemporaneamente, con il proprio spazio di memoria. Utilizza core separati della CPU ed è utile anche per eseguire la comunicazione tra processi per scambiare dati tra processi multipli.

La multiprogrammazione è più dispendiosa in termini di calcolo rispetto al multithreading, poiché non utilizziamo uno spazio di memoria condiviso. Tuttavia, ci consente di eseguire l’esecuzione indipendente e supera i limiti del Global Interpreter Lock.

  

La figura sopra mostra un ambiente di multiprogrammazione in cui un processo principale crea due processi separati e assegna loro attività separate.

 

Implementazione del multithreading

 

È ora di implementare un esempio di base di multithreading usando Python. Python ha un modulo integrato threading utilizzato per l’implementazione del multithreading.

  1. Importazione delle librerie:
import threadingimport os

 

  1. Funzione per calcolare i quadrati:

Questa è una semplice funzione utilizzata per trovare il quadrato dei numeri. Viene fornito un elenco di numeri in ingresso e restituisce il quadrato di ogni numero dell’elenco insieme al nome del thread utilizzato e all’ID del processo associato a quel thread.

def calculate_squares(numbers):    for num in numbers:        square = num * num        print(            f"Il quadrato del numero {num} è {square} | Nome del thread {threading.current_thread().name} | PID del processo {os.getpid()}"        )

 

  1. Funzione principale:

Abbiamo una lista di numeri e divideremo quella lista in parti uguali e le chiameremo rispettivamente first_half e second_half. Ora assegneremo due thread separati t1 e t2 a queste liste.

La funzione Thread crea un nuovo thread, che richiede una funzione con un elenco di argomenti per quella funzione. È possibile assegnare anche un nome separato a un thread.

La funzione .start() avvierà l’esecuzione di questi thread e la funzione .join() bloccherà l’esecuzione del thread principale finché il thread dato non sarà eseguito completamente.

if __name__ == "__main__":    numbers = [1, 2, 3, 4, 5, 6, 7, 8]    half = len(numbers) // 2    first_half = numbers[:half]    second_half = numbers[half:]    t1 = threading.Thread(target=calculate_squares, name="t1", args=(first_half,))    t2 = threading.Thread(target=calculate_squares, name="t2", args=(second_half,))    t1.start()    t2.start()    t1.join()    t2.join()

 

Output:

Il quadrato del numero 1 è 1 | Nome del thread t1 | PID del processo 345Il quadrato del numero 2 è 4 | Nome del thread t1 | PID del processo 345Il quadrato del numero 5 è 25 | Nome del thread t2 | PID del processo 345Il quadrato del numero 3 è 9 | Nome del thread t1 | PID del processo 345Il quadrato del numero 6 è 36 | Nome del thread t2 | PID del processo 345Il quadrato del numero 4 è 16 | Nome del thread t1 | PID del processo 345Il quadrato del numero 7 è 49 | Nome del thread t2 | PID del processo 345Il quadrato del numero 8 è 64 | Nome del thread t2 | PID del processo 345

 

Nota: Tutti i thread creati sopra sono thread non daemon. Per creare un thread daemon, è necessario scrivere t1.setDaemon(True) per rendere il thread t1 un thread daemon.

 

Ora, comprendiamo l’output generato dal codice precedente. Possiamo osservare che l’ID del processo (cioè PID) rimarrà lo stesso per entrambi i thread, il che significa che questi due thread fanno parte dello stesso processo.

Puoi anche osservare che l’output non viene generato in modo sequenziale. Nella prima riga, vedrai l’output generato dal thread1, quindi nella terza riga, l’output generato dal thread2, quindi di nuovo dal thread1 nella quarta riga. Questo indica chiaramente che questi thread lavorano insieme in modo concorrente.

La concorrenza non significa che questi due thread vengano eseguiti in parallelo, poiché viene eseguito solo un thread alla volta. Non riduce il tempo di esecuzione. La CPU inizia a eseguire un thread ma lo abbandona a metà strada e passa a un altro thread e, dopo un po ‘di tempo, torna al thread principale e avvia la sua esecuzione dallo stesso punto in cui si era interrotto l’ultima volta.

 

Implementazione del multiprocessing

 

Spero tu abbia una comprensione di base del multithreading con la sua implementazione e delle relative limitazioni. Ora è tempo di imparare l’implementazione del multiprocessing e come possiamo superare tali limitazioni.

Seguiremo lo stesso esempio, ma anziché creare due thread separati, creeremo due processi indipendenti e discuteremo delle relative osservazioni.

  1. Importazione delle librerie:
from multiprocessing import Processimport os

 

Utilizzeremo il modulo multiprocessing per creare processi indipendenti.

  1. Funzione per calcolare i quadrati:

Questa funzione rimarrà la stessa. Abbiamo solo rimosso l’istruzione di stampa delle informazioni sul threading.

def calcola_quadrati(numeri):    for num in numeri:        quadrato = num * num        print(            f"Quadrato del numero {num} è {quadrato} | PID del processo {os.getpid()}"        )

 

  1. Funzione principale:

Ci sono alcune modifiche nella funzione principale. Abbiamo semplicemente creato un processo separato anziché un thread.

if __name__ == "__main__":    numeri = [1, 2, 3, 4, 5, 6, 7, 8]    meta = len(numeri) // 2    prima_meta = numeri[:meta]    seconda_meta = numeri[meta:]    p1 = Process(target=calcola_quadrati, args=(prima_meta,))    p2 = Process(target=calcola_quadrati, args=(seconda_meta,))    p1.start()    p2.start()    p1.join()    p2.join()

 

Output:

Quadrato del numero 1 è 1 | PID del processo 1125Quadrato del numero 2 è 4 | PID del processo 1125Quadrato del numero 3 è 9 | PID del processo 1125Quadrato del numero 4 è 16 | PID del processo 1125Quadrato del numero 5 è 25 | PID del processo 1126Quadrato del numero 6 è 36 | PID del processo 1126Quadrato del numero 7 è 49 | PID del processo 1126Quadrato del numero 8 è 64 | PID del processo 1126

 

Osserviamo che un processo separato esegue ciascuna lista. Entrambi hanno diversi ID di processo. Per verificare se i nostri processi sono stati eseguiti in modo parallelo, è necessario creare un ambiente separato, di cui parleremo di seguito.

 

Calcolo del tempo di esecuzione con e senza multiprocessing

 

Per verificare se otteniamo una vera parallelismo, calcoleremo il tempo di esecuzione dell’algoritmo con e senza multiprocessing.

Per questo, avremo bisogno di una lista estesa di interi che contenga più di 10^6 interi. Possiamo generare una lista utilizzando la libreria random. Utilizzeremo il modulo time di Python per calcolare il tempo di esecuzione. Di seguito è riportata l’implementazione per questo. Il codice è autoesplicativo, anche se puoi sempre guardare i commenti del codice.

from multiprocessing import Processimport osimport timeimport randomdef calcola_quadrati(numeri):    for num in numeri:        quadrato = num * numif __name__ == "__main__":    numeri = [        random.randrange(1, 50, 1) for i in range(10000000)    ]  # Creazione di una lista casuale di interi di dimensioni 10^7.    meta = len(numeri) // 2    prima_meta = numeri[:meta]    seconda_meta = numeri[meta:]    # ----------------- Creazione di un ambiente con singolo processo ------------------------#    start_time = time.time()  # Tempo di inizio senza multiprocessing    p1 = Process(        target=calcola_quadrati, args=(numeri,)    )  # Il singolo processo P1 esegue tutta la lista    p1.start()    p1.join()    end_time = time.time()  # Tempo di fine senza multiprocessing    print(f"Tempo di esecuzione senza multiprocessing: {(end_time-start_time)*10**3}ms")    # ----------------- Creazione di un ambiente con processi multipli ------------------------#    start_time = time.time()  # Tempo di inizio con multiprocessing    p2 = Process(target=calcola_quadrati, args=(prima_meta,))    p3 = Process(target=calcola_quadrati, args=(seconda_meta,))    p2.start()    p3.start()    p2.join()    p3.join()    end_time = time.time()  # Tempo di fine con multiprocessing    print(f"Tempo di esecuzione con multiprocessing: {(end_time-start_time)*10**3}ms")

 

Output:

Tempo di esecuzione senza multiprocessing: 619.8039054870605ms Tempo di esecuzione con multiprocessing: 321.70287895202637ms

 

Puoi osservare che il tempo con multiprocessing è quasi la metà rispetto a quello senza multiprocessing. Questo mostra che questi due processi vengono eseguiti contemporaneamente e mostrano un comportamento di vera parallelismo.

Puoi anche leggere questo articolo Sequential vs Concurrent vs Parallelism di VoAGI, che ti aiuterà a comprendere la differenza fondamentale tra questi processi sequenziali, concorrenti e paralleli.

[Aryan Garg](https://www.linkedin.com/in/aryan-garg-1bbb791a3/) è uno studente di Ingegneria Elettrica che sta frequentando l’ultimo anno del suo corso di laurea. Il suo interesse risiede nel campo dello sviluppo web e dell’apprendimento automatico. Ha seguito questo interesse ed è desideroso di lavorare ancora di più in queste direzioni.