Il modulo `threading` in Python

Il modulo `threading` in Python

Questo articolo spiega il modulo threading in Python.

YouTube Video

Il modulo threading in Python

Il modulo threading in Python è una libreria standard che supporta la programmazione multithread. Utilizzando i thread, si possono eseguire più processi contemporaneamente, migliorando le prestazioni dei programmi, soprattutto nei casi che coinvolgono operazioni di blocco come le attese di I/O. A causa del Global Interpreter Lock (GIL) di Python, l'efficacia del multithreading è limitata per le operazioni dipendenti dalla CPU, ma funziona in modo efficiente per le operazioni dipendenti dall'I/O.

Le seguenti sezioni spiegano le basi dell'uso del modulo threading e come controllare i thread.

Uso base dei thread

Creare ed eseguire i thread

Per creare un thread e realizzare un'elaborazione concorrente, utilizza la classe threading.Thread. Specifica la funzione di destinazione per creare un thread ed eseguire quel thread.

 1import threading
 2import time
 3
 4# Function to be executed in a thread
 5def worker():
 6    print("Worker thread started")
 7    time.sleep(2)
 8    print("Worker thread finished")
 9
10# Create and start the thread
11thread = threading.Thread(target=worker)
12thread.start()
13
14# Processing in the main thread
15print("Main thread continues to run")
16
17# Wait for the thread to finish
18thread.join()
19print("Main thread finished")
  • In questo esempio, la funzione worker viene eseguita in un thread separato, mentre il thread principale continua a operare. Richiamando il metodo join(), il thread principale aspetta che il sottothread termini.

Assegnare nomi ai thread

Dare nomi significativi ai thread rende più facile il logging e il debug. Puoi specificarlo con l'argomento name.

 1import threading
 2import time
 3
 4# Function to be executed in a thread
 5def worker():
 6    print("Worker thread started")
 7    time.sleep(2)
 8    print("Worker thread finished")
 9
10t = threading.Thread(
11    target=worker,
12    args=("named-worker", 0.3),
13    name="MyWorkerThread"
14)
15
16t.start()
17
18print("Active threads:", threading.active_count())
19for th in threading.enumerate():
20    print(" -", th.name)
21
22t.join()
  • threading.enumerate() restituisce un elenco dei thread attuali, utile per il debug e il monitoraggio dello stato.

Ereditare la classe Thread

Se desideri personalizzare la classe che esegue il thread, puoi definire una nuova classe ereditando la classe threading.Thread.

 1import threading
 2import time
 3
 4# Inherit from the Thread class
 5class WorkerThread(threading.Thread):
 6    def __init__(self, name, delay, repeat=3):
 7        super().__init__(name=name)
 8        self.delay = delay
 9        self.repeat = repeat
10        self.results = []
11
12    def run(self):
13        for i in range(self.repeat):
14            msg = f"{self.name} step {i+1}"
15            print(msg)
16            self.results.append(msg)
17            time.sleep(self.delay)
18
19# Create and start the threads
20t1 = WorkerThread("Worker-A", delay=0.4, repeat=3)
21t2 = WorkerThread("Worker-B", delay=0.2, repeat=5)
22
23t1.start()
24t2.start()
25
26t1.join()
27t2.join()
28
29print("Results A:", t1.results)
30print("Results B:", t2.results)
  • In questo esempio, il metodo run() è sovrascritto per definire il comportamento del thread, permettendo a ciascun thread di mantenere i propri dati. Questo è utile quando i thread eseguono processi complessi o quando si desidera che ciascun thread abbia dati indipendenti.

Sincronizzazione tra i thread

Quando più thread accedono contemporaneamente a risorse condivise, possono verificarsi data races. Per evitarlo, il modulo threading fornisce diversi meccanismi di sincronizzazione.

Blocco (Lock)

L'oggetto Lock viene utilizzato per implementare il controllo esclusivo delle risorse tra i thread. Quando un thread blocca una risorsa, gli altri thread non possono accedere a quella risorsa.

 1import threading
 2
 3lock = threading.Lock()
 4shared_resource = 0
 5
 6def worker():
 7    global shared_resource
 8    with lock:  # Acquire the lock
 9        local_copy = shared_resource
10        local_copy += 1
11        shared_resource = local_copy
12
13threads = [threading.Thread(target=worker) for _ in range(5)]
14
15for t in threads:
16    t.start()
17
18for t in threads:
19    t.join()
20
21print(f"Final value of shared resource: {shared_resource}")
  • In questo esempio, cinque thread accedono a una risorsa condivisa, ma si utilizza il Lock per impedire a più thread di modificare i dati contemporaneamente.

Blocco rientrante (RLock)

Se lo stesso thread deve acquisire un blocco più volte, usa un RLock (blocco rientrante). Questo è utile per le chiamate ricorsive o per le chiamate di libreria che potrebbero acquisire blocchi in chiamate diverse.

 1import threading
 2
 3rlock = threading.RLock()
 4shared = []
 5
 6def outer():
 7    with rlock:
 8        shared.append("outer")
 9        inner()
10
11def inner():
12    with rlock:
13        shared.append("inner")
14
15t = threading.Thread(target=outer)
16t.start()
17t.join()
18print(shared)
  • Con RLock, lo stesso thread può riacquisire un blocco che già possiede, il che aiuta a prevenire deadlock nell'acquisizione annidata dei blocchi.

Condizione (Condition)

Condition è usato affinché i thread attendano fino al soddisfacimento di una condizione specifica. Quando un thread soddisfa una condizione, puoi chiamare notify() per notificare un altro thread, oppure notify_all() per notificare tutti i thread in attesa.

Di seguito è riportato un esempio di produttore e consumatore che utilizzano una Condition.

 1import threading
 2
 3condition = threading.Condition()
 4shared_data = []
 5
 6def producer():
 7    with condition:
 8        shared_data.append(1)
 9        print("Produced an item")
10        condition.notify()  # Notify the consumer
11
12def consumer():
13    with condition:
14        condition.wait()  # Wait until the condition is met
15
16        item = shared_data.pop(0)
17        print(f"Consumed an item: {item}")
18
19# Create the threads
20producer_thread = threading.Thread(target=producer)
21consumer_thread = threading.Thread(target=consumer)
22
23consumer_thread.start()
24producer_thread.start()
25
26producer_thread.join()
27consumer_thread.join()
  • Questo codice utilizza una Condition affinché il produttore notifica quando vengono aggiunti dati, e il consumatore aspetta quella notifica prima di prelevare i dati, ottenendo così la sincronizzazione.

Daemonizzazione dei Thread

I thread daemon vengono terminati forzatamente quando il thread principale termina. Mentre i thread normali devono attendere per terminare, i thread daemon si terminano automaticamente.

 1import threading
 2import time
 3
 4def worker():
 5    while True:
 6        print("Working...")
 7        time.sleep(1)
 8
 9# Create a daemon thread
10thread = threading.Thread(target=worker)
11thread.daemon = True  # Set as a daemon thread
12
13thread.start()
14
15# Processing in the main thread
16time.sleep(3)
17print("Main thread finished")
  • In questo esempio, il thread worker è daemonizzato, quindi viene terminato forzatamente quando il thread principale termina.

Gestione dei thread con ThreadPoolExecutor

Oltre al modulo threading, è possibile utilizzare ThreadPoolExecutor dal modulo concurrent.futures per gestire un pool di thread ed eseguire compiti in parallelo.

 1from concurrent.futures import ThreadPoolExecutor
 2import time
 3
 4def worker(seconds):
 5    print(f"Sleeping for {seconds} second(s)")
 6    time.sleep(seconds)
 7    return f"Finished sleeping for {seconds} second(s)"
 8
 9with ThreadPoolExecutor(max_workers=3) as executor:
10    futures = [executor.submit(worker, i) for i in range(1, 4)]
11    for future in futures:
12        print(future.result())
  • ThreadPoolExecutor crea un pool di thread e processa i compiti in modo efficiente. Specificare il numero di thread da eseguire contemporaneamente con max_workers.

Comunicazione di Eventi tra Thread

Usando threading.Event, è possibile impostare flag tra i thread per notificare ad altri thread il verificarsi di un evento.

 1import threading
 2import time
 3
 4event = threading.Event()
 5
 6def worker():
 7    print("Waiting for event to be set")
 8    event.wait()  # Wait until the event is set
 9
10    print("Event received, continuing work")
11
12thread = threading.Thread(target=worker)
13thread.start()
14
15time.sleep(2)
16print("Setting the event")
17event.set()  # Set the event and notify the thread
  • Questo codice dimostra un meccanismo in cui il thread worker attende il segnale Event e riprende l'elaborazione quando il thread principale chiama event.set().

Gestione delle eccezioni e terminazione dei thread

Quando si verificano eccezioni nei thread, esse non vengono propagate direttamente al thread principale, quindi è necessario un pattern per catturare e condividere le eccezioni.

 1import threading
 2import queue
 3
 4def worker(err_q):
 5    try:
 6        raise ValueError("Something bad")
 7    except Exception as e:
 8        err_q.put(e)
 9
10q = queue.Queue()
11t = threading.Thread(target=worker, args=(q,))
12t.start()
13t.join()
14if not q.empty():
15    exc = q.get()
16    print("Worker raised:", exc)
  • Inserendo le eccezioni in una Queue e recuperandole nel thread principale, puoi rilevare in modo affidabile i fallimenti. Se utilizzi concurrent.futures.ThreadPoolExecutor, le eccezioni vengono rilanciate con future.result(), rendendo la loro gestione più semplice.

Il GIL (Global Interpreter Lock) e i suoi effetti

A causa del meccanismo del GIL (Global Interpreter Lock) in CPython, più bytecode Python non vengono effettivamente eseguiti contemporaneamente all'interno dello stesso processo. Per i compiti che richiedono molta CPU, come calcoli pesanti, è consigliato usare multiprocessing. D'altro canto, per i compiti I/O-bound come la lettura di file o la comunicazione di rete, threading funziona efficacemente.

Riepilogo

Utilizzando il modulo threading di Python, è possibile implementare programmi multithreaded ed eseguire più processi contemporaneamente. Con meccanismi di sincronizzazione come Lock e Condition, è possibile accedere in modo sicuro a risorse condivise ed eseguire sincronizzazioni complesse. Inoltre, utilizzando thread daemon o ThreadPoolExecutor, la gestione dei thread e il processo parallelo efficiente diventano più semplici.

Puoi seguire l'articolo sopra utilizzando Visual Studio Code sul nostro canale YouTube. Controlla anche il nostro canale YouTube.

YouTube Video