Programmazione Concorrente e Distribuita — Prof. Alessandro Ricci

Go, Canali e Modelli a Scambio di Messaggi

2026-04-20112 min registrazione originale

In questa lezione

1. Roadmap e organizzazione del corso

Il professore apre la lezione con una panoramica di dove siamo arrivati nel programma. Siamo ormai alla fine del corso. La lezione precedente (2026-04-17) ha introdotto il modulo 3.1 sui modelli a scambio di messaggi, con la macro-suddivisione tra comunicazione sincrona e asincrona. Oggi si entra nel vivo con un laboratorio pratico su Go, un linguaggio che implementa il modello sincrono attraverso i canali.

Il calendario rimanente prevede: venerdì una lezione sugli attori (modello asincrono), lunedì prossimo un laboratorio con Nicolas su Scala e il framework Akka. Chiude la terza parte del corso. Il professore anticipa anche il terzo assignment: due mini-progetti sulla falsariga del secondo, con la possibilita di applicare sia l'approccio ad attori sia quello a canali.

Per l'esame

Il terzo assignment sara composto da due esercizi / mini-progetti di difficolta simile al secondo (piu semplice del primo). Sara un progettino significativo, non un semplice esercizio. Sara possibile scegliere tra approccio ad attori e approccio con canali Go.

flowchart LR subgraph corso[Corso PCD] M1[Modulo 1: Thread e sincronizzazione] M2[Modulo 2: Programmazione reattiva] M3[Modulo 3: Message Passing] end M3 --> M3_1[3.1 Sincrono - CSP, Go channels] M3 --> M3_2[3.2 Asincrono - Attori, Akka] M3_1 --> OGGI["2026-04-20: Go Lab"] M3_2 --> VEN["2026-04-24: Attori"] VEN --> LUN["2026-04-27: Lab Akka (Nicolas)"] style OGGI fill:#dbeafe,stroke:#2563eb,stroke-width:2px

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

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

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

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

6. Canali: 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.

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

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

9. Lab: 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:

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

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

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

10. Exchange Values Problem: tre strategie a confronto

Il problema dello scambio di valori 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.

11. Dining Philosophers con message passing

Il problema dei filosofi a cena viene rivisitato in chiave message passing, senza memoria condivisa. Il professore presenta due soluzioni: centralizzata (un Waiter unico) e distribuita (un Waiter per forchetta).

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

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
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,fork,fork2));
    [] receive releaseForks(fork1,fork2)
    -> availForks[fork1] = true;
        availForks[fork2] = true;
        foreach Request r in pending
            if (availForks[r.fork1] && availForks[r.fork2])
                pending.remove(r);
                send req.ReplyChanID(req.fork1,req.fork2)
    od
}

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

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

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

12. 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. La tabella seguente (dalle slide) mette in corrispondenza i concetti:

Monitor-BasedProcess-Based (Message Passing)
Variabili permanentiVariabili locali del server
Identificatori di proceduraCanale request + op kind
Chiamata a procedura: acquire()send request(...); receive reply(...)
Entry del monitorreceive request(...)
Return della procedurasend reply(...)
wait(free)Salva richiesta in coda pending
signal(free)Recupera e processa richiesta pendente
Corpo delle procedureCase nello switch sull'op kind

Il processo ResAllocator usa un ciclo con guarded communication per gestire richieste di ACQUIRE e RELEASE, mantenendo una coda dei clienti in attesa:

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.

13. Rendez-vous in Ada

Il linguaggio Ada implementa il rendez-vous in modo diretto attraverso il meccanismo di entry e accept. Un task dichiara nel suo interfaccia pubblico 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).

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
task Gourmet is
    entry Make_A_Hot_Dog;
end Gourmet;

task body Gourmet is
begin
    Put_Line("Sono pronto a fare hot dog per te");
    for Index in 1..4 loop
        accept Make_A_Hot_Dog do
            delay 0.8;
            Put_Line("Salsiccia nel panino con senape");
        end Make_A_Hot_Dog;
    end loop;
    Put_Line("Non ho piu hot dog");
end Gourmet;

Il task chiamante (cliente):

for Index in 1..4 loop
    Gourmet.Make_A_Hot_Dog;          -- chiamata bloccante
    Put_Line("Mangio l'hot dog");
end loop;

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.

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.

14. Reactive Programming: flussi di dati asincroni

Il professore dedica la parte finale della lezione alla programmazione reattiva (modulo 2.2), partendo dal presupposto che sia le CPS (Continuation Passing Style) sia le Promise gestiscono computazioni asincrone che producono un singolo risultato. Ma nelle applicazioni moderne e sempre piu comune dover gestire flussi di dati asincroni (streams).

La programmazione reattiva e un paradigma orientato ai flussi di dati e alla propagazione del cambiamento. E fortemente legata al pattern Observer e alla programmazione event-driven. E stata introdotta negli anni '90, ma oggi e fondamentale per lo sviluppo di applicazioni web reattive e Big Data.

flowchart LR A[Sorgente dati] -->|flusso asincrono| B[Operatore 1] B -->|flusso trasformato| C[Operatore 2] C -->|flusso filtrato| D[Sottoscrittore] D -->|backpressure| A

Le principali librerie reattive sono le Reactive Extensions (Rx), disponibili per tutti i principali linguaggi (RxJS, RxJava, RxScala, RxPython, ecc.). L'idea centrale e che un Observable<T> rappresenta un flusso di dati a cui ci si sottoscrive con un Observer<T>.

// Rx.NET: Observable sequence
IObservable<int> source = Observable.Range(1, 10);
IDisposable subscription = source.Subscribe(
    x => Console.WriteLine("OnNext: {0}", x),
    ex => Console.WriteLine("OnError: {0}", ex.Message),
    () => Console.WriteLine("OnCompleted")
);
subscription.Dispose();
Idea chiave

Rx combina tre pattern: Observer (notifica di cambiamenti), Iterator (scorrimento di sequenze), e Functional Programming (operatori filter, map, reduce sui flussi). Un mouse diventa un database di movimenti e click, su cui si possono fare query con operatori LINQ.

Interfacce Observer/Observable

Le interfacce fondamentali di Rx sono elegantemente semplici:

interface IObservable<T> {
    IDisposable Subscribe(IObserver<T> observer);
}

interface IObserver<T> {
    void OnNext(T value);      // nuovo dato disponibile
    void OnError(Exception e); // errore nel flusso
    void OnCompleted();         // flusso terminato
}

Il professore specifica: "Nothing happens until you subscribe." La configurazione della pipeline e dichiarativa; i dati iniziano a fluire solo quando un subscriber si collega.

15. Hot vs Cold e Backpressure

Il professore introduce due concetti chiave per comprendere i flussi reattivi: la distinzione tra stream hot e cold, e il meccanismo di backpressure.

15.1 Cold streams (freddi)

Un flusso cold inizia a generare elementi solo quando viene osservato (subscribed). Ogni subscriber riceve tutti gli elementi del flusso dal principio, indipendentemente dal momento in cui si sottoscrive. E come un file: se lo leggi da capo, vedi tutti i dati.

Comportamento: ogni subscriber ottiene una nuova sequenza indipendente a partire dal primo elemento.

sequenceDiagram participant S1 as Subscriber 1 participant O as Observable (cold) participant S2 as Subscriber 2 Note over S2: Si iscrive dopo t1 S1->>O: subscribe O->>S1: elemento 1 O->>S1: elemento 2 S2->>O: subscribe O->>S1: elemento 3 O->>S2: elemento 1 (ricomincia!) O->>S1: elemento 4 O->>S2: elemento 2

Comportamento: il flusso produce elementi a prescindere dai subscriber. I nuovi subscriber vedono solo gli elementi emessi dopo la loro sottoscrizione.

sequenceDiagram participant S1 as Subscriber 1 participant OH as Observable (hot) participant S2 as Subscriber 2 Note over S2: Si iscrive dopo t1 S1->>OH: subscribe OH->>S1: elemento 1 OH->>S1: elemento 2 OH->>OH: (elemento non visto) S2->>OH: subscribe OH->>S1: elemento 3 OH->>S2: elemento 3 (solo da qui!) OH->>S1: elemento 4 OH->>S2: elemento 4

15.2 Backpressure

La backpressure e un meccanismo fondamentale per gestire la differenza di velocita tra produttore e consumatore. Quando un produttore e molto piu veloce del consumatore, si crea un accumulo che puo portare a esaurimento della memoria. La backpressure permette al consumatore di "spingere all'indietro" un segnale che dice al produttore di rallentare.

Attenzione

La backpressure e il problema classico del produttore-consumatore: se il produttore produce piu velocemente di quanto il consumatore consumi, il buffer intermedio si riempie. Nei sistemi reattivi, questo problema emerge in modo naturale quando si compongono piu flussi con velocita diverse.

Il professore spiega che la backpressure trasforma il modello push puro in un modello push-pull ibrido: il downstream puo fare pull di N elementi se sono disponibili, ma se non lo sono, l'upstream li pusha quando prodotti.

// In Project Reactor: backpressure esplicita
Flux.range(1, 1000)
    .subscribe(new BaseSubscriber<Integer>() {
        @Override
        protected void hookOnSubscribe(Subscription s) {
            request(10);  // chiede 10 elementi alla volta
        }
        @Override
        protected void hookOnNext(Integer v) {
            System.out.println(v);
            request(1);   // ne chiede 1 dopo ogni ricezione
        }
    });

Il professore fa anche un esempio concreto con il metodo sample di Rx, che campiona un flusso ogni 500 millisecondi, poi fa un mapping su stringa e prende gli ultimi 5 elementi. Questo tipo di operatori sono estremamente utili nella pratica per filtrare flussi ad alta frequenza.

Idea chiave

La backpressure non e solo un dettaglio tecnico: e fondamentale per costruire sistemi reattivi robusti. Senza backpressure, un produttore veloce puo far collassare il consumatore per out-of-memory. E per questo che specifiche come Reactive Streams (per JVM e JS) rendono la backpressure un requisito fondamentale.

Verifica le tue conoscenze

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.

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.

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.

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