La concorrenza
Immaginate di essere in una cucina affollata e di dover preparare una cena complessa. Avete diverse attività da svolgere: tagliare le verdure, cuocere la pasta, preparare la salsa, e magari anche apparecchiare la tavola. Se foste da soli, dovreste fare un’attività alla volta, in sequenza. Ma se aveste degli aiutanti, potreste dividere i compiti e fare più cose contemporaneamente, in concorrenza. La programmazione concorrente è proprio questo: permette al vostro programma di eseguire più attività quasi contemporaneamente, rendendolo più veloce ed efficiente, soprattutto quando si tratta di compiti complessi o che richiedono di aspettare qualcosa (come scaricare dati da internet o aspettare l’input dell’utente).
Invece di eseguire le istruzioni una dopo l’altra, come in un programma sequenziale, in un programma concorrente possiamo avere più “flussi di esecuzione” che sembrano andare avanti nello stesso momento. Questi flussi sono spesso chiamati thread o processi.
Ma con la concorrenza, arrivano delle sfide. Immaginate di nuovo la cucina: se due persone cercano di usare lo stesso coltello nello stesso istante, o di scrivere la lista della spesa sulla stessa lavagnetta contemporaneamente, potrebbe nascere un problema! In programmazione, questo problema si manifesta quando più thread cercano di accedere e modificare le stesse risorse condivise, come una variabile, un file o una parte della memoria.
È qui che entra in gioco il concetto di sezione critica.
Cos’è una Sezione Critica?
Una sezione critica è un pezzo di codice in un programma concorrente che accede a risorse condivise. Pensate ad essa come a quella lavagnetta nella cucina dove si scrive la lista della spesa: è una risorsa condivisa. Se più persone cercano di scrivere sulla lavagnetta nello stesso momento senza un sistema di gestione, il risultato potrebbe essere caotico e la lista della spesa potrebbe diventare illeggibile o incompleta. In programmazione, questo “caos” è chiamato race condition (condizione di gara). Una race condition si verifica quando il risultato di un programma dipende dall’ordine preciso in cui i thread accedono alla sezione critica, e questo ordine può essere imprevedibile.
Esempi Semplici in Python
Python offre librerie per la programmazione concorrente, come threading
e multiprocessing
. Per proteggere le sezioni critiche, possiamo usare meccanismi di sincronizzazione come i lock (o mutex). Un lock è come un “semaforo” che permette a un solo thread alla volta di entrare nella sezione critica.
Esempio 1: Race Condition (senza protezione)
Immaginiamo un semplice contatore condiviso da due thread. Entrambi i thread cercano di incrementare questo contatore.
Python
import threading
contatore = 0
def incrementa_contatore(): global contatore for _ in range(100000): contatore += 1
thread1 = threading.Thread(target=incrementa_contatore)thread2 = threading.Thread(target=incrementa_contatore)
thread1.start()thread2.start()
thread1.join()thread2.join()
print(f"Valore finale del contatore (senza protezione): {contatore}")
Se eseguite questo codice più volte, potreste notare che il valore finale del contatore
non è sempre 200000, come ci si aspetterebbe (100000 incrementi da thread1 + 100000 incrementi da thread2). A volte sarà un numero inferiore. Questo è un esempio di race condition. Quando entrambi i thread cercano di incrementare contatore
contemporaneamente, le operazioni di “lettura del valore attuale”, “incremento”, e “scrittura del nuovo valore” non sono atomiche. Possono essere interrotte da un cambio di thread, portando a sovrascritture e perdite di incrementi.
Esempio 2: Protezione della Sezione Critica con Lock
Ora, proteggiamo la sezione critica usando un Lock
. Definiamo un lock e lo utilizziamo per “bloccare” l’accesso alla sezione critica durante l’incremento del contatore.
Python
import threading
contatore = 0lock = threading.Lock() # Creiamo un lock
def incrementa_contatore_protetto(): global contatore for _ in range(100000): lock.acquire() # Acquisiamo il lock (solo un thread alla volta può farlo) try: contatore += 1 # Sezione critica: accesso a 'contatore' finally: lock.release() # Rilasciamo il lock (per permettere ad altri thread di entrare)
thread1 = threading.Thread(target=incrementa_contatore_protetto)thread2 = threading.Thread(target=incrementa_contatore_protetto)
thread1.start()thread2.start()
thread1.join()thread2.join()
print(f"Valore finale del contatore (con protezione): {contatore}")
In questo secondo esempio, usando lock.acquire()
prima di accedere a contatore
e lock.release()
dopo, assicuriamo che solo un thread alla volta possa eseguire il codice all’interno del blocco try...finally
. Il lock.acquire()
blocca il thread se un altro thread ha già acquisito il lock, e lo sblocca solo quando il lock viene rilasciato con lock.release()
. Il finally:
garantisce che il lock venga sempre rilasciato, anche se si verifica un errore all’interno della sezione critica.
Se eseguite questo secondo codice più volte, vedrete che il valore finale del contatore
sarà sempre 200000, come previsto. Abbiamo eliminato la race condition proteggendo la sezione critica.
Esempio Più Articolato: Simulazione di un Sistema di Prenotazioni di Posti
Immaginiamo un sistema di prenotazione di posti per un evento. Abbiamo un certo numero di posti disponibili e più persone (thread) cercano di prenotare un posto contemporaneamente. Il codice seguente simula questo scenario, mostrando sia il problema della race condition senza protezione, sia la soluzione con un lock:
Python
import threadingimport timeimport random
posti_disponibili = 10lock_posti = threading.Lock()
def prenota_posto(nome_utente): global posti_disponibili posti_richiesti = random.randint(1, 3) # Ogni utente richiede un numero casuale di posti
print(f"{nome_utente} sta cercando di prenotare {posti_richiesti} posti...")
# Sezione critica: controllo e aggiornamento dei posti disponibili (SENZA PROTEZIONE - RACE CONDITION!) if posti_disponibili >= posti_richiesti: posti_disponibili -= posti_richiesti print(f"✅ {nome_utente} ha prenotato {posti_richiesti} posti. Posti rimanenti: {posti_disponibili}") time.sleep(random.uniform(0.1, 0.5)) # Simula un po' di tempo per la prenotazione else: print(f"❌ {nome_utente} non è riuscito a prenotare posti. Posti disponibili: {posti_disponibili}")
utenti = ["Alice", "Bob", "Charlie", "David", "Eve", "Frank", "Grace", "Henry", "Ivy", "Jack"]threads_prenotazioni = []
print("Simulazione di prenotazioni SENZA PROTEZIONE:")for utente in utenti: thread = threading.Thread(target=prenota_posto, args=(utente,)) threads_prenotazioni.append(thread) thread.start()
for thread in threads_prenotazioni: thread.join()
print(f"\nPosti disponibili finali (SENZA PROTEZIONE): {posti_disponibili}")print("-" * 50)
# Reset posti disponibili per la simulazione CON PROTEZIONEposti_disponibili = 10threads_prenotazioni = []
def prenota_posto_protetto(nome_utente): global posti_disponibili posti_richiesti = random.randint(1, 3)
print(f"{nome_utente} sta cercando di prenotare {posti_richiesti} posti...")
lock_posti.acquire() # Protezione con lock try: # Sezione critica: controllo e aggiornamento dei posti disponibili (CON PROTEZIONE) if posti_disponibili >= posti_richiesti: posti_disponibili -= posti_richiesti print(f"✅ {nome_utente} ha prenotato {posti_richiesti} posti. Posti rimanenti: {posti_disponibili}") time.sleep(random.uniform(0.1, 0.5)) else: print(f"❌ {nome_utente} non è riuscito a prenotare posti. Posti disponibili: {posti_disponibili}") finally: lock_posti.release() # Rilascio del lock
print("\nSimulazione di prenotazioni CON PROTEZIONE:")for utente in utenti: thread = threading.Thread(target=prenota_posto_protetto, args=(utente,)) threads_prenotazioni.append(thread) thread.start()
for thread in threads_prenotazioni: thread.join()
print(f"\nPosti disponibili finali (CON PROTEZIONE): {posti_disponibili}")
Eseguite questo codice. Nella simulazione SENZA PROTEZIONE, potreste vedere che vengono prenotati più posti di quelli disponibili inizialmente (il numero di posti disponibili finali potrebbe essere negativo!). Questo è di nuovo un esempio di race condition. Nella simulazione CON PROTEZIONE, grazie all’uso del lock_posti
, il sistema gestisce correttamente le prenotazioni, e il numero di posti disponibili non diventerà mai negativo.
Cooperazione e Competizione tra Processi Concorrenti
Nella programmazione concorrente, i processi (o thread) possono interagire tra loro in due modi principali: cooperazione e competizione. Questi due tipi di interazione definiscono come i processi condividono risorse e raggiungono i loro obiettivi.
Cooperazione: Lavorare Insieme per un Obiettivo Comune
La cooperazione si verifica quando più processi sono progettati per lavorare insieme per risolvere un problema più grande. Invece di competere per le risorse, i processi cooperativi si scambiano informazioni e si coordinano per raggiungere un risultato condiviso. Immaginate una catena di montaggio: ogni operaio (processo) svolge un compito specifico e passa il prodotto parzialmente completato all’operaio successivo. La cooperazione è essenziale quando un compito può essere naturalmente suddiviso in sottocompiti che possono essere eseguiti in parallelo e poi combinati.
Esempio Semplice di Cooperazione in Python: Produttore-Consumatore con una Coda
Un esempio classico di cooperazione è il modello produttore-consumatore. Un processo “produttore” genera dati (ad esempio, legge dati da un sensore, scarica file da internet), e uno o più processi “consumatori” elaborano questi dati (ad esempio, analizzano i dati, li salvano in un database, li visualizzano). Per comunicare e scambiarsi dati, produttore e consumatore utilizzano spesso una coda (queue), una struttura dati che permette di mettere in attesa i dati prodotti fino a quando un consumatore non è pronto a prelevarli.
import threadingimport queueimport timeimport random
coda_dati = queue.Queue() # Coda condivisa per i dati
def produttore(): for i in range(5): # Produce 5 dati dato = f"Dato-{i}" time.sleep(random.uniform(0.1, 0.3)) # Simula tempo di produzione print(f"Produttore: Prodotto '{dato}'") coda_dati.put(dato) # Inserisce il dato nella coda coda_dati.put(None) # Segnale di fine produzione
def consumatore(): while True: dato = coda_dati.get() # Preleva un dato dalla coda (si blocca se vuota) coda_dati.task_done() # Indica che l'elaborazione del dato è completata if dato is None: # Controlla il segnale di fine produzione break time.sleep(random.uniform(0.2, 0.5)) # Simula tempo di consumo print(f"Consumatore: Consumato '{dato}'") print("Consumatore: Fine elaborazione.")
thread_produttore = threading.Thread(target=produttore)thread_consumatore = threading.Thread(target=consumatore)
thread_produttore.start()thread_consumatore.start()
thread_produttore.join()thread_consumatore.join()
print("Programma completato.")
In questo esempio:
- Il
produttore()
genera dei “dati” e li inserisce nellacoda_dati
. - Il
consumatore()
preleva i dati dallacoda_dati
e li “consuma” (in questo caso, semplicemente li stampa). - La
queue.Queue()
agisce come un buffer condiviso e un meccanismo di sincronizzazione. Il produttore può inserire dati nella coda anche se il consumatore è momentaneamente occupato, e il consumatore si blocca in attesa se la coda è vuota. - Il
None
inserito dal produttore alla fine serve come segnale di terminazione per il consumatore.
Questo è un esempio di cooperazione perché il produttore e il consumatore sono progettati per lavorare insieme: il produttore genera i dati che il consumatore deve elaborare. La coda è il mezzo attraverso cui cooperano, scambiandosi informazioni in modo ordinato e sincronizzato.
Competizione: Lottare per le Risorse Condivise
La competizione si verifica quando più processi cercano di accedere alle stesse risorse condivise, ma non sono progettati per coordinarsi tra loro. Abbiamo già visto un esempio di competizione nel capitolo precedente con il problema del contatore e della race condition. In questi scenari, se non si utilizzano meccanismi di sincronizzazione, si possono verificare race condition e risultati imprevedibili o errati.
Esempio Semplice di Competizione in Python: Accesso Concorrente a un File (senza protezione)
Immaginiamo che più processi vogliano scrivere contemporaneamente in un file di log. Se non gestiamo l’accesso concorrente, le righe di log potrebbero essere mischiate o sovrascritte, rendendo il file di log inutilizzabile.
import threadingimport timeimport random
nome_file_log = "esempio_log_competizione.txt"
def scrivi_log(nome_processo): for i in range(3): # Ogni processo scrive 3 righe di log messaggio_log = f"{nome_processo} - Riga {i+1} - Timestamp: {time.strftime('%H:%M:%S')}\n" time.sleep(random.uniform(0.1, 0.3)) # Simula un po' di lavoro with open(nome_file_log, "a") as file_log: # Apre il file in modalità append file_log.write(messaggio_log) print(f"{nome_processo}: Scritta riga {i+1}")
processi = ["Processo-A", "Processo-B", "Processo-C"]threads_log = []
print("Simulazione di scrittura su file SENZA PROTEZIONE:")for processo in processi: thread = threading.Thread(target=scrivi_log, args=(processo,)) threads_log.append(thread) thread.start()
for thread in threads_log: thread.join()
print(f"\nContenuto del file di log '{nome_file_log}':")with open(nome_file_log, "r") as file_log: print(file_log.read())
Eseguendo questo codice, aprite il file esempio_log_competizione.txt
. Noterete che le righe di log dei diversi processi sono interleaved (mescolate) in modo disordinato. Questo è perché più processi competono per scrivere nel file contemporaneamente, e l’ordine di scrittura dipende dalla velocità di esecuzione di ciascun processo e dal sistema operativo. Non c’è un ordine garantito, e il risultato è un file di log confuso.
Esempio di Competizione con Protezione: Lock per l’Accesso al File
Per risolvere il problema della competizione nell’esempio precedente, possiamo usare un Lock
per proteggere la sezione critica di scrittura sul file. Solo un processo alla volta potrà acquisire il lock e scrivere nel file.
import threadingimport timeimport random
nome_file_log_protetto = "esempio_log_competizione_protetto.txt"lock_file = threading.Lock() # Lock per proteggere l'accesso al file
def scrivi_log_protetto(nome_processo): for i in range(3): messaggio_log = f"{nome_processo} - Riga {i+1} - Timestamp: {time.strftime('%H:%M:%S')}\n" time.sleep(random.uniform(0.1, 0.3)) lock_file.acquire() # Acquisizione del lock prima di scrivere try: with open(nome_file_log_protetto, "a") as file_log: file_log.write(messaggio_log) print(f"{nome_processo}: Scritta riga {i+1} (protetto)") finally: lock_file.release() # Rilascio del lock
processi = ["Processo-X", "Processo-Y", "Processo-Z"]threads_log_protetti = []
print("\nSimulazione di scrittura su file CON PROTEZIONE:")for processo in processi: thread = threading.Thread(target=scrivi_log_protetto, args=(processo,)) threads_log_protetti.append(thread) thread.start()
for thread in threads_log_protetti: thread.join()
print(f"\nContenuto del file di log '{nome_file_log_protetto}':")with open(nome_file_log_protetto, "r") as file_log: print(file_log.read())
In questo caso, aprendo esempio_log_competizione_protetto.txt
, vedrete che le righe di log di ciascun processo sono raggruppate. Ogni processo scrive le sue tre righe di log in sequenza, prima che un altro processo possa scrivere. L’uso del lock_file
ha garantito un accesso esclusivo al file durante la scrittura, eliminando la competizione disordinata e producendo un file di log più leggibile e coerente.
Esempio: Elaborazione Ordini Semplificata - Cooperazione e Competizione
Immagina un piccolo sistema di elaborazione ordini dove:
- Un thread “accetta_ordini” (il cameriere) riceve ordini e li mette in una coda.
- Due thread “elabora_ordine_1” e “elabora_ordine_2” (i cuochi) prendono gli ordini dalla coda e li elaborano.
- Tutti e tre i thread competono per incrementare un contatore globale che tiene traccia del numero totale di ordini elaborati.
Questo esempio mostra cooperazione perché i thread “cuoco” cooperano con il thread “cameriere” per elaborare gli ordini messi in coda. Mostra anche competizione perché tutti i thread competono per accedere e modificare in modo sicuro il contatore globale degli ordini elaborati.
import threadingimport queueimport time
# 1. Coda condivisa per gli ordinicoda_ordini = queue.Queue()
# 2. Contatore globale per gli ordini elaborati (risorsa condivisa competitiva)ordini_elaborati_totali = 0lock_contatore = threading.Lock() # Lock per proteggere l'accesso al contatore
# 3. Funzione per il thread "accetta_ordini" (Produttore)def accetta_ordini(): ordini = ["Ordine A", "Ordine B", "Ordine C", "Ordine D", "Ordine E"] for ordine in ordini: time.sleep(0.5) # Simula tempo per ricevere l'ordine print(f"Accetta ordini: Ricevuto '{ordine}', messo in coda.") coda_ordini.put(ordine) # Mette l'ordine nella coda coda_ordini.put(None) # Segnale di fine ordini
# 4. Funzione per i thread "elabora_ordine" (Consumatori)def elabora_ordine(nome_thread): global ordini_elaborati_totali # Indica che usiamo la variabile globale while True: ordine = coda_ordini.get() # Preleva un ordine dalla coda (si blocca se vuota) coda_ordini.task_done() # Segnala che l'ordine è stato prelevato dalla coda if ordine is None: # Segnale di fine ordini break time.sleep(1) # Simula tempo per elaborare l'ordine print(f"{nome_thread}: Inizio elaborazione '{ordine}'.")
# 5. Sezione Critica: Incremento del contatore (COMPETIZIONE!) lock_contatore.acquire() # Acquisisce il lock prima di accedere al contatore try: global ordini_elaborati_totali # Ripetizione necessaria dentro try ordini_elaborati_totali += 1 print(f"{nome_thread}: Finito di elaborare '{ordine}'. Totale ordini elaborati: {ordini_elaborati_totali}") finally: lock_contatore.release() # Rilascia il lock
print(f"{nome_thread}: Fine elaborazione ordini.")
# 6. Creazione e avvio dei threadthread_accetta_ordini = threading.Thread(target=accetta_ordini)thread_elabora_ordine_1 = threading.Thread(target=elabora_ordine, args=("Elabora Ordine 1",))thread_elabora_ordine_2 = threading.Thread(target=elabora_ordine, args=("Elabora Ordine 2",))
thread_accetta_ordini.start()thread_elabora_ordine_1.start()thread_elabora_ordine_2.start()
# 7. Attesa della fine di tutti i threadthread_accetta_ordini.join()thread_elabora_ordine_1.join()thread_elabora_ordine_2.join()
# 8. Stampa del risultato finaleprint(f"\nProgramma completato. Totale ordini elaborati (finale): {ordini_elaborati_totali}")
Spiegazione Riga per Riga:
coda_ordini = queue.Queue()
: Cooperazione: Crea una codaqueue.Queue()
. Questa coda è la risorsa condivisa per la cooperazione. Il threadaccetta_ordini
inserirà gli ordini in questa coda, e i threadelabora_ordine_1
eelabora_ordine_2
preleveranno gli ordini da questa coda. La coda permette una comunicazione ordinata e sincronizzata tra i thread cooperativi.ordini_elaborati_totali = 0
elock_contatore = threading.Lock()
: Competizione:ordini_elaborati_totali = 0
: Definisce una variabile globaleordini_elaborati_totali
che funge da contatore. Questa è la risorsa condivisa competitiva. Più thread cercheranno di incrementare questo contatore.lock_contatore = threading.Lock()
: Crea unthreading.Lock()
. Questo lock è un meccanismo di protezione per la sezione critica. Sarà usato per garantire che solo un thread alla volta possa modificare ilcontatore
ed evitare race condition.
def accetta_ordini(): ...
: Definisce la funzioneaccetta_ordini()
che sarà eseguita dal thread “cameriere”.ordini = ["Ordine A", "Ordine B", "Ordine C", "Ordine D", "Ordine E"]
: Crea una lista di ordini simulati.for ordine in ordini:
: Itera attraverso gli ordini simulati.time.sleep(0.5)
: Simula il tempo necessario per ricevere un ordine.print(f"Accetta ordini: Ricevuto '{ordine}', messo in coda.")
: Stampa un messaggio indicando che un ordine è stato ricevuto e messo in coda.coda_ordini.put(ordine)
: Cooperazione: Inserisce l’ordine nellacoda_ordini
. Questo è il punto in cui il thread “cameriere” coopera con i thread “cuoco”, passando loro il lavoro da fare.coda_ordini.put(None)
: Segnale di fine cooperazione: Dopo aver inserito tutti gli ordini, inserisceNone
nella coda. Questo è un segnale convenzionale per indicare ai thread “cuoco” che non ci sono più ordini da elaborare e che possono terminare.
def elabora_ordine(nome_thread): ...
: Definisce la funzioneelabora_ordine()
che sarà eseguita dai thread “cuoco”.global ordini_elaborati_totali
: Indica che la funzione userà la variabile globaleordini_elaborati_totali
.while True:
: Crea un ciclo infinito. I thread “cuoco” continueranno a cercare ordini nella coda fino a quando non ricevono il segnale di terminazione (None
).ordine = coda_ordini.get()
: Cooperazione: Preleva un ordine dallacoda_ordini
. Se la coda è vuota, questo thread si blocca in attesa che il thread “cameriere” (o un altro thread) inserisca un ordine. Questo è un altro aspetto della cooperazione e sincronizzazione tramite la coda.coda_ordini.task_done()
: Indica alla coda che l’ordine è stato prelevato e che l’elaborazione può iniziare. Questo è importante per la gestione interna della coda e per i metodijoin()
della coda (non usati in questo esempio, ma importanti in scenari più complessi).if ordine is None: break
: Controlla se l’ordine prelevato èNone
(il segnale di fine). Se sì, esce dal ciclowhile
e il thread “cuoco” termina.time.sleep(1)
: Simula il tempo necessario per elaborare un ordine.print(f"{nome_thread}: Inizio elaborazione '{ordine}'.")
: Stampa un messaggio indicando l’inizio dell’elaborazione dell’ordine.- Sezione Critica (Competizione):
lock_contatore.acquire()
: Competizione e Protezione: Il thread tenta di acquisire illock_contatore
. Se un altro thread ha già acquisito il lock, questo thread si blocca in attesa che il lock venga rilasciato. Solo un thread alla volta può proseguire dopo aver acquisito il lock.try: ... finally: ...
: Bloccotry...finally
per garantire che il lock venga sempre rilasciato, anche se si verifica un errore all’interno della sezione critica.global ordini_elaborati_totali
: Indica nuovamente che si usa la variabile globale dentro il bloccotry
.ordini_elaborati_totali += 1
: Competizione: Incrementa il contatoreordini_elaborati_totali
. Questa è la sezione critica in cui i thread competono per modificare la risorsa condivisa.print(f"{nome_thread}: Finito di elaborare '{ordine}'. Totale ordini elaborati: {ordini_elaborati_totali}")
: Stampa un messaggio indicando la fine dell’elaborazione e il valore aggiornato del contatore.lock_contatore.release()
: Competizione e Rilascio Protezione: Rilascia illock_contatore
. Questo permette ad altri thread in attesa di acquisire il lock e di entrare nella sezione critica.
print(f"{nome_thread}: Fine elaborazione ordini.")
: Stampa un messaggio quando il thread “cuoco” termina.
thread_accetta_ordini = threading.Thread(...)
,thread_elabora_ordine_1 = threading.Thread(...)
,thread_elabora_ordine_2 = threading.Thread(...)
: Crea i tre thread: uno peraccetta_ordini
e due perelabora_ordine
. Nota che per i thread “cuoco” passiamo anche un argomentonome_thread
per distinguerli nelle stampe.thread_accetta_ordini.start()
,thread_elabora_ordine_1.start()
,thread_elabora_ordine_2.start()
: Avvia l’esecuzione di tutti e tre i thread in parallelo (o quasi-parallelo, gestito dal sistema operativo).thread_accetta_ordini.join()
,thread_elabora_ordine_1.join()
,thread_elabora_ordine_2.join()
: Il thread principale (quello che esegue il codice principale) aspetta che tutti gli altri thread terminino la loro esecuzione prima di proseguire. Questo garantisce che il programma principale attenda che tutti gli ordini siano stati elaborati prima di stampare il risultato finale.print(f"\nProgramma completato. Totale ordini elaborati (finale): {ordini_elaborati_totali}")
: Stampa il valore finale del contatoreordini_elaborati_totali
dopo che tutti i thread hanno terminato.
Esecuzione e Osservazioni
Eseguendo questo codice, vedrai un output simile a questo (l’ordine preciso potrebbe variare leggermente a causa della concorrenza):
Accetta ordini: Ricevuto 'Ordine A', messo in coda.Accetta ordini: Ricevuto 'Ordine B', messo in coda.Accetta ordini: Ricevuto 'Ordine C', messo in coda.Accetta ordini: Ricevuto 'Ordine D', messo in coda.Accetta ordini: Ricevuto 'Ordine E', messo in coda.Elabora Ordine 1: Inizio elaborazione 'Ordine A'.Elabora Ordine 1: Finito di elaborare 'Ordine A'. Totale ordini elaborati: 1Elabora Ordine 2: Inizio elaborazione 'Ordine B'.Elabora Ordine 2: Finito di elaborare 'Ordine B'. Totale ordini elaborati: 2Elabora Ordine 1: Inizio elaborazione 'Ordine C'.Elabora Ordine 1: Finito di elaborare 'Ordine C'. Totale ordini elaborati: 3Elabora Ordine 2: Inizio elaborazione 'Ordine D'.Elabora Ordine 2: Finito di elaborare 'Ordine D'. Totale ordini elaborati: 4Elabora Ordine 1: Inizio elaborazione 'Ordine E'.Elabora Ordine 1: Finito di elaborare 'Ordine E'. Totale ordini elaborati: 5Elabora Ordine 1: Fine elaborazione ordini.Elabora Ordine 2: Fine elaborazione ordini.Programma completato. Totale ordini elaborati (finale): 5
Noterai che il Totale ordini elaborati (finale)
è sempre 5, che è il numero corretto di ordini, grazie all’uso del lock_contatore
per proteggere la sezione critica.
Cosa Cambierebbe con i Processi?
Se utilizzassimo processi invece di thread (usando la libreria multiprocessing
invece di threading
), i concetti di cooperazione e competizione rimarrebbero gli stessi, ma ci sarebbero alcune differenze fondamentali:
Spazio di Memoria Separato: I processi hanno spazi di memoria separati. Questo significa che le variabili globali come
ordini_elaborati_totali
ecoda_ordini
non sarebbero automaticamente condivise tra i processi. Ogni processo avrebbe la sua copia di queste variabili.Comunicazione Inter-Processo (IPC): Per far cooperare i processi e scambiare dati, dovremmo usare meccanismi di comunicazione inter-processo (IPC) espliciti. La libreria
multiprocessing
fornisce strumenti per questo, comemultiprocessing.Queue
,multiprocessing.Pipe
,multiprocessing.Value
,multiprocessing.Array
, ecc.Protezione delle Risorse Condivise (IPC): Anche con i processi, se vogliamo condividere realmente una risorsa (ad esempio, una memoria condivisa per il contatore), e più processi competono per modificarla, avremmo comunque bisogno di meccanismi di sincronizzazione per evitare race condition.
multiprocessing
fornisce lock, semafori e altri strumenti di sincronizzazione che funzionano tra processi (spesso basati sui meccanismi forniti dal sistema operativo).
Modifiche al Codice per Usare Processi (Concettuale)
Per convertire l’esempio precedente per usare processi, dovremmo fare modifiche come:
- Importare
multiprocessing
:import multiprocessing as mp
- Usare
mp.Queue()
:coda_ordini = mp.Queue()
(per creare una coda inter-processo) - Usare
mp.Lock()
:lock_contatore = mp.Lock()
(per creare un lock inter-processo) - Usare
mp.Process
:thread_accetta_ordini = mp.Process(target=accetta_ordini)
e così via per gli altri thread (anche se ora tecnicamente sarebbero processi). - Variabili Globali Condivise (se necessarie): Se volessimo realmente condividere
ordini_elaborati_totali
tra i processi (cosa che in questo esempio non è strettamente necessaria, potremmo far restituire il conteggio da ogni processo e sommarli alla fine nel processo principale), dovremmo usare meccanismi di memoria condivisa dimultiprocessing
comemp.Value
omp.Array
per creare una variabile condivisa in memoria che sia accessibile a tutti i processi.
Vantaggi e Svantaggi di Thread vs Processi:
Thread:
- Vantaggi: Più leggeri, creazione e cambio di contesto più veloci, condividono lo stesso spazio di memoria (facile condivisione dati, ma richiede sincronizzazione). Più adatti per task I/O-bound (che aspettano operazioni esterne, come rete o disco).
- Svantaggi: Problemi di Global Interpreter Lock (GIL) in CPython (l’implementazione standard di Python) possono limitare il parallelismo reale per task CPU-bound (che usano intensamente la CPU). Vulnerabili a race condition se la sincronizzazione non è gestita correttamente.
Processi:
- Vantaggi: Spazi di memoria separati (maggiore isolamento, meno problemi di race condition accidentali), possono sfruttare appieno i multi-core anche per task CPU-bound (evitano il problema del GIL). Più robusti in caso di crash di un processo (non influenzano gli altri processi).
- Svantaggi: Più pesanti, creazione e cambio di contesto più lenti, comunicazione tra processi più complessa e con overhead (IPC). Condivisione di dati più complessa (richiede IPC esplicito o memoria condivisa). Più overhead di sistema.
Codice con i processi (multiprocessing)
Utilizzando i processi (e il modulo multiprocessing), il codice deve usare multiprocessing.Value
per condividere il contatore degli ordini elaborati tra i processi. Questo esempio dimostrerà la competizione per una risorsa veramente condivisa in memoria tra processi e come proteggerla con un lock inter-processo:
import multiprocessing as mpimport timeimport random
# 1. Coda condivisa per gli ordini (inter-processo)coda_ordini = mp.Queue()
# 2. Contatore globale CONDIVISO tra processi con multiprocessing.Valueordini_elaborati_totali = mp.Value('i', 0) # 'i' indica integer, 0 è il valore inizialelock_contatore = mp.Lock() # Lock inter-processo per proteggere l'accesso al contatore condiviso
# 3. Funzione per il processo "accetta_ordini" (Produttore)def accetta_ordini_processo(coda_ordini_processo): # Passiamo la coda come argomento ordini = ["Ordine A", "Ordine B", "Ordine C", "Ordine D", "Ordine E"] for ordine in ordini: time.sleep(0.5) print(f"Accetta ordini (Processo): Ricevuto '{ordine}', messo in coda.") coda_ordini_processo.put(ordine) # Usa la coda passata come argomento coda_ordini_processo.put(None) # Segnale di fine ordini
# 4. Funzione per i processi "elabora_ordine" (Consumatori)def elabora_ordine_processo(nome_processo, coda_ordini_processo): # Passiamo la coda come argomento # ordini_elaborati_locali = 0 # Non più necessario, usiamo il contatore condiviso global ordini_elaborati_totali # Indica che usiamo la variabile globale condivisa (mp.Value) global lock_contatore # Indica che usiamo il lock inter-processo
while True: ordine = coda_ordini_processo.get() # Preleva un ordine dalla coda passata come argomento coda_ordini_processo.task_done() if ordine is None: break time.sleep(1) print(f"{nome_processo} (Processo): Inizio elaborazione '{ordine}'.")
# 5. Sezione Critica: Incremento del contatore CONDIVISO (COMPETIZIONE!) lock_contatore.acquire() # Acquisisce il lock prima di accedere al contatore condiviso try: # global ordini_elaborati_totali # Non necessario ripetere global qui dentro try in questo caso ordini_elaborati_totali.value += 1 # Incrementa il contatore CONDIVISO (mp.Value) print(f"{nome_processo} (Processo): Finito di elaborare '{ordine}'. Totale ordini elaborati (CONDIVISO): {ordini_elaborati_totali.value}") finally: lock_contatore.release() # Rilascia il lock
print(f"{nome_processo} (Processo): Fine elaborazione ordini.") # return ordini_elaborati_locali # Non restituiamo più il conteggio locale, usiamo quello condiviso
# 6. Creazione e avvio dei processiprocesso_accetta_ordini = mp.Process(target=accetta_ordini_processo, args=(coda_ordini,)) # Passiamo la coda come argomentoprocesso_elabora_ordine_1 = mp.Process(target=elabora_ordine_processo, args=("Elabora Ordine 1", coda_ordini,)) # Passiamo la coda come argomentoprocesso_elabora_ordine_2 = mp.Process(target=elabora_ordine_processo, args=("Elabora Ordine 2", coda_ordini,)) # Passiamo la coda come argomento
processo_accetta_ordini.start()processo_elabora_ordine_1.start()processo_elabora_ordine_2.start()
# 7. Attesa della fine di tutti i processi (non recuperiamo più conteggi locali)processo_accetta_ordini.join()processo_elabora_ordine_1.join()processo_elabora_ordine_2.join()
# 8. Stampa del risultato finale (ora usiamo il contatore condiviso!)print(f"\nProgramma completato.")print(f"Totale ordini elaborati (FINALE, CONDIVISO con mp.Value): {ordini_elaborati_totali.value}") # Stampiamo il valore del contatore condiviso# print(f"Conteggio ordini elaborati da Processo 1: {conteggio_processo_1}") # Non più necessario# print(f"Conteggio ordini elaborati da Processo 2: {conteggio_processo_2}") # Non più necessario
Modifiche Evidenziate e Spiegazione:
ordini_elaborati_totali = mp.Value('i', 0)
:- Abbiamo sostituito
ordini_elaborati_totali = 0
conordini_elaborati_totali = mp.Value('i', 0)
. mp.Value('i', 0)
crea un valore condiviso in memoria tra i processi.'i'
specifica il tipo di dato come intero con segno (signed integer).0
è il valore iniziale del contatore.
ordini_elaborati_totali
ora non è più un semplice intero Python, ma un oggettomp.Value
che rappresenta una locazione di memoria condivisa. Per accedere al valore effettivo, dobbiamo usare.value
(ad esempio,ordini_elaborati_totali.value
).
- Abbiamo sostituito
lock_contatore = mp.Lock()
:- Abbiamo mantenuto
lock_contatore = mp.Lock()
, ma ora è essenziale. Poichéordini_elaborati_totali
è una risorsa veramente condivisa tra processi, l’accesso competitivo a questa risorsa (l’incremento) deve essere protetto per evitare race condition.mp.Lock()
crea un lock che può essere utilizzato per la sincronizzazione tra processi.
- Abbiamo mantenuto
Funzione
elabora_ordine_processo
Modificata:global ordini_elaborati_totali
eglobal lock_contatore
: Indichiamo che la funzione userà la variabile globale condivisaordini_elaborati_totali
(ilmp.Value
) e il lock inter-processolock_contatore
.ordini_elaborati_locali = 0
è stato rimosso. Non abbiamo più bisogno di un contatore locale.- Sezione Critica con Lock e Accesso a
mp.Value
:lock_contatore.acquire()
: Acquisiamo il lock inter-processo prima di accedere e modificareordini_elaborati_totali.value
.ordini_elaborati_totali.value += 1
: Incrementiamo il valore condiviso, accedendo tramite.value
.lock_contatore.release()
: Rilasciamo il lock dopo aver completato l’accesso alla risorsa condivisa.
# return ordini_elaborati_locali
è commentato. Non restituiamo più il conteggio locale perché il conteggio totale è ora mantenuto nelmp.Value
condiviso.
processo.join()
(Semplificato): Le chiamate aprocesso_elabora_ordine_1.join()
eprocesso_elabora_ordine_2.join()
ora servono solo per aspettare la terminazione dei processi. Non cerchiamo più di recuperare valori restituiti dajoin()
, perché il risultato finale sarà nelmp.Value
condiviso.Stampa Finale Modificata:
print(f"Totale ordini elaborati (FINALE, CONDIVISO con mp.Value): {ordini_elaborati_totali.value}")
: Stampiamo il valore finale diordini_elaborati_totali.value
. Questo valore rappresenta il conteggio totale degli ordini elaborati, condiviso e aggiornato da tutti i processi.
Esecuzione e Osservazioni
Eseguendo questo codice, vedrai un output simile a questo (l’ordine preciso potrebbe variare):
Accetta ordini (Processo): Ricevuto 'Ordine A', messo in coda.Accetta ordini (Processo): Ricevuto 'Ordine B', messo in coda.Accetta ordini (Processo): Ricevuto 'Ordine C', messo in coda.Accetta ordini (Processo): Ricevuto 'Ordine D', messo in coda.Accetta ordini (Processo): Ricevuto 'Ordine E', messo in coda.Elabora Ordine 1 (Processo): Inizio elaborazione 'Ordine A'.Elabora Ordine 1 (Processo): Finito di elaborare 'Ordine A'. Totale ordini elaborati (CONDIVISO): 1Elabora Ordine 2 (Processo): Inizio elaborazione 'Ordine B'.Elabora Ordine 2 (Processo): Finito di elaborare 'Ordine B'. Totale ordini elaborati (CONDIVISO): 2Elabora Ordine 1 (Processo): Inizio elaborazione 'Ordine C'.Elabora Ordine 1 (Processo): Finito di elaborare 'Ordine C'. Totale ordini elaborati (CONDIVISO): 3Elabora Ordine 2 (Processo): Inizio elaborazione 'Ordine D'.Elabora Ordine 2 (Processo): Finito di elaborare 'Ordine D'. Totale ordini elaborati (CONDIVISO): 4Elabora Ordine 1 (Processo): Inizio elaborazione 'Ordine E'.Elabora Ordine 1 (Processo): Finito di elaborare 'Ordine E'. Totale ordini elaborati (CONDIVISO): 5Elabora Ordine 1 (Processo): Fine elaborazione ordini.Elabora Ordine 2 (Processo): Fine elaborazione ordini.
Programma completato.Totale ordini elaborati (FINALE, CONDIVISO con mp.Value): 5
Osservazioni Chiave:
- Contatore Totale Condiviso Corretto: Ora,
Totale ordini elaborati (FINALE, CONDIVISO con mp.Value): 5
mostra il valore corretto di 5, che è il numero totale di ordini. Questo è perché stiamo usandomp.Value
per condividere il contatore tra i processi e illock_contatore
per proteggere l’accesso competitivo. - Competizione Reale per la Risorsa Condivisa: In questo esempio, i processi competono realmente per incrementare la stessa locazione di memoria condivisa (
ordini_elaborati_totali
). Senza illock_contatore
, si verificherebbe una race condition e il conteggio finale potrebbe essere incorretto. Con il lock, garantiamo che l’incremento del contatore sia un’operazione atomica, anche tra processi. - Cooperazione e Competizione Insieme: L’esempio continua a illustrare sia la cooperazione (tramite la coda inter-processo
coda_ordini
) che la competizione (per l’accesso al contatore condivisoordini_elaborati_totali
), mostrando uno scenario più completo di programmazione concorrente con processi.
Questo esempio dimostra in modo più completo come usare multiprocessing
per la programmazione concorrente con processi, inclusa la gestione della cooperazione tramite code inter-processo e la gestione della competizione per risorse condivise in memoria utilizzando mp.Value
e mp.Lock
.