Abbiamo già apprezzato l'utilità delle goroutine per creare dei thread leggeri ed efficienti in cui parti di codice vengono impacchettate e attivate in background senza condizionare la reattività del programma.
Quello di cui hanno bisogno questi importanti costrutti sono dei meccanismi che permettano loro di passarsi dati a vicenda e comunicare con il resto del programma. Le goroutine infatti agiscono come dei sottoprogrammi suddivisi in camere stagne, il che offre massimo isolamento nel trattamento dei dati (e ciò è cosa buona) ma limita le possibilità di applicazione quando si deve stabilire una collaborazione tra loro.
Uno dei più tipici modelli multithread dell'informatica è detto produttore-consumatore e vede un certo numero di routine (i produttori) impiegate nel generare dati che altre (i consumatori) utilizzeranno.
Ad esempio, immaginiamo che nel nostro programma vogliamo creare una sorta di catena di montaggio continua che scarica dati dalla Rete e li elabora a qualche scopo. Uno dei modi più comodi per crearla sarà allestire una routine per un produttore in grado di scaricare i dati e metterli a disposizione del consumatore al quale corrisponderà un'altra routine che permetterà di sfruttarli.
Inoltre, per rendere il meccanismo più efficiente, potremo parallelizzare il lavoro ulteriormente creando consumatori e produttori multipli che velocizzeranno il tutto: in questi casi, il lavoro di sincronizzazione tra loro sarà di vitale importanza.
Definizione di un channel
Un channel può essere visto come una sorta di condotto in grado di trasportare dati tra una goroutine e l'altra. Come molti altri costrutti in Go anche i channel vengono definiti con poche parole chiave e simboli che spesso sfuggono alla tipica sintassi procedurale. In particolare, dovremo imparare a gestire tre elementi:
- la funzione
make
che definisce un channel con la sintassimake(chan tipo-di-dato)
dove a "tipo-di-dato" sostituiremo il tipo di informazione che verrà passata nel channel. Ad esempio,make(chan int)
creerà un canale per il passaggio di numeri interi tra goroutine; - l'operatore
<-
si occuperà di inviare dati ad un channel o recuperarli da esso. Conmio_canale <-
immetteremo dati in un channel chiamatomio_canale
mentre con<- mio_canale
ve li estrarremo; - la funzione
close
riceve come unico argomento il nome del channel che deve essere chiuso.
E' arrivato però il momento di vederli al lavoro con un paio di esempi.
Hello world con i channel
Nel nostro primo esempio, ci occuperemo di stampare un semplice ed emblematico "Hello world!" che sarà però prodotto in una Goroutine. L'esempio è elementare ma ci permetterà di saggiare gli elementi sintattici di cui abbiamo parlato poco fa. Con il seguente programmino:
package main
import "fmt"
func routine_saluti(canale_saluti chan <- string) {
canale_saluti <- "Hello world!"
close(canale_saluti)
}
func main() {
canale_saluti := make(chan string)
go routine_saluti(canale_saluti)
saluto:= <- canale_saluti
fmt.Println(saluto)
}
otterremo in output la stringa "Hello world!" ma la cosa interessante sarà che questa non fuoriesce da una semplice variabile ma viene prodotta all'interno di una goroutine (che attiva la funzione routine_saluti
). Notiamo i vari passaggi:
- prima di tutto abbiamo scritto la nostra funzione (per trasformarla in goroutine sarà sufficiente avviarla anteponendo la parola chiave
go
) e le abbiamo passato il riferimento al channel da usare per la produzione della stringa: ciò permetterà di utilizzare l'operatore<-
al suo interno per direzionare l'output verso il channel. In questo caso, possiamo osservare come la funzione, in fin dei conti, ignori del tutto lo scopo del channel e chi ne sarà l'utilizzatore: il suo codice si limita solo a immettervi dei dati all'interno. In questo comportamento, possiamo quindi ravvisare un interessante atteggiamento di disaccoppiamento delle funzionalità in cui le routine sono logicamente separate e prendono atto solo dei channel da sfruttare per la lettura o la scrittura dei dati; - nella funzione
main
dichiariamo il channel e questo non richiederà altro se non l'uso dimake
per la sua costituzione e la determinazione dei tipi di dato da veicolare al suo interno; - alla chiamata della goroutine vedremo come i vari pezzetti saranno messi insieme: con la parola chiave
go
la funzione sarà attivata in background e passando come argomento il channel gli si fornirà una via di comunicazione verso l'esterno.
Implementazione di un modello produttore/consumatore
In questo secondo esempio, avremo gli stessi elementi sintattici del precedente applicati però ad un caso in cui due goroutine saranno al lavoro contemporaneamente: una, il produttore, genererà dei numeri a caso (mediante il modulo math/rand
) che inserirà uno alla volta nel channel mentre l'altra, il consumatore, li leggerà dal channel via via che saranno disponibili e li stamperà in output.
Per poter completare un task del genere, ci verrà incontro una caratteristica dei channel: le operazioni di lettura e scrittura sono bloccanti. Questo, ad esempio, permetterà che le iterazioni del ciclo presente nel consumatore restino in attesa di avere un nuovo numero attraverso il channel. Inoltre, tutto ciò non rallenterà il programma in quanto tutte queste operazioni bloccanti saranno svolte in thread secondari.
In questo nuovo programma, abbiamo due goroutine:
package main
import (
"fmt"
"math/rand"
)
func produttore(numeri chan<- int) {
rand.Seed(50)
for range [10]int{}{
valore:=rand.Intn(100)
fmt.Printf("PRODUTTORE scrive: %d\n", valore)
numeri <- valore
}
close(numeri)
}
func consumatore(numeri <-chan int, finito chan<- bool) {
for numero_estratto := range numeri {
fmt.Printf(" CONSUMATORE legge: %d\n", numero_estratto)
}
finito <- true
}
func main() {
numeri := make(chan int)
finito := make(chan bool)
go produttore(numeri)
go consumatore(numeri, finito)
<-finito
}
Abbiamo scelto una formattazione per l'output (di seguito riportato) in grado di evidenziare come i valori generati da una routine siano effettivamente trattati dall'altra. Ci siamo limitati alla sola stampa dei valori ma per esercizio si potrà espandere il codice finalizzandoli a scopi differenti:
PRODUTTORE scrive: 41
PRODUTTORE scrive: 64
CONSUMATORE legge: 41
CONSUMATORE legge: 64
PRODUTTORE scrive: 87
PRODUTTORE scrive: 57
CONSUMATORE legge: 87
CONSUMATORE legge: 57
PRODUTTORE scrive: 86
PRODUTTORE scrive: 35
CONSUMATORE legge: 86
CONSUMATORE legge: 35
PRODUTTORE scrive: 62
PRODUTTORE scrive: 86
CONSUMATORE legge: 62
CONSUMATORE legge: 86
PRODUTTORE scrive: 48
PRODUTTORE scrive: 29
CONSUMATORE legge: 48
CONSUMATORE legge: 29
Si noti come sia risultata comoda la parola chiave range
utilizzata due volte nell'esercizio ed in entrambi i casi con un ciclo for
. Nel consumatore abbiamo impiegato un for range [10]int{}
semplicemente per far fare dieci iterazioni al ciclo: non è altro che un modo più conciso per generare un contatore.
Nel produttore abbiamo utilizzato invece una forma ancora più utile per gli scopi di questa lezione che consiste in for numero_estratto := range numeri
e mostra come range
possa essere utile per snocciolare uno ad uno tutti i diversi valori pubblicati sul channel.
Infine, il secondo channel, denominato finito
- il cui uso si estrinseca alla fine della procedura principale con <-finito
- è solo un modo comodo per far sì che il consumatore ci lanci un segnale per comunicare che le operazioni si sono concluse.
La freccia verso sinistra anteposta al nome di un channel è un'operazione di lettura e questo eviterà che il programma si chiuda mentre le goroutine stanno ancora girando.
Perché questo sistema funziona? Perché come abbiamo già sottolineato le operazioni di lettura e scrittura sono bloccanti!