Skip to content

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 = 0
lock = 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 threading
import time
import random
posti_disponibili = 10
lock_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 PROTEZIONE
posti_disponibili = 10
threads_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 threading
import queue
import time
import 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 nella coda_dati.
  • Il consumatore() preleva i dati dalla coda_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 threading
import time
import 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 threading
import time
import 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 threading
import queue
import time
# 1. Coda condivisa per gli ordini
coda_ordini = queue.Queue()
# 2. Contatore globale per gli ordini elaborati (risorsa condivisa competitiva)
ordini_elaborati_totali = 0
lock_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 thread
thread_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 thread
thread_accetta_ordini.join()
thread_elabora_ordine_1.join()
thread_elabora_ordine_2.join()
# 8. Stampa del risultato finale
print(f"\nProgramma completato. Totale ordini elaborati (finale): {ordini_elaborati_totali}")

Spiegazione Riga per Riga:

  1. coda_ordini = queue.Queue(): Cooperazione: Crea una coda queue.Queue(). Questa coda è la risorsa condivisa per la cooperazione. Il thread accetta_ordini inserirà gli ordini in questa coda, e i thread elabora_ordine_1 e elabora_ordine_2 preleveranno gli ordini da questa coda. La coda permette una comunicazione ordinata e sincronizzata tra i thread cooperativi.

  2. ordini_elaborati_totali = 0 e lock_contatore = threading.Lock(): Competizione:

    • ordini_elaborati_totali = 0: Definisce una variabile globale ordini_elaborati_totali che funge da contatore. Questa è la risorsa condivisa competitiva. Più thread cercheranno di incrementare questo contatore.
    • lock_contatore = threading.Lock(): Crea un threading.Lock(). Questo lock è un meccanismo di protezione per la sezione critica. Sarà usato per garantire che solo un thread alla volta possa modificare il contatore ed evitare race condition.
  3. def accetta_ordini(): ...: Definisce la funzione accetta_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 nella coda_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, inserisce None nella coda. Questo è un segnale convenzionale per indicare ai thread “cuoco” che non ci sono più ordini da elaborare e che possono terminare.
  4. def elabora_ordine(nome_thread): ...: Definisce la funzione elabora_ordine() che sarà eseguita dai thread “cuoco”.

    • global ordini_elaborati_totali: Indica che la funzione userà la variabile globale ordini_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 dalla coda_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 metodi join() 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 ciclo while 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 il lock_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: ...: Blocco try...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 blocco try.
      • ordini_elaborati_totali += 1: Competizione: Incrementa il contatore ordini_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 il lock_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.
  5. thread_accetta_ordini = threading.Thread(...), thread_elabora_ordine_1 = threading.Thread(...), thread_elabora_ordine_2 = threading.Thread(...): Crea i tre thread: uno per accetta_ordini e due per elabora_ordine. Nota che per i thread “cuoco” passiamo anche un argomento nome_thread per distinguerli nelle stampe.

  6. 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).

  7. 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.

  8. print(f"\nProgramma completato. Totale ordini elaborati (finale): {ordini_elaborati_totali}"): Stampa il valore finale del contatore ordini_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: 1
Elabora Ordine 2: Inizio elaborazione 'Ordine B'.
Elabora Ordine 2: Finito di elaborare 'Ordine B'. Totale ordini elaborati: 2
Elabora Ordine 1: Inizio elaborazione 'Ordine C'.
Elabora Ordine 1: Finito di elaborare 'Ordine C'. Totale ordini elaborati: 3
Elabora Ordine 2: Inizio elaborazione 'Ordine D'.
Elabora Ordine 2: Finito di elaborare 'Ordine D'. Totale ordini elaborati: 4
Elabora Ordine 1: Inizio elaborazione 'Ordine E'.
Elabora Ordine 1: Finito di elaborare 'Ordine E'. Totale ordini elaborati: 5
Elabora 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 e coda_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, come multiprocessing.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:

  1. Importare multiprocessing: import multiprocessing as mp
  2. Usare mp.Queue(): coda_ordini = mp.Queue() (per creare una coda inter-processo)
  3. Usare mp.Lock(): lock_contatore = mp.Lock() (per creare un lock inter-processo)
  4. Usare mp.Process: thread_accetta_ordini = mp.Process(target=accetta_ordini) e così via per gli altri thread (anche se ora tecnicamente sarebbero processi).
  5. 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 di multiprocessing come mp.Value o mp.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 mp
import time
import random
# 1. Coda condivisa per gli ordini (inter-processo)
coda_ordini = mp.Queue()
# 2. Contatore globale CONDIVISO tra processi con multiprocessing.Value
ordini_elaborati_totali = mp.Value('i', 0) # 'i' indica integer, 0 è il valore iniziale
lock_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 processi
processo_accetta_ordini = mp.Process(target=accetta_ordini_processo, args=(coda_ordini,)) # Passiamo la coda come argomento
processo_elabora_ordine_1 = mp.Process(target=elabora_ordine_processo, args=("Elabora Ordine 1", coda_ordini,)) # Passiamo la coda come argomento
processo_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:

  1. ordini_elaborati_totali = mp.Value('i', 0):

    • Abbiamo sostituito ordini_elaborati_totali = 0 con ordini_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 oggetto mp.Value che rappresenta una locazione di memoria condivisa. Per accedere al valore effettivo, dobbiamo usare .value (ad esempio, ordini_elaborati_totali.value).
  2. 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.
  3. Funzione elabora_ordine_processo Modificata:

    • global ordini_elaborati_totali e global lock_contatore: Indichiamo che la funzione userà la variabile globale condivisa ordini_elaborati_totali (il mp.Value) e il lock inter-processo lock_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 modificare ordini_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 nel mp.Value condiviso.
  4. processo.join() (Semplificato): Le chiamate a processo_elabora_ordine_1.join() e processo_elabora_ordine_2.join() ora servono solo per aspettare la terminazione dei processi. Non cerchiamo più di recuperare valori restituiti da join(), perché il risultato finale sarà nel mp.Value condiviso.

  5. Stampa Finale Modificata:

    • print(f"Totale ordini elaborati (FINALE, CONDIVISO con mp.Value): {ordini_elaborati_totali.value}"): Stampiamo il valore finale di ordini_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): 1
Elabora Ordine 2 (Processo): Inizio elaborazione 'Ordine B'.
Elabora Ordine 2 (Processo): Finito di elaborare 'Ordine B'. Totale ordini elaborati (CONDIVISO): 2
Elabora Ordine 1 (Processo): Inizio elaborazione 'Ordine C'.
Elabora Ordine 1 (Processo): Finito di elaborare 'Ordine C'. Totale ordini elaborati (CONDIVISO): 3
Elabora Ordine 2 (Processo): Inizio elaborazione 'Ordine D'.
Elabora Ordine 2 (Processo): Finito di elaborare 'Ordine D'. Totale ordini elaborati (CONDIVISO): 4
Elabora Ordine 1 (Processo): Inizio elaborazione 'Ordine E'.
Elabora Ordine 1 (Processo): Finito di elaborare 'Ordine E'. Totale ordini elaborati (CONDIVISO): 5
Elabora 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 usando mp.Value per condividere il contatore tra i processi e il lock_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 il lock_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 condiviso ordini_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.