Parte III — Message passing e attori · Capitolo 12

Message passing e canali

~50 min di lettura12 widget interattivi

In questo capitolo

  1. Dove siamo: dai thread al message passing
  2. Message passing: motivazioni e filosofia
  3. Canali e primitive di comunicazione
  4. Comunicazione sincrona vs asincrona
  5. Schemi di comunicazione
  6. Client-server e monitor attivi
  7. Allocatori di risorse: passivi vs attivi
  8. Comunicazione guardata
  9. Bounded Buffer Manager
  10. Exchanging Values Problem: tre topologie
  11. Filosofi a cena distribuiti
  12. Go: un linguaggio per la concorrenza
  13. Array e slice
  14. Metodi e interfacce: Go non e OOP
  15. Goroutine: attori leggeri e scheduler M:N
  16. Canali in Go: comunicazione CSP sincrona
  17. Rendez-vous sincrono: quando send incontra receive
  18. Select: guarded communication in Go
  19. Lab Go: producer-consumer, pipeline e million-goroutine
  20. Exchange Values in Go: tre strategie a confronto
  21. Filosofi a cena nel lab Go
  22. Active Resource Manager via canali
  23. Ada e il rendez-vous sincrono
  24. Verifica le tue conoscenze

1. Dove siamo: dai thread al message passing

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.

Nota del redattore

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.

2. Message passing: motivazioni e filosofia

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

Attenzione

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 MemoryMessage Passing
Thread/Lock/Semafori/MonitorSend/Receive/Canali/Attori
Stato condiviso e mutabileStato privato per processo
Race condition impliciteNessuna race (nessuna memoria condivisa)
Sincronizzazione esplicitaSincronizzazione tramite protocolli di messaggi
Difficile da scalare a sistemi distribuitiNaturale per sistemi distribuiti
Nota del redattore

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.

Idea chiave

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

3. Canali e primitive di comunicazione

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

Dichiarazione e primitive

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.

Atomicita

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.

Per l'esame

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.

4. Comunicazione sincrona vs asincrona

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.

  • Non servono buffer: il trasferimento avviene solo quando mittente e destinatario sono entrambi pronti (rendez-vous).
  • La comunicazione e un'azione atomica (una inter-azione fra due processi): i puntatori di controllo di entrambi i processi devono trovarsi sulla send e sulla receive. Se uno dei due arriva prima, si blocca in attesa dell'altro.
  • Modello piu primitivo, usato negli algebre di processi (CSP, CCS).
  • Garantisce che quando la send sblocca, il messaggio e stato effettivamente recapitato.

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.

  • I canali hanno un buffer (in alcuni modelli e sistemi detti port, porte).
  • Disaccoppiamento temporale: mittente e destinatario non devono sincronizzarsi.
  • Modello piu flessibile e naturale per sistemi distribuiti.
  • Non fornisce garanzie di ricezione immediata: dopo una send, il mittente non sa se/quando il messaggio sara ricevuto.

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.

Step simulator: confronto sincrono/asincrono

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.

Attenzione

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.

Sintassi alternativa (notazione algebrica)

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.

5. Schemi di comunicazione

Il Prof. Ricci distingue tre schemi fondamentali di comunicazione basati sul numero di mittenti e destinatari per un dato canale:

SchemaMittentiDestinatariProprieta
One-to-one11Canale dedicato a una coppia di processi. Tipico della comunicazione sincrona (es. Occam, Transputer).
Many-to-manyNMPiu mittenti e piu destinatari condividono lo stesso canale. C'e competizione in ricezione e non-determinismo.
Many-to-oneN1Un singolo destinatario (tipicamente un server), piu mittenti (client). Usato con i port; tipico di client-server e code di richieste.
Idea chiave

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.

Esempio: tre processi orchestrati via canali

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)

Esempio: produttore-consumatore semplice

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)
}

Pipeline: CharToLine

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;
  }
}

6. Client-server e monitor attivi

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.

Per l'esame

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.

7. Allocatori di risorse: passivi vs attivi

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 permanentiVariabili locali del server
Identificatori di proceduraCanale request e tipi di operazione (kind)
Chiamata di procedurasend request(...) + receive reply(...)
Entrata nel monitorreceive request(...)
Ritorno dalla procedurasend reply(...)
wait(cv)Salva richieste in una coda (pending queue)
signal(cv)Recupera e processa richieste in sospeso
Corpo della proceduraCasi 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.

State Explorer: allocatore attivo

Esplora i possibili stati del processo allocatore attivo mentre gestisce richieste di ACQUIRE e RELEASE:

Codice dell'allocatore attivo

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); }
    }
  }
}

8. Comunicazione guardata

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

Statement di comunicazione guardata

// Forma generale
B ; C → S

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 con guarded communication

if B1; C1 → S1;
[] B2; C2 → S2;
[] B3; C3 → S3;
fi

Semantica:

  1. Valuta tutte le espressioni booleane nelle guardie.
  2. Se tutte falliscono, l'if termina senza effetto.
  3. Se almeno una ha successo, ne sceglie una non-deterministicamente.
  4. Se tutte si bloccano, aspetta finche una guardia ha successo.
  5. Esegue C per la guardia scelta, poi S.
Idea chiave

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.

Idea chiave

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.

Loop con guarded communication

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
}

9. Bounded Buffer Manager

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); }
}
Per l'esame

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.

Nota del redattore

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.

10. Exchanging Values Problem: tre topologie

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
    

Soluzione centralizzata

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);
}

Soluzione simmetrica

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;
  }
}

Soluzione ad anello

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);
}
Idea chiave

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.

Per l'esame

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.

11. Filosofi a cena distribuiti

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

Soluzione centralizzata (con cameriere)

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
}

Soluzione distribuita (un cameriere per forchetta)

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]();
  }
}
Attenzione

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.

Idea chiave

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.

12. Go: un linguaggio per la concorrenza

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:

Idea chiave

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

CaratteristicaGoJava / C++
ClassiNon presenti (usa struct)Presenti
EreditarietaNon presentePresente (singola/multipla)
MetodiDefiniti su struct dall'esternoDefiniti dentro la classe
InterfacceImplicite (duck typing strutturale)Esplicite (implements)
GenericsIntrodotti in Go 1.18 (2022)Da sempre
ConcorrenzaGoroutine + canali (built-in)Thread + lock + monitor (libreria)
Nota dal redattore

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.

13. Array e slice

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
Idea chiave

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

14. Metodi e interfacce: Go non e OOP

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)
}
Idea chiave

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.

15. Goroutine: attori leggeri e scheduler M:N

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.

Idea chiave

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.

Attenzione

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.

flowchart TD subgraph OS[Thread OS] T1[Thread 1] T2[Thread 2] T3[Thread 3] end subgraph Runtime[Scheduler Go M:N] R1[Logical Processor 1] R2[Logical Processor 2] R3[Logical Processor 3] end subgraph Goroutines[Goroutine Pool] G1[G1] G2[G2] G3[G3] G4[G4] G5[G5] G6[G6] end R1 --> T1 R2 --> T2 R3 --> T3 G1 --> R1 G2 --> R1 G3 --> R2 G4 --> R2 G5 --> R3 G6 --> R3 T1 -.- CP1[(Core 1)] T2 -.- CP2[(Core 2)] T3 -.- CP3[(Core 3)]

16. Canali in Go: comunicazione CSP sincrona

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
Idea chiave

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.

17. Rendez-vous sincrono: quando send incontra receive

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.

sequenceDiagram participant P as Sender (goroutine P) participant CH as Canale ch participant Q as Receiver (goroutine Q) Note over P: p1: x = produce() Note over P: p2: send ch(x) -- si blocca Note over Q: q1: receive ch(y) -- si blocca P->>CH: ch <- x (attende) Note over P,CH: RENDEZ-VOUS! CH->>Q: y = <-ch Note over P: p3: continua Note over Q: q2: consume(y)

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.

18. Select: guarded communication in Go

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

Idea chiave

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.

19. Lab Go: producer-consumer, pipeline e million-goroutine

Il professore dedica una parte significativa della lezione a mostrare esempi pratici di programmazione concorrente in Go. Ecco i principali:

19.1 Canali direzionali e sincronizzazione

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)
}

19.2 Producer-consumer con canale bufferizzato

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

19.3 Million-goroutine challenge

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]
    }
}
Per l'esame

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.

20. Exchange Values in Go: tre strategie a confronto

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.

flowchart TD C[Coordinatore P0] --- P1 C --- P2 C --- P3 C --- P4 P1 -- send valore --> C P2 -- send valore --> C P3 -- send valore --> C P4 -- send valore --> C C -- send min,max --> P1 C -- send min,max --> P2 C -- send min,max --> P3 C -- send min,max --> P4

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.

flowchart TD P1 -- send a tutti --> P0 P1 -- send a tutti --> P2 P1 -- send a tutti --> P3 P2 -- send a tutti --> P0 P2 -- send a tutti --> P1 P2 -- send a tutti --> P3

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.

flowchart LR P0 -- send --> P1 P1 -- send --> P2 P2 -- send --> P3 P3 -- send --> P0

Vantaggio: pochi messaggi (2N). Svantaggio: concorrenza limitata: ogni fase e sequenziale.

Idea chiave

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.

21. Filosofi a cena nel lab Go

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.

21.1 Soluzione centralizzata con un Waiter

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:

flowchart TD W[Waiter] --- Ph0[Filosofo 0] W --- Ph1[Filosofo 1] W --- Ph2[Filosofo 2] W --- Ph3[Filosofo 3] W --- Ph4[Filosofo 4] Ph0 -- send getForks --> W W -- send forks --> Ph0
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)
    }
}
Per l'esame

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.

21.2 Soluzione distribuita: un Waiter per forchetta

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.

flowchart TD W0[Waiter Forchetta 0] --- Ph0 W0 --- Ph1 W1[Waiter Forchetta 1] --- Ph1 W1 --- Ph2 W2[Waiter Forchetta 2] --- Ph2 W2 --- Ph3 W3[Waiter Forchetta 3] --- Ph3 W3 --- Ph4 W4[Waiter Forchetta 4] --- Ph4 W4 --- Ph0

22. Active Resource Manager via canali

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);
            }
        }
    }
}
Idea chiave

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.

23. Ada e il rendez-vous sincrono

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.

Attenzione

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;
sequenceDiagram participant C as Calling Task (cliente) participant S as Called Task (server) Note over C: T.E(actuals) C->>S: chiamata entry E Note over S: accept E(formals) do Note over S: body del rendez-vous S->>C: fine accept Note over C: continua

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.

Idea chiave

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.

CaratteristicaSend sincronaRendez-vous (call/accept)
Blocco della sendFinche il msg e ricevutoFinche il servizio e completato
Il mittente conosce il destinatarioSi (canale)Si (nome dell'entry)
Il destinatario conosce il mittenteNo (anonimo)No (non necessariamente)
Scambio datiSolo messaggioParametri + 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.

Verifica le tue conoscenze

Qual e la differenza fondamentale tra comunicazione sincrona e asincrona nel message passing?

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 nello schema client-server con message passing ogni client deve avere un proprio canale di reply?

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.

Cosa sono le guarded communication e quando si usano?

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.

Nel problema dell'exchanging values, quali sono i trade-off tra le tre soluzioni?

Centralizzata: pochi messaggi (2(n-1)), ma bottleneck. Simmetrica: massimo parallelismo, ma N*(N-1) messaggi. Ad anello: pochi messaggi (2n), ma parallelismo limitato.

Come si evita il deadlock nella soluzione distribuita dei filosofi a cena?

Usando la resource hierarchy: ogni filosofo prende prima la forchetta con indice minore, poi quella con indice maggiore. Questo rompe la circular wait.

Qual e' la differenza fra un monitor passivo e un active monitor basato su message passing?

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.

Descrivi come si implementa un Active Resource Manager con message passing, e come si differenzia da un monitor classico.

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.

Qual e la differenza fondamentale tra un canale Go con buffer e uno senza buffer?

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.

Cosa succede quando avviamo piu goroutine di quanti sono i core della CPU?

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.

Spiega il funzionamento del select in Go e la sua relazione con la guarded communication di Dijkstra.

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.

Qual e il vantaggio e lo svantaggio della soluzione centralizzata per l'Exchange Values Problem rispetto a quella simmetrica?

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.

Quali sono le tre primitive fondamentali del modello Actor?

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.

Cosa si intende per "macro-step semantics" nel modello Actor?

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

Qual e il problema dello "spaghetti asincrono" e come si affronta?

La logica applicativa viene frammentata in una moltitudine di handler non strutturati. Si affronta con: Futures/Promises, promise pipelines, stashing, e local synchronization constraints.

Cosa dice il teorema di fairness nel modello Actor?

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.

Come si risolve il problema della proattivita' negli attori?

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.

Cosa significa "Nothing happens until you subscribe" in Reactive Extensions?

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.

Cosa sono i glitch nella programmazione reattiva e come vengono evitati?

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.

Spiega la differenza tra hot stream e cold stream in Rx con un esempio concreto.

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