Il Prof. Alessandro Ricci apre il corso di Programmazione Concorrente e Distribuita (PCD) presso l'Universita' di Bologna, sede di Cesena. Il corso e' integrato con un'altra disciplina: le due parti sono indipendenti ma complementari. Il voto finale sara' la media dei voti ottenuti nelle due parti.
Il corso si inserisce nel primo anno della laurea magistrale in Ingegneria e Scienze Informatiche. L'obiettivo e' consolidare competenze trasversali fondamentali, a prescindere dalla specializzazione successiva (data science, AI, system development).
L'esame si compone di due macro-parti:
| Parte | Descrizione |
|---|---|
| Pratica (Assignments) | Quattro piccoli progetti distribuiti durante l'anno, senza scadenza. Servono per applicare subito quanto imparato e costituiscono il portfolio da discutere all'orale. |
| Orale | Colloquio a partire dal portfolio degli assignments. Si discutono i progetti, le scelte progettuali e i concetti teorici sottostanti. |
Gli assignments non hanno deadline: lo studente organizza autonomamente i tempi. Non sono esami parziali, ma strumenti per consolidare l'apprendimento. Il professore sottolinea che questa flessibilita' e' una novita' rispetto agli anni passati.
La concorrenza e' una proprieta' dei sistemi in cui piu' processi computazionali sono in esecuzione contemporaneamente e potenzialmente interagiscono tra loro. E' un concetto che attraversa molti domini: sistemi operativi, programmi multi-thread, sistemi distribuiti, sistemi di controllo, sistemi real-time.
Un programma concorrente specifica due o piu' programmi sequenziali che possono essere eseguiti concorrentemente come processi paralleli. L'esecuzione di un programma concorrente si chiama computazione concorrente o elaborazione concorrente.
La programmazione concorrente e' l'arte di costruire programmi in cui molteplici attivita' computazionali si sovrappongono nel tempo e tipicamente interagiscono in qualche modo. Un programma concorrente e' un insieme finito di programmi sequenziali che possono essere eseguiti in parallelo.
Ogni programma sequenziale in esecuzione viene chiamato processo: un singolo thread di controllo, una sequenza di istruzioni che opera come un gruppo. Il termine e' astratto e non va confuso con il processo del sistema operativo.
| Proprieta' | Significato |
|---|---|
| Indipendenza dalla velocita' | L'esecuzione dei processi e' completamente asincrona: non si possono fare assunzioni sulla loro velocita' relativa. |
| Non-determinismo | L'ordine di interleaving delle istruzioni non e' deterministico, puo' variare a ogni esecuzione. |
Il professor Ricci fa l'esempio dei sistemi operativi: in un sistema moderno, decine o centinaia di processi convivono. Il sistema operativo e' un esempio classico di sistema concorrente, dove la concorrenza emerge dalla gestione di eventi asincroni (interrupt), scheduler, e interazioni tra processi.
E' fondamentale distinguere tre concetti spesso confusi:
Livello logico/astratto. Si concentra sull'organizzazione del programma: strutturare il software come insieme di attivita' che si sovrappongono. Non richiede necessariamente piu' processori fisici.
Livello fisico. L'esecuzione dei programmi si sovrappone nel tempo perche' eseguita su processori fisici separati. L'attenzione e' sulle performance.
Rete. I processori sono distribuiti su una rete, senza memoria condivisa. La comunicazione avviene tramite scambio di messaggi.
Il professor Ricci cita Rob Pike (co-creatore di Go): la concorrenza e' un modo di strutturare il software, di comporre computazioni indipendenti. Il parallelismo e' un aspetto algoritmico, legato alle performance. La concorrenza abilita il parallelismo, ma non e' parallelismo.
"Concurrency is the composition of independently executing computations. It is a way of structuring software, to write clean code that interacts well with the real world. It is not parallelism; it enables parallelism." — Rob Pike
Herb Sutter ha coniato l'espressione "The Free Lunch is Over": per decenni i programmatori hanno beneficiato dell'aumento automatico delle prestazioni grazie all'incremento della frequenza dei processori. Questa era e' finita. Oggi le prestazioni si migliorano aggiungendo core, non aumentando la frequenza.
I processori moderni integrano multi-core sullo stesso chip, condividendo RAM e talvolta livelli di cache. Esempi: Intel Core i7, AMD Ryzen Threadripper (fino a 64 core).
Le architetture recenti sono ibride, combinando core di dimensioni diverse:
| Tipo | Caratteristiche |
|---|---|
| Performance-cores (P-core) | Design tradizionale, frequenze elevate (es. 5.8 GHz), massima potenza di calcolo. |
| Efficient-cores (E-core) | Core piu' lenti ma fisicamente piu' piccoli, consumano molta meno energia. Ideali per task in background. |
Esempio: Intel Core i9-14th Gen — 24 core (8 P-core + 16 E-core). Anche AMD con l'architettura Zen 5 adotta un approccio simile, con chiplet design che collega piccole unita' (Core Complex Dies) tramite un'interconnessione ad alta velocita'.
I chip moderni integrano processori specializzati: GPU, NPU (Neural Processing Unit), ISP (Image Signal Processor), FPGA. Esempio: Apple Silicon M5 — System-on-Chip con CPU (P-core + E-core), GPU, NPU per machine learning, e vari coprocessori.
I supercomputer (es. Fugaku del RIKEN Center) hanno milioni di core connessi da reti ad-hoc. I cluster usano componenti commodity (computer standard) connessi da reti standard (Gigabit Ethernet, InfiniBand). Il cloud computing (AWS EC2, Azure, Google App Engine) offre risorse come servizio su rete pubblica.
I supercomputer nella top 500 usano prevalentemente Linux. L'Italia e' presente nella classifica. La lista viene aggiornata periodicamente (novembre 2025 e' l'ultima citata nel corso).
La tassonomia di Flynn classifica tutti i sistemi di calcolo in base al numero di flussi di istruzioni e flussi di dati:
| Classe | Descrizione | Esempio |
|---|---|---|
| SISD (Single Instruction, Single Data) | Un flusso di istruzioni, un flusso di dati. | Modello di Von Neumann, processori single-core. |
| SIMD (Single Instruction, Multiple Data) | Un flusso di istruzioni trasmesso a piu' processori, ognuno con i propri dati. | Parallelismo fine, processori vettoriali, GPU. |
| MISD (Multiple Instruction, Single Data) | Piu' flussi di istruzioni su unico flusso di dati. | Nessun sistema noto implementa questo modello. |
| MIMD (Multiple Instruction, Multiple Data) | Ogni processore ha il proprio flusso di istruzioni e i propri dati. | Maggior parte dei sistemi moderni: multi-core, cluster. |
La categoria MIMD si suddivide ulteriormente in base all'organizzazione della memoria:
Tutti i processi condividono un unico spazio di indirizzi. Comunicano leggendo e scrivendo variabili condivise. Due sottoclassi:
Ogni processo ha il proprio spazio di indirizzi. Comunicano tramite scambio di messaggi. Sottoclassi:
Lo speedup misura il miglioramento delle prestazioni quando si usa un algoritmo parallelo rispetto a quello sequenziale:
S = T1 / TN
T1 = tempo di esecuzione con 1 processore
TN = tempo di esecuzione con N processori
N = numero di processori
La legge di Amdahl stabilisce un limite teorico allo speedup ottenibile parallelizzando un programma:
S = 1 / ((1 - P) + P/N)
P = proporzione del programma parallelizzabile
1 - P = parte non parallelizzabile (sequenziale)
La parte sequenziale (1-P) ha un impatto drammatico sulle performance. Spesso la sequenzializzazione e' necessaria per la correttezza (es. lock per evitare corse critiche). Una gestione inefficiente dei lock puo' vanificare i benefici di architetture multi-core.
L'efficienza e' una misura normalizzata dello speedup che indica quanto efficacemente ogni processore viene utilizzato:
E = S / N
S = speedup, N = numero di processori
L'efficienza ideale e' 1 (tutti i processori usati a piena capacita'), ma in pratica e' quasi sempre inferiore.
Oltre alla legge di Amdahl, un altro fattore limitante e' la memoria condivisa e il bus: solo un'operazione di memoria puo' avvenire alla volta. Da qui l'importanza della cache e dei protocolli di coerenza della cache, sempre piu' complessi e sofisticati.
Qualsiasi programma concorrente non banale si basa su processi che interagiscono. I tipi fondamentali di interazione sono tre:
| Tipo | Natura | Descrizione |
|---|---|---|
| Cooperazione | Prevista e desiderata | I processi collaborano per un obiettivo comune. Include comunicazione (scambio di informazioni, tipicamente messaggi) e sincronizzazione (relazioni temporali tra processi, segnali temporali). |
| Competizione/Contention | Prevista ma non desiderata | Necessaria per coordinare l'accesso a risorse condivise. Include mutua esclusione (regolare l'accesso a sezioni critiche) e critical sections (esecuzione concorrente di blocchi di azioni). |
| Interferenza | NON prevista ne' desiderata | Produce effetti negativi solo quando il rapporto tra le velocita' dei processi assume valori specifici (errori tempo-dipendenti). Sono gli "heisen-bug" della programmazione concorrente. |
Il professor Ricci cita Buhr & Harji (2005): sostengono che "sincronizzazione = mutua esclusione" e' una leggenda metropolitana ancora presente in libri di testo e articoli di ricerca. I due concetti sono diversi:
Definisce una relazione temporale tra processi: azioni che avvengono nello stesso momento, alla stessa velocita', o in una relazione di precedenza. Non richiede necessariamente dati condivisi.
Definisce una restrizione sull'accesso a dati condivisi. E' priva di significato se non ci sono dati condivisi. La mutua esclusione richiede forme implicite di sincronizzazione (bloccare azioni, attendere altre).
Il professor Ricci presenta il celebre esempio di Alice e Bob per illustrare le difficolta' della sincronizzazione. Alice e Bob vivono insieme e devono comprare il latte quando finisce:
Entrambi controllano il frigo; se non c'e' latte e non c'e' un biglietto sul frigo, lasciano un biglietto, vanno a comprare il latte, lo mettono in frigo e rimuovono il biglietto. Il programma sembra funzionare ma in realta' e' soggetto a race conditions.
Una race condition (o race hazard) si verifica quando due o piu' processi accedono e aggiornano concorrentemente risorse condivise, e il risultato dell'aggiornamento dipende dall'ordine specifico in cui avvengono gli accessi. Si manifesta in due forme principali:
I problemi nei programmi concorrenti possono portare a tre situazioni critiche:
Deadlock (o "abbraccio mortale" secondo Dijkstra): situazione in cui due o piu' azioni (processi) sono in attesa che l'altra finisca, e nessuna delle due procede mai. Riguarda tipicamente il rilascio di una risorsa condivisa bloccata, la ricezione di un segnale temporale o un messaggio. Coinvolge piu' processi.
Esempio classico: Processo A tiene il lock su risorsa 1 e attende risorsa 2; Processo B tiene il lock su risorsa 2 e attende risorsa 1.
Starvation (o mancanza di equita'): situazione in cui un processo viene bloccato in un'attesa infinita. La starvation di risorse si verifica quando a un processo viene negato perpetuamente l'accesso alle risorse necessarie. Riguarda un singolo processo.
Esempio: Uno scheduler che favorisce sempre i processi ad alta priorita' e non concede mai CPU a quelli a bassa priorita'.
Livelock: simile al deadlock, ma gli stati dei processi coinvolti cambiano continuamente l'uno rispetto all'altro, senza che nessuno progredisca. E' un caso speciale di starvation: la definizione generale dice solo che un processo specifico non sta progredendo.
Esempio: Due processi che si passano continuamente una risorsa senza mai utilizzarla, in un "cortese" scambio infinito.
Una macchina concorrente fornisce il supporto per eseguire programmi concorrenti, mettendo a disposizione tanti processori virtuali quanti sono i processi della computazione concorrente. Fornisce tre meccanismi di base:
| Approccio | Esempi | Descrizione |
|---|---|---|
| Linguaggio sequenziale + libreria | C + PThreads | Si aggiungono primitive concorrenti tramite librerie esterne. |
| Linguaggio nativo per concorrenza | OCCAM, ADA, Erlang, Go | Il linguaggio e' progettato fin dall'inizio per la concorrenza. |
| Approccio ibrido | Java, Scala | Paradigma sequenziale esteso con supporto nativo alla concorrenza + librerie (es. java.util.concurrent). |
Il panorama della programmazione concorrente va ben oltre i thread tradizionali:
La programmazione asincrona e' uno stile sempre piu' importante, supportato ormai da tutti i principali framework e piattaforme (Java, .NET, iOS, Android, JavaScript/Node.js). Si tratta dell'esecuzione e gestione di computazioni/richieste/processi asincroni, astraendo dai thread.
Un programma e' fatto di gestori di eventi (event handler) che vengono chiamati (eseguiti, attivati) quando si verificano eventi. Gli eventi possono essere azioni utente (click, pressioni di tasti), output di sensori, messaggi, risposte ricevute. E' una "programmazione senza call stack": dopo l'esecuzione di ogni handler lo stack e' vuoto.
L'event loop e' un'architettura di controllo in cui un singolo thread di controllo attende eventi ed esegue gli handler corrispondenti. Una coda degli eventi tiene traccia degli eventi generati dall'ambiente o dagli handler stessi. Gli handler sono eseguiti atomicamente: se un evento si verifica mentre un handler e' in esecuzione, sara' servito solo al termine dell'handler corrente.
loop {
Event ev = waitForEvent(eventQueue)
Handler handler = selectHandler(ev)
execute(handler)
}
L'event loop estrae un evento dalla coda, seleziona l'handler registrato e lo esegue. Gli handler non devono mai bloccare.
Gli event handler non devono mai bloccare ne' contenere loop infiniti. Una chiamata bloccante bloccherebbe l'intero event loop, impedendo il processamento degli eventi in coda. Le chiamate bloccanti devono essere sostituite da richieste asincrone, che genereranno un evento al completamento.
Le richieste asincrone usano il modello callback: una funzione (callback) viene specificata come argomento della chiamata asincrona. La callback viene invocata dall'event loop quando il risultato e' pronto. Questo definisce una continuazione della computazione, da cui Continuation-Passing Style (CPS).
La composizione di callback annidate porta a un problema ben noto: callback hell (o "piramide della morte"). La struttura del codice si espande orizzontalmente invece che verticalmente, diventando difficile da leggere, mantenere ed estendere.
step1(function(result1) {
step2(function(result2) {
step3(function(result3) {
// e cosi' via...
})
})
})
Esempio concreto discusso dal professore — la funzione loadUserPic in versione sincrona e asincrona:
function loadUserPic(userId) {
let user = findUserById(userId);
return loadPic(user.picId);
}
function loadUserPic(userId, ret) {
findUserById(userId, (user) => {
loadPic(user.picId, ret);
});
}
loadUserPic('john', (pic) => {
ui.show(pic);
});
Nel server Node.js, l'annidamento e' ancora piu' pronunciato:
http.createServer((request, response) => {
let uri = url.parse(request.url).pathname;
let filename = path.join(process.cwd(), uri);
path.exists(filename, (exists) => {
if(exists) {
fs.readFile(filename, (err, data) => {
response.writeHead(200);
response.end(data);
});
} else {
response.writeHead(404);
response.end();
}
});
}).listen(8080);
Le callback sono spesso implementate come closure: un record che memorizza una funzione insieme all'ambiente lessicale in cui e' stata creata. Questo permette alla callback di accedere alle variabili del contesto originale quando viene invocata dall'event loop.
Le callback hanno problemi noti: "asynchronous spaghetti" (frammentazione della computazione), "pyramid of doom" (annidamento), difficolta' di gestione degli errori. Le Promise sono state proposte per risolvere questi problemi.
Una Promise e' un oggetto proxy che rappresenta un risultato non ancora disponibile ma che sara' calcolato in futuro (simile ai futures, proposti nel 1976 da Friedman e Wise). Incapsula azioni asincrone, comportandosi come un valore di ritorno di una computazione, anche se il valore potrebbe non essere ancora disponibile.
La caratteristica chiave per appiattire la "piramide della morte": il metodo then ritorna a sua volta una Promise, permettendo il chaining:
let promise = new Promise((resolve, reject) => {
resolve(1);
});
promise.then((val) => {
console.log(val); // 1
return val + 2;
}).then((val) => {
console.log(val); // 3
});
L'API delle Promise fornisce due metodi fondamentali per la composizione:
| Metodo | Comportamento | Esempio d'uso |
|---|---|---|
Promise.all([...]) | Attende che tutte le Promise siano risolte (o una sia rigettata). Le callback ricevono un array con i valori. | Eseguire piu' richieste indipendenti in parallelo e attendere tutte le risposte. |
Promise.race([...]) | Completa non appena una qualsiasi Promise si risolve o viene rigettata. | Timeout su una richiesta: chi arriva prima tra richiesta e timer. |
// Promise.all - join point per task paralleli
let myProm1 = delayWithRand(1000);
let myProm2 = delayWithRand(2000);
let myTotalProm = Promise.all([myProm1, myProm2]);
myTotalProm.then((values) => {
console.log(values[0]); // risultato del primo
console.log(values[1]); // risultato del secondo
});
// Promise.race - chi arriva prima
let myProm1 = delayWithRand(1000);
let myProm2 = delayWithRand(2000);
let myTotalProm = Promise.race([myProm1, myProm2]);
myTotalProm.then((value) => {
console.log(value); // risultato del piu' veloce
});
then, serve wrappare in arrow function.L'estensione async/await e' la soluzione piu' recente e promettente nella programmazione asincrona. L'obiettivo e' fornire uno stile di programmazione sincrono mantenendo un core asincrono. Introdotta in ES2017 (JavaScript), implementata in .NET TAP (C#), Python, DART, Kotlin e molti altri.
L'idea fondamentale:
// Stile async con Promise
let p = asyncFunc(...);
p.then(onCompleteFunc, onErrorFunc)
// Stile async/await - sembra sincrono ma e' asincrono
let p = asyncFunc(...);
let res = await p;
// usa res
await sospende l'esecuzione della funzione finche' la Promise non e' risolta, ma non blocca il thread di controllo: il controllo viene ceduto (yield), salvando lo stato corrente della computazione.
await puo' essere usato solo all'interno di funzioni marcate come async. Non e' utilizzabile al livello top-level del programma: serve introdurre almeno una funzione main asincrona.
| Problema | Descrizione |
|---|---|
await solo in async | Non utilizzabile al livello top-level, serve una funzione async wrapper. |
| Event-loop semantics preserved | Una funzione async non puo' riprendersi mentre il thread e' occupato ad eseguire altri handler. |
| Blocchi {…} non atomici | L'esecuzione del codice di un singolo blocco puo' estendersi su piu' iterazioni dell'event loop, aprendo a race condition. |
| Composizione | Serve mescolare async/await con l'API Promise per composizioni complesse (es. Promise.all). |
| Design clash sincrono/asincrono | Mescolare stili sincrono e asincrono richiede forte disciplina per evitare comportamenti difficili da comprendere. |
Un esempio discusso dal professore mostra come sia facile sbagliare se non si rispetta la "catena" async:
// Questo NON funziona come ci si aspetterebbe
function g() {
console.log("started g");
f(); // f e' async, ma g non la awaita
console.log("done g");
}
// Versione corretta
async function g() {
console.log("started g");
await f();
console.log("done g");
}
Le coroutine sono una generalizzazione del concetto di subroutine: permettono di sospendere e riprendere l'esecuzione. Sono il meccanismo di basso livello usato per implementare async/await. A differenza delle subroutine tradizionali (che hanno un solo punto di ingresso e terminano sempre), le coroutine possono avere multipli punti di ingresso e uscita tramite yield.
Le coroutine sono un meccanismo di basso livello per implementare cooperative multitasking (non preemptive). Sono utilizzate come base per: cooperative tasks, eccezioni, event loop, iteratori, liste infinite, pipe. Non sono direttamente correlate all'event-driven programming.
// Esempio: producer-consumer cooperativo con coroutine
var q := new queue
coroutine produce
loop
while q is not full
create some new items
add the items to q
yield to consume
coroutine consume
loop
while q is not empty
remove some items from q
use the items
yield to produce
Kotlin e' un esempio moderno di linguaggio con supporto nativo alle coroutine. Concetti principali:
| Concetto | Descrizione |
|---|---|
| Suspending computations | Computazioni che possono sospendere la loro esecuzione senza bloccare il thread in cui risiedono, permettendo al thread di essere usato per altre computazioni. |
| Coroutine dispatchers | Determinano su quale thread avviare o riprendere una coroutine. |
| Coroutine builders | async() (quando serve un risultato), launch() (senza risultato), runBlocking() (ponte tra codice bloccante e suspendable). |
Le fiber sono thread leggeri che usano cooperative multitasking (a differenza dei thread, che usano preemptive multitasking). Le fiber condividono lo spazio di indirizzi come i thread. Possono essere implementate usando un singolo thread. A livello di astrazione:
Il professor Ricci cita Brian Goetz: i virtual threads forniscono una migliore modularita' e incapsulamento per processi logici e flussi di controllo rispetto all'async programming. Tuttavia, come i thread fisici, non offrono un trattamento first-class per eventi e computazioni reattive.
Esempio Kotlin discusso a lezione:
import kotlinx.coroutines.*
fun main(args: Array<String>) = runBlocking {
println("before async call")
val result = async {
println("inside the async call")
delay(1000)
println("exiting the async call")
100
}
println("after the async call, before greet")
greetDelayed(200)
println("after greet trigger")
println("${result.await()}")
}
suspend fun greetDelayed(delayMillis: Long) {
delay(delayMillis)
println("Hello, World!")
}
La programmazione concorrente e' un modo di strutturare il software come insieme di attivita' che si sovrappongono, al livello logico/astratto. Il parallelismo riguarda l'esecuzione fisica su processori separati, con focus sulle performance. La concorrenza abilita il parallelismo ma non e' parallelismo.
Lo speedup massimo ottenibile parallelizzando un programma e' limitato dalla porzione non parallelizzabile: S = 1 / ((1-P) + P/N). Anche con P=0.95 (95% parallelizzabile), lo speedup massimo e' 20x. Se il 50% e' sequenziale, lo speedup non supera 2x qualsiasi sia il numero di core.
La sincronizzazione definisce relazioni temporali tra processi (precedenza, simultaneita'), mentre la mutua esclusione definisce restrizioni sull'accesso a dati condivisi. La mutua esclusione richiede forme implicite di sincronizzazione, ma la sincronizzazione non richiede necessariamente dati condivisi.
Alice e Bob controllano entrambi il frigo. Se non c'e' latte e non c'e' un biglietto, lasciano un biglietto, comprano il latte, lo mettono in frigo e rimuovono il biglietto. Il problema: a causa dell'interleaving delle operazioni, entrambi possono controllare il frigo simultaneamente, vedere nessun biglietto, lasciare ciascuno un biglietto e comprare entrambi il latte. E' un classico esempio di race condition dovuta a mancanza di atomicita' nell'operazione di controllo + scrittura.
Deadlock: due o piu' processi attendono che l'altro finisca e nessuno procede. Starvation: un singolo processo e' perpetuamente negato nell'accesso alle risorse. Livelock: come il deadlock ma gli stati dei processi cambiano continuamente senza che nessuno progredisca (un caso speciale di starvation).
La regola never-blocking: gli event handler non devono mai bloccare (ne' contenere chiamate bloccanti o loop infiniti). Una chiamata bloccante bloccherebbe l'intero event loop, impedendo il processamento degli eventi in coda. Le operazioni bloccanti devono essere sostituite da richieste asincrone.
Le Promise sono oggetti proxy che rappresentano un risultato futuro di un'operazione asincrona. Risolvono il problema del callback hell appiattendo l'annidamento tramite then chaining e migliorando la gestione degli errori. Limiti: eagerness (esecuzione immediata), impossibilita' di passare parametri in then, nessun supporto a cicli, non cancellabili.
Le coroutine sono una generalizzazione delle subroutine che permette di sospendere e riprendere l'esecuzione. Il meccanismo async/await e' implementato tipicamente usando coroutine a basso livello: await sospende la funzione (yielding il controllo) e la coroutine viene ripresa asincronamente quando l'evento corrispondente e' pronto nella coda dell'event loop.
MPP (Massively Parallel Processors): processori e rete strettamente accoppiati e specializzati per HPC. Cluster: computer commodity connessi da rete standard (es. Beowulf). Grid: risorse eterogenee distribuite su LAN/WAN senza un punto di amministrazione comune.
Herb Sutter ha coniato questa espressione per indicare la fine dell'era in cui le prestazioni dei programmi aumentavano automaticamente grazie all'incremento della frequenza dei processori. Oggi il miglioramento delle prestazioni passa attraverso l'aumento del numero di core, richiedendo programmazione concorrente per sfruttarli.