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.).
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. 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), 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 dichiarato globalmente ai processi e specifica la struttura dei messaggi che puo trasportare.
chan ch(type id1, ..., type idn)
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.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. La receive si blocca se il buffer e vuoto.
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-one | N | 1 | Un singolo destinatario (tipicamente un server), piu mittenti (client). Usato con i port. |
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.
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;
}
}
chan request(int, kind, arg_type);
chan[NCLIENTS] reply(arg_result);
process Client[i = 0 to 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 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.
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
}
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 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.
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;
}
}
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 problema dei filosofi a cena puo essere ripensato in termini di scambio di messaggi. Il Prof. Ricci osserva: "I canali diventano le forchette."
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
}
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.
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.
-- 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;
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;
| 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 |
Con la seconda parte della lezione, il Prof. Ricci introduce il modello Actor, sviluppato originariamente da Carl Hewitt al MIT negli anni Settanta nel contesto dell'intelligenza artificiale, e successivamente approfondito da Gul Agha come unificazione tra OOP e concorrenza.
La vera essenza della programmazione orientata agli oggetti, secondo Alan Kay, non erano le classi o l'ereditarieta, ma il message passing. Gli attori realizzano questa visione originale: tutto e un attore, l'unica comunicazione e lo scambio asincrono di messaggi.
Un attore e un'entita computazionale che incapsula stato, comportamento e un flusso di controllo logico. A differenza degli oggetti classici, gli attori sono autonomi e reattivi: lavorano solo quando ricevono un messaggio.
flowchart LR
subgraph A1 [Attore A]
S1[(Stato)] & C1[Comportamento] & ID1[ID]
end
subgraph A2 [Attore B]
S2[(Stato)] & C2[Comportamento] & ID2[ID]
end
A1 -->|messaggio asincrono| A2
A2 -->|messaggio asincrono| A1
| Principio | Descrizione |
|---|---|
| Comportamento puramente reattivo | Un attore lavora solo quando riceve un messaggio. Nessun messaggio = bloccato. |
| Incapsulamento dello stato | Un attore non puo accedere direttamente allo stato interno di un altro attore. |
| Macro-step semantics | Una volta ricevuto un messaggio, la computazione viene eseguita completamente prima di servire un altro messaggio (run-to-completion). |
| Fairness | Un messaggio inviato a un attore viene prima o poi recapitato e processato. |
| Location transparency | Per inviare un messaggio a un attore basta conoscere la sua identita, non la sua locazione fisica. |
La macro-step semantics e cruciale: evita le race condition perche l'handler viene eseguito atomicamente rispetto agli altri messaggi. Tuttavia, complica la programmazione perche un handler non puo bloccarsi in attesa di un altro messaggio — bloccherebbe tutto l'attore.
L'implementazione pratica del modello attore si basa su un event loop implicito o esplicito.
loop {
msg <- waitForMsg()
handler <- selectHandler(msg)
execute(handler)
}
Esempi: ActorFoundry, Akka, Vert.x, Web Workers.
Il programmatore scrive il loop di ricezione e usa pattern matching. Esempio: Erlang.
La macro-step semantics e condivisa da entrambi gli approcci: un handler viene eseguito completamente prima di ricevere il messaggio successivo. L'unico punto di blocco e la receive. Questo implica che gli handler devono avere un comportamento non bloccante.
public class PingActor extends Actor {
ActorName otherPinger;
@message
public void start(ActorName other) {
otherPinger = other;
send(otherPinger, "ping", self(), Id.stamp()+"called from "+self());
}
@message
public void ping(ActorName caller, String msg) {
send(stdout, "println", Id.stamp()+"Received ping ("+msg+") from "+caller+"...");
send(caller, "alive", Id.stamp()+self().toString()+" is alive");
}
@message
public void alive(String reply) {
send(stdout, "println", Id.stamp()+"Received "+reply+" from pinged actor");
}
}
import akka.actor.UntypedActor;
import akka.event.Logging;
import akka.event.LoggingAdapter;
public class MyUntypedActor extends UntypedActor {
LoggingAdapter log = Logging.getLogger(getContext().system(), this);
public void onReceive(Object message) throws Exception {
if (message instanceof String) {
log.info("Received String message: {}", message);
getSender().tell("received");
} else unhandled(message);
}
}
import akka.actor.Actor
import akka.actor.Props
import akka.event.Logging
class MyActor extends Actor {
val log = Logging(context.system, this)
def receive = {
case "test" => log.info("received test")
case _ => log.info("received unknown message")
}
}
Il "asynchronous spaghetti": la logica applicativa viene frammentata in un insieme non strutturato di handler/callback, ciascuno dei quali reagisce a un evento specifico. Quando un flusso di lavoro richiede una sequenza di passi, ogni passo diventa un handler separato.
Supponiamo di voler calcolare sin(x) * cos(y) usando attori separati per sin e cos. Con l'approccio a handler: invia x a Sin, invia y a Cos, attendi entrambe le risposte, poi moltiplica. Ogni risposta richiede un handler diverso, e bisogna tracciare lo stato per sapere se l'altra risposta e gia arrivata.
import akka.actor.Stash
class ActorWithProtocol extends Actor with Stash {
def receive = {
case "open" => unstashAll(); context.become({
case "write" => // do writing...
case "close" => unstashAll(); context.unbecome()
case msg => stash()
}, discardOld = false)
case msg => stash()
}
}
@Disable(messageName = "put")
public Boolean disablePut(Integer x) {
if (bufferReady) { return (tail == bufferSize); }
else return true;
}
Le LSC (Local Synchronization Constraints) separano il quando un messaggio viene processato dal come. Un messaggio disabilitato viene messo in una save queue per essere processato in seguito.
Erlang e il linguaggio che ha portato il modello attore nella produzione industriale. Sviluppato da Ericsson dal 1987 per telecomunicazioni.
Joe Armstrong, il creatore di Erlang, ha definito il linguaggio come "il miglior modo per scrivere sistemi concorrenti, distribuiti e fault-tolerant". La BEAM supporta centinaia di migliaia di processi con costo di creazione di pochi microsecondi.
% Creazione processo
Pid = spawn(math, fact, [999]).
% Invio messaggio
Pid ! Message.
% Ricezione con pattern matching
receive
Pattern1 [when Guard1] -> Expression1;
Pattern2 [when Guard2] -> Expression2;
...
end
-module(counter).
-export([start/0]).
start() -> loop(0).
loop(Sum) ->
receive
{inc} -> loop(Sum + 1);
{getValue, Pid} -> Pid ! {count_value, Sum}, loop(Sum)
end.
account(Balance) ->
receive
{deposit, Amount, Whom} -> Whom ! {deposit_receipt, Amount},
account(Balance + Amount);
{balance, Whom} -> Whom ! {balance, Balance},
account(Balance);
{withdrawal, Amount, Whom} when Amount > Balance ->
Whom ! overdraft, account(Balance);
{withdrawal, Amount, Whom} -> Whom ! {withdrawal_receipt, Amount},
account(Balance - Amount)
end.
Gli attori sono entita puramente reattive. Come modellare un comportamento pro-attivo? Soluzioni: splitting in sotto-attori, self-sending di messaggi.
Un future e un attore proxy per un risultato non ancora disponibile. Tre stati: iniziale (nessun valore, nessun cliente), attesa (clienti accodati), risolto (clienti notificati, valore immutabile). Il future puo essere passato ad altri attori prima di essere risolto.
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.
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.
L'handler corrispondente a un messaggio viene eseguito completamente prima che l'attore possa servire un altro messaggio (run-to-completion). Questo evita race condition ma implica che gli handler non possano bloccarsi.
La logica applicativa viene frammentata in una moltitudine di handler non strutturati. Si affronta con: Futures/Promises, promise pipelines, stashing, e local synchronization constraints.
Send: invio asincrono di un messaggio. Create: creazione di un nuovo attore. Become: cambio del comportamento per il prossimo messaggio.
Usando la resource hierarchy: ogni filosofo prende prima la forchetta con indice minore, poi quella con indice maggiore. Questo rompe la circular wait.