Il professor Ricci apre questa parte del corso con un breve recap del percorso affrontato finora. Siamo arrivati alla terza parte del corso, che riguarda i modelli di programmazione concorrente basati sullo scambio di messaggi. L'assignment 3 riguarderà proprio questi modelli.
Thread, modello a memoria condivisa, mutua esclusione con semafori e monitor. L'interazione fra processi avviene attraverso meccanismi che condividono la memoria: variabili condivise protette da lock, semafori, monitor. È la fondazione classica della programmazione concorrente.
Approcci asincroni e ad eventi: programmazione reattiva, flussi di eventi, callback. Pur cambiando radicalmente lo stile di programmazione, resta un'idea di condivisione della memoria sottostante. Strumenti come i flussi reattivi e le promise pipeline facilitano la gestione della complessità.
Il modello a scambio di messaggi come alternativa radicale: processi (o attori) comunicano esclusivamente inviandosi messaggi, senza alcuna memoria condivisa. Da qui partiamo con i modelli a message passing puri, per arrivare al modello Actor di Hewitt e alle sue implementazioni moderne (Erlang, Akka, ActorFoundry).
La terza parte si divide in due moduli: il modulo 3.1 introduce i modelli di message passing classici (canali, send/receive, sincrono/asincrono, guarded communication), mentre il modulo 3.2 approfondisce il modello Actor, la sua semantica e le implementazioni in linguaggi come Erlang, Scala/Akka, e ActorFoundry.
Roadmap dichiarata a lezione (20 aprile): il modulo 3.1 sul modello sincrono viene messo in pratica con un laboratorio su Go (goroutine e canali), la lezione successiva introduce gli attori (modello asincrono), e chiude un laboratorio su Scala e il framework Akka — nel corso attuale il framework di riferimento è il fork Apache Pekko. Il terzo assignment sarà composto da due esercizi / mini-progetti di difficoltà simile al secondo (più semplice del primo): un progettino significativo, non un semplice esercizio, con la possibilità di scegliere tra approccio ad attori e approccio con canali Go.
Finora abbiamo studiato modelli di programmazione concorrente basati su memoria condivisa: semafori, monitor, variabili condition, lock. In tutti questi modelli i thread/processi interagiscono leggendo e scrivendo variabili in uno spazio di indirizzamento comune, protetto da meccanismi di mutua esclusione e sincronizzazione.
Il message passing capovolge completamente questo paradigma: l'unico modo per due processi di interagire e scambiarsi messaggi esplicitamente attraverso primitive di send e receive. Non esistono oggetti condivisi, monitor, lock o semafori. Questa filosofia, come sottolinea il Prof. Ricci, e alla base della programmazione distribuita ma sta guadagnando terreno anche nella programmazione concorrente tradizionale, perche elimina alla radice intere categorie di bug (data race, deadlock da lock dimenticati, etc.).
La programmazione concorrente con memoria condivisa è notoriamente difficile: race condition, deadlock, data race, complessità della sincronizzazione fine. Il message passing semplifica il ragionamento eliminando la memoria condivisa: ogni processo ha il proprio stato privato e l'unica interazione possibile è lo scambio di messaggi.
Il modello nasce storicamente per la programmazione distribuita, dove i processi risiedono su nodi diversi e non hanno memoria condivisa. Tuttavia, con l'avvento del multi-core e del many-core, il message passing ha guadagnato terreno anche come alternativa ai meccanismi classici di sincronizzazione per la programmazione concorrente su singola macchina.
| Shared Memory | Message Passing |
|---|---|
| Thread/Lock/Semafori/Monitor | Send/Receive/Canali/Attori |
| Stato condiviso e mutabile | Stato privato per processo |
| Race condition implicite | Nessuna race (nessuna memoria condivisa) |
| Sincronizzazione esplicita | Sincronizzazione tramite protocolli di messaggi |
| Difficile da scalare a sistemi distribuiti | Naturale per sistemi distribuiti |
Il message passing non e un'idea nuova: Brinch-Hansen la introdusse nel 1970 per i sistemi operativi del computer RC4000. Bob Balzer introdusse il concetto di port nel 1971, alla base del message passing asincrono moderno. La comunicazione sincrona fu formalizzata da Hoare nel 1978 con il CSP (Communicating Sequential Processes). Carl Hewitt e Gul Agha svilupparono il modello Actor a partire dal 1973. Oggi queste idee vivono in Go (goroutine + canali), Erlang/Elixir, Akka, Dart isolates, Web Workers, e molti altri.
Il messaggio non e solo un dato: e l'unita atomica di interazione. Un processo invia un messaggio su un canale; un altro processo lo riceve dallo stesso canale. Non c'e accoppiamento forte come nella chiamata di procedura — i due processi possono essere su core diversi, macchine diverse, continenti diversi.
«Do not communicate by sharing memory; instead, share memory by communicating.» — Questo principio, reso famoso da Go (Rob Pike) ma radicato nel CSP di Hoare, cattura l'essenza: invece di proteggere l'accesso a dati condivisi con lock, i processi si scambiano dati per valore attraverso canali, ciascuno restando padrone del proprio stato locale.
Il modello introduce un nuovo tipo di dato astratto: il canale. Un canale e un'entità dichiarata globalmente ai processi che funge da mezzo di comunicazione e specifica la struttura dei messaggi che puo trasportare (nome, tipi dei dati, e opzionalmente la dimensione del buffer).
chan ch(type id1, ..., type idn) // dichiarazione di canale
send ch(expr1, expr2, ...) // invio messaggio
receive ch(var1, var2, ..., varn) // ricezione messaggio
Ad esempio: chan request(int value) definisce un canale chiamato request che trasporta messaggi contenenti un singolo intero.
send ch(expr1, expr2, ...) — invia un messaggio composto dalle espressioni sul canale ch. I tipi delle espressioni devono coincidere con quelli dichiarati.receive ch(var1, var2, ...) — riceve un messaggio dal canale ch depositando i valori nelle variabili specificate. Le variabili devono avere tipi corrispondenti ai campi del canale.L'accesso al contenuto del canale e atomico: quando un processo esegue una send (o una receive), quell'operazione non puo essere interrotta da un altro processo sul medesimo canale. Questo garantisce che non ci siano race condition a livello del canale stesso.
Ricordate che l'atomicita riguarda l'accesso al canale, non l'intera sezione di codice. I canali sono dichiarati globali ai processi, proprio come le variabili condivise, ma il meccanismo di accesso e radicalmente diverso.
La distinzione fondamentale nel message passing e tra comunicazione sincrona e asincrona. Il Prof. Ricci dedica molta attenzione a questo punto, perche determina le proprieta di sincronizzazione, l'accoppiamento temporale, e la complessita dei sistemi.
Nella comunicazione sincrona, la send si blocca finche il messaggio non viene ricevuto sul canale. La receive si blocca finche un messaggio non e disponibile.
Nella comunicazione asincrona, la send non si blocca: il messaggio viene accodato in un buffer FIFO associato al canale e la send ha successo immediatamente non appena il messaggio e accodato. La receive si blocca se il buffer e vuoto.
La scelta fra sincrono e asincrono ha implicazioni profonde sulla semantica del programma concorrente. Il modello sincrono è più deterministico ma meno flessibile; quello asincrono è più espressivo ma introduce problemi di ordinamento e buffering. La maggior parte dei sistemi moderni adotta una forma di comunicazione asincrona, spesso con supporto per pattern di sincronizzazione opzionali.
Il simulatore seguente mostra la differenza tra una send sincrona e una asincrona in un semplice schema produttore-consumatore. Clicca "Avanza" per eseguire un passo per volta.
Nel caso sincrono, la stampa di "done" sul produttore avviene sempre dopo che il consumatore ha eseguito la receive. Nel caso asincrono, il produttore puo stampare "done" molto prima che il consumatore riceva il messaggio — o addirittura, in linea di principio, prima che il consumatore abbia ancora iniziato la receive. Questo disaccoppiamento e potente ma richiede attenzione nella progettazione.
send ch(msg): ch ! msg
receive ch(msg): ch ? msg
Questa notazione e usata frequentemente nella letteratura sugli algebre di processi (CSP, CCS) e in linguaggi come Go per i canali.
Il Prof. Ricci distingue tre schemi fondamentali di comunicazione basati sul numero di mittenti e destinatari per un dato canale:
| Schema | Mittenti | Destinatari | Proprieta |
|---|---|---|---|
| One-to-one | 1 | 1 | Canale dedicato a una coppia di processi. Tipico della comunicazione sincrona (es. Occam, Transputer). |
| Many-to-many | N | M | Piu mittenti e piu destinatari condividono lo stesso canale. C'e competizione in ricezione e non-determinismo. |
| Many-to-one | N | 1 | Un singolo destinatario (tipicamente un server), piu mittenti (client). Usato con i port; tipico di client-server e code di richieste. |
La competizione nello schema many-to-many significa che quando piu processi fanno receive sullo stesso canale, uno solo otterra il messaggio. L'ordine di arrivo determina chi vince, ma non c'e garanzia di fairness intrinseca. Il non-determinismo e una caratteristica del modello, non un bug.
Il professore mostra un esempio classico con tre processi che comunicano attraverso canali distinti: P invia requestA a Q e requestB a R, poi riceve le risposte dai rispettivi canali di risposta, somma i risultati e li stampa. Questo pattern semplicissimo illustra come la comunicazione via canali permetta di orchestrare interazioni complesse in modo strutturato.
chan requestA(int value)
chan requestB(int value)
chan response(int value)
// Processo P
send requestA(5)
send requestB(6)
receive response(r1)
receive response(r2)
write(r1 + r2)
// Processo Q
receive requestA(r)
send response(r * 2)
// Processo R
receive requestB(r)
send response(r + 1)
Il classico problema del produttore-consumatore con un singolo canale e l'esempio piu semplice di comunicazione many-to-one (o one-to-one): il produttore invia dati sul canale, il consumatore li riceve. Con semantica asincrona, il buffer del canale disaccoppia i due processi.
chan buf(int);
process Producer {
integer x
loop forever:
p1: x <- produce
p2: send buf(x)
}
process Consumer {
integer y
loop forever:
q1: receive buf(y)
q2: consume(y)
}
chan input(char), output(char[MAXLINE]);
process CharToLine {
char line[MAXLINE+1];
int i = 0;
while (true) {
receive input(line[i]);
while (line[i] != CR and i < MAXLINE) {
i = i + 1;
receive input(line[i]);
}
line[i] = EOL;
send output(line);
i = 0;
}
}
Un pattern fondamentale nei sistemi a message passing e l'interazione client-server. Il server e un processo che resta in attesa di richieste su un canale di request e risponde su canali dedicati per ogni client.
chan request(int, kind, arg_type)
chan[NCLIENTS] reply(arg_result)
process Server {
while (true) {
receive request(clientID, kind, args)
if (kind == op1) { /* corpo di op1 */ }
else if (kind == opN) { /* corpo di opN */ }
send reply[clientID](results)
}
}
process Client[i = 0...N-1] {
arg_type myargs;
res_type myres;
<init args>
send request(i, opXXX, myargs);
receive reply[i](myres);
}
Nota importante: ogni client ha un canale di reply dedicato (reply[i]). Il server usa l'ID del client (passato come primo campo della richiesta) per sapere su quale canale inviare la risposta.
Perche non si puo usare un unico canale di reply condiviso da tutti i client? Perche la receive base non permette di specificare un pattern — prende il primo messaggio disponibile. Se ci fosse un unico canale, un client potrebbe ricevere la risposta destinata a un altro. La competizione sul canale di reply romperebbe la corrispondenza richiesta-risposta. Ogni client deve avere il proprio canale (o si deve usare una primitiva con pattern matching, come il receive di Erlang).
Il server agisce come un active monitor: come un monitor classico, incapsula risorse e garantisce accesso mutuamente esclusivo (un messaggio alla volta viene elaborato), ma a differenza del monitor passivo, il server e un processo attivo con un suo flusso di controllo. E tipicamente usato per implementare allocatori di risorse, come mostra la tabella di corrispondenza nella prossima sezione.
Il Prof. Ricci dedica una parte importante della lezione al confronto tra allocatori di risorse con monitor passivi (memoria condivisa) e processi attivi (message passing). La tabella seguente riassume la corrispondenza:
| Monitor-Based (passivo) | Process-Based (attivo) |
|---|---|
| Variabili permanenti | Variabili locali del server |
| Identificatori di procedura | Canale request e tipi di operazione (kind) |
| Chiamata di procedura | send request(...) + receive reply(...) |
| Entrata nel monitor | receive request(...) |
| Ritorno dalla procedura | send reply(...) |
wait(cv) | Salva richieste in una coda (pending queue) |
signal(cv) | Recupera e processa richieste in sospeso |
| Corpo della procedura | Casi in uno switch sull'operazione (kind) |
L'idea centrale: le condition variable nei monitor servono a bloccare un thread finche una condizione non e soddisfatta. Con i messaggi, lo stesso effetto si ottiene parcheggiando la richiesta in una coda interna e rimandando la risposta a quando la risorsa sara disponibile.
Esplora i possibili stati del processo allocatore attivo mentre gestisce richieste di ACQUIRE e RELEASE:
chan request(int clientID, int type);
chan[N] reply(res_id id);
process ResAllocator {
int clientID;
int avail = MAXUNITS;
queue pending;
set units = <valore iniziale>;
op_kind kind; res_id id;
while (true) {
receive request(clientID, kind);
if (kind == ACQUIRE) {
if (avail > 0) {
avail = avail - 1;
id = remove(units);
send reply[clientID](id);
} else { insert(pending, clientID); }
} else if (kind == RELEASE) {
insert(units, id);
if (empty(pending)) { avail = avail + 1; }
else { remove(pending, clientID); send reply[clientID](id); }
}
}
}
Un problema fondamentale: come ricevere messaggi che possono arrivare su multipli canali contemporaneamente? La soluzione classica, introdotta da Dijkstra nel 1974, e la comunicazione guardata (guarded communication).
// Forma generale
B ; C → S
B e una guardia booleana (se omessa, vale true).C e uno statement di comunicazione (tipicamente una receive).S e il blocco di statement da eseguire.La guardia ha successo se B e vera e C puo essere eseguito senza bloccarsi. Fallisce se B e falsa. Si blocca se B e vera ma C non puo ancora essere eseguito.
if B1; C1 → S1;
[] B2; C2 → S2;
[] B3; C3 → S3;
fi
Semantica:
C per la guardia scelta, poi S.Il non-determinismo nella scelta tra guardie che hanno successo e una caratteristica fondamentale: permette di scrivere codice che non dipende dall'ordine di arrivo dei messaggi, delegando al runtime la decisione. Questo semplifica il ragionamento sulla correttezza.
La guarded communication separa what (quali messaggi sono processabili) da when (quando possono essere processati) — la condizione booleana esprime la disponibilità logica, la receive l'arrivo fisico del messaggio.
Il costrutto do ripete la selezione finche tutte le guardie falliscono:
do B1; C1 → S1;
[] B2; C2 → S2;
[] B3; C3 → S3;
od
Esempio — processo Copy che trasferisce caratteri usando un buffer circolare:
process Copy(chan in(char), chan out(char)) {
char buffer[10];
int front = 0, rear = 0, count = 0;
do count < 10; receive in(buffer[rear])
→ count++; rear = (rear + 1) % 10;
[] count > 0; send out(buffer[front])
→ count--; front = (front + 1) % 10;
od
}
Un esempio concreto mostra come un processo BoundedBufferManager gestisca un buffer circolare condiviso usando guarded communication. Il processo ha due possibili operazioni: put (solo se buffer non pieno) e get (solo se buffer non vuoto). Il produttore e il consumatore interagiscono con il manager attraverso protocolli di richiesta-risposta:
process BoundedBufferManager {
int nItems = 0;
int maxElems = ...;
Queue<ItemType> queue = ...;
ItemType item;
boolean ack = true;
chan replyChan;
do nItems < maxElems; receive put(item, replyChan)
→ queue.add(item); nItems++; send replyChan(ack);
[] nItems > 0; receive get(replyChan)
→ ItemType el = queue.remove(); nItems--; send replyChan(el);
od
}
process Producer(chan myChan) {
boolean ack;
loop { ItemType el = produce(); send put(el, myChan); receive myChan(ack); }
}
process Consumer(chan myChan) {
loop { ItemType el; send get(myChan); receive myChan(el); consume(el); }
}
Confrontate questa soluzione con il bounded buffer realizzato con monitor e condition variable. Nel monitor, i thread si bloccano su wait() in attesa di spazio/dati. Qui, il manager parcheggia le richieste nella guarded communication. L'effetto e simile, ma il meccanismo e completamente diverso: non ci sono lock, non c'e memoria condivisa.
Il simulatore interattivo seguente permette di avanzare passo-passo il produttore e il consumatore, mostrando l'effetto delle guardie sull'evoluzione del sistema.
Nella simulazione, il Produttore produce un elemento e tenta di inviarlo. Se il buffer e' pieno (nItems = maxItems), la put viene bloccata. Il Consumatore richiede un elemento; se il buffer e' vuoto (nItems = 0), la get viene bloccata. La gestione dei canali di reply (ack) e' semplificata per chiarezza didattica.
Il Prof. Ricci introduce un problema classico per illustrare come topologie diverse portino a soluzioni con proprieta diverse: abbiamo N processi, ognuno con un valore locale v. Vogliamo che tutti conoscano il valore minimo e massimo tra tutti i valori. Tre soluzioni, tre topologie, con trade-off diversi fra numero di messaggi, grado di concorrenza, e centralizzazione.
flowchart TB
subgraph Centralizzato
C0((P0)) ---|coordinator| C1((P1)) & C2((P2)) & C3((P3))
end
subgraph Simmetrico
S0((P0)) --- S1((P1))
S0 --- S2((P2))
S0 --- S3((P3))
S1 --- S2
S1 --- S3
S2 --- S3
end
subgraph Anello
direction LR
R0((P0)) --> R1((P1)) --> R2((P2)) --> R3((P3)) --> R0
end
chan values(int), results[n](int smallest, int largest);
process P[0] {
int v = ....;
int new, smallest = v, largest = v;
for i in [1...n-1] {
receive values(new);
if (new < smallest) smallest = new;
if (new > largest) largest = new;
}
for i in [1...n-1] { send results[i](smallest, largest); }
}
process P[i] {
int v = ..., smallest, largest;
send values(v);
receive results[i](smallest, largest);
}
chan values[n](int);
process P[i = 0 to n-1] {
int v = ....;
int new, smallest = v, largest = v;
for j in [0...n-1], j != i { send values[j](v); }
for k in [1...n-1] {
receive values[i](new);
if (new < smallest) smallest = new;
if (new > largest) largest = new;
}
}
I processi sono organizzati in un anello logico: ogni processo riceve dal predecessore e invia al successore. Due fasi: determinazione del min/max globale, poi propagazione del risultato.
process P[0] {
int v = ....;
int new, smallest = v, largest = v;
send values[1](smallest, largest);
receive values[0](smallest, largest);
send values[1](smallest, largest);
}
process P[i = 1 to n-1] {
int v = ..., smallest, largest;
receive values[i](smallest, largest);
if (v < smallest) smallest = v;
if (v > largest) largest = v;
send values[(i+1)%n](smallest, largest);
receive values[i](smallest, largest);
send values[(i+1)%n](smallest, largest);
}
Queste tre soluzioni mostrano un trade-off fondamentale: parallelismo vs. numero di messaggi. La soluzione simmetrica massimizza il parallelismo ma costa O(N^2) messaggi. La soluzione ad anello minimizza i messaggi (O(N)) ma serializza la computazione. La centralizzata e un punto intermedio.
Il professore sottolinea l'importanza di saper analizzare e confrontare le tre soluzioni in termini di numero di messaggi scambiati, grado di parallelismo, e presenza di bottleneck. Questo tipo di analisi e' tipico delle domande d'esame.
Il problema dei filosofi a cena puo essere ripensato in termini di scambio di messaggi, in ambiente distribuito: ogni filosofo e' su un nodo diverso della rete, e le forchette (risorse) possono essere distribuite. Il Prof. Ricci osserva: "I canali diventano le forchette."
Un processo Waiter funge da allocatore centrale di forchette. I filosofi inviano richieste di getForks e releaseForks; il waiter mantiene una coda delle richieste pending e gestisce la deadlock avoidance, concedendo le forchette solo quando sono entrambe disponibili.
flowchart LR
W[Waiter] --- Ph0[Ph 0] & Ph1[Ph 1] & Ph2[Ph 2] & Ph3[Ph 3] & Ph4[Ph 4]
chan getForks(int, int, chan);
chan releaseForks(int, int, chan);
process Waiter[i:0..N-1] {
List<Request> pending = ...;
boolean availForks[0..N-1] = {false, ...};
do receive getForks(fork1, fork2, ReplyChanID)
→ if availForks[fork1] && availForks[fork2] {
availForks[fork1] = false; availForks[fork2] = false;
send ReplyChanID(fork1, fork2);
} else { pending.add(new Request(Reply, fork1, fork2)); }
[] receive releaseForks(fork1, fork2)
→ availForks[fork1] = true; availForks[fork2] = true;
for each Request r in pending {
if (availForks[r.fork1] && availForks[r.fork2]) {
pending.remove(r);
availForks[r.fork1] = false; availForks[r.fork2] = false;
send req.ReplyChanID(req.fork1, req.fork2);
}
}
od
}
Ogni forchetta e' gestita da un proprio processo Waiter. I filosofi adottano una strategia di resource hierarchy (prendono prima la forchetta con indice minore) per evitare deadlock. I waiter sono piu' semplici (gestiscono una sola forchetta), ma il protocollo di coordinamento fra filosofi e waiter e' piu' articolato.
flowchart LR
W0[W 0] --- Ph0[Ph 0] & Ph1[Ph 1]
W1[W 1] --- Ph1 & Ph2[Ph 2]
W2[W 2] --- Ph2 & Ph3[Ph 3]
W3[W 3] --- Ph3 & Ph4[Ph 4]
W4[W 4] --- Ph4 & Ph0
process Waiter[i:0..N-1] {
loop {
receive getFork[i]();
send getForkReply[i]();
receive releaseFork[i]();
send releaseForkReply[i]();
}
}
process Philosopher[i:0..N-1] {
int first = i; int second = (i+1) % N;
if (second < first) { first = second; second = i; }
loop {
think();
send getFork[first](); receive getForkReply[first]();
send getFork[second](); receive getForkReply[second]();
eat();
send releaseFork[first](); receive releaseForkReply[first]();
send releaseFork[second](); receive releaseForkReply[second]();
}
}
Nella soluzione distribuita, l'uso della resource hierarchy (prendere sempre la forchetta con indice minore prima) e fondamentale per evitare il deadlock. La gerarchia rompe la circular wait.
La soluzione distribuita elimina il collo di bottiglia del waiter centralizzato, ma richiede un protocollo piu' sofisticato per evitare deadlock. La resource hierarchy (ordinare le forchette per indice) e' la stessa strategia vista nel corso per la prevenzione dei deadlock.
Go (o Golang) e un linguaggio a tipizzazione statica sviluppato da Robert Griesemer, Rob Pike e Ken Thompson in Google a partire dal 2007. La sintassi deriva liberamente dal C, con l'aggiunta di garbage collection, type safety, capacita di type inference, tipi built-in come array dinamici (slice) e mappe chiave-valore, e una ricca libreria standard.
La caratteristica che lo rende centrale per questo corso e il supporto built-in alla concorrenza: goroutine (processi leggeri), canali, e select. Le radici concorrenti di Go affondano nel CSP (Communicating Sequential Processes) di Hoare (1978). Il motto del linguaggio e emblematico:
"Do not communicate by sharing memory; instead, share memory by communicating." — Rob Pike. Go inverte la logica tradizionale: invece di proteggere la memoria condivisa con lock, si condivide la memoria scambiando messaggi attraverso canali. Cio non significa che Go non offra anche mutex e meccanismi classici di sincronizzazione quando servono.
Go non e un linguaggio object-oriented nel senso classico. Non ha classi ne ereditarieta. Ha invece struct per incapsulare dati, metodi che si definiscono esternamente alle struct, e interfacce per il polimorfismo. I progettisti hanno deliberatamente distillato solo gli aspetti dell'OOP che considerano significativi, scartando il resto.
| Caratteristica | Go | Java / C++ |
|---|---|---|
| Classi | Non presenti (usa struct) | Presenti |
| Ereditarieta | Non presente | Presente (singola/multipla) |
| Metodi | Definiti su struct dall'esterno | Definiti dentro la classe |
| Interfacce | Implicite (duck typing strutturale) | Esplicite (implements) |
| Generics | Introdotti in Go 1.18 (2022) | Da sempre |
| Concorrenza | Goroutine + canali (built-in) | Thread + lock + monitor (libreria) |
Il professore consiglia vivamente il tour ufficiale di Go su golang.org per imparare le basi del linguaggio passo passo. Secondo la sua esperienza e il modo piu efficace per prendere confidenza con Go, specialmente per la parte che interessa il corso: la concorrenza.
Il professore mostra la sintassi per dichiarare array in Go, commentando con ironia che la notazione — nome, dimensione, tipo — gli sembra esteticamente discutibile, ma evidentemente ci sono canoni diversi.
// Dichiarazione di array: nome, dimensione, tipo
var frimes [5]int // array di 5 interi
var colors [2]string = [2]string{"rosso", "blu"} // array di 2 stringhe
primes := [6]int{2, 3, 5, 7, 11, 13} // type inference, inizializzazione
Gli array in Go sono a dimensione fissa. Per lavorare con sequenze dinamiche si usano le slice, che sono porzioni di array con dimensione variabile. Le slice sono il modo fondamentale di gestire collezioni in Go: sono flessibili, supportano operatori di aggiunta e affettatura, e sono reference a porzioni di un array sottostante.
// Slice: vista dinamica su un array
s := primes[1:4] // slice degli elementi da indice 1 a 3
s = append(s, 17) // aggiunge elemento; se necessario, alloca nuovo array
fmt.Println(len(s), cap(s)) // lunghezza e capacita della slice
Le slice sono IL modo di gestire liste dinamiche in Go. Non ci sono liste concatenate built-in: si usano slice con append, che e ottimizzato per crescite graduali. Ogni slice ha una lunghezza (elementi visibili) e una capacita (elementi allocati nell'array sottostante).
Go adotta un approccio peculiare alla programmazione: separa nettamente i dati (struct) dal comportamento (metodi e funzioni). I metodi si dichiarano esternamente alla struct, utilizzando un receiver esplicito:
type Vertex struct {
X, Y float64
}
// Metodo con receiver per valore
func (v Vertex) Abs() float64 {
return math.Sqrt(v.X*v.X + v.Y*v.Y)
}
// Metodo con receiver per riferimento (modifica l'originale)
func (v *Vertex) Scale(f float64) {
v.X = v.X * f
v.Y = v.Y * f
}
Le interfacce in Go sono soddisfatte implicitamente: un tipo implementa un'interfaccia semplicemente avendo tutti i metodi richiesti, senza bisogno di una dichiarazione implements esplicita. Questo si chiama structural typing (o duck typing statico).
type Stringer interface {
String() string
}
// Vertex implementa automaticamente Stringer
func (v Vertex) String() string {
return fmt.Sprintf("(%g, %g)", v.X, v.Y)
}
Il sistema di interfacce implicite permette di definire contratti in modo leggero: non serve piu pianificare una gerarchia di tipi. Se un tipo ha i metodi giusti, l'interfaccia e soddisfatta automaticamente. Questo favorisce la composizione rispetto all'ereditarieta.
Le goroutine sono il cuore della concorrenza in Go. Una goroutine e un thread leggero gestito dal runtime di Go, non dal sistema operativo. Si crea con la semplice parola chiave go:
go myFunction() // avvia myFunction in una nuova goroutine
go func() { // oppure con funzione anonima
fmt.Println("Ciao dal goroutine!")
}()
Il runtime di Go implementa uno scheduler M:N che mappa M goroutine su N thread OS (tipicamente uno per core). Questo permette di avere milioni di goroutine attive contemporaneamente senza il costo di un thread OS per ciascuna. Lo scheduler gestisce in modo ottimizzato l'esecuzione, la sospensione e la ripresa delle goroutine, anche quando interagiscono in modo sincronizzato sui canali.
Una goroutine non e un thread OS. Ha uno stack iniziale piccolissimo (pochi KB, contro il MB di un thread) che cresce e si riduce dinamicamente. Lanciare centinaia di migliaia di goroutine e normale in Go; un milione e possibile ma comincia a mostrare i limiti pratici dello scheduler.
Il professore nota che lo scheduler Go non e semplice: deve considerare che le goroutine possono bloccarsi in attesa su canali, e avere un pool di thread pari al numero di core puo non bastare se molte goroutine sono bloccate. Il runtime usa strategie sofisticate per massimizzare la concorrenza effettiva.
I canali sono il meccanismo di comunicazione tra goroutine. Un canale e un tubo tipizzato attraverso cui si inviano e ricevono valori. La creazione avviene con make:
ch := make(chan int) // canale senza buffer: sincrono!
ch := make(chan int, 10) // canale con buffer di 10: asincrono
La sintassi per inviare e ricevere usa l'operatore freccia <-:
ch <- 42 // send: invia 42 sul canale ch
val := <-ch // receive: riceve un valore da ch e lo assegna a val
Di default, i canali in Go sono sincroni (senza buffer). Una send si blocca fino a quando un'altra goroutine non fa una receive sullo stesso canale. Una receive si blocca fino a quando una goroutine non invia. Questo realizza il rendez-vous CSP: send e receive si incontrano per lo scambio del dato.
Il rendez-vous e il cuore del modello sincrono. Il professore spiega che nella comunicazione sincrona, il trasferimento del dato sul canale ch avviene solo quando il puntatore di controllo del sender e sulla send, e quello del receiver e sulla receive. Entrambi si bloccano fino all'incontro.
Il professore illustra questo meccanismo con l'esempio classico del produttore-consumatore. Con la semantica sincrona, il trasferimento avviene solo quando il produttore P e al punto p2 (send) E il consumatore Q e al punto q1 (receive):
// Produttore
func producer(ch chan<- int) {
for i := 0; ; i++ {
x := produce(i) // p1
ch <- x // p2: bloccante finche Q non riceve
// p3: continua
}
}
// Consumatore
func consumer(ch <-chan int) {
for y := range ch { // q1: bloccante finche P non invia
consume(y) // q2
}
}
func main() {
ch := make(chan int) // canale sincrono (senza buffer)
go producer(ch)
consumer(ch)
}
Nel codice sopra, chan<- int indica un canale solo-send (produttore), mentre <-chan int indica un canale solo-receive (consumatore). Go permette di vincolare la direzione dei canali a livello di tipo, aumentando la sicurezza.
Come gestire messaggi che possono arrivare su piu canali contemporaneamente? Il problema e lo stesso della guarded communication introdotta da Dijkstra nel 1974 (vedi la sezione 8). Go lo risolve con l'istruzione select, che permette a una goroutine di attendere su piu operazioni di canale contemporaneamente.
select {
case msg1 := <-ch1:
fmt.Println("Ricevuto da ch1:", msg1)
case msg2 := <-ch2:
fmt.Println("Ricevuto da ch2:", msg2)
case ch3 <- 42:
fmt.Println("Inviato 42 su ch3")
default:
fmt.Println("Nessun canale pronto: esco senza bloccare")
}
Il select si blocca finche uno dei case non puo procedere. Se piu case sono pronti, ne sceglie uno in modo non deterministico (random uniforme). Se c'e il default, viene eseguito immediatamente se nessun canale e pronto (non-blocking select).
Il select realizza in Go il concetto di comunicazione guardata delle slide: la guardia B (condizione booleana) e implicita nella disponibilita del canale. Ogni case e una guardia che verifica se il canale e pronto per send o receive. La scelta non deterministica tra guardie pronte e una caratteristica fondamentale dei sistemi concorrenti.
Un pattern molto utile e il timeout con select:
select {
case msg := <-ch:
fmt.Println("Ricevuto:", msg)
case <-time.After(2 * time.Second):
fmt.Println("Timeout dopo 2 secondi")
}
Qui time.After crea un canale che riceve un valore dopo il tempo specificato. Se la ricezione da ch non avviene entro 2 secondi, il timeout scatta.
La send si blocca finche un receiver non e pronto. La receive si blocca finche un sender non invia. Non c'e buffer: il trasferimento del dato e simultaneo. Modello piu primitivo, usato negli algebri di processi (CSP, CCS). Non c'e bisogno di buffer: e la semantica piu pura.
Vantaggio: sincronizzazione implicita, nessun problema di buffer overflow. Svantaggio: meno flessibile, puo causare deadlock piu facilmente.
I canali con buffer (creati con make(chan T, N)) permettono fino a N send senza che ci sia un receiver pronto. La send si blocca solo quando il buffer e pieno; la receive si blocca solo quando il buffer e vuoto. Equivalente ai port di Brinch-Hansen.
Vantaggio: disaccoppia produttore e consumatore, permette burst di produzione. Svantaggio: non da garanzie di sincronizzazione immediata, possibile accumulo nel buffer.
Il professore dedica una parte significativa della lezione a mostrare esempi pratici di programmazione concorrente in Go. Ecco i principali:
Il primo esempio mostra come creare goroutine che comunicano attraverso un canale. Il main attende la ricezione prima di procedere, dimostrando la sincronizzazione intrinseca:
func main() {
ch := make(chan string)
go func() {
ch <- "Ciao dalla goroutine!"
}()
msg := <-ch
fmt.Println(msg)
}
Il secondo esempio estende il pattern a piu produttori e consumatori, usando un canale bufferizzato per disaccoppiare le fasi:
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
fmt.Printf("Worker %d lavora su job %d\n", id, j)
results <- j * 2
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
// Avvia 3 worker
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// Invia 9 job
for j := 1; j <= 9; j++ {
jobs <- j
}
close(jobs)
// Raccogli risultati
for r := 1; r <= 9; r++ {
<-results
}
}
Il professore fa una dimostrazione dal vivo: creare un milione di goroutine che comunicano via canali. Il codice crea un milione di agenti (goroutine) ciascuno con il proprio canale, invia un messaggio a ciascuno, e riceve le risposte. La creazione richiede circa 3 secondi principalmente a causa della stampa su standard output. Senza la stampa, e molto piu veloce.
const N = 1_000_000
func myAgent(id int, ch chan string) {
msg := <-ch
_ = msg
}
func main() {
channels := make([]chan string, N)
for i := 0; i < N; i++ {
ch := make(chan string)
channels[i] = ch
go myAgent(i, ch)
}
for i := 0; i < N; i++ {
channels[i] <- fmt.Sprintf("Messaggio %d", i)
}
for i := 0; i < N; i++ {
<-channels[i]
}
}
Il pattern di creare un canale per ogni agente e restituire il canale al chiamante e un idioma fondamentale in Go. Permette di interagire con le goroutine in modo type-safe e sincronizzato. La factory dell'agente crea il canale, avvia la goroutine, e restituisce il canale come "maniglia" per la comunicazione.
Il professore mostra anche come il compilatore Go rilevi variabili non utilizzate (è un errore!): quando si riceve da un canale ma non si usa il valore ricevuto, si assegna a _ (blank identifier) per soddisfare il compilatore.
Il problema dello scambio di valori (sezione 10) e un classico esempio di coordinazione peer-to-peer. Abbiamo N processi, ciascuno con un valore intero locale. L'obiettivo e che ogni processo conosca il minimo e il massimo di tutti i valori. Il professore propone di implementare in Go le tre strategie viste nel modulo 3.1.
Un processo coordinatore riceve i valori da tutti gli altri, calcola min e max, e invia i risultati a ciascuno.
Vantaggio: pochi messaggi (2N-2). Svantaggio: il coordinatore e un bottleneck; la receive del coordinatore e ritardata dalla concorrenza.
Ogni processo invia il proprio valore a tutti gli altri, poi ciascuno calcola indipendentemente min e max.
Vantaggio: massima distribuzione e parallelismo. Svantaggio: numero elevato di messaggi N*(N-1).
I processi sono organizzati in un anello logico. Ogni processo riceve dal predecessore, aggiorna min/max, e invia al successore. Due fasi: determinazione min/max globale, poi propagazione.
Vantaggio: pochi messaggi (2N). Svantaggio: concorrenza limitata: ogni fase e sequenziale.
I tre approcci mostrano un trade-off fondamentale nei sistemi concorrenti distribuiti: da un lato la centralizzazione (pochi messaggi, ma bottleneck), dall'altro la distribuzione (massimo parallelismo, ma tanti messaggi). La soluzione ad anello e un compromesso intermedio.
Nel laboratorio il problema dei filosofi a cena viene rivisitato in chiave message passing, senza memoria condivisa, riprendendo le due soluzioni della sezione 11: centralizzata (un Waiter unico) e distribuita (un Waiter per forchetta), da implementare con goroutine e canali.
Un singolo processo Waiter funge da allocatore di risorse (forchette). Riceve richieste dai filosofi e concede le forchette quando sono entrambe disponibili. Se non lo sono, accoda la richiesta (il codice completo del Waiter e nella sezione 11). Il filosofo interagisce cosi:
process Philo[i:0..N-1, chan reply] {
int first = i;
int second = (i+1)%N;
loop {
think();
send getForks(first,second,reply)
receive reply(first,second)
eat();
send releaseForks(first,second)
}
}
Notate la differenza fondamentale rispetto alla soluzione con monitor: qui il waiter mantiene una coda dei pending esplicita e decide quando rispondere. Nel monitor, il thread si bloccava su una condition variable. La comunicazione via messaggi richiede una gestione esplicita delle richieste pendenti.
In questa versione, ogni forchetta ha il proprio processo Waiter. I filosofi acquisiscono le forchette seguendo il protocollo di gerarchia delle risorse (prendere prima la forchetta con indice minore) per evitare deadlock — il codice di Waiter e Philosopher e riportato nella sezione 11.
Il professore mostra come il pattern del monitor passivo per l'allocazione di risorse possa essere implementato come processo attivo che comunica via canali, secondo la tabella di corrispondenza vista nella sezione 7. Il processo ResAllocator usa un ciclo con guarded communication per gestire richieste di ACQUIRE e RELEASE, mantenendo una coda dei clienti in attesa. La versione commentata mostrata a lezione:
process ResAllocator {
int avail = MAXUNITS; queue pending; set units;
op_kind kind; res_id id; int clientID;
while (true) {
receive request(clientID, kind);
if (kind == ACQUIRE) {
if (avail > 0) { // risorsa disponibile subito
avail = avail - 1;
id = remove(units);
send reply[clientID](id);
} else { // accoda la richiesta
insert(pending, clientID);
}
} else if (kind == RELEASE) {
if empty(pending) { // nessuno in attesa: restituisci
insert(units, id); avail++;
} else { // c'e un pending: passa direttamente
remove(pending, clientID);
send reply[clientID](id);
}
}
}
}
Il processo attivo ResAllocator generalizza il monitor: invece di bloccare il thread chiamante su una condition variable, accoda esplicitamente le richieste e decide quando evaderle. Questo approccio e piu flessibile in contesti distribuiti, dove non c'e memoria condivisa per implementare condition variable.
Il rendez-vous e un'estensione della comunicazione sincrona in cui il mittente non solo aspetta che il ricevente accetti il messaggio, ma attende anche la risposta: aspetta che il destinatario completi l'elaborazione e restituisca il risultato.
Ada e' uno dei pochi linguaggi mainstream che offre il rendez-vous sincrono come primitiva di comunicazione nativa fra task. Un task dichiara nella sua interfaccia pubblica delle entry, e nel corpo del task implementa ogni entry con un blocco accept. Il task chiamante usa la chiamata a entry (simile a una chiamata di procedura, ma e una comunicazione tra task) e si blocca fino a quando il task chiamato non accetta la chiamata.
Il nome "rendez-vous" richiama l'immagine di due persone che si danno appuntamento: il primo che arriva deve aspettare l'arrivo del secondo. La comunicazione avviene solo quando entrambi sono pronti.
-- Call e Accept in Ada
task T;
entry E(formals);
...
T.E(actuals); -- chiama l'entry
accept E(formals) do
-- corpo del rendez-vous
end accept;
L'esempio mostrato dal professore e' un classico programma Ada con due task:
with Ada.Text_IO; use Ada.Text_IO;
procedure HotDog is
task Gourmet is
entry Make_A_Hot_Dog;
end Gourmet;
task body Gourmet is
begin
Put_Line("I am ready to make a hot dog for you");
for Index in 1..4 loop
accept Make_A_Hot_Dog do
delay 0.8;
Put("Put hot dog in bun "); Put_Line("and add mustard");
end Make_A_Hot_Dog;
end loop;
Put_Line("I am out of hot dogs");
end Gourmet;
begin
for Index in 1..4 loop
Gourmet.Make_A_Hot_Dog;
delay 0.1;
Put_Line("Eat the resulting hot dog"); New_Line;
end loop;
Put_Line("I am not hungry any longer");
end HotDog;
L'esecuzione produce la sequenza vista nelle slide: il Gourmet annuncia la disponibilita, per 4 volte prepara un hot dog (con ritardo di 0.8 secondi) mentre il cliente mangia, poi il Gourmet esaurisce le scorte. Il cliente, che chiama con un ritardo minore (0.1 s), rimane in attesa sul accept finche il Gourmet non completa la preparazione. Ogni chiamata al Gourmet si sincronizza con un accept, producendo l'output ordinato: e un esempio di sincronizzazione intrinseca garantita dal modello.
Il rendez-vous Ada estende la sincronizzazione base: il chiamante non solo attende che il chiamato riceva la richiesta, ma attende anche il risultato della computazione. L'immagine guida e quella di due persone che scelgono un luogo per incontrarsi: il primo che arriva aspetta il secondo.
| Caratteristica | Send sincrona | Rendez-vous (call/accept) |
|---|---|---|
| Blocco della send | Finche il msg e ricevuto | Finche il servizio e completato |
| Il mittente conosce il destinatario | Si (canale) | Si (nome dell'entry) |
| Il destinatario conosce il mittente | No (anonimo) | No (non necessariamente) |
| Scambio dati | Solo messaggio | Parametri + risultati |
Mentre Ada e il CSP di Hoare adottano il modello sincrono, la maggior parte dei linguaggi e framework moderni preferisce il message passing asincrono, considerato piu' flessibile e scalabile. Il modello Actor, che vedremo nel prossimo capitolo, e' basato su comunicazione asincrona.
Nella comunicazione sincrona, la send si blocca finche il messaggio non viene ricevuto (non serve buffer). Nella comunicazione asincrona, la send non si blocca: il messaggio viene accodato in un buffer FIFO. La sincrona garantisce che dopo la send il messaggio sia stato recapitato; l'asincrona disaccoppia temporalmente mittente e destinatario. Il modello Actor usa comunicazione asincrona.
Perche la receive base non permette di specificare un pattern (filtro sul mittente). Se tutti i client condividessero un unico canale di reply, la receive prenderebbe il primo messaggio disponibile — che potrebbe essere la risposta destinata a un altro client.
Introdotte da Dijkstra nel 1974, permettono di ricevere messaggi da piu canali contemporaneamente. Una guardia e composta da: condizione booleana (B), comunicazione (C), e blocco di codice (S). Si usano con i costrutti if...fi e do...od.
Centralizzata: pochi messaggi (2(n-1)), ma bottleneck. Simmetrica: massimo parallelismo, ma N*(N-1) messaggi. Ad anello: pochi messaggi (2n), ma parallelismo limitato.
Usando la resource hierarchy: ogni filosofo prende prima la forchetta con indice minore, poi quella con indice maggiore. Questo rompe la circular wait.
Un monitor passivo (OOP classico) incapsula variabili e procedure con mutua esclusione, ma e' un'entita' passiva: non ha un flusso di controllo proprio. Un active monitor e' un processo attivo che riceve richieste su un canale e risponde su canali dedicati. La mutua esclusione e' garantita dal fatto che il processo server elabora un messaggio alla volta. La tabella di corrispondenza mostra come mappare: variabili del monitor a variabili locali del server, procedure a tipi di operazione, chiamate a send+receive, return a send reply.
Un Active Resource Manager e un processo che riceve richieste su un canale, con un campo kind per distinguere ACQUIRE da RELEASE. A differenza del monitor (che blocca il thread su una condition variable con wait), il processo attivo mantiene una coda pending esplicita e decide autonomamente quando evadere le richieste. Se una risorsa non e disponibile, la richiesta viene accodata; quando arriva una RELEASE, se ci sono pending viene servito direttamente il primo in coda, altrimenti la risorsa torna disponibile.
Un canale senza buffer (make(chan T)) e sincrono: la send si blocca finche un receiver non e pronto, e la receive si blocca finche un sender non invia. Un canale con buffer (make(chan T, N)) e asincrono: la send si blocca solo quando il buffer e pieno, la receive solo quando il buffer e vuoto. Il canale senza buffer realizza il rendez-vous CSP puro.
Lo scheduler M:N di Go mappa le M goroutine su N thread OS (tipicamente uno per core). Le goroutine vengono schedulate in modo cooperativo: quando una si blocca (es. su un canale, una syscall, o I/O), lo scheduler ne attiva un'altra. Si possono avere milioni di goroutine attive perche il loro stack iniziale e molto piccolo (pochi KB) e cresce dinamicamente.
L'istruzione select realizza il concetto di guarded communication: ogni case e una guardia che verifica la disponibilita di un canale per send o receive. Se piu guardie sono pronte, Go ne sceglie una in modo non deterministico (random uniforme). Se nessuna e pronta, il select si blocca (a meno di un default, che lo rende non-bloccante). Corrisponde esattamente al costrutto if B1; C1 -> S1 [] B2; C2 -> S2 fi delle slide.
Centralizzata: pochi messaggi (2N-2), ma il coordinatore e un bottleneck e la sua receive puo essere ritardata se molti processi inviano contemporaneamente. Simmetrica: massima distribuzione e parallelismo (ogni processo calcola indipendentemente), ma il numero di messaggi e N*(N-1), che scala male con N crescente.
send (invio asincrono di un messaggio a un attore), create (creazione di un nuovo attore con un comportamento specificato), become (specifica di un nuovo comportamento per il prossimo messaggio). Send e' analoga alla chiamata di procedura nella programmazione sequenziale; create all'astrazione di procedura; become fornisce comportamento history-sensitive.
La macro-step semantics (run-to-completion) stabilisce che una volta ricevuto un messaggio, il corrispondente handler viene eseguito completamente prima di servire il messaggio successivo. Questo elimina le race condition sullo stato interno dell'attore, ma complica la programmazione perche' un handler non puo' bloccarsi in attesa di risultati — deve organizzarsi in handler separati, portando al problema degli "spaghetti asincroni".
La logica applicativa viene frammentata in una moltitudine di handler non strutturati. Si affronta con: Futures/Promises, promise pipelines, stashing, e local synchronization constraints.
La fairness in message delivery stabilisce che se un attore invia un messaggio a un altro attore, questo viene eventualmente recapitato e processato. Non ci sono garanzie sul tempo di consegna ne' sull'ordine di arrivo. Anche due send successive dallo stesso mittente possono arrivare in ordine diverso. Ogni forma di ordinamento deve essere implementata tramite pattern di scambio messaggi.
Gli attori sono puramente reattivi, quindi per comportamenti proattivi si usa il self-sending: l'attore invia messaggi a se stesso per procedere incrementalmente in un compito. Ogni passo e' una reazione a un messaggio auto-inviato. Se arriva un messaggio di stop, l'attore smette di auto-inviarsi messaggi e il compito si ferma. E' una soluzione parziale; la ricerca propone astrazioni linguistiche piu' avanzate.
Significa che la configurazione della pipeline (gli operatori) e puramente dichiarativa: definisce cosa succedera quando i dati fluiranno, ma il flusso non parte finche un Subscriber non si collega all'ultimo operatore. Solo la chiamata a subscribe attiva la richiesta di dati che si propaga all'indietro fino alla sorgente. Questo e il modello lazy/pull applicato agli stream.
I glitch sono inconsistenze temporanee che si verificano durante la propagazione dei cambiamenti in un modello push-based. Ad esempio, se var2 = var1 * 1 e var3 = var1 + var2, quando var1 cambia da 1 a 2, una propagazione ingenua potrebbe ricalcolare var3 prima di var2, producendo momentaneamente var3=3 invece di 4. I glitch si evitano con tecniche di ordinamento topologico del grafo delle dipendenze, garantendo che ogni espressione sia ricalcolata solo dopo che tutte le sue dipendenze sono aggiornate.
Un cold stream crea una nuova sequenza per ogni subscriber: e come un file video on demand, ogni spettatore lo vede dall'inizio. Un hot stream produce dati indipendentemente dai subscriber: e come una diretta TV, chi si sintonizza in ritardo perde le scene gia trasmesse (a meno di caching/replay). Esempio pratico: un timer con timerB(100) in Flapjax e cold (ogni subscriber vede il timer dall'inizio); gli eventi del mouse (click, move) sono hot (se non sei iscritto quando arriva un click, lo perdi).