Fine settembre 2026, istituto tecnico, indirizzo Informatica e telecomunicazioni: in quarta, TPSIT (Tecnologie e Progettazione di Sistemi Informatici e di Telecomunicazioni) apre l’anno con processi e thread, per poi arrivare rapidamente alla concorrenza vera e propria — quella dei programmi che fanno più cose insieme e possono interferire tra loro. La prima verifica, spesso scritto più prova pratica al PC, cade fra cinque e otto settimane, quindi fra fine ottobre e novembre.
Se in quarta hai già affrontato le classi in C++ su Sistemi (ne parlo in Classi e oggetti in quarta informatica), qui il salto concettuale è diverso: non è più organizzare i dati dentro un oggetto, è far coesistere più flussi di esecuzione che leggono e scrivono le stesse variabili senza pestarsi i piedi a vicenda.
Programma, processo e thread
Il primo blocco è terminologico, e va tenuto ben distinto perché è il punto di partenza di ogni domanda successiva:
- Programma: il codice scritto, statico, fermo su disco — una sequenza di istruzioni che non sta eseguendo nulla.
- Processo: un programma in esecuzione, con il proprio spazio di memoria isolato (variabili, stack, heap) e le proprie risorse assegnate dal sistema operativo. Due processi diversi non condividono memoria, a meno di meccanismi espliciti di comunicazione tra processi.
- Thread: un flusso di esecuzione dentro un processo. Un processo può avere più thread, e questi thread condividono lo stesso spazio di memoria del processo che li contiene — è proprio questa condivisione la fonte di tutti i problemi che vedremo.
Un processo può trovarsi in diversi stati durante la sua vita: pronto (in attesa della CPU), in esecuzione, bloccato (in attesa di una risorsa, per esempio un dato da tastiera), terminato. Il sistema operativo passa da un processo all’altro con il cambio di contesto (context switch): salva lo stato del processo in esecuzione e ripristina quello del prossimo, un’operazione che ha un costo in tempo macchina anche se richiede pochi microsecondi.
Perché la concorrenza crea problemi: la race condition
Il problema nasce esattamente dalla condivisione di memoria fra thread. Immagina due thread che eseguono entrambi l’istruzione x = x + 1 sulla stessa variabile condivisa x, partendo da x = 0. Sembra ovvio che il risultato finale sia x = 2, ma non è garantito, perché x = x + 1 non è un’unica operazione atomica: si scompone in tre passi separati a livello di CPU:
- Leggi il valore corrente di
xin un registro. - Incrementa il registro di 1.
- Scrivi il registro di nuovo in
x.
Se il sistema operativo interrompe il primo thread esattamente dopo il passo 1 (lettura) e fa partire il secondo thread, succede questo interleaving:
| Passo | Thread A | Thread B | Valore di x |
|---|---|---|---|
| 1 | legge x (0) | — | 0 |
| 2 | — | legge x (0) | 0 |
| 3 | incrementa (0→1) | — | 0 |
| 4 | — | incrementa (0→1) | 0 |
| 5 | scrive x = 1 | — | 1 |
| 6 | — | scrive x = 1 | 1 |
Entrambi i thread hanno letto x = 0 prima che l’altro scrivesse il suo incremento, quindi il risultato finale è x = 1, non x = 2: un incremento è andato perso. Questo fenomeno si chiama race condition (condizione di gara): il risultato finale dipende dall’ordine imprevedibile con cui i thread vengono schedulati, e cambia da un’esecuzione all’altra — è per questo che questi bug sono tra i più difficili da riprodurre e correggere.
Il tratto di codice in cui avviene l’accesso alla risorsa condivisa — qui, le tre istruzioni di lettura-incremento-scrittura — si chiama sezione critica. Il problema della concorrenza si riduce sempre a garantire la mutua esclusione: solo un thread alla volta può eseguire la sezione critica su una data risorsa.
I semafori di Dijkstra
Lo strumento classico per garantire la mutua esclusione è il semaforo, introdotto da Edsger Dijkstra. Un semaforo è una variabile intera associata a due operazioni atomiche (indivisibili, non interrompibili a metà):
- P (dall’olandese proberen, “provare”; in inglese spesso
wait): decrementa il semaforo; se il valore risultante è negativo, il thread si blocca in attesa. - V (dall’olandese verhogen, “aumentare”; in inglese spesso
signal): incrementa il semaforo, e se ci sono thread in attesa ne sblocca uno.
Un mutex (mutual exclusion) è semplicemente un semaforo binario, cioè che può assumere solo i valori 0 e 1: rappresenta una risorsa che può essere posseduta da un thread alla volta. Il pattern classico per proteggere una sezione critica è:
mutex.wait() # P: entra in sezione critica
# ... sezione critica ...
mutex.signal() # V: esce dalla sezione critica
In Python questo si scrive con threading.Lock, che è concettualmente un mutex:
import threading
lock = threading.Lock()
x = 0
def incrementa():
global x
with lock: # acquisisce il lock, lo rilascia automaticamente all'uscita
x = x + 1
In C con la libreria pthread lo stesso pattern usa pthread_mutex_lock e pthread_mutex_unlock attorno alla sezione critica. Molte scuole usano Java per questi esempi (synchronized, wait()/notify()): i concetti sono identici, cambia solo la sintassi — qui li vediamo in Python e C perché sono i linguaggi su cui lavoriamo.
Il problema produttore-consumatore
È l’esempio classico che mette insieme mutua esclusione e sincronizzazione: un produttore genera dati e li inserisce in un buffer di dimensione limitata, un consumatore li preleva ed elabora. Servono tre semafori:
mutex(binario, inizializzato a 1): protegge l’accesso al buffer.vuoti(inizializzato a N, la capacità del buffer): conta quanti spazi liberi ci sono.pieni(inizializzato a 0): conta quanti elementi sono pronti da consumare.
Pseudocodice del produttore:
ripeti:
produci_elemento()
vuoti.wait() # aspetta che ci sia spazio libero
mutex.wait() # entra in sezione critica sul buffer
inserisci_nel_buffer(elemento)
mutex.signal()
pieni.signal() # segnala che c'è un elemento pronto
Pseudocodice del consumatore:
ripeti:
pieni.wait() # aspetta che ci sia un elemento pronto
mutex.wait() # entra in sezione critica sul buffer
elemento = preleva_dal_buffer()
mutex.signal()
vuoti.signal() # segnala che si è liberato spazio
consuma(elemento)
L’ordine delle due wait() prima di mutex.wait() non è a caso: prima si aspetta la disponibilità della risorsa (spazio o elemento), poi si acquisisce il mutex per manipolare il buffer. Invertire questo ordine è esattamente l’errore tipico di cui parlo sotto.
Deadlock e le condizioni di Coffman
Un deadlock (stallo) è una situazione in cui due o più thread restano bloccati per sempre, ciascuno in attesa di una risorsa posseduta da un altro thread anch’esso bloccato. L’esempio didattico standard è il problema dei filosofi a cena: cinque filosofi siedono a una tavola rotonda, fra ogni coppia c’è una sola forchetta, e per mangiare un filosofo ha bisogno di entrambe le forchette alla sua sinistra e alla sua destra. Se tutti e cinque afferrano contemporaneamente la forchetta alla loro sinistra, ciascuno aspetta per sempre quella alla propria destra, già impugnata dal vicino: nessuno mangia più.
Un deadlock si verifica solo se sono presenti contemporaneamente tutte e quattro le condizioni di Coffman:
- Mutua esclusione: almeno una risorsa è posseduta in modo esclusivo da un thread alla volta.
- Possesso e attesa: un thread trattiene una risorsa mentre ne aspetta un’altra.
- Niente prerilascio: una risorsa non può essere tolta a un thread con la forza, solo lui la può rilasciare.
- Attesa circolare: esiste una catena circolare di thread, ciascuno in attesa di una risorsa posseduta dal successivo nella catena.
Rompere anche una sola di queste quattro condizioni evita il deadlock — nel caso dei filosofi, per esempio, basta imporre che almeno un filosofo afferri prima la forchetta destra invece della sinistra, rompendo la simmetria e quindi l’attesa circolare.
L’errore tipico
L’errore più frequente nel produttore-consumatore è invertire l’ordine delle wait: acquisire prima mutex e solo dopo vuoti (o pieni). Sembra un dettaglio stilistico, ma non lo è: se il produttore acquisisce mutex e poi si blocca su vuoti.wait() perché il buffer è pieno, tiene il mutex bloccato — e il consumatore, che avrebbe bisogno proprio di quel mutex per liberare spazio nel buffer, non può mai entrare. È un deadlock generato da due righe scambiate di posto.
L’altro errore, più concettuale, è pensare “tanto la race condition succede raramente, in pratica non se ne accorge nessuno”: è vero che su una macchina veloce l’interleaving problematico può non manifestarsi per migliaia di esecuzioni, ma prima o poi si presenta — tipicamente in produzione, sotto carico, nel momento peggiore. In verifica, un codice che “funziona quasi sempre” senza sezione critica protetta è comunque sbagliato.
Tre esercizi svolti
Esercizio 1 — quali valori finali può assumere x? Due thread eseguono entrambi, senza alcuna protezione, l’istruzione x = x + 1 su una variabile condivisa inizializzata a x = 0. Quali valori finali sono possibili?
Se le due esecuzioni non si sovrappongono mai (un thread completa lettura-incremento-scrittura prima che l’altro inizi), il risultato è x = 2. Se invece, come nell’esempio visto sopra, entrambi i thread leggono x = 0 prima che uno dei due scriva il proprio incremento, un aggiornamento va perso e il risultato è x = 1. Non è possibile ottenere x = 0 (nessuno dei due thread può “non incrementare”) né un valore maggiore di 2 (ogni thread incrementa al massimo una volta). Quindi i valori finali possibili sono 1 oppure 2, mai altro.
Esercizio 2 — completare lo schema produttore-consumatore. Dato lo pseudocodice del produttore con una riga mancante, completarlo:
ripeti:
produci_elemento()
vuoti.wait()
______________ # riga da completare
inserisci_nel_buffer(elemento)
mutex.signal()
pieni.signal()
La riga mancante è mutex.wait(): va acquisito il mutex dopo aver verificato la disponibilità di spazio (vuoti.wait()) e prima di manipolare il buffer, altrimenti si rischia il deadlock descritto sopra.
Esercizio 3 — riconoscere un deadlock. Due thread A e B devono entrambi accedere a due risorse, R1 e R2, protette da due mutex distinti. Il thread A esegue R1.wait() poi R2.wait(). Il thread B esegue R2.wait() poi R1.wait(). Può verificarsi un deadlock? Se sì, quale delle quattro condizioni di Coffman conviene rompere, e come?
Sì: se A acquisisce R1 e, prima che prosegua, B acquisisce R2, allora A resta bloccato in attesa di R2 (posseduta da B) e B resta bloccato in attesa di R1 (posseduta da A) — è un’attesa circolare classica fra due soli thread. La condizione più semplice da rompere è l’attesa circolare: basta imporre a entrambi i thread lo stesso ordine di acquisizione delle risorse (per esempio sempre prima R1, poi R2, anche per B). In questo modo non può mai formarsi un ciclo di attesa.
Su questo argomento non esiste ancora una simulazione dedicata sul sito, come per nessun altro argomento di informatica del tecnico: trovi tutte quelle disponibili, su altre materie, in /simulazioni/.
Se la concorrenza ti sta creando più dubbi che certezze, o vuoi ripassarla insieme prima della verifica pratica, trovi tutte le materie che seguiamo in /materie/informatica/ — la prima chiamata conoscitiva di 30 minuti è gratuita, la trovi in /contatti/.